@clear-capabilities/agentic-security-scanner 0.128.1 → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/bin/agentic-security.js +33 -0
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +209 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +217 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/415.index.js +1 -1
  9. package/dist/435.index.js +2 -2
  10. package/dist/526.index.js +555 -0
  11. package/dist/637.index.js +1 -1
  12. package/dist/830.index.js +1 -1
  13. package/dist/agentic-security.mjs +113 -162
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/package.json +22 -14
  16. package/src/dataflow/CLAUDE.md +4 -1
  17. package/src/dataflow/async-sequencing.js +8 -3
  18. package/src/dataflow/catalog.js +278 -11
  19. package/src/dataflow/cross-repo.js +1 -1
  20. package/src/dataflow/cross-service-taint.js +1 -1
  21. package/src/dataflow/engine.js +182 -61
  22. package/src/dataflow/ifds.js +10 -5
  23. package/src/dataflow/index.js +15 -3
  24. package/src/dataflow/points-to.js +8 -2
  25. package/src/dataflow/proof-gate.js +7 -0
  26. package/src/dataflow/sanitizer-gate.js +89 -0
  27. package/src/dataflow/tabulation.js +14 -3
  28. package/src/engine.js +154 -7
  29. package/src/integrations/index.js +1 -1
  30. package/src/ir/CLAUDE.md +49 -4
  31. package/src/ir/call-sites.js +66 -0
  32. package/src/ir/callgraph.js +174 -7
  33. package/src/ir/class-hierarchy.js +22 -2
  34. package/src/ir/index.js +138 -51
  35. package/src/ir/ir-stats.js +126 -0
  36. package/src/ir/parser-cpp.js +829 -0
  37. package/src/ir/parser-cs.js +4 -1
  38. package/src/ir/parser-go.js +4 -1
  39. package/src/ir/parser-js.js +5 -1
  40. package/src/ir/parser-kt.js +4 -1
  41. package/src/ir/parser-php.js +10 -3
  42. package/src/ir/parser-py-cst.js +62 -10
  43. package/src/ir/tree-sitter-loader.js +13 -1
  44. package/src/llm-validator/index.js +9 -2
  45. package/src/llm-validator/redact.js +157 -0
  46. package/src/posture/CLAUDE.md +115 -0
  47. package/src/posture/accuracy-scorecard.js +317 -0
  48. package/src/posture/api-contract.js +1 -1
  49. package/src/posture/attestation.js +199 -0
  50. package/src/posture/auditor-walkthrough.js +12 -3
  51. package/src/posture/compliance-policy.js +1 -1
  52. package/src/posture/cross-lang-openapi.js +1 -1
  53. package/src/posture/custom-rules.js +1 -1
  54. package/src/posture/execution-proof.js +52 -0
  55. package/src/posture/exploitability-probability.js +1 -1
  56. package/src/posture/falsification.js +45 -1
  57. package/src/posture/fix-verify.js +55 -2
  58. package/src/posture/license-policy.js +1 -1
  59. package/src/posture/profile.js +1 -1
  60. package/src/posture/proof-tier.js +33 -0
  61. package/src/posture/relevance.js +379 -0
  62. package/src/posture/rule-overrides.js +1 -1
  63. package/src/posture/sca-policy.js +1 -1
  64. package/src/posture/scan-checkpoint.js +277 -0
  65. package/src/posture/suppressions.js +1 -1
  66. package/src/posture/test-runner.js +147 -0
  67. package/src/posture/verification-separation.js +131 -0
  68. package/src/report/index.js +11 -0
  69. package/src/runScan.js +3 -1
  70. package/src/sandbox/CLAUDE.md +218 -0
  71. package/src/sandbox/backend-disabled.js +14 -0
  72. package/src/sandbox/backend-namespace.js +83 -0
  73. package/src/sandbox/backend-userspace.js +100 -0
  74. package/src/sandbox/capabilities.js +53 -0
  75. package/src/sandbox/index.js +30 -0
  76. package/src/sandbox/limits.js +42 -0
  77. package/src/sandbox/result.js +104 -0
  78. package/src/sca/dep-confusion.js +1 -1
  79. package/src/util/yaml.js +24 -0
@@ -30,10 +30,17 @@
30
30
  // Sinks: anywhere a CFG node calls a catalog-registered sink with a tainted
31
31
  // argument, we emit a finding.
32
32
  //
33
- // Sanitizers: a call to a catalog-registered sanitizer kills the taint on its
34
- // argument (the call's return value is treated as clean).
33
+ // Sanitizers: NOT consulted by this walk. `matchSinkOrSanitizer()` returns
34
+ // catalog hits of both kinds, but every consumer in this directory keeps only
35
+ // `e.kind === 'sink'`; no `'sanitizer'` branch exists anywhere in dataflow/*.
36
+ // Taint dies here only when a variable is re-assigned from a clean expression
37
+ // (removePathAndDescendants, below) — which is orthogonal to whether the RHS
38
+ // happens to be a catalog sanitizer. Recognising sanitizer entries at a call
39
+ // site is open work; until it lands, treat the catalog's sanitizer half as
40
+ // documentation consumed by other layers, not by this engine.
35
41
 
36
42
  import { matchSource, matchSinkOrSanitizer } from './catalog.js';
43
+ import { functionRecord } from '../ir/callgraph.js';
37
44
  import { accessPathOf, isCoveredBy, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual } from './access-paths.js';
38
45
  import { aliasesForVar } from './points-to.js';
39
46
  import { higherOrderTaintFlow } from './higher-order.js';
@@ -64,9 +71,54 @@ function _addPathAliasAware(state, path, callContext) {
64
71
  }
65
72
 
66
73
  let _activeConstantVars = null;
74
+ // The file of the function currently being analyzed. Set at the top of
75
+ // analyzeFunction (same context-threading pattern as _activeConstantVars
76
+ // above) so exprIsSource/exprTaint/step can pass it to matchSource /
77
+ // matchSinkOrSanitizer without plumbing it through every call signature.
78
+ // It scopes language-specific catalog entries (currently just `cpp`) to
79
+ // files of that language — see the header comment in catalog.js.
80
+ let _currentFile = null;
81
+
82
+ // Flatten a callee — which may be a plain dotted STRING (Go/PHP/Ruby/C++/
83
+ // Python parsers all emit call targets this way) or an expression object
84
+ // (JS/TS's `exprOf`-shaped `{kind:'ident',name}` / `{kind:'member',...}`) —
85
+ // into a name `callGraph.resolve()` can look up. Mirrors the normalisation
86
+ // catalog.js's matchSinkOrSanitizer/matchSource already apply to callees, so
87
+ // the interprocedural resolve path and the catalog match agree on shape.
88
+ // The string path is returned unchanged — languages that already flatten to
89
+ // a string at parse time must keep working exactly as before.
90
+ function _flattenCalleeName(calleeExpr) {
91
+ if (!calleeExpr) return null;
92
+ if (typeof calleeExpr === 'string') return calleeExpr;
93
+ if (calleeExpr.kind === 'ident') return calleeExpr.name || null;
94
+ if (calleeExpr.kind === 'member' && calleeExpr.prop) {
95
+ return (calleeExpr.object && calleeExpr.object.kind === 'ident')
96
+ ? `${calleeExpr.object.name}.${calleeExpr.prop}`
97
+ : calleeExpr.prop;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ // Narrower than _flattenCalleeName: the name to hand to callGraph.resolve().
103
+ // Only a bare identifier call (`helper()`) — or a pre-flattened STRING, which
104
+ // is how the Go/PHP/Ruby/Python/C++ parsers already emit call targets —
105
+ // genuinely identifies one resolvable function. A JS/TS *member* call
106
+ // (`loader.read()`) does not: resolve()'s generic dotted-name fallback
107
+ // strips a dotted name to its last segment and matches ANY same-named
108
+ // function project-wide, inventing a call edge that may not exist (found in
109
+ // engine-reconnect review — `loader.read()` resolved to an unrelated local
110
+ // `read()`, producing 8 false positives on this repo's own hooks/scripts).
111
+ // A missing edge here is a false negative; a wrong edge invents a data-flow
112
+ // path that isn't there — refuse to guess.
113
+ function _resolvableCalleeName(calleeExpr) {
114
+ if (!calleeExpr) return null;
115
+ if (typeof calleeExpr === 'string') return calleeExpr;
116
+ if (calleeExpr.kind === 'ident') return calleeExpr.name || null;
117
+ return null;
118
+ }
67
119
 
68
120
  function exprTaint(expr, state) {
69
- if (expr && expr.kind === 'member' && exprIsSource(expr)) return true;
121
+ if (expr && (expr.kind === 'member' || expr.kind === 'call') && exprIsSource(expr)) return true;
70
122
  if (!expr) return false;
71
123
  // Constant propagation: variables assigned from literals are never tainted
72
124
  if (expr.kind === 'ident' && _activeConstantVars && _activeConstantVars.has(expr.name)) return false;
@@ -155,14 +207,14 @@ function _sourcesReachingExpr(expr, _state, taintSources) {
155
207
  function exprIsSource(expr) {
156
208
  if (!expr) return null;
157
209
  if (expr.kind === 'member') {
158
- const hit = matchSource(expr);
210
+ const hit = matchSource(expr, _currentFile);
159
211
  if (hit) return hit;
160
212
  }
161
213
  // R3 (PRD §5): call-shaped sources (r.FormValue(), r.URL.Query(), c.Query()).
162
214
  // Previously only member reads were recognized, so Go's call-style sources
163
215
  // never tainted the assignment target. matchSource now resolves call sources.
164
216
  if (expr.kind === 'call') {
165
- const hit = matchSource(expr);
217
+ const hit = matchSource(expr, _currentFile);
166
218
  if (hit) return hit;
167
219
  }
168
220
  if (expr.kind === 'member' && expr.object) {
@@ -194,6 +246,78 @@ function literalSkeletonMatchesFamily(expr, cwe) {
194
246
  return true;
195
247
  }
196
248
 
249
+ // Shared sink-matching logic for a call expression, regardless of whether
250
+ // that call appears in statement position (`case 'call'`) or on an
251
+ // assignment's right-hand side (`case 'assign'`). Split into two parts
252
+ // (compute, then emit) so `case 'call'` can compute `cat`/`argTaints` at its
253
+ // original position — BEFORE the mutated-param / Object.assign / array-taint
254
+ // passes that follow it read and rely on those values, and that themselves
255
+ // mutate `state` and `callContext._taintSources` in ways the finding-emission
256
+ // step (further below, unchanged position) must observe — while still
257
+ // sharing the actual matching + emission code with `case 'assign'`, which has
258
+ // no such ordering constraint. Extracted rather than duplicated: this
259
+ // repository has twice had a rule implemented at one call site and re-broken
260
+ // by the next change.
261
+
262
+ // calleeExpr / argExprs: the IR nodes for the call's callee and arguments.
263
+ // state: the taint-state Set to evaluate argument taint against.
264
+ // Returns { cat, argTaints }.
265
+ function _matchCallCatalog(calleeExpr, argExprs, state) {
266
+ const cat = matchSinkOrSanitizer(calleeExpr, _currentFile);
267
+ const argTaints = (argExprs || []).map(a => exprTaint(a, state));
268
+ return { cat, argTaints };
269
+ }
270
+
271
+ // cat / argTaints: the result of _matchCallCatalog (computed by the caller,
272
+ // at whatever point in its case is appropriate for its own state-mutation
273
+ // ordering).
274
+ // state: used only to attribute reaching sources to the tainted argument
275
+ // expression (via callContext._taintSources, not the state Set itself).
276
+ // line: source line to attach to any emitted finding.
277
+ // Returns { findings }.
278
+ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callContext, line) {
279
+ const findings = [];
280
+ if (cat) {
281
+ for (const e of cat) {
282
+ if (e.kind === 'sink' && (
283
+ e.argIndex === 'all' ? argTaints.some(Boolean) :
284
+ (typeof e.argIndex === 'number' && argTaints[e.argIndex])
285
+ )) {
286
+ const taintedArgIdx = e.argIndex === 'all'
287
+ ? argTaints.findIndex(Boolean) : e.argIndex;
288
+ const taintedArgExpr = (argExprs || [])[taintedArgIdx];
289
+ // String content analysis: skip if literal skeleton doesn't match injection family
290
+ if (e.vuln && taintedArgExpr && !literalSkeletonMatchesFamily(taintedArgExpr, e.vuln.cwe)) continue;
291
+ // Premortem #10: attribute the source for THIS sink to the
292
+ // source(s) that taint the actual argument expression — not the
293
+ // first source the worklist happened to record. We walk the
294
+ // expression's free vars / access paths against the recorded
295
+ // _taintSources and keep entries whose root variable still
296
+ // covers something in the expression.
297
+ const reachingSources = _sourcesReachingExpr(taintedArgExpr, state, callContext._taintSources);
298
+ const traceForThisFinding = reachingSources.length
299
+ ? reachingSources.slice(0, 5)
300
+ // Fallback: better to surface "no precise source" than the wrong source.
301
+ : [];
302
+ findings.push({
303
+ kind: 'taint',
304
+ sinkId: e.id,
305
+ vuln: e.vuln?.name || 'Tainted Sink',
306
+ severity: e.vuln?.severity || 'high',
307
+ cwe: e.vuln?.cwe || null,
308
+ remediation: e.vuln?.remediation || null,
309
+ line,
310
+ argIndex: taintedArgIdx,
311
+ callee: calleeExpr,
312
+ sourceProvenance: (traceForThisFinding[0]?.provenance) || null,
313
+ trace: traceForThisFinding,
314
+ });
315
+ }
316
+ }
317
+ }
318
+ return { findings };
319
+ }
320
+
197
321
  // Apply a CFG node to a taint-state. Returns the new state + any finding emitted.
198
322
  function step(node, stateIn, callContext) {
199
323
  const state = new Set(stateIn);
@@ -209,6 +333,21 @@ function step(node, stateIn, callContext) {
209
333
  case 'assign': {
210
334
  const src = exprIsSource(node.source);
211
335
  const target = typeof node.target === 'string' ? node.target : null;
336
+ // Sink matching is additive to this case's existing source/target/taint
337
+ // handling below: an assignment's RHS can itself be a sink call (e.g.
338
+ // `const rows = db.query(tainted)`), which previously went unreported
339
+ // because this case never consulted the catalog at all. Computed here,
340
+ // against the incoming (pre-mutation) `state`, mirroring how `case
341
+ // 'call'` computes its own cat/argTaints before its mutation passes —
342
+ // and pushed into the shared `findings` array so it survives every
343
+ // return path below, including the early interprocedural returns.
344
+ if (node.source && node.source.kind === 'call') {
345
+ const { cat: _sinkCat, argTaints: _sinkArgTaints } =
346
+ _matchCallCatalog(node.source.callee, node.source.args, state);
347
+ findings.push(..._sinkFindingsForCall(
348
+ node.source.callee, node.source.args, _sinkCat, _sinkArgTaints,
349
+ state, callContext, node.line).findings);
350
+ }
212
351
  // Constant propagation: track variables assigned from literals
213
352
  if (target && _activeConstantVars) {
214
353
  if (node.source && node.source.kind === 'literal') _activeConstantVars.set(target, node.source.value);
@@ -220,12 +359,20 @@ function step(node, stateIn, callContext) {
220
359
  // the return is tainted, taint the assignment target. This makes the
221
360
  // simplest cross-function flow (helper reads req.body and returns it)
222
361
  // visible to the engine — the case the cache was built for.
223
- const calleeName = node.source && node.source.kind === 'call' && typeof node.source.callee === 'string'
224
- ? node.source.callee : null;
362
+ const calleeName = node.source && node.source.kind === 'call'
363
+ ? _flattenCalleeName(node.source.callee) : null;
225
364
  if (target && calleeName && callContext._summaryCache && callContext._callGraph) {
226
365
  const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
227
- const resolved = callContext._callGraph.resolve ? callContext._callGraph.resolve(calleeName, _callerFile) : null;
228
- const fn = resolved && resolved.qid ? resolved : null;
366
+ const _resolvableName = node.source && node.source.kind === 'call'
367
+ ? _resolvableCalleeName(node.source.callee) : null;
368
+ // resolveKnownCallee: never guess via resolve()'s bare-tail
369
+ // fallback. _resolvableCalleeName already refuses JS member
370
+ // expressions, but a pre-flattened STRING callee (Go/PHP/Ruby/
371
+ // C++/Python parsers) can still be dotted, and only the resolver
372
+ // itself can tell — see callgraph.js.
373
+ const resolved = (_resolvableName && callContext._callGraph.resolveKnownCallee)
374
+ ? callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile) : null;
375
+ const fn = functionRecord(callContext._callGraph, resolved);
229
376
  const qid = resolved && (resolved.qid || resolved);
230
377
  if (typeof qid === 'string') {
231
378
  // v0.66 — context-sensitive lookup. Build the entry-state from
@@ -277,7 +424,7 @@ function step(node, stateIn, callContext) {
277
424
  sum, paramNames, callArgs, callerTainted);
278
425
  for (const v of mutated.mutated) newState = addPath(newState, v);
279
426
  }
280
- if (sum && sum.returnTainted) return { state: newState, findings: [] };
427
+ if (sum && sum.returnTainted) return { state: newState, findings };
281
428
  } else if (target && calleeName) {
282
429
  // Fallback: check builtin summaries for unresolved external calls
283
430
  const builtin = lookupBuiltinSummary(calleeName);
@@ -286,7 +433,7 @@ function step(node, stateIn, callContext) {
286
433
  newState = _addPathAliasAware(newState, target, callContext);
287
434
  } else if (!builtin.returnTainted) {
288
435
  newState = removePathAndDescendants(newState, target);
289
- return { state: newState, findings: [] };
436
+ return { state: newState, findings };
290
437
  }
291
438
  if (builtin.mutatedParams && builtin.mutatedParams.size) {
292
439
  for (const idx of builtin.mutatedParams) {
@@ -325,16 +472,22 @@ function step(node, stateIn, callContext) {
325
472
 
326
473
  case 'call': {
327
474
  // 1. Catalog match: sanitizer, sink, or just an external/unresolved call.
328
- const cat = matchSinkOrSanitizer(node.callee);
329
- const argTaints = (node.args || []).map(a => exprTaint(a, state));
475
+ // Computed here (before the mutation passes below) so that argTaints
476
+ // reflects the pre-mutation state, exactly as before this logic was
477
+ // extracted into _matchCallCatalog/_sinkFindingsForCall.
478
+ const { cat, argTaints } = _matchCallCatalog(node.callee, node.args, state);
330
479
  // v0.66 — apply mutated-param taint at plain (non-assign) call sites.
331
480
  // Object.assign(target, tainted) → target becomes tainted in caller.
332
- if (callContext._summaryCache && callContext._callGraph
333
- && typeof node.callee === 'string') {
481
+ const _plainCallCalleeName = _flattenCalleeName(node.callee);
482
+ if (callContext._summaryCache && callContext._callGraph && _plainCallCalleeName) {
334
483
  const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
335
- const resolved = callContext._callGraph.resolve
336
- ? callContext._callGraph.resolve(node.callee, _callerFile) : null;
337
- const fn = resolved && resolved.qid ? resolved : null;
484
+ const _resolvableName = _resolvableCalleeName(node.callee);
485
+ // resolveKnownCallee: see the comment at the sibling call site above
486
+ // a pre-flattened dotted STRING callee must not be guessed via
487
+ // resolve()'s bare-tail fallback.
488
+ const resolved = (_resolvableName && callContext._callGraph.resolveKnownCallee)
489
+ ? callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile) : null;
490
+ const fn = functionRecord(callContext._callGraph, resolved);
338
491
  const qid = resolved && (resolved.qid || resolved);
339
492
  if (typeof qid === 'string' && fn && Array.isArray(fn.params)) {
340
493
  const paramNames = fn.params;
@@ -376,7 +529,7 @@ function step(node, stateIn, callContext) {
376
529
  // Built-in mutation functions: Object.assign(target, ...sources),
377
530
  // _.merge(target, ...sources), etc. When any source arg is tainted,
378
531
  // taint the target in the caller's scope.
379
- const calleeName = typeof node.callee === 'string' ? node.callee : null;
532
+ const calleeName = _plainCallCalleeName;
380
533
  if (calleeName && /^(?:Object\.assign|_\.merge|_\.extend|_\.defaultsDeep|_\.defaults|Object\.defineProperties?)$/.test(calleeName)) {
381
534
  const targetArg = (node.args || [])[0];
382
535
  const sourceArgsTainted = argTaints.slice(1).some(Boolean);
@@ -405,44 +558,7 @@ function step(node, stateIn, callContext) {
405
558
  const _arrRecv = accessPathOf(node.callee.object);
406
559
  if (_arrRecv) state.add(_arrRecv);
407
560
  }
408
- if (cat) {
409
- for (const e of cat) {
410
- if (e.kind === 'sink' && (
411
- e.argIndex === 'all' ? argTaints.some(Boolean) :
412
- (typeof e.argIndex === 'number' && argTaints[e.argIndex])
413
- )) {
414
- const taintedArgIdx = e.argIndex === 'all'
415
- ? argTaints.findIndex(Boolean) : e.argIndex;
416
- const taintedArgExpr = (node.args || [])[taintedArgIdx];
417
- // String content analysis: skip if literal skeleton doesn't match injection family
418
- if (e.vuln && taintedArgExpr && !literalSkeletonMatchesFamily(taintedArgExpr, e.vuln.cwe)) continue;
419
- // Premortem #10: attribute the source for THIS sink to the
420
- // source(s) that taint the actual argument expression — not the
421
- // first source the worklist happened to record. We walk the
422
- // expression's free vars / access paths against the recorded
423
- // _taintSources and keep entries whose root variable still
424
- // covers something in the expression.
425
- const reachingSources = _sourcesReachingExpr(taintedArgExpr, state, callContext._taintSources);
426
- const traceForThisFinding = reachingSources.length
427
- ? reachingSources.slice(0, 5)
428
- // Fallback: better to surface "no precise source" than the wrong source.
429
- : [];
430
- findings.push({
431
- kind: 'taint',
432
- sinkId: e.id,
433
- vuln: e.vuln?.name || 'Tainted Sink',
434
- severity: e.vuln?.severity || 'high',
435
- cwe: e.vuln?.cwe || null,
436
- remediation: e.vuln?.remediation || null,
437
- line: node.line,
438
- argIndex: taintedArgIdx,
439
- callee: node.callee,
440
- sourceProvenance: (traceForThisFinding[0]?.provenance) || null,
441
- trace: traceForThisFinding,
442
- });
443
- }
444
- }
445
- }
561
+ findings.push(..._sinkFindingsForCall(node.callee, node.args, cat, argTaints, state, callContext, node.line).findings);
446
562
  // 2. P1.3 — higher-order taint flow. When the call is `arr.map(fn)` or
447
563
  // `promise.then(fn)` and the receiver is tainted, propagate taint
448
564
  // into the callback's first parameter. v1: we propagate AT THE
@@ -452,7 +568,7 @@ function step(node, stateIn, callContext) {
452
568
  const hoFlow = (() => {
453
569
  // Heuristic receiver-tainted check: if the callee string is
454
570
  // "<recv>.<method>", check whether <recv> is in state.
455
- const callee = typeof node.callee === 'string' ? node.callee : null;
571
+ const callee = _plainCallCalleeName;
456
572
  if (!callee) return null;
457
573
  const dot = callee.lastIndexOf('.');
458
574
  if (dot <= 0) return null;
@@ -526,6 +642,7 @@ function analyzeFunction(fn, entryState, callContext) {
526
642
  inStates.set(fn.cfg.entry, new Set(entryState));
527
643
  work.push(fn.cfg.entry);
528
644
  _activeConstantVars = new Map();
645
+ _currentFile = fn.file || null;
529
646
  // v0.70 #2 — points-to context for the step() transfer. Setting it here
530
647
  // (instead of plumbing through step's signature) keeps the worklist loop
531
648
  // unchanged and lets `step` consult `aliasesForVar` when callContext._pointsTo
@@ -583,7 +700,7 @@ function analyzeFunction(fn, entryState, callContext) {
583
700
  for (const [nid, ctx] of ictx) {
584
701
  const node = nodes[nid];
585
702
  if (!node || node.kind !== 'call') continue;
586
- const cat = matchSinkOrSanitizer(node.callee);
703
+ const cat = matchSinkOrSanitizer(node.callee, _currentFile);
587
704
  const sink = cat && cat.find((e) => e.kind === 'sink');
588
705
  if (!sink) continue;
589
706
  const inS = inStates.get(nid) || new Set();
@@ -783,8 +900,12 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
783
900
  if (Date.now() > deadlineMs) break;
784
901
  const inv = hoInvocations[hi];
785
902
  if (!inv.callee || !inv.taintedParam) continue;
786
- const resolved = callGraph.resolve ? callGraph.resolve(inv.callee, fn && fn.file) : null;
787
- const cbFn = resolved && resolved.qid ? resolved : null;
903
+ // inv.callee is always a bare ident (only cb.kind === 'ident' pushes a
904
+ // higher-order invocation see the push site above), so resolve()'s
905
+ // bare-tail guess never triggers here either way; resolveKnownCallee
906
+ // for consistency with the other call-graph lookups in this file.
907
+ const resolved = callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(inv.callee, fn && fn.file) : null;
908
+ const cbFn = functionRecord(callGraph, resolved);
788
909
  if (!cbFn || !cbFn.params || !cbFn.params.length) continue;
789
910
  const cbEntry = new Set([cbFn.params[inv.paramIndex || 0]]);
790
911
  let cbSummary = summaryCache.get(cbFn.qid, cbEntry);
@@ -28,6 +28,7 @@
28
28
  // engine is gated on the CVE-replay regression corpus.
29
29
 
30
30
  import { matchSource, matchSinkOrSanitizer } from './catalog.js';
31
+ import { functionRecord } from '../ir/callgraph.js';
31
32
 
32
33
  // Special bottom fact: "no taint yet, but reachable." Every node propagates
33
34
  // 0̂ → 0̂ to mark reachability, then layers domain facts on top.
@@ -129,10 +130,10 @@ function _exprAccessPath(expr) {
129
130
 
130
131
  // Sink detection at call nodes. Returns a list of sink-finding records
131
132
  // {sinkId, vuln, severity, cwe, line} when `fact` reaches a sink arg.
132
- function _detectSinkAtCall(node, fact) {
133
+ function _detectSinkAtCall(node, fact, file = null) {
133
134
  if (!node || node.kind !== 'call') return [];
134
135
  if (fact === ZERO) return [];
135
- const cat = matchSinkOrSanitizer(node.callee);
136
+ const cat = matchSinkOrSanitizer(node.callee, file);
136
137
  if (!cat) return [];
137
138
  const findings = [];
138
139
  const args = node.args || [];
@@ -251,14 +252,18 @@ export class IFDSSolver {
251
252
  if (!node) return;
252
253
  // Emit findings if this is a sink.
253
254
  if (node.kind === 'call') {
254
- for (const f of _detectSinkAtCall(node, currentFact)) {
255
+ for (const f of _detectSinkAtCall(node, currentFact, fn && fn.file)) {
255
256
  this.findings.push({ ...f, _fnQid: fn.qid, _entryFact: entryFact });
256
257
  }
257
258
  // v0.73 — interprocedural call: if the callee is resolved AND we
258
259
  // can derive an entry fact for it, look up (or queue) its summary
259
260
  // and apply the resulting exit facts at the return site.
260
- const resolved = this.callGraph.resolve ? this.callGraph.resolve(node.callee) : null;
261
- const callee = resolved && resolved.qid ? resolved : null;
261
+ // resolveKnownCallee (not resolve): never invent an edge from a dotted
262
+ // name's last segment. `fn.file` enables the same-file preference — a
263
+ // bare `handler` would otherwise bind to whichever file iterates first.
264
+ const resolved = this.callGraph.resolveKnownCallee
265
+ ? this.callGraph.resolveKnownCallee(node.callee, fn && fn.file) : null;
266
+ const callee = functionRecord(this.callGraph, resolved);
262
267
  if (callee && callee.cfg) {
263
268
  const calleeEntryFact = _entryFactForCall(node, currentFact, callee);
264
269
  // Seed the callee at its entry with the derived fact.
@@ -173,9 +173,21 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
173
173
  if (callGraph && callGraph.functions) {
174
174
  for (const fn of callGraph.functions.values()) {
175
175
  if (!Array.isArray(fn.calls)) continue;
176
- for (const callee of fn.calls) {
177
- if (!callers[callee]) callers[callee] = [];
178
- callers[callee].push(fn.qid);
176
+ for (const c of fn.calls) {
177
+ // fn.calls entries are objects ({site, callee, args, line}); using one
178
+ // as an object key stringifies it to "[object Object]" and collapses
179
+ // every entry into a single bogus bucket. Key by the RESOLVED qid so
180
+ // this map is queryable by the qids the worklist actually uses.
181
+ const name = c && typeof c === 'object' ? c.callee : c;
182
+ if (!name) continue;
183
+ // resolveKnownCallee (not resolve): a raw fn.calls name may be a
184
+ // flattened member-call string ("loader.read"); the plain
185
+ // resolve() would guess via its bare-tail fallback and invent an
186
+ // edge to an unrelated same-named function. See callgraph.js.
187
+ const qid = callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(name, fn.file) : null;
188
+ if (!qid) continue;
189
+ if (!callers[qid]) callers[qid] = [];
190
+ callers[qid].push(fn.qid);
179
191
  }
180
192
  }
181
193
  }
@@ -1,3 +1,5 @@
1
+ import { functionRecord } from '../ir/callgraph.js';
2
+
1
3
  // Steensgaard points-to / alias analysis (v0.70 #2).
2
4
  //
3
5
  // Unification-based, near-linear pointer analysis. The classical reference
@@ -247,8 +249,12 @@ export function buildPointsTo(perFileIR, callGraph) {
247
249
  for (const nid of Object.keys(fn.cfg.nodes)) {
248
250
  const node = fn.cfg.nodes[nid];
249
251
  if (!node || node.kind !== 'call') continue;
250
- const resolved = callGraph.resolve ? callGraph.resolve(node.callee) : null;
251
- const target = resolved && resolved.qid ? resolved : null;
252
+ // resolveKnownCallee (not resolve): never invent an edge from a dotted
253
+ // name's last segment. `fn.file` enables the same-file preference — a
254
+ // bare `handler` would otherwise bind to whichever file iterates first.
255
+ const resolved = callGraph.resolveKnownCallee
256
+ ? callGraph.resolveKnownCallee(node.callee, fn && fn.file) : null;
257
+ const target = functionRecord(callGraph, resolved);
252
258
  if (!target || !Array.isArray(target.params)) continue;
253
259
  const args = node.args || [];
254
260
  for (let i = 0; i < target.params.length && i < args.length; i++) {
@@ -48,6 +48,13 @@ export function verdictForFinding(f) {
48
48
  const sanitizers = (f.provenanceProof && f.provenanceProof.sanitizers) || [];
49
49
  return { verdict: 'proven-clean', reason: sanitizers.length ? `parameterized via ${sanitizers.join(', ')}` : 'sql-parameterizer-on-path' };
50
50
  }
51
+ // sanitizer-gate.js — same "proven-clean" demotion, generalised beyond SQL to
52
+ // every family the catalog's `appliesTo` tags cover (xss, url, cmd, ...).
53
+ // Purely additive: it never overrides f.provenClean above.
54
+ if (f.sanitized === true) {
55
+ const sanitizers = (f.sanitizerProof && f.sanitizerProof.sanitizers) || [];
56
+ return { verdict: 'proven-clean', reason: sanitizers.length ? `sanitized via ${sanitizers.join(', ')}` : 'sanitizer-on-path' };
57
+ }
51
58
  if (f._provenUnreachable === true) {
52
59
  return { verdict: 'proven-infeasible', reason: f._provenUnreachableReason || 'sanitizer-excludes-metacharacters' };
53
60
  }
@@ -0,0 +1,89 @@
1
+ // Generalised, recall-preserving sanitizer gate.
2
+ //
3
+ // The catalog carries 65 sanitizer entries tagged by family via `appliesTo`
4
+ // (sql, xss, url, cmd). Before this module only `appliesTo: ['sql']` was ever
5
+ // consumed — by proven-clean.js — so a correctly sanitized xss/url/cmd flow was
6
+ // still reported at full confidence. That is a pure false-positive source.
7
+ //
8
+ // This gate NEVER removes a finding. It sets `sanitized` plus a proof object and
9
+ // lets the existing proof gate in engine.js do the demotion, exactly as
10
+ // proven-clean.js does. That matters because a mislabelled sanitizer would
11
+ // otherwise hide a real vulnerability: the C/C++ work found strncpy and snprintf
12
+ // tagged effect:'strip' when they bound length rather than sanitising content.
13
+
14
+ import { CATALOG } from './catalog.js';
15
+
16
+ // Map a finding to a sanitizer family using its CWE first (stable) and its vuln
17
+ // text second (human-authored, so only a fallback).
18
+ const _CWE_FAMILY = {
19
+ 'CWE-89': 'sql',
20
+ 'CWE-79': 'xss',
21
+ 'CWE-78': 'cmd',
22
+ 'CWE-22': 'url',
23
+ 'CWE-918': 'url',
24
+ 'CWE-601': 'url',
25
+ };
26
+
27
+ const _TEXT_FAMILY = [
28
+ [/sql/i, 'sql'],
29
+ [/xss|cross-site scripting/i, 'xss'],
30
+ [/command injection/i, 'cmd'],
31
+ [/path traversal|ssrf|redirect/i, 'url'],
32
+ ];
33
+
34
+ export function familyOfFinding(f) {
35
+ if (!f) return null;
36
+ if (f.cwe && _CWE_FAMILY[f.cwe]) return _CWE_FAMILY[f.cwe];
37
+ const text = `${f.vuln || ''} ${f.family || ''}`;
38
+ for (const [re, fam] of _TEXT_FAMILY) if (re.test(text)) return fam;
39
+ return null;
40
+ }
41
+
42
+ // callee name → set of families it sanitizes, built once from the catalog.
43
+ let _index = null;
44
+ function _sanitizerIndex() {
45
+ if (_index) return _index;
46
+ _index = new Map();
47
+ for (const e of CATALOG) {
48
+ if (!e || e.kind !== 'sanitizer') continue;
49
+ const callee = e.match && e.match.type === 'call' ? e.match.callee : null;
50
+ if (!callee) continue;
51
+ const fams = Array.isArray(e.appliesTo) ? e.appliesTo : [];
52
+ const cur = _index.get(callee) || new Set();
53
+ for (const f of fams) cur.add(f);
54
+ _index.set(callee, cur);
55
+ }
56
+ return _index;
57
+ }
58
+
59
+ export function _sanitizerFamilies() {
60
+ const out = new Set();
61
+ for (const fams of _sanitizerIndex().values()) for (const f of fams) out.add(f);
62
+ return [...out].sort();
63
+ }
64
+
65
+ // ctx.sanitizersOnPath: { [findingId]: string[] } — callee names observed on the
66
+ // flow that produced the finding. The engine supplies it; when absent the gate
67
+ // is a no-op, which keeps this safe to call unconditionally.
68
+ export function applySanitizerGate(findings, ctx) {
69
+ const list = Array.isArray(findings) ? findings : [];
70
+ const onPath = (ctx && ctx.sanitizersOnPath) || null;
71
+ if (!onPath) return list;
72
+ const index = _sanitizerIndex();
73
+
74
+ for (const f of list) {
75
+ const fam = familyOfFinding(f);
76
+ if (!fam) continue;
77
+ const observed = onPath[f.id] || onPath[f.stableId];
78
+ if (!Array.isArray(observed) || !observed.length) continue;
79
+ const matching = observed.filter(name => {
80
+ const fams = index.get(name);
81
+ return fams && fams.has(fam);
82
+ });
83
+ if (!matching.length) continue;
84
+ // Label only. The proof gate decides what to do with the label.
85
+ f.sanitized = true;
86
+ f.sanitizerProof = { sanitizers: matching, family: fam };
87
+ }
88
+ return list;
89
+ }
@@ -49,7 +49,7 @@ export function enumerateSinks(perFileIR, callGraph) {
49
49
  for (const nid of Object.keys(cfg.nodes)) {
50
50
  const node = cfg.nodes[nid];
51
51
  if (!node || node.kind !== 'call') continue;
52
- const cat = matchSinkOrSanitizer(node.callee);
52
+ const cat = matchSinkOrSanitizer(node.callee, fn.file || null);
53
53
  if (!cat) continue;
54
54
  for (const e of cat) {
55
55
  if (e.kind !== 'sink') continue;
@@ -84,8 +84,19 @@ export function reachabilitySliceFromSinks(sinks, callGraph, maxCallerDepth = MA
84
84
  const callersOf = new Map();
85
85
  for (const fn of callGraph.functions.values()) {
86
86
  for (const callee of (fn.calls || [])) {
87
- if (!callersOf.has(callee.callee)) callersOf.set(callee.callee, []);
88
- callersOf.get(callee.callee).push(fn.qid);
87
+ // callee.callee is a SOURCE-LEVEL name ("b.fill"); the worklist below
88
+ // looks this map up by qid, so keying by the name means the lookup can
89
+ // never match. Resolve to a qid first.
90
+ const name = callee && typeof callee === 'object' ? callee.callee : callee;
91
+ if (!name) continue;
92
+ // resolveKnownCallee (not resolve): callee.callee may be a flattened
93
+ // member-call string ("loader.read"); the plain resolve() would guess
94
+ // via its bare-tail fallback and invent an edge to an unrelated
95
+ // same-named function. See callgraph.js.
96
+ const qid = callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(name, fn.file) : null;
97
+ if (!qid) continue;
98
+ if (!callersOf.has(qid)) callersOf.set(qid, []);
99
+ callersOf.get(qid).push(fn.qid);
89
100
  }
90
101
  }
91
102