@@ -48,12 +48,15 @@ const BUILTINS = new Set(builtinModules);
4848// as local — an accepted miss.)
4949interface Bindings {
5050 resolve ( name : string ) : string | undefined ;
51+ /** For `import { saveOrder as write }`, maps the local name back to the EXPORTED name. */
52+ exportNameOf ( name : string ) : string | undefined ;
5153 imports : Set < string > ;
5254 locals : Set < string > ;
5355}
5456function buildModuleBindings ( sf : any , ts : TsModule ) : Bindings {
5557 const nameToModule = new Map < string , string > ( ) ; // local name → module specifier
5658 const declared = new Set < string > ( ) ; // every name declared in this file
59+ const exportNames = new Map < string , string > ( ) ; // local alias → exported name
5760 const imports = new Set < string > ( ) ;
5861
5962 const record = ( local : string , mod : string ) => { nameToModule . set ( local , mod ) ; imports . add ( mod ) ; } ;
@@ -74,7 +77,11 @@ function buildModuleBindings(sf: any, ts: TsModule): Bindings {
7477 const nb = clause ?. namedBindings ;
7578 if ( nb ) {
7679 if ( ts . isNamespaceImport ( nb ) ) record ( nb . name . text , mod ) ;
77- else if ( ts . isNamedImports ( nb ) ) for ( const el of nb . elements ) record ( el . name . text , mod ) ;
80+ else if ( ts . isNamedImports ( nb ) ) for ( const el of nb . elements ) {
81+ record ( el . name . text , mod ) ;
82+ // `import { saveOrder as write }` — looking up `write` in the target module would miss.
83+ if ( el . propertyName && ts . isIdentifier ( el . propertyName ) ) exportNames . set ( el . name . text , el . propertyName . text ) ;
84+ }
7885 }
7986 }
8087 if ( ts . isFunctionDeclaration ( node ) && node . name ) declared . add ( node . name . text ) ;
@@ -129,7 +136,7 @@ function buildModuleBindings(sf: any, ts: TsModule): Bindings {
129136 if ( ! changed ) break ;
130137 }
131138 const locals = new Set ( [ ...declared ] . filter ( ( n ) => ! nameToModule . has ( n ) ) ) ;
132- return { resolve : ( name : string ) => nameToModule . get ( name ) , imports, locals } ;
139+ return { resolve : ( name : string ) => nameToModule . get ( name ) , exportNameOf : ( name : string ) => exportNames . get ( name ) , imports, locals } ;
133140}
134141
135142// Root identifier of what a function body returns (`return createClient(…)` → "createClient"), for
@@ -198,7 +205,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
198205 let boundary = cwd ;
199206 try { boundary = realpathSync ( cwd ) ; } catch { /* use cwd as-is */ }
200207
201- const graph = createModuleGraph ( ts ) ; // shared cache across files
208+ const graph = createModuleGraph ( ts , { cwd , boundary , followOutside : options . followSymlinks } ) ; // shared cache
202209 const stats : WalkStats = { discovered : 0 } ;
203210 const files = collectSources ( cwd , boundary , { followOutside : options . followSymlinks } , [ ] , new Set ( ) , stats ) ;
204211 let parsed = 0 ;
@@ -813,7 +820,7 @@ export interface ModuleGraph {
813820 importedSinks ( fromFile : string , specifier : string , exportName : string ) : Sink [ ] ;
814821}
815822
816- function createModuleGraph ( ts : TsModule ) : ModuleGraph {
823+ function createModuleGraph ( ts : TsModule , opts : { cwd : string ; boundary : string ; followOutside ?: boolean } ) : ModuleGraph {
817824 // file → { fnSinks, calleesOf } | null (unreadable/unparseable)
818825 const cache = new Map < string , { fnSinks : Map < string , Sink [ ] > ; calleesOf : Map < string , string [ ] > } | null > ( ) ;
819826
@@ -836,14 +843,24 @@ function createModuleGraph(ts: TsModule): ModuleGraph {
836843 importedSinks ( fromFile , specifier , exportName ) {
837844 const target = resolveRelativeModule ( fromFile , specifier ) ;
838845 if ( ! target ) return [ ] ;
846+ // Stay inside the project: `../../other-repo/db` (or a symlink) would otherwise pull an unrelated
847+ // codebase into this app's attack surface. The primary walker enforces this; so must the resolver.
848+ if ( ! opts . followOutside ) {
849+ let real = target ;
850+ try { real = realpathSync ( target ) ; } catch { /* use as-is */ }
851+ if ( ! isInside ( real , opts . boundary ) ) return [ ] ;
852+ }
839853 const mod = load ( target ) ;
840854 if ( ! mod ) return [ ] ;
841- const out = [ ...( mod . fnSinks . get ( exportName ) ?? [ ] ) ] ;
855+ const collected = [ ...( mod . fnSinks . get ( exportName ) ?? [ ] ) ] ;
842856 // One same-file hop inside the target: `export function saveOrder(){ return doInsert() }`.
843857 for ( const callee of mod . calleesOf . get ( exportName ) ?? [ ] ) {
844- for ( const s of mod . fnSinks . get ( callee ) ?? [ ] ) out . push ( s ) ;
858+ for ( const s of mod . fnSinks . get ( callee ) ?? [ ] ) collected . push ( s ) ;
845859 }
846- return out ;
860+ // `line` refers to the HELPER's file, not the endpoint's — carry the file so the coordinate is
861+ // interpretable (and so flow linking never claims `precise` for a sink it cannot see locally).
862+ const rel = relative ( opts . cwd , target ) ;
863+ return collected . map ( ( s ) => ( { ...s , file : rel } ) ) ;
847864 } ,
848865 } ;
849866}
@@ -904,7 +921,7 @@ function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: Map<string, Sink[
904921 if ( ctx ) {
905922 const spec = bindings . resolve ( called ) ;
906923 if ( spec && spec . startsWith ( '.' ) ) {
907- for ( const s of ctx . graph . importedSinks ( ctx . file , spec , called ) ) sinks . push ( s ) ;
924+ for ( const s of ctx . graph . importedSinks ( ctx . file , spec , bindings . exportNameOf ( called ) ?? called ) ) sinks . push ( s ) ;
908925 }
909926 }
910927 }
@@ -1026,32 +1043,58 @@ function linkFlows(
10261043 ts : TsModule ,
10271044) : Flow [ ] {
10281045 if ( ! bodyNode || sinks . length === 0 || inputs . length === 0 ) return [ ] ;
1046+
1047+ // Roots that carry untrusted data: the handler's params, and locals aliased from them / from a
1048+ // request-body read. `leafOfLocal` maps a DESTRUCTURED local back to the field it came from, so
1049+ // `const { title: t } = await req.json()` links a read of `t` to the input `title`.
10291050 const taintedRoots = new Set < string > ( ) ;
1051+ const leafOfLocal = new Map < string , string > ( ) ;
10301052 for ( const p of params ?? [ ] ) {
10311053 if ( ! p ?. name ) continue ;
10321054 if ( ts . isIdentifier ( p . name ) ) taintedRoots . add ( p . name . text ) ;
10331055 else if ( ts . isObjectBindingPattern ( p . name ) ) {
1034- for ( const el of p . name . elements ) if ( ts . isBindingElement ( el ) && ts . isIdentifier ( el . name ) ) taintedRoots . add ( el . name . text ) ;
1056+ for ( const el of p . name . elements ) {
1057+ if ( ! ts . isBindingElement ( el ) || ! ts . isIdentifier ( el . name ) ) continue ;
1058+ taintedRoots . add ( el . name . text ) ;
1059+ const key = bindingKey ( el , ts ) ;
1060+ if ( key ) leafOfLocal . set ( el . name . text , key ) ;
1061+ }
10351062 }
10361063 }
1037- // Local aliases of tainted data: `const body = await request.json()`, `const { title } = data`.
1064+ const isRequestRead = ( init : any ) : boolean => {
1065+ let cur = init ;
1066+ while ( cur && ( ts . isAwaitExpression ( cur ) || ts . isParenthesizedExpression ( cur ) || ts . isAsExpression ( cur ) || ts . isNonNullExpression ( cur ) ) ) cur = cur . expression ;
1067+ if ( cur && ts . isCallExpression ( cur ) && ts . isPropertyAccessExpression ( cur . expression ) ) {
1068+ const m = cur . expression . name . text ;
1069+ if ( [ 'json' , 'formData' , 'text' ] . includes ( m ) ) {
1070+ const root = rootIdentifier ( cur . expression . expression , ts ) ;
1071+ return root ? taintedRoots . has ( root ) : false ;
1072+ }
1073+ }
1074+ if ( cur && ts . isPropertyAccessExpression ( cur ) && REQ_SOURCES . includes ( cur . name . text ) ) {
1075+ const root = rootIdentifier ( cur . expression , ts ) ;
1076+ return root ? taintedRoots . has ( root ) : false ;
1077+ }
1078+ const root = cur ? rootIdentifier ( cur , ts ) : undefined ;
1079+ return root ? taintedRoots . has ( root ) : false ;
1080+ } ;
10381081 const aliasVisit = ( n : any ) => {
1039- if ( ts . isVariableDeclaration ( n ) && n . initializer ) {
1040- const root = rootIdentifier ( n . initializer , ts ) ;
1041- const fromTainted = root ? taintedRoots . has ( root ) : false ;
1042- const isRequestRead = / \b ( j s o n | f o r m D a t a | t e x t | b o d y | q u e r y | p a r a m s ) \b / . test ( n . initializer . getText ?. ( ) ?? '' ) ;
1043- if ( fromTainted || isRequestRead ) {
1044- if ( ts . isIdentifier ( n . name ) ) taintedRoots . add ( n . name . text ) ;
1045- else if ( ts . isObjectBindingPattern ( n . name ) ) {
1046- for ( const el of n . name . elements ) if ( ts . isBindingElement ( el ) && ts . isIdentifier ( el . name ) ) taintedRoots . add ( el . name . text ) ;
1082+ if ( ts . isVariableDeclaration ( n ) && n . initializer && isRequestRead ( n . initializer ) ) {
1083+ if ( ts . isIdentifier ( n . name ) ) taintedRoots . add ( n . name . text ) ;
1084+ else if ( ts . isObjectBindingPattern ( n . name ) ) {
1085+ for ( const el of n . name . elements ) {
1086+ if ( ! ts . isBindingElement ( el ) || ! ts . isIdentifier ( el . name ) ) continue ;
1087+ taintedRoots . add ( el . name . text ) ;
1088+ const key = bindingKey ( el , ts ) ;
1089+ if ( key ) leafOfLocal . set ( el . name . text , key ) ;
10471090 }
10481091 }
10491092 }
10501093 ts . forEachChild ( n , aliasVisit ) ;
10511094 } ;
10521095 aliasVisit ( bodyNode ) ;
10531096
1054- // Index sink call sites by line so a sink (which carries `line`) can be matched to its AST node.
1097+ // Index sink call sites by line so a sink (which carries `line`) can be matched back to its AST node.
10551098 const callsByLine = new Map < number , any [ ] > ( ) ;
10561099 const callVisit = ( n : any ) => {
10571100 if ( ts . isCallExpression ( n ) ) {
@@ -1068,30 +1111,83 @@ function linkFlows(
10681111
10691112 const flows : Flow [ ] = [ ] ;
10701113 for ( const sink of sinks ) {
1071- const candidates = sink . line !== undefined ? ( callsByLine . get ( sink . line ) ?? [ ] ) : [ ] ;
1072- // Text of every argument at this sink's call site(s) — where a tainted value would appear.
1073- let argText = '' ;
1114+ // A sink from an imported module has no call site in THIS function — never claim precise for it.
1115+ const candidates = sink . file === undefined && sink . line !== undefined ? ( callsByLine . get ( sink . line ) ?? [ ] ) : [ ] ;
1116+ const reads = new Set < string > ( ) ;
10741117 for ( const c of candidates ) {
1075- for ( const a of c . arguments ?? [ ] ) {
1076- try { argText += ' ' + a . getText ( ) ; } catch { /* ignore */ }
1077- }
1118+ // Collect from the enclosing statement so a chained builder counts as one operation:
1119+ // `db.from(t).update({…}).eq('id', data.id)` — both `…` and `data.id` feed the same update.
1120+ for ( const leaf of taintedReadLeaves ( enclosingStatement ( c , ts ) ?? c , ts , taintedRoots , leafOfLocal ) ) reads . add ( leaf ) ;
10781121 }
10791122 for ( const input of inputs ) {
10801123 const leaf = input . name . split ( '.' ) . pop ( ) ! . replace ( / \[ \] $ / , '' ) ;
1081- // `data.title` / `{ title }` / `req.body.title` — the leaf name appearing in the sink's args,
1082- // qualified by a tainted root when it's a member path.
1083- const mentionsLeaf = argText . length > 0 && new RegExp ( `\\b${ escapeRe ( leaf ) } \\b` ) . test ( argText ) ;
1084- const mentionsTaintedRoot = [ ...taintedRoots ] . some ( ( r ) => new RegExp ( `\\b${ escapeRe ( r ) } \\b` ) . test ( argText ) ) ;
1085- if ( mentionsLeaf && ( mentionsTaintedRoot || taintedRoots . has ( leaf ) ) ) {
1086- flows . push ( { input : input . name , sink, confidence : 'precise' , line : sink . line } ) ;
1087- } else {
1088- flows . push ( { input : input . name , sink, confidence : 'heuristic' , line : sink . line } ) ;
1089- }
1124+ const precise = reads . has ( leaf ) ;
1125+ flows . push ( { input : input . name , sink, confidence : precise ? 'precise' : 'heuristic' , line : sink . line } ) ;
10901126 }
10911127 }
10921128 return flows ;
10931129}
10941130
1131+ /** Nearest enclosing statement, so a whole fluent chain is considered one operation. */
1132+ function enclosingStatement ( node : any , ts : TsModule ) : any {
1133+ let cur = node ;
1134+ while ( cur && ! ts . isStatement ( cur ) ) cur = cur . parent ;
1135+ return cur ;
1136+ }
1137+
1138+ /**
1139+ * Leaf names of values that are genuinely READ from a tainted source inside `node`. This is the
1140+ * evidence behind a `precise` flow, so it is deliberately strict about what counts as a read:
1141+ * - `data.title` / `req.body.title` → yields `title` (a member read off a tainted root)
1142+ * - `{ title }` (shorthand) → yields `title` (a read of the tainted local)
1143+ * - `fn(title)` → yields `title`
1144+ * and explicitly NOT:
1145+ * - `{ title: "system" }` → `title` here is a property KEY, not a read of anything
1146+ * - `x.title` where `x` is untainted → not tainted data
1147+ * (Text matching previously conflated these, so a key plus an unrelated tainted mention elsewhere in
1148+ * the same argument list produced a false `precise`.)
1149+ */
1150+ function taintedReadLeaves ( node : any , ts : TsModule , taintedRoots : Set < string > , leafOfLocal : Map < string , string > ) : Set < string > {
1151+ const out = new Set < string > ( ) ;
1152+ const visit = ( n : any ) => {
1153+ if ( ! n ) return ;
1154+ // A member read rooted in tainted data: take the accessed property as the leaf.
1155+ if ( ts . isPropertyAccessExpression ( n ) ) {
1156+ const root = rootIdentifier ( n . expression , ts ) ;
1157+ if ( root && taintedRoots . has ( root ) ) {
1158+ out . add ( n . name . text ) ;
1159+ return ; // don't descend: the inner identifiers are the path, not separate reads
1160+ }
1161+ }
1162+ if ( ts . isElementAccessExpression ( n ) ) {
1163+ const root = rootIdentifier ( n . expression , ts ) ;
1164+ if ( root && taintedRoots . has ( root ) ) {
1165+ const arg = n . argumentExpression ;
1166+ if ( arg && ts . isStringLiteralLike ( arg ) ) out . add ( arg . text ) ;
1167+ return ;
1168+ }
1169+ }
1170+ if ( ts . isIdentifier ( n ) && taintedRoots . has ( n . text ) && isValueRead ( n , ts ) ) {
1171+ out . add ( leafOfLocal . get ( n . text ) ?? n . text ) ;
1172+ }
1173+ ts . forEachChild ( n , visit ) ;
1174+ } ;
1175+ visit ( node ) ;
1176+ return out ;
1177+ }
1178+
1179+ /** Is this identifier occurrence a VALUE read (rather than a property key, a member name, a binding)? */
1180+ function isValueRead ( id : any , ts : TsModule ) : boolean {
1181+ const p = id . parent ;
1182+ if ( ! p ) return true ;
1183+ if ( ts . isPropertyAssignment ( p ) && p . name === id ) return false ; // { title: … } — a key
1184+ if ( ts . isPropertyAccessExpression ( p ) && p . name === id ) return false ; // x.title — the member name
1185+ if ( ts . isBindingElement ( p ) && p . propertyName === id ) return false ; // { title: t } — the source key
1186+ if ( ( ts . isVariableDeclaration ( p ) || ts . isParameter ( p ) || ts . isBindingElement ( p ) ) && p . name === id ) return false ;
1187+ if ( ts . isPropertySignature ( p ) || ts . isMethodSignature ( p ) ) return false ;
1188+ return true ; // includes ShorthandPropertyAssignment `{ title }`, which IS a read
1189+ }
1190+
10951191function escapeRe ( s : string ) : string {
10961192 return s . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
10971193}
0 commit comments