@clear-capabilities/agentic-security-scanner 0.172.1 → 0.173.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 (194) hide show
  1. package/CHANGELOG.md +15 -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 +28 -3
  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 +272 -20
  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/lineage/build-attested-correlation.js +63 -0
  157. package/src/lineage/deployment-identity.js +76 -0
  158. package/src/mcp/tools.js +13 -34
  159. package/src/pipeline/canonical-output.js +64 -0
  160. package/src/pipeline/detector-runner.js +7 -0
  161. package/src/pipeline/scan-health.js +14 -0
  162. package/src/pipeline/supervised-analyzers.js +125 -0
  163. package/src/pipeline/supervised-child.js +17 -0
  164. package/src/pipeline/telemetry.js +79 -0
  165. package/src/posture/accuracy-scorecard.js +98 -9
  166. package/src/posture/artifact-registry.js +5 -0
  167. package/src/posture/assurance/evidence-registry.js +215 -0
  168. package/src/posture/assurance/release-package-verify.js +135 -0
  169. package/src/posture/assurance/release-package.js +118 -0
  170. package/src/posture/assurance/release-verdict.js +94 -0
  171. package/src/posture/assurance/waiver.js +200 -0
  172. package/src/posture/compliance-truth.js +100 -0
  173. package/src/posture/evaluation/scan-child.js +3 -1
  174. package/src/posture/evaluation/why-missed.js +3 -1
  175. package/src/posture/exploitability-evidence.js +141 -0
  176. package/src/posture/fix-verify.js +4 -2
  177. package/src/posture/fleet/exposure-ranking.js +115 -0
  178. package/src/posture/fleet/service-chain-graph.js +150 -0
  179. package/src/posture/graph-reconciliation.js +154 -0
  180. package/src/posture/provenance/coverage.js +103 -0
  181. package/src/posture/provenance/git-evidence.js +84 -0
  182. package/src/posture/provenance/origin-resolver.js +7 -1
  183. package/src/posture/provenance/predicate-replay.js +14 -4
  184. package/src/posture/runtime-correlation.js +61 -15
  185. package/src/posture/runtime-explanation.js +116 -0
  186. package/src/posture/runtime-trace-ingest.js +194 -0
  187. package/src/posture/scan-checkpoint.js +38 -10
  188. package/src/posture/waf-ingest.js +7 -2
  189. package/src/report/index.js +4 -0
  190. package/src/report/oscal.js +38 -0
  191. package/src/report/perspectives.js +150 -0
  192. package/src/sast/_comment-strip.js +8 -1
  193. package/src/sast/ruby.js +26 -0
  194. 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
  }
@@ -1323,9 +1480,20 @@ function _memberWriteSinkFindings(hits, sourceExpr, state, callContext, line, ta
1323
1480
  // nested in runTaintEngine) because step()'s assign/plain-call interproc
1324
1481
  // branches call it too, and step() is a top-level function with no access
1325
1482
  // to runTaintEngine's locals.
1483
+ // 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
1484
+ // 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
1485
+ // 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
1486
+ // same sink reached again by the same finding is one finding, so a copy is skipped when this context already holds it.
1487
+ function _findingKey(f) { return `${f.file ?? ''}|${f.line ?? ''}|${f.cwe ?? ''}|${f.vuln ?? ''}|${typeof f.callee === 'string' ? f.callee : ''}|${f.id ?? ''}`; }
1326
1488
  function _mergeSummaryFindings(callContext, callerQid, sum, via) {
1327
1489
  if (!sum || !Array.isArray(sum.findings) || !sum.findings.length) return;
1328
- callContext._findings.push(...sum.findings.map(f => ({ ...f, _funcQid: callerQid || null, _via: via })));
1490
+ if (!callContext._findingKeys) callContext._findingKeys = new Set(callContext._findings.map(_findingKey));
1491
+ for (const f of sum.findings) {
1492
+ const key = _findingKey(f);
1493
+ if (callContext._findingKeys.has(key)) continue;
1494
+ callContext._findingKeys.add(key);
1495
+ callContext._findings.push({ ...f, _funcQid: callerQid || null, _via: via });
1496
+ }
1329
1497
  }
1330
1498
 
1331
1499
  // Apply a CFG node to a taint-state. Returns the new state + any finding emitted.
@@ -1435,9 +1603,9 @@ function step(node, stateIn, callContext) {
1435
1603
  const callerTainted = newState;
1436
1604
  const callArgs = (node.source.args || []);
1437
1605
  const paramNames = (fn && Array.isArray(fn.params)) ? fn.params : [];
1438
- const entry = paramNames.length
1606
+ const entry = _withCapturedTaint(paramNames.length
1439
1607
  ? entryStateFromCall(paramNames, callArgs, callerTainted, (a) => exprTaint(a, callerTainted, callContext))
1440
- : new Set();
1608
+ : new Set(), fn, callerTainted, callContext);
1441
1609
  let sum = callContext._summaryCache.get(qid, entry);
1442
1610
  if (!sum && fn && fn.cfg) {
1443
1611
  // Lazy compute under this entry state. Use a fresh ctx so we
@@ -1537,6 +1705,25 @@ function step(node, stateIn, callContext) {
1537
1705
  }
1538
1706
  }
1539
1707
  }
1708
+ if (target && node.source && node.source.kind === 'array' && !src && _isJsFile(_currentFile) && !(node.source.spreads && node.source.spreads.length)) {
1709
+ // E2-03: an array literal without spread elements is assigned index by index, so `[tainted, 'ls']` leaves `arr[1]` clean. Reading the
1710
+ // 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.
1711
+ const lit = _arrayLiteralTaint(node.source, '', newState, callContext);
1712
+ newState = removePathAndDescendants(newState, target);
1713
+ if (lit.whole) newState = _addPathAliasAware(newState, target, callContext);
1714
+ for (const p of lit.paths) newState = _addPathAliasAware(newState, `${target}.${p}`, callContext);
1715
+ return { state: newState, findings };
1716
+ }
1717
+ if (target && node.source && node.source.kind === 'object' && !src && _isJsFile(_currentFile)) {
1718
+ // 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
1719
+ // a tainted value or a computed key (whose property is unknown) taints the whole object. The literal is a fresh value, so whatever `o`
1720
+ // held before is replaced, which also makes a later `o.cmd = 'ls'` an effective overwrite.
1721
+ const lit = _objectLiteralTaint(node.source, '', newState, callContext);
1722
+ newState = removePathAndDescendants(newState, target);
1723
+ if (lit.whole) newState = _addPathAliasAware(newState, target, callContext);
1724
+ for (const p of lit.paths) newState = _addPathAliasAware(newState, `${target}.${p}`, callContext);
1725
+ return { state: newState, findings };
1726
+ }
1540
1727
  if (target && _isCoercionCall(node.source)) {
1541
1728
  // See _isCoercionCall's header comment: a real type coercion kills
1542
1729
  // taint outright, unconditionally of sink family — the one case in
@@ -1581,9 +1768,9 @@ function step(node, stateIn, callContext) {
1581
1768
  const qid = _resolvedTarget && _resolvedTarget.qid;
1582
1769
  if (typeof qid === 'string' && fn && Array.isArray(fn.params)) {
1583
1770
  const paramNames = fn.params;
1584
- const entry = paramNames.length
1771
+ const entry = _withCapturedTaint(paramNames.length
1585
1772
  ? entryStateFromCall(paramNames, node.args || [], state, (a) => exprTaint(a, state, callContext))
1586
- : new Set();
1773
+ : new Set(), fn, state, callContext);
1587
1774
  let sum = callContext._summaryCache.get(qid, entry);
1588
1775
  // FR-SEM-2: context-sensitive lazy compute at the plain-call site,
1589
1776
  // mirroring the assign-call site. On a miss for a NON-empty entry,
@@ -1733,7 +1920,15 @@ function step(node, stateIn, callContext) {
1733
1920
  if (_end >= 0) _recv = _segs.slice(0, _end + 1).join('.');
1734
1921
  }
1735
1922
  }
1736
- if (_recv) state.add(_recv);
1923
+ if (_recv) {
1924
+ // E2-03: `m.set('k', v)` with a literal key taints that key only, so a later `m.get('other')` stays clean.
1925
+ const keyArg = node.args && node.args[0];
1926
+ const onlyValueTainted = argTaints.length >= 2 && !argTaints[0] && argTaints.slice(1).some(Boolean);
1927
+ if (node.callee && node.callee.kind === 'member' && node.callee.prop === 'set' && node.args && node.args.length === 2
1928
+ && keyArg && keyArg.kind === 'literal' && typeof keyArg.value === 'string' && onlyValueTainted && accessPathOf(node.callee.object) === _recv) {
1929
+ state.add(`${_recv}.$${keyArg.value}`);
1930
+ } else state.add(_recv);
1931
+ }
1737
1932
  }
1738
1933
  findings.push(..._sinkFindingsForCall(node.callee, node.args, cat, argTaints, state, callContext, node.line, node.kwargs).findings);
1739
1934
  findings.push(..._nestedSinkFindings(node.args, state, callContext, node.line));
@@ -1751,7 +1946,7 @@ function step(node, stateIn, callContext) {
1751
1946
  const dot = callee.lastIndexOf('.');
1752
1947
  if (dot <= 0) return null;
1753
1948
  const recv = callee.slice(0, dot);
1754
- const recvTainted = isCoveredBy(state, recv);
1949
+ const recvTainted = isCoveredBy(state, recv) || hasTaintedDescendant(state, recv);
1755
1950
  // higherOrderTaintFlow requires a flattened STRING callee (its own
1756
1951
  // `typeof callee !== 'string'` guard) — passing the raw `node` here
1757
1952
  // handed it a JS/TS structured callee expr ({kind:'member',...})
@@ -1760,6 +1955,7 @@ function step(node, stateIn, callContext) {
1760
1955
  // `callee` here is `_plainCallCalleeName`, already flattened above.
1761
1956
  return higherOrderTaintFlow({ ...node, callee }, recvTainted);
1762
1957
  })();
1958
+ _recordCallbackInvocations(node, state, callContext);
1763
1959
  if (hoFlow && hoFlow.taintsCallbackParam === 0) {
1764
1960
  // The first arg should be the callback. If it's a plain ident or
1765
1961
  // function-value, the engine's per-callee summary path will pick it
@@ -1892,7 +2088,42 @@ function _siteKey(expr) {
1892
2088
  return h.toString(16);
1893
2089
  }
1894
2090
 
2091
+ // E2-04: framework propagation through a shared request object. In Express/Koa/Connect style apps a middleware writes `req.user = ...` and
2092
+ // a later handler reads `req.user`; the engine analyses each function alone, so the handler never saw the write. Here every function whose first
2093
+ // 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
2094
+ // 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
2095
+ // only for fields some function assigns, so `req.other` stays clean. Cross-file middleware chains need a router model and are out of scope.
2096
+ const _REQ_PARAM = /^(?:req|request|ctx)$/;
2097
+ let _requestFieldSeeds = new Map(); // file -> Set<'field' | 'a.b'>
2098
+ function _buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs) {
2099
+ _requestFieldSeeds = new Map();
2100
+ for (const fn of fnList) {
2101
+ if (!fn || !Array.isArray(fn.params) || !_REQ_PARAM.test(fn.params[0] || '') || !fn.cfg || !fn.cfg.nodes) continue;
2102
+ const p0 = fn.params[0];
2103
+ const targets = new Set();
2104
+ 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);
2105
+ if (!targets.size) continue;
2106
+ 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 };
2107
+ let exit = null;
2108
+ try { analyzeFunction(fn, _unionAnnotationTaint(fn, new Set()), ctx); exit = ctx._exitState; } catch { exit = null; }
2109
+ if (!exit) continue;
2110
+ for (const t of targets) {
2111
+ // the target itself (or a prefix of it) tainted seeds the target; otherwise each tainted path below it is seeded, so a literal
2112
+ // `req.ctx = { cmd: tainted, safe: 'ls' }` seeds `ctx.cmd` and leaves `ctx.safe` clean
2113
+ const paths = isCoveredBy(exit, t) ? [t] : [...exit].filter((q) => typeof q === 'string' && q.startsWith(`${t}.`));
2114
+ for (const q of paths) {
2115
+ if (!_requestFieldSeeds.has(fn.file)) _requestFieldSeeds.set(fn.file, new Set());
2116
+ _requestFieldSeeds.get(fn.file).add(q.slice(p0.length + 1));
2117
+ }
2118
+ }
2119
+ }
2120
+ }
2121
+
1895
2122
  function _unionAnnotationTaint(fn, entrySet) {
2123
+ const seeds = _requestFieldSeeds.get(fn.file);
2124
+ if (seeds && seeds.size && Array.isArray(fn.params) && _REQ_PARAM.test(fn.params[0] || '')) {
2125
+ entrySet = new Set([...entrySet, ...[...seeds].map((f) => `${fn.params[0]}.${f}`)]);
2126
+ }
1896
2127
  if (!fn.paramAnnotations || !fn.paramAnnotations.length) return entrySet;
1897
2128
  const extra = matchAnnotationParams(fn.paramAnnotations, fn.file);
1898
2129
  if (!extra.size) return entrySet;
@@ -1950,7 +2181,7 @@ function analyzeFunction(fn, entryState, callContext) {
1950
2181
  if (++iterations > ITER_BUDGET) break;
1951
2182
  // Check the global deadline every 100 iterations — Date.now() is cheap
1952
2183
  // but not free; this keeps overhead negligible on small functions.
1953
- if ((iterations & 0x7f) === 0 && Date.now() > deadlineMs) break;
2184
+ if ((iterations & 0x7f) === 0 && Date.now() > deadlineMs) { _noteIncomplete('deadline', `${fn.qid} worklist`); break; }
1954
2185
  const nid = work.shift();
1955
2186
  const node = nodes[nid];
1956
2187
  if (!node) continue;
@@ -2118,7 +2349,24 @@ function stateEq(a, b) {
2118
2349
  // The infra for it is in callContext.)
2119
2350
  //
2120
2351
  // Returns a flat array of findings, each enriched with file/line/etc.
2352
+ // 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
2353
+ // that runs out used to leave a result that looked complete; now `runTaintEngine`'s return value carries `analysisIncomplete`, and the scan
2354
+ // reports it as `deepStatus.incomplete`.
2355
+ let _incomplete = new Map();
2356
+ function _noteIncomplete(reason, detail) {
2357
+ const cur = _incomplete.get(reason);
2358
+ if (cur) cur.count += 1; else _incomplete.set(reason, { reason, count: 1, example: String(detail ?? '').slice(0, 160) });
2359
+ }
2360
+ export const INCOMPLETE_REASONS = Object.freeze({
2361
+ 'summary-budget': 'the interprocedural summary budget ran out; later callees were treated as having no effect',
2362
+ 'deadline': 'the deep-analysis time budget ran out before every function was analysed',
2363
+ 'function-limit': 'more functions than the per-scan function limit; the rest were not analysed',
2364
+ 'higher-order-limit': 'more callback invocations than the per-function limit; the rest were not analysed',
2365
+ 'analysis-error': 'the analysis of a function threw and its result was discarded',
2366
+ });
2367
+
2121
2368
  export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2369
+ _incomplete = new Map();
2122
2370
  const all = [];
2123
2371
  const seen = new Set();
2124
2372
  const fnLimit = opts.fnLimit || 5000;
@@ -2154,6 +2402,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2154
2402
  a.qid < b.qid ? -1 : a.qid > b.qid ? 1 : 0
2155
2403
  );
2156
2404
  _buildRegexNames(fnList);
2405
+ _buildRequestFieldSeeds(fnList, callGraph, opts, deadlineMs);
2157
2406
  // PRD W0.1 (SARD_80_F1_EXECUTION_PRD.md) — the main per-function loop below
2158
2407
  // silently `break`s once `n > fnLimit` (default 5000), which for a single
2159
2408
  // large SARD CWE directory (Java CWE-89 alone: 3668 files, 17604
@@ -2578,8 +2827,8 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2578
2827
  }
2579
2828
  const _progressTotal = Math.min(fnList.length, fnLimit);
2580
2829
  for (const fn of fnList) {
2581
- if (++n > fnLimit) break;
2582
- if (Date.now() > deadlineMs) break; // global timeout
2830
+ if (++n > fnLimit) { _noteIncomplete('function-limit', `limit ${fnLimit}`); break; }
2831
+ if (Date.now() > deadlineMs) { _noteIncomplete('deadline', 'main function loop'); break; } // global timeout
2583
2832
  if (onProgress) onProgress({ current: n, total: _progressTotal });
2584
2833
  // Module-level functions: analyze with an empty entry state. The function
2585
2834
  // discovers its own sources from req.body/process.env/etc. as it walks.
@@ -2605,13 +2854,14 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2605
2854
  };
2606
2855
  try {
2607
2856
  analyzeFunction(fn, _unionAnnotationTaint(fn, new Set()), callContext);
2608
- } catch { continue; }
2857
+ } catch (e) { _noteIncomplete('analysis-error', `${fn.qid}: ${e && e.message}`); continue; }
2609
2858
  // Process higher-order invocations: resolve callbacks and analyze with
2610
2859
  // tainted first-param. Feed findings back into the caller's finding set.
2611
2860
  const hoInvocations = callContext._higherOrderInvocations || [];
2612
2861
  const HO_CAP = 50;
2862
+ if (hoInvocations.length > HO_CAP) _noteIncomplete('higher-order-limit', `${fn.qid}: ${hoInvocations.length} callbacks`);
2613
2863
  for (let hi = 0; hi < Math.min(hoInvocations.length, HO_CAP); hi++) {
2614
- if (Date.now() > deadlineMs) break;
2864
+ if (Date.now() > deadlineMs) { _noteIncomplete('deadline', 'callback analysis'); break; }
2615
2865
  const inv = hoInvocations[hi];
2616
2866
  if ((!inv.callee && !inv.calleeQid) || !inv.taintedParam) continue;
2617
2867
  // Two resolution strategies, matching the two shapes the push site can
@@ -2677,6 +2927,8 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
2677
2927
  if (dg[f.severity]) f.severity = dg[f.severity];
2678
2928
  }
2679
2929
  Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
2930
+ if (summaryCache._budgetHits) _noteIncomplete('summary-budget', `${summaryCache._budgetHits} summary requests after ${summaryCache._maxIter} computations`);
2931
+ Object.defineProperty(all, 'analysisIncomplete', { value: [..._incomplete.values()].sort((a, b) => (a.reason < b.reason ? -1 : 1)), enumerable: false });
2680
2932
  _collapseNestedHsSinks(all);
2681
2933
  return all;
2682
2934
 
@@ -0,0 +1,174 @@
1
+ // Static service relationships (E2-07): which service calls, publishes to, or stores for which other service, read from the code.
2
+ //
3
+ // OPT-IN. Nothing here runs unless a caller passes the service roots; a single-service repository gets nothing, and no relationship is invented
4
+ // from a naming coincidence alone.
5
+ //
6
+ // WHAT AN EDGE CARRIES. Each edge says how it is known:
7
+ // observed both ends are literal in the code and agree (an HTTP client calling `http://orders/api/items` and an `orders` route `POST /api/items`;
8
+ // a producer and a consumer naming the same literal topic; two services naming the same table AND the project declaring one shared database)
9
+ // inferred the ends match but one rests on a weaker fact (two services name the same table with no declaration that they share a database;
10
+ // a client URL whose host comes from a configured base URL)
11
+ // unknown one end is dynamic (a computed topic, a URL assembled from user data) or has no counterpart: it is recorded and never connected
12
+ //
13
+ // WHAT IT NEVER SAYS. A chain is `static-observed` only when every hop is observed, `static-inferred` when the weakest hop is inferred, and
14
+ // `unproven` is reserved for a caller that supplies its own edges: this module never connects across an unknown one. No label is ever `proven`: this module reads text, it does not run the services, so ambiguity across
15
+ // a service boundary cannot be described as a demonstrated exploit chain. Traversal is bounded by hop count and a visited set, so a cycle ends.
16
+ //
17
+ // SCOPE. JavaScript, TypeScript and Python sources. HTTP (Express/Koa/Fastify-style routes, Flask/FastAPI decorators; fetch/axios/requests/got
18
+ // clients), messaging (Kafka/AMQP/Redis/NATS style publish and subscribe with a literal topic), persistence (SQL text and common collection calls).
19
+ // Serializer contracts (protobuf/JSON-schema sharing) are not read: recorded as a limit.
20
+ import { createHash } from 'node:crypto';
21
+
22
+ const EDGE_KINDS = Object.freeze(['http', 'messaging', 'persistence']);
23
+ const EVIDENCE = Object.freeze(['observed', 'inferred', 'unknown']);
24
+ export const MAX_HOPS = 6;
25
+ const RANK = { observed: 0, inferred: 1, unknown: 2 };
26
+ const id = (parts) => `svcedge:${createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16)}`;
27
+ const posix = (p) => String(p).replace(/\\/g, '/');
28
+ const lineOf = (code, index) => code.slice(0, index).split('\n').length;
29
+
30
+ function serviceOf(file, services) {
31
+ const f = posix(file);
32
+ let best = null;
33
+ for (const s of services) { const root = posix(s.root).replace(/\/$/, ''); if ((f === root || f.startsWith(`${root}/`)) && (!best || root.length > posix(best.root).length)) best = s; }
34
+ return best;
35
+ }
36
+
37
+ const norm = (p) => `/${String(p).replace(/^https?:\/\/[^/]+/, '').replace(/[?#].*$/, '').split('/').filter(Boolean).join('/')}`;
38
+ // `/items/:id`, `/items/{id}`, `/items/<id>`, `/items/${id}` all name the same shape
39
+ // a concrete segment (`/items/42`) matches a parameter segment of a route; two concrete segments must be equal
40
+ const matchesShape = (routePath, clientPath) => { const r = shape(routePath).split('/'); const c = shape(clientPath).split('/'); return r.length === c.length && r.every((seg, i) => seg === ':p' || seg === c[i]); };
41
+ const shape = (p) => norm(p).split('/').map((seg) => (/^(?::|\{|<|\$\{)/.test(seg) ? ':p' : seg)).join('/');
42
+
43
+ // ---- extraction -------------------------------------------------------------------------------------------------------------
44
+
45
+ const ROUTE_JS = /\b(?:app|router|server|fastify|api)\s*\.\s*(get|post|put|patch|delete|all)\s*\(\s*(['"`])(\/[^'"`]*)\2/g;
46
+ const ROUTE_PY = /@\s*(?:app|router|bp|blueprint)\s*\.\s*(get|post|put|patch|delete|route)\s*\(\s*(['"])(\/[^'"]*)\2([^)]*)\)/g;
47
+ const CLIENT_JS = /\b(fetch|axios(?:\s*\.\s*(?:get|post|put|patch|delete))?|got(?:\s*\.\s*(?:get|post|put|patch|delete))?)\s*\(\s*([`'"])([^`'"]*)\2/g;
48
+ const CLIENT_PY = /\brequests\s*\.\s*(get|post|put|patch|delete)\s*\(\s*(['"])([^'"]*)\2/g;
49
+ const CLIENT_DYN = /\b(?:fetch|axios(?:\s*\.\s*(?:get|post|put|patch|delete))?|requests\s*\.\s*(?:get|post|put|patch|delete)|got)\s*\(\s*([A-Za-z_$][\w$.]*)\s*[,)]/g;
50
+ const PRODUCE = /\b(?:producer|channel|publisher|pubsub|nats|client|redis|bus|queue)\s*\.\s*(?:send|publish|emit|produce|sendToQueue|xadd|lpush)\s*\(\s*(?:\{\s*topic\s*:\s*)?(['"`])([^'"`]+)\1/g;
51
+ const PRODUCE_DYN = /\b(?:producer|channel|publisher|pubsub|nats|bus|queue)\s*\.\s*(?:send|publish|emit|produce|sendToQueue)\s*\(\s*(?:\{\s*topic\s*:\s*)?([A-Za-z_$][\w$.]*)\s*[,})]/g;
52
+ const CONSUME = /\b(?:consumer|channel|subscriber|pubsub|nats|bus|queue|client)\s*\.\s*(?:subscribe|consume|on|psubscribe|xread|brpop)\s*\(\s*(?:\{\s*topic\s*:\s*)?(['"`])([^'"`]+)\1/g;
53
+ const SQL_WRITE = /\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+[`"']?([A-Za-z_][\w.]*)/gi;
54
+ const SQL_READ = /\bSELECT\b[^;]{0,300}?\bFROM\s+[`"']?([A-Za-z_][\w.]*)/gi;
55
+ const COLL_WRITE = /\b(?:db|database|mongo)\s*\.\s*(?:collection\s*\(\s*['"`]([\w.-]+)['"`]\s*\)|([A-Za-z_]\w*))\s*\.\s*(?:insertOne|insertMany|updateOne|updateMany|deleteOne|deleteMany|save|create|insert|update|remove)\s*\(/g;
56
+ const COLL_READ = /\b(?:db|database|mongo)\s*\.\s*(?:collection\s*\(\s*['"`]([\w.-]+)['"`]\s*\)|([A-Za-z_]\w*))\s*\.\s*(?:find|findOne|findMany|findById|get|select|aggregate|count)\s*\(/g;
57
+ const DB_URL = /\b(DATABASE_URL|DB_URL|MONGO_URL|MONGODB_URI|POSTGRES_URL|REDIS_URL)\b/g;
58
+
59
+ function* matches(re, code) { re.lastIndex = 0; let m; while ((m = re.exec(code))) yield m; }
60
+
61
+ /** @returns {{routes:object[], clients:object[], produces:object[], consumes:object[], writes:object[], reads:object[], dbRefs:string[], unknown:object[]}} */
62
+ export function extractFacts(file, code) {
63
+ const out = { routes: [], clients: [], produces: [], consumes: [], writes: [], reads: [], dbRefs: [], unknown: [] };
64
+ const py = /\.py$/.test(file);
65
+ const at = (m) => lineOf(code, m.index);
66
+ for (const m of matches(py ? ROUTE_PY : ROUTE_JS, code)) {
67
+ let method = m[1].toUpperCase();
68
+ if (py && method === 'ROUTE') { const mm = /methods\s*=\s*\[\s*['"](\w+)['"]/.exec(m[4] || ''); method = mm ? mm[1].toUpperCase() : 'GET'; }
69
+ out.routes.push({ method, path: m[3], file, line: at(m) });
70
+ }
71
+ for (const m of matches(py ? CLIENT_PY : CLIENT_JS, code)) {
72
+ const target = py ? m[3] : m[3];
73
+ // `fetch(url, { method: 'POST' })` names its verb in the options object; `axios.post` and `requests.post` in the callee
74
+ const optionVerb = /\bmethod\s*:\s*['"`](\w+)['"`]/.exec(code.slice(m.index, m.index + 300));
75
+ const verb = py ? m[1].toUpperCase() : ((/\.\s*(get|post|put|patch|delete)/.exec(m[1]) || [null, optionVerb ? optionVerb[1] : 'GET'])[1]).toUpperCase();
76
+ if (/^https?:\/\//.test(target) || /^\$\{[^}]+\}\//.test(target)) out.clients.push({ method: verb, url: target, file, line: at(m), literalHost: /^https?:\/\/[^/${]+/.test(target) });
77
+ }
78
+ for (const m of matches(CLIENT_DYN, code)) out.unknown.push({ kind: 'http', file, line: at(m), reason: `client URL comes from \`${m[1]}\`, not a literal` });
79
+ for (const m of matches(PRODUCE, code)) out.produces.push({ topic: m[2], file, line: at(m) });
80
+ for (const m of matches(PRODUCE_DYN, code)) out.unknown.push({ kind: 'messaging', file, line: at(m), reason: `topic comes from \`${m[1]}\`, not a literal` });
81
+ for (const m of matches(CONSUME, code)) out.consumes.push({ topic: m[2], file, line: at(m) });
82
+ for (const m of matches(SQL_WRITE, code)) out.writes.push({ table: m[1].toLowerCase(), file, line: at(m) });
83
+ for (const m of matches(SQL_READ, code)) out.reads.push({ table: m[1].toLowerCase(), file, line: at(m) });
84
+ for (const m of matches(COLL_WRITE, code)) out.writes.push({ table: String(m[1] || m[2]).toLowerCase(), file, line: at(m) });
85
+ for (const m of matches(COLL_READ, code)) out.reads.push({ table: String(m[1] || m[2]).toLowerCase(), file, line: at(m) });
86
+ for (const m of matches(DB_URL, code)) out.dbRefs.push(m[1]);
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * @param {Record<string,string>} fileContents repository-relative path -> source
92
+ * @param {{services:{name:string, root:string, hosts?:string[]}[], sharedDatabases?:{name?:string, services:string[], tables?:string[]}[]}} opts
93
+ * `services` is REQUIRED: without it nothing is inferred. `hosts` are the names other services use to reach this one.
94
+ * `sharedDatabases` is the declaration that makes a persistence edge observed rather than inferred.
95
+ */
96
+ export function buildServiceEdges(fileContents, opts = {}) {
97
+ const services = (opts.services || []).filter((s) => s && s.name && s.root);
98
+ if (services.length < 2) return { edges: [], unknown: [], services: services.map((s) => s.name), limits: LIMITS, optedIn: false, note: 'fewer than two services declared: no service relationships are inferred' };
99
+ const facts = new Map(services.map((s) => [s.name, { routes: [], clients: [], produces: [], consumes: [], writes: [], reads: [], dbRefs: new Set(), unknown: [] }]));
100
+ for (const [file, code] of Object.entries(fileContents || {}).sort(([a], [b]) => (a < b ? -1 : 1))) {
101
+ if (typeof code !== 'string' || !/\.(?:[cm]?[jt]sx?|py)$/.test(file)) continue;
102
+ const svc = serviceOf(file, services);
103
+ if (!svc) continue;
104
+ const f = extractFacts(posix(file), code);
105
+ const acc = facts.get(svc.name);
106
+ for (const k of ['routes', 'clients', 'produces', 'consumes', 'writes', 'reads', 'unknown']) acc[k].push(...f[k].map((x) => ({ ...x, service: svc.name })));
107
+ for (const d of f.dbRefs) acc.dbRefs.add(d);
108
+ }
109
+ const hostsOf = (s) => new Set([s.name, ...(s.hosts || [])].map((h) => h.toLowerCase()));
110
+ const edges = []; const unknown = [];
111
+ for (const [name, f] of facts) for (const u of f.unknown) unknown.push({ service: name, ...u, evidence: 'unknown' });
112
+
113
+ // HTTP: a client URL names a host and a path; the host picks the service, the method and path shape pick the route
114
+ for (const [from, f] of facts) for (const c of f.clients) {
115
+ const hm = /^https?:\/\/([^/:${]+)/.exec(c.url);
116
+ const host = hm ? hm[1].toLowerCase() : null;
117
+ const target = host ? services.find((s) => s.name !== from && hostsOf(s).has(host)) : null;
118
+ if (!target) { unknown.push({ service: from, kind: 'http', file: c.file, line: c.line, reason: host ? `host ${host} is not a declared service` : 'the host is configured at run time', evidence: 'unknown' }); continue; }
119
+ const route = facts.get(target.name).routes.find((r) => (r.method === c.method || r.method === 'ALL') && matchesShape(r.path, c.url));
120
+ if (!route) { unknown.push({ service: from, kind: 'http', file: c.file, line: c.line, reason: `${target.name} has no ${c.method} route matching ${norm(c.url)}`, evidence: 'unknown' }); continue; }
121
+ edges.push({ id: id(['http', from, c.file, c.method, shape(c.url), target.name, route.file]), kind: 'http', from: { service: from, file: c.file, line: c.line }, to: { service: target.name, file: route.file, line: route.line }, via: { method: c.method, path: shape(route.path) }, evidence: 'observed', basis: 'a literal client URL and a route with the same method and path shape' });
122
+ }
123
+ // Messaging: the same literal topic on a producer and a consumer in different services
124
+ for (const [from, f] of facts) for (const p of f.produces) {
125
+ const consumers = [...facts].filter(([n]) => n !== from).flatMap(([n, g]) => g.consumes.filter((c) => c.topic === p.topic).map((c) => ({ ...c, service: n })));
126
+ if (!consumers.length) { unknown.push({ service: from, kind: 'messaging', file: p.file, line: p.line, reason: `no other service consumes topic "${p.topic}"`, evidence: 'unknown' }); continue; }
127
+ for (const c of consumers) edges.push({ id: id(['messaging', from, p.file, p.topic, c.service, c.file]), kind: 'messaging', from: { service: from, file: p.file, line: p.line }, to: { service: c.service, file: c.file, line: c.line }, via: { topic: p.topic }, evidence: 'observed', basis: 'the same literal topic is produced and consumed' });
128
+ }
129
+ // Persistence: one service writes a table another reads
130
+ const shared = (opts.sharedDatabases || []).map((d) => ({ services: new Set(d.services), tables: d.tables ? new Set(d.tables.map((t) => t.toLowerCase())) : null }));
131
+ for (const [from, f] of facts) for (const w of f.writes) for (const [to, g] of facts) {
132
+ if (to === from) continue;
133
+ for (const r of g.reads.filter((x) => x.table === w.table)) {
134
+ const declared = shared.some((d) => d.services.has(from) && d.services.has(to) && (!d.tables || d.tables.has(w.table)));
135
+ edges.push({ id: id(['persistence', from, w.file, w.table, to, r.file]), kind: 'persistence', from: { service: from, file: w.file, line: w.line }, to: { service: to, file: r.file, line: r.line }, via: { table: w.table }, evidence: declared ? 'observed' : 'inferred', basis: declared ? 'both services use the table and the project declares them as sharing a database' : 'both services name the table, but nothing declares that they share a database' });
136
+ }
137
+ }
138
+ edges.sort((a, b) => (a.id < b.id ? -1 : 1));
139
+ unknown.sort((a, b) => (a.service + a.file + a.line < b.service + b.file + b.line ? -1 : 1));
140
+ return { edges, unknown, services: services.map((s) => s.name), limits: LIMITS, optedIn: true };
141
+ }
142
+
143
+ const LIMITS = Object.freeze([
144
+ 'only JavaScript, TypeScript and Python sources are read',
145
+ 'a topic, URL host or table assembled at run time is recorded as unknown and never connected',
146
+ 'serializer contracts (protobuf, JSON schema) are not read, so a shared message shape is not an edge',
147
+ 'an edge says two services are related in the code; it does not say attacker data crosses it. That needs a finding on each side',
148
+ 'no chain is ever labelled proven: this module reads text and does not run the services',
149
+ ]);
150
+
151
+ /**
152
+ * Bounded breadth-first trace from a service. Each path carries the weakest evidence on it. A path through an inferred edge is
153
+ * `static-inferred`; the label `static-observed` needs every hop observed; nothing is `proven`.
154
+ */
155
+ export function traceFrom(graph, start, { maxHops = MAX_HOPS, maxPaths = 200 } = {}) {
156
+ const hops = Math.max(1, Math.min(MAX_HOPS, maxHops));
157
+ const byFrom = new Map();
158
+ for (const e of graph.edges) { if (!byFrom.has(e.from.service)) byFrom.set(e.from.service, []); byFrom.get(e.from.service).push(e); }
159
+ const out = []; let truncated = false;
160
+ const queue = [{ at: start, path: [], seen: new Set([start]) }];
161
+ while (queue.length) {
162
+ const cur = queue.shift();
163
+ if (cur.path.length >= hops) { if ((byFrom.get(cur.at) || []).length) truncated = true; continue; }
164
+ for (const e of byFrom.get(cur.at) || []) {
165
+ if (cur.seen.has(e.to.service)) continue;
166
+ const path = [...cur.path, e];
167
+ if (out.length >= maxPaths) { truncated = true; break; }
168
+ const weakest = path.reduce((w, x) => (RANK[x.evidence] > RANK[w] ? x.evidence : w), 'observed');
169
+ out.push({ services: [start, ...path.map((x) => x.to.service)], edges: path.map((x) => x.id), kinds: path.map((x) => x.kind), evidence: weakest, label: weakest === 'observed' ? 'static-observed' : weakest === 'inferred' ? 'static-inferred' : 'unproven' });
170
+ queue.push({ at: e.to.service, path, seen: new Set([...cur.seen, e.to.service]) });
171
+ }
172
+ }
173
+ return { start, paths: out, truncated, maxHops: hops };
174
+ }