@@ -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} ;
2017use 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+
13941573impl < ' 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