Skip to content

Commit a67d3f1

Browse files
committed
refactor(auth-checker): move auth scheme detection to IR-level inspection, remove PartialConst from value lattice
Remove PartialConst variant from the Value enum and revert interp to return Unknown for partial concatenations. Instead, detect Basic/Bearer auth schemes directly in AuthHeaderChecker by walking the IR body's instructions to inspect BinOp(Add) and Template rvalues that produced the Authorization header value, following Read(Var) chains up to depth 4. Also scope the Authorization projection search to the specific headers VarId to prevent cross-contamination between call sites in the same function body. Extend extract_url_prefix_from_body to follow Read(Var) chains so template URLs like `https://api.atlassian.com/...` are correctly resolved. Remove is_basic_auth_concat_prefix and is_bearer_prefix from utils.rs. Fix fetch/forgeFetch/node-fetch URL check: all Intrinsic::Fetch calls now require URL validation against is_atlassian_url; only requestJira/Confluence/Bitbucket/Graph bypass the URL check as inherently Atlassian-bound.
1 parent d271cd3 commit a67d3f1

4 files changed

Lines changed: 259 additions & 101 deletions

File tree

crates/forge_analyzer/src/checkers.rs

Lines changed: 249 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,11 @@ use crate::{
77
WithCallStack,
88
},
99
ir::{
10-
Base, BasicBlock, BasicBlockId, Inst, Intrinsic, Literal, Location, Operand, Projection,
11-
VarId, VarKind, Variable,
10+
Base, BasicBlock, BasicBlockId, BinOp, Inst, Intrinsic, Literal, Location, Operand,
11+
Projection, Rvalue, VarId, VarKind, Variable,
1212
},
1313
reporter::{IntoVuln, Reporter, Severity, Vulnerability},
14-
utils::{
15-
add_elements_to_intrinsic_struct, convert_lit_to_raw, is_basic_auth_concat_prefix,
16-
is_bearer_prefix, translate_request_type,
17-
},
14+
utils::{add_elements_to_intrinsic_struct, convert_lit_to_raw, translate_request_type},
1815
worklist::WorkList,
1916
};
2017
use core::fmt;
@@ -1391,6 +1388,188 @@ pub fn is_atlassian_url(url: &str) -> bool {
13911388
atlassian_path_re().is_match(url)
13921389
}
13931390

1391+
/// Detected authorization scheme from IR-level inspection.
1392+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1393+
enum AuthScheme {
1394+
Basic,
1395+
Bearer,
1396+
}
1397+
1398+
/// Inspects a literal string to determine if it starts with an auth scheme prefix.
1399+
fn classify_auth_literal(s: &str) -> Option<AuthScheme> {
1400+
if s.len() >= 6 && s[..6].eq_ignore_ascii_case("basic ") {
1401+
Some(AuthScheme::Basic)
1402+
} else if s.len() >= 7 && s[..7].eq_ignore_ascii_case("bearer ") {
1403+
Some(AuthScheme::Bearer)
1404+
} else {
1405+
None
1406+
}
1407+
}
1408+
1409+
/// Extracts an auth scheme prefix from the IR instructions that define the
1410+
/// Authorization header value. This handles cases where the value resolves to
1411+
/// `Unknown` because it was built via concatenation (e.g. `"Basic " + token`)
1412+
/// or a template literal (e.g. `` `Basic ${token}` ``).
1413+
///
1414+
/// The function searches two patterns:
1415+
/// 1. Direct assignment to `target_varid` with no projection (the value was
1416+
/// assigned to a temp var that the value manager later resolved).
1417+
/// 2. Assignment with a projection ending in `Authorization`/`authorization`
1418+
/// on the headers object VarId (inline object literal case).
1419+
///
1420+
/// For each match, it inspects the rvalue for `BinOp(Add, ...)` or `Template`
1421+
/// with a recognizable auth scheme literal prefix.
1422+
///
1423+
/// Returns `Some(AuthScheme)` if a recognizable prefix is found, `None` otherwise.
1424+
fn extract_auth_scheme_from_body(body: &crate::ir::Body, target: VarId) -> Option<AuthScheme> {
1425+
for (_, block) in body.iter_blocks_enumerated() {
1426+
for inst in &block.insts {
1427+
let (assigned_var, rvalue) = match inst {
1428+
Inst::Assign(var, rval) => (var, rval),
1429+
Inst::Expr(_) => continue,
1430+
};
1431+
1432+
let is_direct_target = assigned_var.base == Base::Var(target)
1433+
&& assigned_var.projections.is_empty();
1434+
1435+
// Only match Authorization projections on the specific headers
1436+
// VarId we're inspecting — not on any VarId in the body — to
1437+
// avoid cross-contaminating auth headers from different call sites
1438+
// within the same function body.
1439+
let is_auth_projection = assigned_var.base == Base::Var(target)
1440+
&& assigned_var.projections.iter().any(|p| {
1441+
matches!(p, Projection::Known(name) if name.eq_ignore_ascii_case("authorization"))
1442+
});
1443+
1444+
if !is_direct_target && !is_auth_projection {
1445+
continue;
1446+
}
1447+
1448+
if let Some(scheme) = classify_rvalue_auth_scheme(rvalue, body) {
1449+
return Some(scheme);
1450+
}
1451+
}
1452+
}
1453+
None
1454+
}
1455+
1456+
/// Inspects an Rvalue for auth scheme prefixes in concatenations and templates.
1457+
/// Also follows `Rvalue::Read(Var(v))` chains (up to a bounded depth) to handle
1458+
/// cases where the Authorization property reads from a separate variable that
1459+
/// holds the concat result, possibly through intermediate copies.
1460+
fn classify_rvalue_auth_scheme(rvalue: &Rvalue, body: &crate::ir::Body) -> Option<AuthScheme> {
1461+
match rvalue {
1462+
// "Basic " + token or token + "Basic ..."
1463+
Rvalue::Bin(BinOp::Add, op1, op2) => {
1464+
operand_auth_scheme(op1).or_else(|| operand_auth_scheme(op2))
1465+
}
1466+
// `Basic ${token}` — check the first quasi
1467+
Rvalue::Template(template) => template
1468+
.quasis
1469+
.first()
1470+
.and_then(|q| classify_auth_literal(q)),
1471+
// Authorization: basicAuthHeader — follow read chain to find the defining instruction
1472+
Rvalue::Read(Operand::Var(Variable {
1473+
base: Base::Var(source_var),
1474+
projections,
1475+
})) if projections.is_empty() => follow_var_to_auth_scheme(body, *source_var, 4),
1476+
_ => None,
1477+
}
1478+
}
1479+
1480+
/// Follows a VarId through `Read(Var)` assignments up to `depth` levels to find
1481+
/// a `BinOp(Add, ...)` or `Template(...)` that reveals the auth scheme prefix.
1482+
fn follow_var_to_auth_scheme(
1483+
body: &crate::ir::Body,
1484+
target: VarId,
1485+
depth: u8,
1486+
) -> Option<AuthScheme> {
1487+
if depth == 0 {
1488+
return None;
1489+
}
1490+
for (_, blk) in body.iter_blocks_enumerated() {
1491+
for inst in &blk.insts {
1492+
if let Inst::Assign(var, rval) = inst
1493+
&& var.base == Base::Var(target)
1494+
&& var.projections.is_empty()
1495+
{
1496+
match rval {
1497+
Rvalue::Bin(BinOp::Add, op1, op2) => {
1498+
return operand_auth_scheme(op1)
1499+
.or_else(|| operand_auth_scheme(op2));
1500+
}
1501+
Rvalue::Template(template) => {
1502+
return template
1503+
.quasis
1504+
.first()
1505+
.and_then(|q| classify_auth_literal(q));
1506+
}
1507+
Rvalue::Read(Operand::Var(Variable {
1508+
base: Base::Var(next_var),
1509+
projections,
1510+
})) if projections.is_empty() => {
1511+
return follow_var_to_auth_scheme(body, *next_var, depth - 1);
1512+
}
1513+
_ => {}
1514+
}
1515+
}
1516+
}
1517+
}
1518+
None
1519+
}
1520+
1521+
/// Checks whether an operand is a literal string with an auth scheme prefix.
1522+
fn operand_auth_scheme(op: &Operand) -> Option<AuthScheme> {
1523+
match op {
1524+
Operand::Lit(Literal::Str(s)) => classify_auth_literal(s),
1525+
_ => None,
1526+
}
1527+
}
1528+
1529+
/// Tries to extract the URL string from a VarId by walking the IR when the
1530+
/// value lattice resolves to `Unknown` (e.g. template literals with unknown
1531+
/// substitutions where the static quasis still contain the host). Follows
1532+
/// `Read(Var)` chains up to a bounded depth to handle intermediate copies.
1533+
fn extract_url_prefix_from_body(body: &crate::ir::Body, target: VarId) -> Option<String> {
1534+
extract_url_prefix_from_var(body, target, 4)
1535+
}
1536+
1537+
fn extract_url_prefix_from_var(body: &crate::ir::Body, target: VarId, depth: u8) -> Option<String> {
1538+
if depth == 0 {
1539+
return None;
1540+
}
1541+
for (_, block) in body.iter_blocks_enumerated() {
1542+
for inst in &block.insts {
1543+
let (assigned_var, rvalue) = match inst {
1544+
Inst::Assign(var, rval) => (var, rval),
1545+
Inst::Expr(_) => continue,
1546+
};
1547+
if assigned_var.base != Base::Var(target) || !assigned_var.projections.is_empty() {
1548+
continue;
1549+
}
1550+
match rvalue {
1551+
Rvalue::Bin(BinOp::Add, Operand::Lit(Literal::Str(s)), _) => {
1552+
return Some(s.to_string());
1553+
}
1554+
Rvalue::Template(template) => {
1555+
let joined: String = template.quasis.iter().map(|q| q.as_ref()).collect();
1556+
if !joined.is_empty() {
1557+
return Some(joined);
1558+
}
1559+
}
1560+
Rvalue::Read(Operand::Var(Variable {
1561+
base: Base::Var(source_var),
1562+
projections,
1563+
})) if projections.is_empty() => {
1564+
return extract_url_prefix_from_var(body, *source_var, depth - 1);
1565+
}
1566+
_ => {}
1567+
}
1568+
}
1569+
}
1570+
None
1571+
}
1572+
13941573
impl<'cx> Runner<'cx> for AuthHeaderChecker {
13951574
type State = SecretState;
13961575
type Dataflow = AuthHeaderDataflow;
@@ -1424,25 +1603,22 @@ impl<'cx> Runner<'cx> for AuthHeaderChecker {
14241603
// For fetch and all other request* shims, options is at index 1.
14251604
let opts_index = if is_request_graph { 2 } else { 1 };
14261605

1427-
// Resolve URL from operand 0 (only meaningful for Fetch)
1428-
let url_str: Option<String> = if is_fetch {
1429-
match ops.first() {
1430-
Some(Operand::Var(Variable {
1431-
base: Base::Var(varid),
1432-
..
1433-
})) => match interp.get_value(def, *varid, None) {
1434-
Some(Value::Const(Const::Literal(s))) => Some(s.clone()),
1435-
Some(Value::PartialConst(Const::Literal(s))) => Some(s.clone()),
1436-
Some(Value::Phi(phi)) => {
1437-
phi.iter().map(|Const::Literal(s)| s.clone()).next()
1438-
}
1439-
_ => None,
1440-
},
1441-
Some(Operand::Lit(lit)) => convert_lit_to_raw(lit),
1442-
_ => None,
1443-
}
1444-
} else {
1445-
None
1606+
// Resolve URL from operand 0.
1607+
// Try the value lattice first; fall back to IR inspection for
1608+
// template literals / concatenations with unknown parts.
1609+
let url_str: Option<String> = match ops.first() {
1610+
Some(Operand::Var(Variable {
1611+
base: Base::Var(varid),
1612+
..
1613+
})) => match interp.get_value(def, *varid, None) {
1614+
Some(Value::Const(Const::Literal(s))) => Some(s.clone()),
1615+
Some(Value::Phi(phi)) => {
1616+
phi.iter().map(|Const::Literal(s)| s.clone()).next()
1617+
}
1618+
_ => extract_url_prefix_from_body(interp.body(), *varid),
1619+
},
1620+
Some(Operand::Lit(lit)) => convert_lit_to_raw(lit),
1621+
_ => None,
14461622
};
14471623

14481624
if let Some(Operand::Var(Variable {
@@ -1466,57 +1642,60 @@ impl<'cx> Runner<'cx> for AuthHeaderChecker {
14661642
.get_value(def, *varid, Some(auth_proj.clone()))
14671643
.or_else(|| interp.get_value(def, *varid, Some(aut_proj_lower.clone())));
14681644

1469-
let auth_str = match auth_val {
1470-
Some(Value::Const(Const::Literal(s))) => Some(s.as_str()),
1471-
Some(Value::PartialConst(Const::Literal(s))) => Some(s.as_str()),
1472-
Some(Value::Phi(phi)) => {
1473-
phi.iter().map(|Const::Literal(s)| s.as_str()).next()
1645+
// Try to determine the auth scheme from the resolved value.
1646+
// If the value is fully known, classify directly. If unknown
1647+
// (e.g. "Basic " + variable), walk the IR to inspect the
1648+
// operands of the concatenation/template that produced it.
1649+
let auth_scheme: Option<AuthScheme> = match auth_val {
1650+
Some(Value::Const(Const::Literal(s))) => classify_auth_literal(s),
1651+
Some(Value::Phi(phi)) => phi
1652+
.iter()
1653+
.find_map(|Const::Literal(s)| classify_auth_literal(s)),
1654+
Some(Value::Unknown) | None => {
1655+
// Value collapsed to Unknown — inspect the IR directly.
1656+
// The auth header VarId is `*varid` from the headers object.
1657+
extract_auth_scheme_from_body(interp.body(), *varid)
14741658
}
14751659
_ => None,
14761660
};
14771661

1478-
if let Some(auth) = auth_str {
1479-
if is_basic_auth_concat_prefix(auth) {
1480-
// Platform API shims (requestJira/Confluence/Bitbucket/Graph,
1481-
// forgeFetch) always target Atlassian APIs, so the URL check
1482-
// is skipped for them. For bare fetch / api.fetch, classify
1483-
// the URL using is_atlassian_url, which handles full URLs,
1484-
// templated/empty-substituted relative paths, and known
1485-
// Atlassian product REST path patterns.
1486-
let should_flag =
1487-
is_platform_api || url_str.as_deref().is_some_and(is_atlassian_url);
1488-
if should_flag {
1489-
self.vulns.push(AuthHeaderVuln::new(
1490-
AuthHeaderVulnKind::BasicAuth,
1491-
interp.callstack(),
1492-
interp.env(),
1493-
interp.entry(),
1494-
));
1662+
if let Some(scheme) = auth_scheme {
1663+
match scheme {
1664+
AuthScheme::Basic => {
1665+
// Platform API shims (requestJira, requestConfluence,
1666+
// requestBitbucket, requestGraph) always target
1667+
// Atlassian APIs — their route operand is opaque
1668+
// (tagged template) so URL resolution won't help.
1669+
// For fetch / api.fetch / node-fetch the full URL
1670+
// must target an Atlassian endpoint.
1671+
let should_flag = is_platform_api
1672+
|| url_str.as_deref().is_some_and(is_atlassian_url);
1673+
if should_flag {
1674+
self.vulns.push(AuthHeaderVuln::new(
1675+
AuthHeaderVulnKind::BasicAuth,
1676+
interp.callstack(),
1677+
interp.env(),
1678+
interp.entry(),
1679+
));
1680+
}
14951681
}
1496-
} else if is_fetch && is_bearer_prefix(auth) {
1497-
// BearerAdmin is only checked for fetch / api.fetch,
1498-
// not for platform API shims.
1499-
//
1500-
// Flag a Bearer-token call when EITHER:
1501-
// (a) the URL points at api.atlassian.com AND mentions
1502-
// "admin" (legacy heuristic), OR
1503-
// (b) the URL/path matches a known admin endpoint
1504-
// pattern (`/admin/v[12]/orgs/...`,
1505-
// `/users/<id>/manage/...`) — these are the
1506-
// admin-scoped routes that admin Bearer tokens
1507-
// actually leak through, regardless of host.
1508-
let should_flag = url_str.as_deref().is_some_and(|s| {
1509-
(s.contains("api.atlassian.com") && s.contains("admin"))
1510-
|| is_admin_path(s)
1511-
});
1512-
if should_flag {
1513-
self.vulns.push(AuthHeaderVuln::new(
1514-
AuthHeaderVulnKind::BearerAdmin,
1515-
interp.callstack(),
1516-
interp.env(),
1517-
interp.entry(),
1518-
));
1682+
AuthScheme::Bearer if is_fetch => {
1683+
// BearerAdmin is only checked for fetch, not
1684+
// platform API shims.
1685+
let should_flag = url_str.as_deref().is_some_and(|s| {
1686+
(s.contains("api.atlassian.com") && s.contains("admin"))
1687+
|| is_admin_path(s)
1688+
});
1689+
if should_flag {
1690+
self.vulns.push(AuthHeaderVuln::new(
1691+
AuthHeaderVulnKind::BearerAdmin,
1692+
interp.callstack(),
1693+
interp.env(),
1694+
interp.entry(),
1695+
));
1696+
}
15191697
}
1698+
_ => {}
15201699
}
15211700
}
15221701
}

0 commit comments

Comments
 (0)