@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
22import { builtinModules } from 'node:module' ;
33import { createHash } from 'node:crypto' ;
44import { join , relative , isAbsolute , dirname , resolve as resolvePath } from 'node:path' ;
5- import type { SiteInputMap , Endpoint , InputField , InputSource , Sink , Flow , ArgumentRole , CandidateFamily , TsModule } from './types.js' ;
5+ import type { SiteInputMap , Endpoint , InputField , InputSource , Sink , Flow , Limitation , ArgumentRole , CandidateFamily , TsModule } from './types.js' ;
66
77// Framework-AGNOSTIC input-flow extractor. It doesn't gate on a specific stack — it walks any JS/TS
88// source and applies recognizer tables for (1) entry points, (2) inputs, (3) sinks, so it generalizes
@@ -218,11 +218,12 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
218218 const stats : WalkStats = { discovered : 0 } ;
219219 const files = collectSources ( cwd , boundary , { followOutside : options . followSymlinks } , [ ] , new Set ( ) , stats ) ;
220220 let parsed = 0 ;
221+ let preFiltered = 0 ;
221222
222223 for ( const file of files ) {
223224 try {
224225 const text = readFileSync ( file , 'utf8' ) ;
225- if ( ! hasEntrySignal ( text ) ) continue ;
226+ if ( ! hasEntrySignal ( text ) ) { preFiltered ++ ; continue ; }
226227 parsed ++ ;
227228 // Coordinates are only valid for the exact file content they were derived from.
228229 const fingerprint = createHash ( 'sha256' ) . update ( text ) . digest ( 'hex' ) . slice ( 0 , 16 ) ;
@@ -282,6 +283,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
282283 adapter : 'agnostic-v1' ,
283284 filesDiscovered : stats . discovered ,
284285 filesParsed : parsed ,
286+ filesPreFiltered : preFiltered ,
285287 filesSkipped : failed . length ,
286288 roots : [ '.' ] ,
287289 notes,
@@ -514,7 +516,7 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map<string, Sink[]>,
514516 ...spanOf ( decl ) ,
515517 inputs,
516518 sinks,
517- flows : linkFlows ( handlerBody , handlerFn ?. parameters , inputs , sinks , ts ) ,
519+ ... linkedFlows ( handlerBody , handlerFn ?. parameters , inputs , sinks , ts ) ,
518520 } ;
519521 // Honesty marker: a validator EXISTS but couldn't be read — inputs are unknown, not "none".
520522 if ( validatorCall && inputs . length === 0 ) ep . inputsResolved = false ;
@@ -675,7 +677,7 @@ function handlerEntry(
675677 end : extra . end ,
676678 inputs,
677679 sinks,
678- flows : linkFlows ( body , params , inputs , sinks , ts ) ,
680+ ... linkedFlows ( body , params , inputs , sinks , ts ) ,
679681 } ;
680682}
681683
@@ -1105,18 +1107,36 @@ function directSinks(node: any, ts: TsModule, bindings: Bindings): Sink[] {
11051107 }
11061108 }
11071109 }
1108- // bare calls: fetch( / exec( / readFile( / eval( — unless the name is a plain local function.
1110+ // Bare calls: `fetch(…)` / `exec(…)` / `readFile(…)` / `eval(…)`. A dangerous NAME is not a
1111+ // dangerous API: `import { fetch } from './util'` and a callback parameter named `fetch` both look
1112+ // identical here, and treating either as an HTTP request produced a FALSE SSRF candidate. So the
1113+ // call must be justified — either it resolves to a module that plausibly provides that API, or it
1114+ // is a genuine unresolved global (only `fetch`/`eval`/`Function` ever are).
11091115 if ( ts . isIdentifier ( callee ) && ! bindings . locals . has ( callee . text ) ) {
11101116 const name = callee . text ;
1111- const pkg = npmPackageOf ( bindings . resolve ( name ) ) ;
1112- // `fetch` is a global — never attribute it to an unrelated imported http client.
1113- if ( HTTP_CALLS . test ( name ) ) push ( { kind : 'http' , provider : name , package : pkg ?? ( name === 'fetch' ? undefined : infer ( 'http' ) ) , op : 'request' , ...spanOf ( n ) } ) ;
1114- if ( FS_CALLS . test ( name ) ) push ( { kind : 'fs' , package : pkg , op : name , ...spanOf ( n ) } ) ;
1115- if ( EXEC_CALLS . test ( name ) ) push ( { kind : 'exec' , package : pkg , op : name , ...spanOf ( n ) } ) ;
1116- if ( name === 'eval' ) push ( { kind : 'eval' , op : 'eval' , ...spanOf ( n ) } ) ;
1117+ const spec = bindings . resolve ( name ) ;
1118+ const pkg = npmPackageOf ( spec ) ;
1119+ const shadowed = isShadowedByEnclosingBinding ( n , name , ts ) ;
1120+ // A relative import resolves to no package: it's app code, not the API it shares a name with.
1121+ const fromModule = spec !== undefined ;
1122+ const trueGlobal = ! fromModule && ! shadowed ;
1123+
1124+ if ( HTTP_CALLS . test ( name ) ) {
1125+ if ( pkg && isHttpPackage ( pkg ) ) push ( { kind : 'http' , provider : name , package : pkg , op : 'request' , ...spanOf ( n ) } ) ;
1126+ else if ( name === 'fetch' && trueGlobal ) push ( { kind : 'http' , provider : 'fetch' , op : 'request' , ...spanOf ( n ) } ) ;
1127+ }
1128+ // `readFile`/`exec` are never globals: without a matching module binding this is app code.
1129+ if ( FS_CALLS . test ( name ) && pkg && / ^ n o d e : f s ( \/ p r o m i s e s ) ? $ / . test ( pkg ) ) {
1130+ push ( { kind : 'fs' , package : pkg , op : name , ...spanOf ( n ) } ) ;
1131+ }
1132+ if ( EXEC_CALLS . test ( name ) && pkg === 'node:child_process' ) {
1133+ push ( { kind : 'exec' , package : pkg , op : name , ...spanOf ( n ) } ) ;
1134+ }
1135+ if ( name === 'eval' && trueGlobal ) push ( { kind : 'eval' , op : 'eval' , ...spanOf ( n ) } ) ;
11171136 }
11181137 }
1119- if ( ts . isNewExpression ( n ) && ts . isIdentifier ( n . expression ) && n . expression . text === 'Function' ) {
1138+ if ( ts . isNewExpression ( n ) && ts . isIdentifier ( n . expression ) && n . expression . text === 'Function'
1139+ && ! bindings . locals . has ( 'Function' ) && ! isShadowedByEnclosingBinding ( n , 'Function' , ts ) ) {
11201140 push ( { kind : 'eval' , op : 'new Function' , ...spanOf ( n ) } ) ;
11211141 }
11221142 ts . forEachChild ( n , visit ) ;
@@ -1145,14 +1165,20 @@ function isUninvokedFunctionDeclaration(n: any, ts: TsModule): boolean {
11451165// Deliberately conservative: a match yields `precise`; no match yields `heuristic` (the input and sink
11461166// merely co-occur). It never claims a flow it didn't see, which is the point — a consumer pinning a
11471167// rule to a parameter should trust `precise` and treat `heuristic` as "may reach".
1168+ // Spread onto an endpoint: `flows`, plus `limitations` only when there are any (keeps the common case clean).
1169+ function linkedFlows ( body : any , params : any , inputs : InputField [ ] , sinks : Sink [ ] , ts : TsModule ) : { flows : Flow [ ] ; limitations ?: Limitation [ ] } {
1170+ const { flows, limitations } = linkFlows ( body , params , inputs , sinks , ts ) ;
1171+ return limitations . length > 0 ? { flows, limitations } : { flows } ;
1172+ }
1173+
11481174function linkFlows (
11491175 bodyNode : any ,
11501176 params : any ,
11511177 inputs : InputField [ ] ,
11521178 sinks : Sink [ ] ,
11531179 ts : TsModule ,
1154- ) : Flow [ ] {
1155- if ( ! bodyNode || sinks . length === 0 || inputs . length === 0 ) return [ ] ;
1180+ ) : { flows : Flow [ ] ; limitations : Limitation [ ] } {
1181+ if ( ! bodyNode || sinks . length === 0 || inputs . length === 0 ) return { flows : [ ] , limitations : [ ] } ;
11561182
11571183 // Tainted roots and the PATH each one stands for. `req` → '' (its own members are the path);
11581184 // `const { billing } = await req.json()` → billing stands for 'billing', so a read of
@@ -1224,14 +1250,17 @@ function linkFlows(
12241250 // is ambiguous — the end distinguishes them.
12251251 const callBySpan = new Map < string , any > ( ) ;
12261252 const callVisit = ( n : any ) => {
1227- if ( ts . isCallExpression ( n ) ) {
1253+ // NewExpression too, or `new Function(...)` — inventoried as an eval sink — could never be located,
1254+ // leaving its flows permanently heuristic and its argument-role entry unreachable.
1255+ if ( ts . isCallExpression ( n ) || ts . isNewExpression ( n ) ) {
12281256 try { callBySpan . set ( `${ n . getStart ( ) } :${ n . getEnd ( ) } ` , n ) ; } catch { /* synthetic */ }
12291257 }
12301258 ts . forEachChild ( n , callVisit ) ;
12311259 } ;
12321260 callVisit ( bodyNode ) ;
12331261
12341262 const flows : Flow [ ] = [ ] ;
1263+ const allLimits : Limitation [ ] = [ ] ;
12351264 for ( const sink of sinks ) {
12361265 // A sink from an imported module has no call site here — never claim precise for it.
12371266 const node = sink . file === undefined && sink . start !== undefined && sink . end !== undefined
@@ -1240,15 +1269,17 @@ function linkFlows(
12401269 // path → the argument ROLES it was read into. Per-argument attribution is what makes a candidate
12411270 // possible: the same value in `url` vs `body`, or `path` vs `content`, implies different mitigations.
12421271 const reads = new Map < string , Set < ArgumentRole > > ( ) ;
1272+ const sinkLimits : Limitation [ ] = [ ] ;
12431273 if ( node ) {
12441274 // ONLY this sink call's own arguments, plus other calls in the SAME fluent chain
12451275 // (`.update({…}).eq('id', data.id)` is one operation). Never the enclosing statement: a sibling
12461276 // expression such as `Promise.all([audit(data.title), db.insert({…})])` must not lend evidence.
12471277 for ( const call of fluentChainCalls ( node , ts ) ) {
12481278 const method = calleeName ( call , ts ) ;
12491279 const args = call . arguments ?? [ ] ;
1280+ for ( const a of args ) for ( const l of sinkArgumentLimitations ( a , ts , rootPath ) ) sinkLimits . push ( l ) ;
12501281 for ( let i = 0 ; i < args . length ; i ++ ) {
1251- const role = argumentRoleOf ( sink . kind , method , i ) ;
1282+ const role = argumentRoleOf ( sink . kind , method , i , args . length ) ;
12521283 for ( const path of taintedReadPaths ( args [ i ] , ts , rootPath ) ) {
12531284 const set = reads . get ( path ) ?? new Set < ArgumentRole > ( ) ;
12541285 set . add ( role ) ;
@@ -1279,6 +1310,13 @@ function linkFlows(
12791310 if ( sink . file !== undefined ) reasons . push ( 'sink is in an imported module: no local call-site evidence' ) ;
12801311 if ( sink . start === undefined ) reasons . push ( 'sink call could not be located in the source' ) ;
12811312 if ( precise && argumentRole === 'unknown' ) reasons . push ( `sink argument role is not modelled for ${ sink . kind } .${ sink . op ?? '?' } ` ) ;
1313+ // A dynamic key or a spread in this sink's arguments means no coordinate can name the field that
1314+ // actually reaches it — report the specific cause rather than a generic "heuristic".
1315+ for ( const l of sinkLimits ) {
1316+ reasons . push ( l . kind === 'dynamic-key'
1317+ ? `dynamic computed key reaches this sink (${ l . detail } ): the field cannot be named by a parameter`
1318+ : `spread reaches this sink (${ l . detail } ): the specific field is not identifiable` ) ;
1319+ }
12821320 if ( precise && argumentRole && argumentRole !== 'unknown' && ! family ) {
12831321 // e.g. a request value in a parameterized db `values` object: real reachability, but not a
12841322 // pattern a generic blocking rule can express.
@@ -1295,8 +1333,19 @@ function linkFlows(
12951333 ruleGeneratableReasons : reasons ,
12961334 } ) ;
12971335 }
1336+ for ( const l of sinkLimits ) allLimits . push ( l ) ;
12981337 }
1299- return flows ;
1338+ return { flows, limitations : dedupeLimitations ( allLimits ) } ;
1339+ }
1340+
1341+ function dedupeLimitations ( list : Limitation [ ] ) : Limitation [ ] {
1342+ const seen = new Set < string > ( ) ;
1343+ return list . filter ( ( l ) => {
1344+ const k = `${ l . kind } :${ l . detail } :${ l . line } ` ;
1345+ if ( seen . has ( k ) ) return false ;
1346+ seen . add ( k ) ;
1347+ return true ;
1348+ } ) ;
13001349}
13011350
13021351/** Join two path segments, tolerating an empty base. */
@@ -1351,17 +1400,45 @@ const CANDIDATE_FAMILIES: Record<string, Partial<Record<ArgumentRole, CandidateF
13511400 eval : { code : 'code-injection' } ,
13521401} ;
13531402
1403+ /**
1404+ * Is `name` bound by an enclosing function parameter (or catch clause) at this call site? If so the call
1405+ * is NOT the global of that name — a callback parameter called `fetch` is the single most likely way to
1406+ * fake an SSRF candidate. Scoped to parameters/catch bindings: cheap, and it covers the shadowing shapes
1407+ * that occur in practice. Erring here loses a candidate rather than inventing one.
1408+ */
1409+ function isShadowedByEnclosingBinding ( node : any , name : string , ts : TsModule ) : boolean {
1410+ for ( let cur = node ?. parent ; cur ; cur = cur . parent ) {
1411+ if ( ts . isCatchClause ( cur ) && cur . variableDeclaration && ts . isIdentifier ( cur . variableDeclaration . name )
1412+ && cur . variableDeclaration . name . text === name ) return true ;
1413+ const params = ( cur as any ) . parameters ;
1414+ if ( ! params ) continue ;
1415+ for ( const p of params ) {
1416+ if ( ! p ?. name ) continue ;
1417+ if ( ts . isIdentifier ( p . name ) && p . name . text === name ) return true ;
1418+ if ( ts . isObjectBindingPattern ( p . name ) || ts . isArrayBindingPattern ( p . name ) ) {
1419+ for ( const el of p . name . elements ) {
1420+ if ( ts . isBindingElement ( el ) && ts . isIdentifier ( el . name ) && el . name . text === name ) return true ;
1421+ }
1422+ }
1423+ }
1424+ }
1425+ return false ;
1426+ }
1427+
13541428/** Method name a call invokes (`db.from(t).insert(x)` → "insert", `exec(x)` → "exec"). */
13551429function calleeName ( call : any , ts : TsModule ) : string | undefined {
13561430 const c = call ?. expression ;
13571431 if ( ! c ) return undefined ;
13581432 if ( ts . isPropertyAccessExpression ( c ) ) return c . name . text ;
1359- if ( ts . isIdentifier ( c ) ) return c . text ;
1433+ if ( ts . isIdentifier ( c ) ) return c . text ; // also covers `new Function(...)`
13601434 return undefined ;
13611435}
13621436
13631437/** Role of argument `index` for this call, given the sink kind it was recognized as. */
1364- function argumentRoleOf ( sinkKind : string , method : string | undefined , index : number ) : ArgumentRole {
1438+ function argumentRoleOf ( sinkKind : string , method : string | undefined , index : number , total = 0 ) : ArgumentRole {
1439+ // `new Function(a, b, "return a+b")` — every argument but the LAST declares a parameter name; only the
1440+ // last one is executable code. An index-based table cannot express that.
1441+ if ( sinkKind === 'eval' && method === 'Function' ) return index === total - 1 ? 'code' : 'args' ;
13651442 const table = method ? ARGUMENT_ROLES [ sinkKind ] ?. [ method ] : undefined ;
13661443 return table ?. [ index ] ?? 'unknown' ;
13671444}
@@ -1396,7 +1473,7 @@ function fluentChainCalls(call: any, ts: TsModule): any[] {
13961473 const out : any [ ] = [ ] ;
13971474 const collect = ( n : any ) => {
13981475 if ( ! n ) return ;
1399- if ( ts . isCallExpression ( n ) ) out . push ( n ) ;
1476+ if ( ts . isCallExpression ( n ) || ts . isNewExpression ( n ) ) out . push ( n ) ;
14001477 if ( ts . isCallExpression ( n ) || ts . isPropertyAccessExpression ( n ) || ts . isAwaitExpression ( n ) || ts . isParenthesizedExpression ( n ) || ts . isNonNullExpression ( n ) ) {
14011478 collect ( n . expression ) ;
14021479 }
@@ -1429,6 +1506,46 @@ function taintedReadPaths(node: any, ts: TsModule, rootPath: Map<string, string>
14291506 return out ;
14301507}
14311508
1509+ /**
1510+ * Shapes that defeat parameter pinning, found in a sink call's arguments. Reporting these is the point:
1511+ * "we could not model this" is far more useful to an operator than an endpoint that silently shows no
1512+ * flow, and it is the queue for improving the extractor.
1513+ * - `insert({ v: body[field] })` → the field is chosen at runtime; no coordinate can name it.
1514+ * - `insert({ ...body })` → the whole payload reaches the sink; which field is unidentifiable.
1515+ */
1516+ function sinkArgumentLimitations ( node : any , ts : TsModule , rootPath : Map < string , string > ) : Limitation [ ] {
1517+ const out : Limitation [ ] = [ ] ;
1518+ const seen = new Set < string > ( ) ;
1519+ const add = ( kind : Limitation [ 'kind' ] , detail : string , n : any ) => {
1520+ const key = `${ kind } :${ detail } ` ;
1521+ if ( seen . has ( key ) ) return ;
1522+ seen . add ( key ) ;
1523+ out . push ( { kind, detail, line : lineOf ( n ) } ) ;
1524+ } ;
1525+ const text = ( n : any ) => {
1526+ try { return String ( n . getText ( ) ) . replace ( / \s + / g, ' ' ) . slice ( 0 , 120 ) ; } catch { return '<expression>' ; }
1527+ } ;
1528+ const visit = ( n : any ) => {
1529+ if ( ! n ) return ;
1530+ // A computed member read off tainted data with a non-literal index.
1531+ if ( ts . isElementAccessExpression ( n ) ) {
1532+ const root = rootIdentifier ( n . expression , ts ) ;
1533+ const arg = n . argumentExpression ;
1534+ if ( root && rootPath . has ( root ) && arg && ! ts . isStringLiteralLike ( arg ) && ! ts . isNumericLiteral ( arg ) ) {
1535+ add ( 'dynamic-key' , text ( n ) , n ) ;
1536+ }
1537+ }
1538+ // A spread of tainted data into the sink's argument.
1539+ if ( ( ts . isSpreadAssignment ?.( n ) || ts . isSpreadElement ( n ) ) && n . expression ) {
1540+ const root = rootIdentifier ( n . expression , ts ) ;
1541+ if ( root && rootPath . has ( root ) ) add ( 'spread-into-sink' , text ( n . parent ?? n ) , n ) ;
1542+ }
1543+ ts . forEachChild ( n , visit ) ;
1544+ } ;
1545+ visit ( node ) ;
1546+ return out ;
1547+ }
1548+
14321549/** Canonical path of a member/element access rooted in a tainted binding, or undefined if not tainted. */
14331550function pathFromTainted ( node : any , ts : TsModule , rootPath : Map < string , string > ) : string | undefined {
14341551 const segs : string [ ] = [ ] ;
@@ -1489,12 +1606,21 @@ function localCalls(node: any, ts: TsModule): string[] {
14891606 return names ;
14901607}
14911608
1609+ // Deterministic identity for a sink, so `Flow.sink` (an embedded copy) can be correlated back to the
1610+ // inventory entry without deep-equality.
1611+ function sinkId ( s : Sink ) : string {
1612+ return createHash ( 'sha256' )
1613+ . update ( [ s . kind , s . provider , s . package , s . table , s . op , s . file , s . start , s . end ] . join ( '|' ) )
1614+ . digest ( 'hex' )
1615+ . slice ( 0 , 12 ) ;
1616+ }
1617+
14921618function dedupeSinks ( sinks : Sink [ ] ) : Sink [ ] {
14931619 const seen = new Set < string > ( ) ;
14941620 const out : Sink [ ] = [ ] ;
14951621 for ( const s of sinks ) {
1496- const key = `${ s . kind } :${ s . provider } :${ s . package } :${ s . table } :${ s . op } :${ s . line } ` ;
1497- if ( ! seen . has ( key ) ) { seen . add ( key ) ; out . push ( s ) ; }
1622+ const key = `${ s . kind } :${ s . provider } :${ s . package } :${ s . table } :${ s . op } :${ s . line } : ${ s . start } ` ;
1623+ if ( ! seen . has ( key ) ) { seen . add ( key ) ; out . push ( { ... s , id : sinkId ( s ) } ) ; }
14981624 }
14991625 return out ;
15001626}
0 commit comments