@clear-capabilities/agentic-security-scanner 0.172.1 → 0.174.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 (201) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bin/agentic-security.js +37 -0
  3. package/dist/119.index.js +197 -0
  4. package/dist/128.index.js +129 -0
  5. package/dist/1293.index.js +5689 -0
  6. package/dist/1351.index.js +133 -0
  7. package/dist/1387.index.js +271 -0
  8. package/dist/1437.index.js +23 -0
  9. package/dist/1445.index.js +110 -0
  10. package/dist/1505.index.js +151 -0
  11. package/dist/1557.index.js +293 -0
  12. package/dist/1621.index.js +311 -0
  13. package/dist/1751.index.js +160 -0
  14. package/dist/1765.index.js +727 -0
  15. package/dist/1861.index.js +690 -0
  16. package/dist/1898.index.js +116 -0
  17. package/dist/1906.index.js +433 -0
  18. package/dist/1920.index.js +1 -291
  19. package/dist/208.index.js +1631 -0
  20. package/dist/2199.index.js +0 -239
  21. package/dist/2223.index.js +159 -0
  22. package/dist/2530.index.js +152 -0
  23. package/dist/2565.index.js +116 -0
  24. package/dist/2574.index.js +127 -0
  25. package/dist/2620.index.js +345 -0
  26. package/dist/2773.index.js +98 -0
  27. package/dist/2775.index.js +32 -0
  28. package/dist/2966.index.js +171 -0
  29. package/dist/2972.index.js +87 -0
  30. package/dist/2991.index.js +131 -0
  31. package/dist/3063.index.js +833 -0
  32. package/dist/3075.index.js +227 -0
  33. package/dist/3121.index.js +297 -0
  34. package/dist/3314.index.js +263 -0
  35. package/dist/3480.index.js +1822 -0
  36. package/dist/3481.index.js +24 -0
  37. package/dist/3506.index.js +268 -0
  38. package/dist/3533.index.js +591 -0
  39. package/dist/3653.index.js +450 -0
  40. package/dist/3822.index.js +131 -0
  41. package/dist/3868.index.js +292 -0
  42. package/dist/3917.index.js +152 -0
  43. package/dist/3918.index.js +192 -0
  44. package/dist/4090.index.js +288 -0
  45. package/dist/4121.index.js +1190 -0
  46. package/dist/4142.index.js +0 -239
  47. package/dist/4336.index.js +436 -0
  48. package/dist/4387.index.js +532 -0
  49. package/dist/4413.index.js +302 -0
  50. package/dist/4476.index.js +4510 -0
  51. package/dist/4491.index.js +133 -0
  52. package/dist/4538.index.js +185 -0
  53. package/dist/4684.index.js +319 -0
  54. package/dist/4740.index.js +165 -0
  55. package/dist/4780.index.js +209 -0
  56. package/dist/4802.index.js +225 -0
  57. package/dist/4812.index.js +379 -0
  58. package/dist/4835.index.js +126 -0
  59. package/dist/494.index.js +226 -0
  60. package/dist/4991.index.js +549 -0
  61. package/dist/5199.index.js +305 -0
  62. package/dist/5216.index.js +109 -0
  63. package/dist/5439.index.js +301 -0
  64. package/dist/5443.index.js +97 -0
  65. package/dist/5496.index.js +377 -0
  66. package/dist/550.index.js +2 -1
  67. package/dist/5801.index.js +588 -0
  68. package/dist/5858.index.js +223 -0
  69. package/dist/5933.index.js +143 -0
  70. package/dist/601.index.js +23 -0
  71. package/dist/6173.index.js +273 -0
  72. package/dist/6291.index.js +1871 -0
  73. package/dist/6312.index.js +666 -0
  74. package/dist/634.index.js +16182 -0
  75. package/dist/644.index.js +87 -0
  76. package/dist/6508.index.js +283 -0
  77. package/dist/6560.index.js +645 -0
  78. package/dist/6576.index.js +422 -0
  79. package/dist/6579.index.js +271 -0
  80. package/dist/6745.index.js +305 -0
  81. package/dist/6818.index.js +21642 -0
  82. package/dist/6972.index.js +167 -0
  83. package/dist/6995.index.js +163 -0
  84. package/dist/7249.index.js +191 -0
  85. package/dist/7372.index.js +1197 -0
  86. package/dist/7546.index.js +541 -0
  87. package/dist/76.index.js +448 -0
  88. package/dist/7879.index.js +163 -0
  89. package/dist/8025.index.js +130 -0
  90. package/dist/8054.index.js +248 -0
  91. package/dist/8097.index.js +201 -0
  92. package/dist/8126.index.js +191 -0
  93. package/dist/8178.index.js +78 -0
  94. package/dist/818.index.js +212 -0
  95. package/dist/8382.index.js +93 -0
  96. package/dist/8408.index.js +261 -0
  97. package/dist/8540.index.js +174 -0
  98. package/dist/8635.index.js +802 -0
  99. package/dist/8688.index.js +410 -0
  100. package/dist/8699.index.js +779 -0
  101. package/dist/8891.index.js +457 -0
  102. package/dist/8916.index.js +885 -0
  103. package/dist/9146.index.js +170 -0
  104. package/dist/9168.index.js +841 -0
  105. package/dist/9292.index.js +369 -0
  106. package/dist/9382.index.js +135 -11
  107. package/dist/9422.index.js +866 -0
  108. package/dist/9440.index.js +453 -0
  109. package/dist/9479.index.js +152 -0
  110. package/dist/9481.index.js +220 -0
  111. package/dist/9539.index.js +100 -0
  112. package/dist/9547.index.js +103 -0
  113. package/dist/9560.index.js +41 -61
  114. package/dist/9692.index.js +90 -0
  115. package/dist/9800.index.js +213 -1
  116. package/dist/9899.index.js +138 -0
  117. package/dist/9981.index.js +206 -0
  118. package/dist/999.index.js +117 -0
  119. package/dist/agentic-security.mjs +27 -27
  120. package/dist/agentic-security.mjs.sha256 +1 -1
  121. package/package.json +31 -6
  122. package/src/context-intelligence/candidates.js +2 -1
  123. package/src/context-intelligence/capabilities.js +2 -1
  124. package/src/context-intelligence/cli.js +2 -0
  125. package/src/context-intelligence/config.js +3 -1
  126. package/src/context-intelligence/controls.js +49 -1
  127. package/src/context-intelligence/counterexample.js +200 -0
  128. package/src/context-intelligence/cross-route.js +111 -0
  129. package/src/context-intelligence/findings.js +166 -0
  130. package/src/context-intelligence/invariants.js +197 -0
  131. package/src/context-intelligence/queue.js +6 -1
  132. package/src/context-intelligence/reachability.js +2 -2
  133. package/src/context-intelligence/review.js +68 -4
  134. package/src/dataflow/access-paths.js +11 -0
  135. package/src/dataflow/analysis-limits.js +66 -0
  136. package/src/dataflow/engine.js +282 -24
  137. package/src/dataflow/service-edges.js +174 -0
  138. package/src/dataflow/summaries.js +7 -1
  139. package/src/discovery/judge.js +2 -1
  140. package/src/engine.js +82 -14
  141. package/src/fix/apply-fix-service.js +55 -8
  142. package/src/fix/batch-journal.js +84 -0
  143. package/src/fix/exploit-replay.js +51 -0
  144. package/src/fix/footprint.js +65 -0
  145. package/src/fix/patch-policy.js +49 -0
  146. package/src/fix/secret-persistence.js +39 -0
  147. package/src/fix/test-commands.js +84 -0
  148. package/src/fix/verification-adapters.js +97 -0
  149. package/src/fix/verification-record.js +243 -0
  150. package/src/fix/verify-patch.js +89 -0
  151. package/src/ir/CLAUDE.md +5 -0
  152. package/src/ir/callgraph.js +23 -1
  153. package/src/ir/module-resolver.js +315 -0
  154. package/src/ir/parser-js.js +21 -2
  155. package/src/ir/semantic-graph.js +245 -0
  156. package/src/language/haskell-findings.js +2 -0
  157. package/src/language/haskell-guards.js +1 -1
  158. package/src/language/haskell-ir.js +3 -1
  159. package/src/language/haskell-models.js +94 -0
  160. package/src/language/haskell-security-rules.js +20 -0
  161. package/src/language/nixos-hardening.js +72 -0
  162. package/src/lineage/build-attested-correlation.js +63 -0
  163. package/src/lineage/deployment-identity.js +76 -0
  164. package/src/mcp/tools.js +13 -34
  165. package/src/pipeline/canonical-output.js +64 -0
  166. package/src/pipeline/detector-runner.js +7 -0
  167. package/src/pipeline/scan-health.js +14 -0
  168. package/src/pipeline/supervised-analyzers.js +125 -0
  169. package/src/pipeline/supervised-child.js +17 -0
  170. package/src/pipeline/telemetry.js +79 -0
  171. package/src/posture/accuracy-scorecard.js +98 -9
  172. package/src/posture/artifact-registry.js +5 -0
  173. package/src/posture/assurance/evidence-registry.js +215 -0
  174. package/src/posture/assurance/release-package-verify.js +135 -0
  175. package/src/posture/assurance/release-package.js +118 -0
  176. package/src/posture/assurance/release-verdict.js +94 -0
  177. package/src/posture/assurance/waiver.js +200 -0
  178. package/src/posture/compliance-truth.js +100 -0
  179. package/src/posture/evaluation/scan-child.js +3 -1
  180. package/src/posture/evaluation/why-missed.js +3 -1
  181. package/src/posture/exploitability-evidence.js +141 -0
  182. package/src/posture/fix-verify.js +4 -2
  183. package/src/posture/fleet/exposure-ranking.js +115 -0
  184. package/src/posture/fleet/service-chain-graph.js +150 -0
  185. package/src/posture/graph-reconciliation.js +154 -0
  186. package/src/posture/provenance/coverage.js +103 -0
  187. package/src/posture/provenance/git-evidence.js +84 -0
  188. package/src/posture/provenance/origin-resolver.js +7 -1
  189. package/src/posture/provenance/predicate-replay.js +14 -4
  190. package/src/posture/runtime-correlation.js +61 -15
  191. package/src/posture/runtime-explanation.js +116 -0
  192. package/src/posture/runtime-trace-ingest.js +194 -0
  193. package/src/posture/scan-checkpoint.js +38 -10
  194. package/src/posture/waf-ingest.js +7 -2
  195. package/src/report/index.js +4 -0
  196. package/src/report/oscal.js +38 -0
  197. package/src/report/perspectives.js +150 -0
  198. package/src/sast/_comment-strip.js +8 -1
  199. package/src/sast/ruby.js +26 -0
  200. package/src/sast/zip-slip.js +79 -0
  201. package/src/util/string-memo.js +38 -0
@@ -44,7 +44,7 @@
44
44
 
45
45
  import { matchSource, matchSinkOrSanitizer, matchMemberWriteSink, matchAnnotationParams } from './catalog.js';
46
46
  import { functionRecord } from '../ir/callgraph.js';
47
- import { accessPathOf, isCoveredBy, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual, pathIsCoveredByPrefix } from './access-paths.js';
47
+ import { accessPathOf, isCoveredBy, hasTaintedDescendant, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual, pathIsCoveredByPrefix } from './access-paths.js';
48
48
  import { aliasesForVar } from './points-to.js';
49
49
  import { higherOrderTaintFlow } from './higher-order.js';
50
50
  import { SummaryCache, entryStateFromCall } from './summaries.js';
@@ -342,6 +342,14 @@ function _resolveMemberCalleeViaCHA(calleeExpr, callContext) {
342
342
  function _resolveCalleeForSummary(calleeExpr, callContext) {
343
343
  if (!callContext || !callContext._callGraph || !callContext._callGraph.resolveKnownCallee) return null;
344
344
  const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
345
+ // E2-02: a member call through an IMPORTED MODULE (`const svc = require('./service'); svc.run(x)`) is resolved by following the import,
346
+ // not by name. This is exact (the binding names the module), so it does not reopen the guessed-edge problem described above: only a
347
+ // callee whose head identifier is an import binding of this very file is eligible, and the answer is the module's own export.
348
+ if (calleeExpr && calleeExpr.kind === 'member' && typeof calleeExpr.prop === 'string' && calleeExpr.object && calleeExpr.object.kind === 'ident' && callContext._callGraph.moduleIndex && _callerFile) {
349
+ let viaImport = null;
350
+ try { viaImport = callContext._callGraph.moduleIndex.resolveCall(_callerFile, `${calleeExpr.object.name}.${calleeExpr.prop}`); } catch { viaImport = null; }
351
+ if (viaImport && viaImport.status === 'resolved') return { qid: viaImport.qid, fn: functionRecord(callContext._callGraph, viaImport.qid) };
352
+ }
345
353
  const _resolvableName = _resolvableCalleeName(calleeExpr);
346
354
  if (_resolvableName) {
347
355
  const resolved = callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile);
@@ -379,9 +387,9 @@ function _nestedCallReturnTainted(calleeExpr, argExprs, state, callContext, argT
379
387
  if (!target) return false;
380
388
  const { qid, fn } = target;
381
389
  const paramNames = (fn && Array.isArray(fn.params)) ? fn.params : [];
382
- const entry = paramNames.length
390
+ const entry = _withCapturedTaint(paramNames.length
383
391
  ? entryStateFromCall(paramNames, argExprs || [], state, argTaint || ((a) => exprTaint(a, state, callContext)))
384
- : new Set();
392
+ : new Set(), fn, state, callContext);
385
393
  let sum = callContext._summaryCache.get(qid, entry);
386
394
  if (!sum && fn && fn.cfg) {
387
395
  sum = callContext._summaryCache.compute(qid, entry, () => {
@@ -422,18 +430,38 @@ function _nestedCallReturnTainted(calleeExpr, argExprs, state, callContext, argT
422
430
  // after its LAST '.' and checking that prefix directly against the access-
423
431
  // path lattice via `isCoveredBy` (handles both a bare var and a longer
424
432
  // chain, e.g. "req.session.user.toString" -> receiver "req.session.user").
425
- function _calleeReceiverTainted(callee, state, callContext) {
433
+ function _calleeReceiverTainted(callee, state, callContext, args) {
426
434
  if (!callee) return false;
435
+ // E2-03: `m.get('k')` reads ONE key of a keyed collection. It is tainted when the collection as a whole is, or when that key was set
436
+ // tainted (`m.set('k', v)` records `m.$k`); a sibling key's taint (`m.$other`) does not reach it. Non-path receivers, and receivers that
437
+ // are themselves sources, take the generic route below.
438
+ if (typeof callee === 'object' && callee.kind === 'member' && callee.prop === 'get' && Array.isArray(args) && args.length === 1
439
+ && args[0] && args[0].kind === 'literal' && typeof args[0].value === 'string') {
440
+ const recvPath = accessPathOf(callee.object);
441
+ if (recvPath) return exprIsSource(callee.object, callContext) ? true : (isCoveredBy(state, recvPath) || isCoveredBy(state, `${recvPath}.$${args[0].value}`));
442
+ }
427
443
  if (typeof callee === 'string') {
428
444
  const idx = callee.lastIndexOf('.');
429
445
  if (idx <= 0) return false;
430
- return isCoveredBy(state, callee.slice(0, idx));
446
+ return isCoveredBy(state, callee.slice(0, idx)) || hasTaintedDescendant(state, callee.slice(0, idx));
431
447
  }
432
448
  if (callee.kind === 'member' && callee.object) {
449
+ // A tainted FIELD below the receiver (`o.cmd`) taints a call on the whole receiver only for methods that read the container's contents
450
+ // (`arr.join()`, `list.map(..)`); on an arbitrary object it does not, because the method need not touch that field (object-instance
451
+ // sensitivity: see the single-field getter handling).
452
+ const recvPath = accessPathOf(callee.object);
453
+ if (recvPath && typeof callee.prop === 'string') {
454
+ if (exprIsSource(callee.object, callContext) || isCoveredBy(state, recvPath)) return true;
455
+ if (_CONTAINER_READ.test(callee.prop)) return hasTaintedDescendant(state, recvPath);
456
+ // `m.get(k)` with a non-literal key reads any key a literal `m.set('x', v)` recorded as `m.$x`
457
+ if (callee.prop === 'get') { const kp = `${recvPath}.$`; for (const st of state) if (typeof st === 'string' && st.startsWith(kp)) return true; }
458
+ return false;
459
+ }
433
460
  return exprTaint(callee.object, state, callContext);
434
461
  }
435
462
  return false;
436
463
  }
464
+ const _CONTAINER_READ = /^(?:join|map|filter|flatMap|flat|slice|concat|reduce|reduceRight|find|findLast|findIndex|some|every|forEach|at|toString|toSorted|toReversed|entries|values|keys|pop|shift|sort|reverse|includes|indexOf|lastIndexOf)$/;
437
465
 
438
466
  // Next-gen taint capability #3 — object-instance field taint via method
439
467
  // calls (JS/TS). `classTaintedFields` (below, in the pre-pass) already
@@ -526,7 +554,8 @@ function exprTaint(expr, state, callContext) {
526
554
  // shorter prefix in the state covers it. This is what makes
527
555
  // `user.password` distinguishable from `user.email`.
528
556
  const ap = accessPathOf(expr);
529
- if (ap !== null) return isCoveredBy(state, ap);
557
+ // E2-03: a whole-value read of `o` is tainted when any field below it is (`o.cmd`); a sibling read (`o.safe`) is not.
558
+ if (ap !== null) return isCoveredBy(state, ap) || hasTaintedDescendant(state, ap);
530
559
  switch (expr.kind) {
531
560
  case 'literal': return false;
532
561
  case 'binary':
@@ -595,7 +624,7 @@ function exprTaint(expr, state, callContext) {
595
624
  // receiver taint matters exactly as much as `tainted.toString()`'s, and
596
625
  // this is already OR'd with argsTainted so it only WIDENS recall,
597
626
  // matching this engine's recall-preserving precedent everywhere else.
598
- const receiverTainted = _calleeReceiverTainted(expr.callee, state, callContext);
627
+ const receiverTainted = _calleeReceiverTainted(expr.callee, state, callContext, expr.args);
599
628
  return argsTainted || nestedTainted || receiverTainted;
600
629
  }
601
630
  case 'unknown': return false;
@@ -772,9 +801,137 @@ function literalSkeletonMatchesFamily(expr, cwe) {
772
801
  // state: the taint-state Set to evaluate argument taint against.
773
802
  // callContext: context from the engine (contains _cha for CHA lookups).
774
803
  // Returns { cat, argTaints }.
804
+ // E2-02: a member call through an imported PROJECT module (`util.exec(c)` where `util` is `require('./util')`) calls that module's own
805
+ // function, whatever it is named. The catalog matches sinks by callee name, so `util.exec` looked like child_process's `exec` and every
806
+ // caller of a helper called `exec`/`query`/`send` was reported as a sink. When the import resolves to a function in this project the sink
807
+ // match is dropped: the helper's body is analysed through its summary, so a real sink inside it is still found, at the real line.
808
+ function _isInternalModuleCall(calleeExpr, callContext) {
809
+ let text = null;
810
+ if (calleeExpr && calleeExpr.kind === 'member' && typeof calleeExpr.prop === 'string' && calleeExpr.object && calleeExpr.object.kind === 'ident') text = `${calleeExpr.object.name}.${calleeExpr.prop}`;
811
+ else if (calleeExpr && calleeExpr.kind === 'ident' && typeof calleeExpr.name === 'string') text = calleeExpr.name; // `const { exec } = require('./util'); exec(c)`
812
+ if (!text) return false;
813
+ const mi = callContext && callContext._callGraph && callContext._callGraph.moduleIndex;
814
+ if (!mi || !_currentFile) return false;
815
+ try { return mi.resolveCall(_currentFile, text).status === 'resolved'; } catch { return false; }
816
+ }
817
+
818
+ // The field-by-field literal rules use the access paths parser-js.js produces (`o.cmd`, `arr.0`). The other parsers lower subscripts and
819
+ // comprehensions to different path shapes (`d.[]`), so the rules apply to JavaScript and TypeScript only; elsewhere the whole-value rule is unchanged.
820
+ const _JS_FILE = /\.(?:[cm]?[jt]sx?)$/i;
821
+ function _isJsFile(file) { return typeof file === 'string' && _JS_FILE.test(file); }
822
+
823
+ function _arrayLiteralTaint(expr, prefix, state, callContext) {
824
+ const out = { whole: false, paths: [] };
825
+ (expr.elements || []).forEach((el, i) => {
826
+ if (!el) return;
827
+ const path = prefix ? `${prefix}.${i}` : String(i);
828
+ if (el.kind === 'object') {
829
+ const inner = _objectLiteralTaint(el, path, state, callContext);
830
+ if (inner.whole) out.paths.push(path);
831
+ out.paths.push(...inner.paths);
832
+ } else if (el.kind === 'array' && !(el.spreads && el.spreads.length)) {
833
+ const inner = _arrayLiteralTaint(el, path, state, callContext);
834
+ if (inner.whole) out.paths.push(path);
835
+ out.paths.push(...inner.paths);
836
+ } else if (exprTaint(el, state, callContext)) out.paths.push(path);
837
+ });
838
+ return out;
839
+ }
840
+
841
+ function _objectLiteralTaint(expr, prefix, state, callContext) {
842
+ const out = { whole: false, paths: [] };
843
+ for (const p of expr.props || []) {
844
+ if (!p || !p.value) continue;
845
+ if (p.spread) {
846
+ // `{ ...a }` copies the fields of `a`: a tainted FIELD of `a` stays that field, a wholly tainted `a` (or a spread of something that is not
847
+ // a plain variable, such as a request object) has unknown keys and taints everything.
848
+ const sp = accessPathOf(p.value);
849
+ if (sp && !isCoveredBy(state, sp) && !exprIsSource(p.value, callContext)) {
850
+ for (const st of state) if (typeof st === 'string' && st.startsWith(`${sp}.`)) out.paths.push(prefix ? `${prefix}.${st.slice(sp.length + 1)}` : st.slice(sp.length + 1));
851
+ } else if (exprTaint(p.value, state, callContext)) out.whole = true;
852
+ continue;
853
+ }
854
+ if (p.key === '*' || typeof p.key !== 'string') { if (exprTaint(p.value, state, callContext)) out.whole = true; continue; }
855
+ const path = prefix ? `${prefix}.${p.key}` : p.key;
856
+ // a later explicit key overrides whatever an earlier spread brought in under that name
857
+ out.paths = out.paths.filter((q) => q !== path && !q.startsWith(`${path}.`));
858
+ if (p.value.kind === 'object') {
859
+ const inner = _objectLiteralTaint(p.value, path, state, callContext);
860
+ if (inner.whole) out.paths.push(path); // unknown keys below: the whole sub-object
861
+ out.paths.push(...inner.paths);
862
+ } else if (exprTaint(p.value, state, callContext)) out.paths.push(path);
863
+ }
864
+ return out;
865
+ }
866
+
867
+ // E2-03: a function defined INSIDE the one being analysed (`const f = () => exec(c)`) reads the caller's variables without receiving them as
868
+ // parameters. When such a callee is entered, the caller's tainted paths whose root is not one of the callee's own parameters are carried in as
869
+ // its entry state, so `c` is tainted inside `f`. Lexical nesting comes from the qid (`file::<enclosing function>::name@line`); a callee defined
870
+ // at module level or elsewhere gets nothing extra. An assignment inside the callee still kills the taint, as it would anywhere.
871
+ function _withCapturedTaint(entry, fn, state, callContext) {
872
+ if (!fn || typeof fn.qid !== 'string' || !callContext || !callContext._currentFnQid) return entry;
873
+ const callee = fn.qid.split('::');
874
+ const caller = String(callContext._currentFnQid).split('::');
875
+ if (callee.length < 3 || caller.length < 3 || callee[0] !== caller[0]) return entry;
876
+ const callerName = caller[caller.length - 1].replace(/@\d+$/, '');
877
+ if (callee[1] !== callerName || callee[1] === '<module>') return entry;
878
+ const params = new Set(fn.params || []);
879
+ let out = entry;
880
+ for (const p of state) {
881
+ const root = String(p).split('.')[0];
882
+ if (params.has(root)) continue;
883
+ if (out === entry) out = new Set(entry);
884
+ out.add(p);
885
+ }
886
+ return out;
887
+ }
888
+
889
+ // E2-04: callbacks. A function passed as an argument runs later with values the call site supplies, so its parameters can be tainted. Two
890
+ // shapes are recognised, both resolved structurally and never by guessing:
891
+ // 1. promise and iterator methods (`p.then(cb)`, `list.map(cb)`): the callback's first parameter receives the RECEIVER's value, so it is
892
+ // tainted when the receiver expression is (a call, an awaited value, a path);
893
+ // 2. a project function that CALLS its own parameter (`function later(v, cb) { cb(v); }`): the callback's parameter k is tainted when the
894
+ // argument that function passes at position k derives from one of its own tainted parameters.
895
+ // Each tainted parameter becomes a `_higherOrderInvocations` entry, which the engine already consumes by analysing the callback with that
896
+ // parameter tainted.
897
+ const _RECEIVER_TO_CALLBACK = /^(?:then|catch|finally|map|forEach|filter|flatMap|find|findLast|some|every|reduce|reduceRight|sort)$/;
898
+ function _recordCallbackInvocations(node, state, callContext) {
899
+ if (!node || !Array.isArray(node.args) || !node.args.length) return;
900
+ const push = (cb, paramIndex, via) => {
901
+ callContext._higherOrderInvocations = callContext._higherOrderInvocations || [];
902
+ callContext._higherOrderInvocations.push({ callee: cb.kind === 'ident' ? cb.name : null, calleeQid: cb.kind === 'function-value' ? (cb.qid || null) : null, paramIndex, taintedParam: true, line: node.line, via });
903
+ };
904
+ const isCb = (a) => a && (a.kind === 'function-value' || a.kind === 'ident');
905
+ const callee = node.callee;
906
+ if (callee && callee.kind === 'member' && typeof callee.prop === 'string' && _RECEIVER_TO_CALLBACK.test(callee.prop) && isCb(node.args[0]) && callee.prop !== 'catch') {
907
+ if (exprTaint(callee.object, state, callContext)) push(node.args[0], callee.prop === 'reduce' || callee.prop === 'reduceRight' ? 1 : 0, `receiver:${callee.prop}`);
908
+ }
909
+ // 2. project function that invokes its own callback parameter
910
+ const target = _resolveCalleeForSummary(node.callee, callContext);
911
+ const fn = target && target.fn;
912
+ if (!fn || !Array.isArray(fn.params) || !Array.isArray(fn.calls)) return;
913
+ node.args.forEach((cb, i) => {
914
+ if (!isCb(cb)) return;
915
+ const cbParam = fn.params[i];
916
+ if (!cbParam) return;
917
+ for (const inv of fn.calls) {
918
+ if (inv.callee !== cbParam) continue;
919
+ (inv.args || []).forEach((innerArg, k) => {
920
+ const root = innerArg && innerArg.kind === 'ident' ? innerArg.name : (accessPathOf(innerArg) || '').split('.')[0];
921
+ const j = root ? fn.params.indexOf(root) : -1;
922
+ if (j >= 0 && j !== i && node.args[j] && exprTaint(node.args[j], state, callContext)) push(cb, k, 'user-callback');
923
+ });
924
+ }
925
+ });
926
+ }
927
+
775
928
  function _matchCallCatalog(calleeExpr, argExprs, state, callContext) {
776
929
  const receiverType = _receiverTypeFor(calleeExpr, callContext);
777
- const cat = matchSinkOrSanitizer(calleeExpr, _currentFile, receiverType);
930
+ let cat = matchSinkOrSanitizer(calleeExpr, _currentFile, receiverType);
931
+ if (Array.isArray(cat) && _isInternalModuleCall(calleeExpr, callContext)) {
932
+ const kept = cat.filter((e) => e.kind !== 'sink');
933
+ cat = kept.length ? kept : null;
934
+ }
778
935
  const argTaints = (argExprs || []).map(a => exprTaint(a, state, callContext));
779
936
  return { cat, argTaints };
780
937
  }
@@ -1266,13 +1423,18 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
1266
1423
  // real-world nesting. Deliberately does NOT re-check the TOP-LEVEL call
1267
1424
  // itself (the caller already did that via its own _matchCallCatalog +
1268
1425
  // _sinkFindingsForCall), so this never duplicates a finding.
1426
+ // Haskell only: a call written on its own line inside a larger expression (`get "/r" $ do ... liftIO (callCommand c)`) carries that line, while the node that
1427
+ // finally executes it sits on the line the handler starts. The sink is where the call is written. Other IRs lower nested calls differently and keep the node's line.
1428
+ const _sinkLine = (call, fallback) => (/\.(?:hs|lhs)$/i.test(_currentFile || '') && call && Number.isInteger(call.line) && call.line >= 1 ? call.line : fallback);
1429
+
1269
1430
  function _nestedSinkFindings(argExprs, state, callContext, line) {
1270
1431
  const findings = [];
1271
1432
  for (const arg of (argExprs || [])) {
1272
1433
  if (!arg || arg.kind !== 'call') continue;
1273
1434
  const { cat, argTaints } = _matchCallCatalog(arg.callee, arg.args, state, callContext);
1274
- findings.push(..._sinkFindingsForCall(arg.callee, arg.args, cat, argTaints, state, callContext, line).findings);
1275
- findings.push(..._nestedSinkFindings(arg.args, state, callContext, line));
1435
+ const at = _sinkLine(arg, line);
1436
+ findings.push(..._sinkFindingsForCall(arg.callee, arg.args, cat, argTaints, state, callContext, at).findings);
1437
+ findings.push(..._nestedSinkFindings(arg.args, state, callContext, at));
1276
1438
  }
1277
1439
  return findings;
1278
1440
  }
@@ -1323,9 +1485,20 @@ function _memberWriteSinkFindings(hits, sourceExpr, state, callContext, line, ta
1323
1485
  // nested in runTaintEngine) because step()'s assign/plain-call interproc
1324
1486
  // branches call it too, and step() is a top-level function with no access
1325
1487
  // to runTaintEngine's locals.
1488
+ // E2-05: a callee's findings are merged into its caller's every time the call is evaluated, and a call can be evaluated several times (the
1489
+ // fixed-point passes, and JavaScript emits both a call node and a return node for `return g(x)`). Each level of a call chain then doubled the
1490
+ // list: a chain of 20 one-line wrappers produced 131072 copies of one finding, and the explosion ended in the whole result being dropped. The
1491
+ // same sink reached again by the same finding is one finding, so a copy is skipped when this context already holds it.
1492
+ function _findingKey(f) { return `${f.file ?? ''}|${f.line ?? ''}|${f.cwe ?? ''}|${f.vuln ?? ''}|${typeof f.callee === 'string' ? f.callee : ''}|${f.id ?? ''}`; }
1326
1493
  function _mergeSummaryFindings(callContext, callerQid, sum, via) {
1327
1494
  if (!sum || !Array.isArray(sum.findings) || !sum.findings.length) return;
1328
- callContext._findings.push(...sum.findings.map(f => ({ ...f, _funcQid: callerQid || null, _via: via })));
1495
+ if (!callContext._findingKeys) callContext._findingKeys = new Set(callContext._findings.map(_findingKey));
1496
+ for (const f of sum.findings) {
1497
+ const key = _findingKey(f);
1498
+ if (callContext._findingKeys.has(key)) continue;
1499
+ callContext._findingKeys.add(key);
1500
+ callContext._findings.push({ ...f, _funcQid: callerQid || null, _via: via });
1501
+ }
1329
1502
  }
1330
1503
 
1331
1504
  // Apply a CFG node to a taint-state. Returns the new state + any finding emitted.
@@ -1435,9 +1608,9 @@ function step(node, stateIn, callContext) {
1435
1608
  const callerTainted = newState;
1436
1609
  const callArgs = (node.source.args || []);
1437
1610
  const paramNames = (fn && Array.isArray(fn.params)) ? fn.params : [];
1438
- const entry = paramNames.length
1611
+ const entry = _withCapturedTaint(paramNames.length
1439
1612
  ? entryStateFromCall(paramNames, callArgs, callerTainted, (a) => exprTaint(a, callerTainted, callContext))
1440
- : new Set();
1613
+ : new Set(), fn, callerTainted, callContext);
1441
1614
  let sum = callContext._summaryCache.get(qid, entry);
1442
1615
  if (!sum && fn && fn.cfg) {
1443
1616
  // Lazy compute under this entry state. Use a fresh ctx so we
@@ -1537,6 +1710,25 @@ function step(node, stateIn, callContext) {
1537
1710
  }
1538
1711
  }
1539
1712
  }
1713
+ if (target && node.source && node.source.kind === 'array' && !src && _isJsFile(_currentFile) && !(node.source.spreads && node.source.spreads.length)) {
1714
+ // E2-03: an array literal without spread elements is assigned index by index, so `[tainted, 'ls']` leaves `arr[1]` clean. Reading the
1715
+ // array as a whole (`arr.join()`, passing `arr`) is still tainted through the descendant rule. A literal with a spread keeps the whole-array rule.
1716
+ const lit = _arrayLiteralTaint(node.source, '', newState, callContext);
1717
+ newState = removePathAndDescendants(newState, target);
1718
+ if (lit.whole) newState = _addPathAliasAware(newState, target, callContext);
1719
+ for (const p of lit.paths) newState = _addPathAliasAware(newState, `${target}.${p}`, callContext);
1720
+ return { state: newState, findings };
1721
+ }
1722
+ if (target && node.source && node.source.kind === 'object' && !src && _isJsFile(_currentFile)) {
1723
+ // E2-03: an object literal is assigned FIELD BY FIELD. `{ cmd: tainted, safe: 'ls' }` taints `o.cmd` and leaves `o.safe` clean; a spread of
1724
+ // a tainted value or a computed key (whose property is unknown) taints the whole object. The literal is a fresh value, so whatever `o`
1725
+ // held before is replaced, which also makes a later `o.cmd = 'ls'` an effective overwrite.
1726
+ const lit = _objectLiteralTaint(node.source, '', newState, callContext);
1727
+ newState = removePathAndDescendants(newState, target);
1728
+ if (lit.whole) newState = _addPathAliasAware(newState, target, callContext);
1729
+ for (const p of lit.paths) newState = _addPathAliasAware(newState, `${target}.${p}`, callContext);
1730
+ return { state: newState, findings };
1731
+ }
1540
1732
  if (target && _isCoercionCall(node.source)) {
1541
1733
  // See _isCoercionCall's header comment: a real type coercion kills
1542
1734
  // taint outright, unconditionally of sink family — the one case in
@@ -1581,9 +1773,9 @@ function step(node, stateIn, callContext) {
1581
1773
  const qid = _resolvedTarget && _resolvedTarget.qid;
1582
1774
  if (typeof qid === 'string' && fn && Array.isArray(fn.params)) {
1583
1775
  const paramNames = fn.params;
1584
- const entry = paramNames.length
1776
+ const entry = _withCapturedTaint(paramNames.length
1585
1777
  ? entryStateFromCall(paramNames, node.args || [], state, (a) => exprTaint(a, state, callContext))
1586
- : new Set();
1778
+ : new Set(), fn, state, callContext);
1587
1779
  let sum = callContext._summaryCache.get(qid, entry);
1588
1780
  // FR-SEM-2: context-sensitive lazy compute at the plain-call site,
1589
1781
  // mirroring the assign-call site. On a miss for a NON-empty entry,
@@ -1733,7 +1925,15 @@ function step(node, stateIn, callContext) {
1733
1925
  if (_end >= 0) _recv = _segs.slice(0, _end + 1).join('.');
1734
1926
  }
1735
1927
  }
1736
- if (_recv) state.add(_recv);
1928
+ if (_recv) {
1929
+ // E2-03: `m.set('k', v)` with a literal key taints that key only, so a later `m.get('other')` stays clean.
1930
+ const keyArg = node.args && node.args[0];
1931
+ const onlyValueTainted = argTaints.length >= 2 && !argTaints[0] && argTaints.slice(1).some(Boolean);
1932
+ if (node.callee && node.callee.kind === 'member' && node.callee.prop === 'set' && node.args && node.args.length === 2
1933
+ && keyArg && keyArg.kind === 'literal' && typeof keyArg.value === 'string' && onlyValueTainted && accessPathOf(node.callee.object) === _recv) {
1934
+ state.add(`${_recv}.$${keyArg.value}`);
1935
+ } else state.add(_recv);
1936
+ }
1737
1937
  }
1738
1938
  findings.push(..._sinkFindingsForCall(node.callee, node.args, cat, argTaints, state, callContext, node.line, node.kwargs).findings);
1739
1939
  findings.push(..._nestedSinkFindings(node.args, state, callContext, node.line));
@@ -1751,7 +1951,7 @@ function step(node, stateIn, callContext) {
1751
1951
  const dot = callee.lastIndexOf('.');
1752
1952
  if (dot <= 0) return null;
1753
1953
  const recv = callee.slice(0, dot);
1754
- const recvTainted = isCoveredBy(state, recv);
1954
+ const recvTainted = isCoveredBy(state, recv) || hasTaintedDescendant(state, recv);
1755
1955
  // higherOrderTaintFlow requires a flattened STRING callee (its own
1756
1956
  // `typeof callee !== 'string'` guard) — passing the raw `node` here
1757
1957
  // handed it a JS/TS structured callee expr ({kind:'member',...})
@@ -1760,6 +1960,7 @@ function step(node, stateIn, callContext) {
1760
1960
  // `callee` here is `_plainCallCalleeName`, already flattened above.
1761
1961
  return higherOrderTaintFlow({ ...node, callee }, recvTainted);
1762
1962
  })();
1963
+ _recordCallbackInvocations(node, state, callContext);
1763
1964
  if (hoFlow && hoFlow.taintsCallbackParam === 0) {
1764
1965
  // The first arg should be the callback. If it's a plain ident or
1765
1966
  // function-value, the engine's per-callee summary path will pick it
@@ -1827,9 +2028,10 @@ function step(node, stateIn, callContext) {
1827
2028
  // expression is directly a sink.
1828
2029
  if (node.value && node.value.kind === 'call') {
1829
2030
  const { cat, argTaints } = _matchCallCatalog(node.value.callee, node.value.args, state, callContext);
2031
+ const _at = _sinkLine(node.value, node.line);
1830
2032
  findings.push(..._sinkFindingsForCall(
1831
- node.value.callee, node.value.args, cat, argTaints, state, callContext, node.line, node.value.kwargs).findings);
1832
- findings.push(..._nestedSinkFindings(node.value.args, state, callContext, node.line));
2033
+ node.value.callee, node.value.args, cat, argTaints, state, callContext, _at, node.value.kwargs).findings);
2034
+ findings.push(..._nestedSinkFindings(node.value.args, state, callContext, _at));
1833
2035
  } else if (node.value && node.value.kind === 'union' && /\.(?:hs|lhs)$/i.test(_currentFile || '')) {
1834
2036
  // Haskell: `if c then A else B` and guards return a union of the branch ACTIONS, each of which
1835
2037
  // runs when its branch is taken, so every branch call is a sink site in its own right. (Scoped
@@ -1892,7 +2094,42 @@ function _siteKey(expr) {
1892
2094
  return h.toString(16);
1893
2095
  }
1894
2096
 
2097
+ // E2-04: framework propagation through a shared request object. In Express/Koa/Connect style apps a middleware writes `req.user = ...` and
2098
+ // a later handler reads `req.user`; the engine analyses each function alone, so the handler never saw the write. Here every function whose first
2099
+ // parameter is a request (`req`/`request`/`ctx`) and which ASSIGNS to a field of it is analysed once with nothing tainted; the fields that end up
2100
+ // tainted are remembered per file and seeded into the entry state of the other request-taking functions of the SAME file. Same-file only, and
2101
+ // only for fields some function assigns, so `req.other` stays clean. Cross-file middleware chains need a router model and are out of scope.
2102
+ const _REQ_PARAM = /^(?:req|request|ctx)$/;
2103
+ let _requestFieldSeeds = new Map(); // file -> Set<'field' | 'a.b'>
2104
+ function _buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs) {
2105
+ _requestFieldSeeds = new Map();
2106
+ for (const fn of fnList) {
2107
+ if (!fn || !Array.isArray(fn.params) || !_REQ_PARAM.test(fn.params[0] || '') || !fn.cfg || !fn.cfg.nodes) continue;
2108
+ const p0 = fn.params[0];
2109
+ const targets = new Set();
2110
+ for (const n of Object.values(fn.cfg.nodes)) if (n && n.kind === 'assign' && typeof n.target === 'string' && n.target.startsWith(`${p0}.`)) targets.add(n.target);
2111
+ if (!targets.size) continue;
2112
+ const ctx = { _findings: [], _taintSources: [], _returnTainted: false, _stack: new Set(), deadlineMs, _summaryCache: new SummaryCache(), _callGraph: callGraph, _mutatedParamsOut: new Set(), _currentFnQid: fn.qid, _cha: opts && opts._cha, _pointsTo: opts && opts._pointsTo };
2113
+ let exit = null;
2114
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, new Set()), ctx); exit = ctx._exitState; } catch { exit = null; }
2115
+ if (!exit) continue;
2116
+ for (const t of targets) {
2117
+ // the target itself (or a prefix of it) tainted seeds the target; otherwise each tainted path below it is seeded, so a literal
2118
+ // `req.ctx = { cmd: tainted, safe: 'ls' }` seeds `ctx.cmd` and leaves `ctx.safe` clean
2119
+ const paths = isCoveredBy(exit, t) ? [t] : [...exit].filter((q) => typeof q === 'string' && q.startsWith(`${t}.`));
2120
+ for (const q of paths) {
2121
+ if (!_requestFieldSeeds.has(fn.file)) _requestFieldSeeds.set(fn.file, new Set());
2122
+ _requestFieldSeeds.get(fn.file).add(q.slice(p0.length + 1));
2123
+ }
2124
+ }
2125
+ }
2126
+ }
2127
+
1895
2128
  function _unionAnnotationTaint(fn, entrySet) {
2129
+ const seeds = _requestFieldSeeds.get(fn.file);
2130
+ if (seeds && seeds.size && Array.isArray(fn.params) && _REQ_PARAM.test(fn.params[0] || '')) {
2131
+ entrySet = new Set([...entrySet, ...[...seeds].map((f) => `${fn.params[0]}.${f}`)]);
2132
+ }
1896
2133
  if (!fn.paramAnnotations || !fn.paramAnnotations.length) return entrySet;
1897
2134
  const extra = matchAnnotationParams(fn.paramAnnotations, fn.file);
1898
2135
  if (!extra.size) return entrySet;
@@ -1950,7 +2187,7 @@ function analyzeFunction(fn, entryState, callContext) {
1950
2187
  if (++iterations > ITER_BUDGET) break;
1951
2188
  // Check the global deadline every 100 iterations — Date.now() is cheap
1952
2189
  // but not free; this keeps overhead negligible on small functions.
1953
- if ((iterations & 0x7f) === 0 && Date.now() > deadlineMs) break;
2190
+ if ((iterations & 0x7f) === 0 && Date.now() > deadlineMs) { _noteIncomplete('deadline', `${fn.qid} worklist`); break; }
1954
2191
  const nid = work.shift();
1955
2192
  const node = nodes[nid];
1956
2193
  if (!node) continue;
@@ -2118,7 +2355,24 @@ function stateEq(a, b) {
2118
2355
  // The infra for it is in callContext.)
2119
2356
  //
2120
2357
  // Returns a flat array of findings, each enriched with file/line/etc.
2358
+ // E2-05: places where the analysis stopped short, reported instead of left silent. Each entry is a reason with a count and one example. A budget
2359
+ // that runs out used to leave a result that looked complete; now `runTaintEngine`'s return value carries `analysisIncomplete`, and the scan
2360
+ // reports it as `deepStatus.incomplete`.
2361
+ let _incomplete = new Map();
2362
+ function _noteIncomplete(reason, detail) {
2363
+ const cur = _incomplete.get(reason);
2364
+ if (cur) cur.count += 1; else _incomplete.set(reason, { reason, count: 1, example: String(detail ?? '').slice(0, 160) });
2365
+ }
2366
+ export const INCOMPLETE_REASONS = Object.freeze({
2367
+ 'summary-budget': 'the interprocedural summary budget ran out; later callees were treated as having no effect',
2368
+ 'deadline': 'the deep-analysis time budget ran out before every function was analysed',
2369
+ 'function-limit': 'more functions than the per-scan function limit; the rest were not analysed',
2370
+ 'higher-order-limit': 'more callback invocations than the per-function limit; the rest were not analysed',
2371
+ 'analysis-error': 'the analysis of a function threw and its result was discarded',
2372
+ });
2373
+
2121
2374
  export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2375
+ _incomplete = new Map();
2122
2376
  const all = [];
2123
2377
  const seen = new Set();
2124
2378
  const fnLimit = opts.fnLimit || 5000;
@@ -2154,6 +2408,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2154
2408
  a.qid < b.qid ? -1 : a.qid > b.qid ? 1 : 0
2155
2409
  );
2156
2410
  _buildRegexNames(fnList);
2411
+ _buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs);
2157
2412
  // PRD W0.1 (SARD_80_F1_EXECUTION_PRD.md) — the main per-function loop below
2158
2413
  // silently `break`s once `n > fnLimit` (default 5000), which for a single
2159
2414
  // large SARD CWE directory (Java CWE-89 alone: 3668 files, 17604
@@ -2578,8 +2833,8 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2578
2833
  }
2579
2834
  const _progressTotal = Math.min(fnList.length, fnLimit);
2580
2835
  for (const fn of fnList) {
2581
- if (++n > fnLimit) break;
2582
- if (Date.now() > deadlineMs) break; // global timeout
2836
+ if (++n > fnLimit) { _noteIncomplete('function-limit', `limit ${fnLimit}`); break; }
2837
+ if (Date.now() > deadlineMs) { _noteIncomplete('deadline', 'main function loop'); break; } // global timeout
2583
2838
  if (onProgress) onProgress({ current: n, total: _progressTotal });
2584
2839
  // Module-level functions: analyze with an empty entry state. The function
2585
2840
  // discovers its own sources from req.body/process.env/etc. as it walks.
@@ -2605,13 +2860,14 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2605
2860
  };
2606
2861
  try {
2607
2862
  analyzeFunction(fn, _unionAnnotationTaint(fn, new Set()), callContext);
2608
- } catch { continue; }
2863
+ } catch (e) { _noteIncomplete('analysis-error', `${fn.qid}: ${e && e.message}`); continue; }
2609
2864
  // Process higher-order invocations: resolve callbacks and analyze with
2610
2865
  // tainted first-param. Feed findings back into the caller's finding set.
2611
2866
  const hoInvocations = callContext._higherOrderInvocations || [];
2612
2867
  const HO_CAP = 50;
2868
+ if (hoInvocations.length > HO_CAP) _noteIncomplete('higher-order-limit', `${fn.qid}: ${hoInvocations.length} callbacks`);
2613
2869
  for (let hi = 0; hi < Math.min(hoInvocations.length, HO_CAP); hi++) {
2614
- if (Date.now() > deadlineMs) break;
2870
+ if (Date.now() > deadlineMs) { _noteIncomplete('deadline', 'callback analysis'); break; }
2615
2871
  const inv = hoInvocations[hi];
2616
2872
  if ((!inv.callee && !inv.calleeQid) || !inv.taintedParam) continue;
2617
2873
  // Two resolution strategies, matching the two shapes the push site can
@@ -2677,6 +2933,8 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2677
2933
  if (dg[f.severity]) f.severity = dg[f.severity];
2678
2934
  }
2679
2935
  Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
2936
+ if (summaryCache._budgetHits) _noteIncomplete('summary-budget', `${summaryCache._budgetHits} summary requests after ${summaryCache._maxIter} computations`);
2937
+ Object.defineProperty(all, 'analysisIncomplete', { value: [..._incomplete.values()].sort((a, b) => (a.reason < b.reason ? -1 : 1)), enumerable: false });
2680
2938
  _collapseNestedHsSinks(all);
2681
2939
  return all;
2682
2940