@clear-capabilities/agentic-security-scanner 0.86.0 → 0.119.1

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 (128) hide show
  1. package/CHANGELOG.md +660 -0
  2. package/bin/agentic-security.js +78 -1
  3. package/dist/178.index.js +1 -1
  4. package/dist/384.index.js +1 -1
  5. package/dist/415.index.js +90 -0
  6. package/dist/444.index.js +89 -0
  7. package/dist/503.index.js +288 -0
  8. package/dist/637.index.js +15 -1
  9. package/dist/838.index.js +1 -1
  10. package/dist/985.index.js +5 -1
  11. package/dist/agentic-security.mjs +84 -84
  12. package/dist/agentic-security.mjs.sha256 +1 -1
  13. package/package.json +21 -10
  14. package/src/dataflow/CLAUDE.md +18 -6
  15. package/src/dataflow/catalog.js +59 -11
  16. package/src/dataflow/engine.js +106 -5
  17. package/src/dataflow/implicit-flow.js +4 -2
  18. package/src/dataflow/index.js +17 -4
  19. package/src/dataflow/proof-gate.js +100 -0
  20. package/src/dataflow/summaries.js +63 -6
  21. package/src/engine.js +292 -10
  22. package/src/ir/CLAUDE.md +1 -0
  23. package/src/ir/callgraph.js +12 -1
  24. package/src/ir/parser-go.js +8 -0
  25. package/src/ir/tree-sitter-loader.js +108 -0
  26. package/src/mcp/tools.js +6 -2
  27. package/src/posture/corpus-status.js +74 -0
  28. package/src/posture/coverage-report.js +113 -0
  29. package/src/posture/fix-history.js +24 -0
  30. package/src/posture/holdout-eval.js +57 -0
  31. package/src/posture/provenance.js +39 -0
  32. package/src/posture/router.js +33 -3
  33. package/src/posture/sca-verdict.js +130 -0
  34. package/src/posture/secret-history.js +72 -0
  35. package/src/pr-delta.js +13 -0
  36. package/src/report/index.js +67 -0
  37. package/src/sast/CLAUDE.md +9 -3
  38. package/src/sast/agent-untrusted-flow.js +107 -0
  39. package/src/sast/api-authz.js +71 -0
  40. package/src/sast/code-injection-multilang.js +117 -0
  41. package/src/sast/cross-service.js +98 -0
  42. package/src/sast/crypto-protocol.js +43 -1
  43. package/src/sast/csharp-structural.js +65 -0
  44. package/src/sast/csharp.js +3 -0
  45. package/src/sast/csrf.js +24 -3
  46. package/src/sast/csv-injection.js +84 -0
  47. package/src/sast/event-entrypoint.js +90 -0
  48. package/src/sast/frontend-hygiene.js +106 -0
  49. package/src/sast/go-structural.js +60 -0
  50. package/src/sast/iac-terraform.js +107 -0
  51. package/src/sast/java-structural.js +81 -0
  52. package/src/sast/js-framework-structural.js +115 -0
  53. package/src/sast/kotlin.js +72 -0
  54. package/src/sast/ldap-injection.js +73 -17
  55. package/src/sast/open-redirect.js +47 -5
  56. package/src/sast/php.js +35 -0
  57. package/src/sast/python-structural.js +83 -0
  58. package/src/sast/rbac-consistency.js +113 -0
  59. package/src/sast/redos-nfa.js +75 -0
  60. package/src/sast/response-splitting.js +48 -19
  61. package/src/sast/ruby.js +38 -0
  62. package/src/sast/secret-concat.js +60 -0
  63. package/src/sast/ssrf-cloud-metadata.js +13 -0
  64. package/src/sast/stored-taint.js +88 -0
  65. package/src/sast/tree-sitter-sinks.js +62 -0
  66. package/src/sast/weak-password-hash.js +28 -0
  67. package/src/sast/weak-randomness.js +46 -5
  68. package/src/sast/wrong-context-sanitizer.js +149 -0
  69. package/src/sast/xpath-injection.js +30 -6
  70. package/src/sast/xss-reflected-multilang.js +116 -0
  71. package/src/sast/xxe.js +67 -0
  72. package/src/sca/image-packages.js +74 -0
  73. package/src/sca/import-reachability.js +271 -0
  74. package/src/sca/install-script-analysis.js +82 -0
  75. package/bin/.agentic-security/findings.json +0 -1907
  76. package/bin/.agentic-security/last-scan.json +0 -1907
  77. package/bin/.agentic-security/last-scan.json.sig +0 -1
  78. package/bin/.agentic-security/scan-history.json +0 -166
  79. package/bin/.agentic-security/streak.json +0 -20
  80. package/src/.agentic-security/findings.json +0 -117732
  81. package/src/.agentic-security/last-scan.json +0 -117732
  82. package/src/.agentic-security/last-scan.json.sig +0 -1
  83. package/src/.agentic-security/scan-history.json +0 -12946
  84. package/src/.agentic-security/streak.json +0 -21
  85. package/src/dataflow/.agentic-security/findings.json +0 -6086
  86. package/src/dataflow/.agentic-security/last-scan.json +0 -6086
  87. package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
  88. package/src/dataflow/.agentic-security/scan-history.json +0 -250
  89. package/src/dataflow/.agentic-security/streak.json +0 -21
  90. package/src/ir/.agentic-security/findings.json +0 -4011
  91. package/src/ir/.agentic-security/last-scan.json +0 -4011
  92. package/src/ir/.agentic-security/last-scan.json.sig +0 -1
  93. package/src/ir/.agentic-security/scan-history.json +0 -193
  94. package/src/ir/.agentic-security/streak.json +0 -20
  95. package/src/mcp/.agentic-security/findings.json +0 -8632
  96. package/src/mcp/.agentic-security/last-scan.json +0 -8632
  97. package/src/mcp/.agentic-security/last-scan.json.sig +0 -1
  98. package/src/mcp/.agentic-security/scan-history.json +0 -331
  99. package/src/mcp/.agentic-security/streak.json +0 -20
  100. package/src/posture/.agentic-security/dpia.md +0 -26
  101. package/src/posture/.agentic-security/findings.json +0 -77181
  102. package/src/posture/.agentic-security/last-scan.json +0 -77181
  103. package/src/posture/.agentic-security/last-scan.json.sig +0 -1
  104. package/src/posture/.agentic-security/pqc-migration-plan.json +0 -65
  105. package/src/posture/.agentic-security/pqc-migration-plan.md +0 -30
  106. package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
  107. package/src/posture/.agentic-security/scan-history.json +0 -8904
  108. package/src/posture/.agentic-security/streak.json +0 -21
  109. package/src/posture/.agentic-security/threat-model.json +0 -2038
  110. package/src/posture/.agentic-security/threat-model.md +0 -73
  111. package/src/posture/compliance-frameworks/.agentic-security/findings.json +0 -80
  112. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +0 -80
  113. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +0 -1
  114. package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +0 -90
  115. package/src/posture/compliance-frameworks/.agentic-security/streak.json +0 -22
  116. package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
  117. package/src/report/.agentic-security/threat-model.json +0 -7
  118. package/src/report/.agentic-security/threat-model.md +0 -22
  119. package/src/sast/.agentic-security/findings.json +0 -6154
  120. package/src/sast/.agentic-security/last-scan.json +0 -6154
  121. package/src/sast/.agentic-security/last-scan.json.sig +0 -1
  122. package/src/sast/.agentic-security/scan-history.json +0 -941
  123. package/src/sast/.agentic-security/streak.json +0 -22
  124. package/src/sca/.agentic-security/findings.json +0 -7460
  125. package/src/sca/.agentic-security/last-scan.json +0 -7460
  126. package/src/sca/.agentic-security/last-scan.json.sig +0 -1
  127. package/src/sca/.agentic-security/scan-history.json +0 -113
  128. package/src/sca/.agentic-security/streak.json +0 -21
@@ -1,9 +1,18 @@
1
1
  // Function-summary cache for context-sensitive interprocedural taint.
2
2
  //
3
- // PRD §6.2: "k-CFA configurable per analysis." This module implements the
4
- // k=1 monovariant version — each function gets ONE summary per distinct
5
- // entry-taint-state, cached by hash. Higher k = exponential blowup we don't
6
- // pay yet.
3
+ // PRD §6.2: "k-CFA configurable per analysis." This module is VALUE-context
4
+ // sensitive (FR-SEM-2): a function gets a distinct summary PER distinct
5
+ // entry-taint-state, cached by hash and computed lazily at the call sites
6
+ // that need it (see engine.js). So a helper that is pure when called with
7
+ // clean args but vulnerable when called with tainted args is modelled
8
+ // correctly at each call site, not collapsed to one empty-entry result.
9
+ //
10
+ // To bound the blowup that per-context computation invites, the number of
11
+ // distinct NON-empty contexts kept per function is capped
12
+ // (`_maxContextsPerFn`, env `AGENTIC_SECURITY_KCFA_MAX_CONTEXTS`, default 16;
13
+ // 0 = pure monovariant). Over the cap we reuse the empty-entry summary.
14
+ // Call-string (k>1) sensitivity is still not modelled — context is the
15
+ // value-abstraction (which params are tainted), not the call stack.
7
16
  //
8
17
  // A summary captures, given a set of tainted parameter names at function
9
18
  // entry, what the function does:
@@ -43,12 +52,42 @@ export class SummaryCache {
43
52
  this._stack = new Set(); // qids currently being analyzed (recursion guard)
44
53
  this._iter = 0;
45
54
  this._maxIter = 5000;
55
+ // FR-SEM-2 (roadmap #2): the engine computes a distinct summary per
56
+ // entry-taint-state (value-context sensitivity), computed lazily at call
57
+ // sites. To stop that from blowing up when a hot helper is reached under
58
+ // many distinct tainted-arg combinations, cap the number of NON-empty
59
+ // entry contexts kept per function. Over the cap we reuse the empty-entry
60
+ // (monovariant) summary — the conservative base the pre-pass computed.
61
+ // AGENTIC_SECURITY_KCFA_MAX_CONTEXTS=N (default 16)
62
+ // ...=0 disables context-sensitivity (pure monovariant).
63
+ this._contextsByQid = new Map(); // qid → Set<stateHash> of computed contexts
64
+ const envCap = Number(process.env.AGENTIC_SECURITY_KCFA_MAX_CONTEXTS);
65
+ this._maxContextsPerFn = Number.isFinite(envCap) && envCap >= 0 ? envCap : 16;
66
+ this._contextCapHits = 0;
67
+ // R2 (PRD §5): bounded k=1 call-string sensitivity. OPT-IN (default OFF).
68
+ // When on, the cache key is extended with the immediate caller's qid, so two
69
+ // call paths reaching a helper with the SAME tainted-arg shape get DISTINCT
70
+ // summaries instead of sharing one (removes the over-merge that the pure
71
+ // value-context cache produces). Off by default → keys are byte-identical to
72
+ // before, so no behavior change for the shipped engine.
73
+ this._callString = process.env.AGENTIC_SECURITY_KCFA_CALLSTRING === '1';
74
+ this._callerCtx = null;
75
+ }
76
+
77
+ // Set the immediate-caller qid for call-string keying; returns the previous
78
+ // value so the engine can save/restore it around nested callee analysis.
79
+ setCallerContext(callerQid) {
80
+ const prev = this._callerCtx;
81
+ this._callerCtx = callerQid || null;
82
+ return prev;
46
83
  }
47
84
 
48
85
  _key(qid, taintedParams, receiverType) {
49
86
  // P1.2: when a receiver type is provided, extend the cache key with
50
87
  // its hash. Backward-compatible: no receiverType → same key as before.
51
- const base = `${qid}::${_hashState(taintedParams)}`;
88
+ let base = `${qid}::${_hashState(taintedParams)}`;
89
+ // R2: append the caller qid when call-string sensitivity is enabled.
90
+ if (this._callString && this._callerCtx) base = `${base}@${this._callerCtx}`;
52
91
  if (!receiverType) return base;
53
92
  return `${base}::${hashReceiverType(receiverType)}`;
54
93
  }
@@ -77,6 +116,24 @@ export class SummaryCache {
77
116
  const cached = this._cache.get(k);
78
117
  if (!cached._recursive) return cached;
79
118
  }
119
+ // Context cap (FR-SEM-2). Empty entry is always allowed — it's the base
120
+ // summary the pre-pass computes for every function. For a NON-empty
121
+ // context that's new and over budget, reuse the empty-entry summary
122
+ // instead of computing a fresh one.
123
+ const stateHash = _hashState(taintedParams);
124
+ if (stateHash !== 'empty') {
125
+ let ctxs = this._contextsByQid.get(qid);
126
+ if (!ctxs) { ctxs = new Set(); this._contextsByQid.set(qid, ctxs); }
127
+ if (!ctxs.has(stateHash) && ctxs.size >= this._maxContextsPerFn) {
128
+ this._contextCapHits++;
129
+ const base = this._cache.get(this._key(qid, new Set()));
130
+ if (base && !base._recursive) return base;
131
+ // No base summary yet → fall through and compute this one (we need a
132
+ // result), but don't grow the context set.
133
+ } else {
134
+ ctxs.add(stateHash);
135
+ }
136
+ }
80
137
  if (this._stack.has(qid)) {
81
138
  this._hitRecursion = true;
82
139
  return { returnTainted: false, mutatedParams: new Set(), taintedGlobals: new Set(), findings: [], _recursive: true };
@@ -125,7 +182,7 @@ export class SummaryCache {
125
182
  }
126
183
 
127
184
  size() { return this._cache.size; }
128
- clear() { this._cache.clear(); this._iter = 0; }
185
+ clear() { this._cache.clear(); this._iter = 0; this._contextsByQid.clear(); this._contextCapHits = 0; }
129
186
  }
130
187
 
131
188
  function _summaryEq(a, b) {
package/src/engine.js CHANGED
@@ -35,6 +35,10 @@ import { scanMobileManifest } from './sast/mobile-manifest.js';
35
35
  import { scanQuarkusHardening } from './sast/quarkus-hardening.js';
36
36
  import { scanFastapiHardening } from './sast/fastapi-hardening.js';
37
37
  import { scanAuthZ } from './sast/authz.js';
38
+ import { scanApiBrokenAuthz } from './sast/api-authz.js';
39
+ import { scanTerraform } from './sast/iac-terraform.js';
40
+ import { scanCrossService } from './sast/cross-service.js';
41
+ import { scanRbacConsistency } from './sast/rbac-consistency.js';
38
42
  import { scanModelLoad } from './sast/model-load.js';
39
43
  import { scanPromptTemplate } from './sast/prompt-template.js';
40
44
  import { scanXXE } from './sast/xxe.js';
@@ -81,10 +85,27 @@ import { scanLDAPInjection } from './sast/ldap-injection.js';
81
85
  import { scanXPathInjection } from './sast/xpath-injection.js';
82
86
  import { scanSSTI } from './sast/ssti.js';
83
87
  import { scanOpenRedirect } from './sast/open-redirect.js';
88
+ import { scanWrongContextSanitizer, scanSanitizerContextMismatch } from './sast/wrong-context-sanitizer.js';
89
+ import { scanFrontendHygiene } from './sast/frontend-hygiene.js';
90
+ import { scanCsvInjection } from './sast/csv-injection.js';
91
+ import { scanStoredTaint } from './sast/stored-taint.js';
92
+ import { scanTreeSitterSinks } from './sast/tree-sitter-sinks.js';
93
+ import { scanJavaStructural } from './sast/java-structural.js';
94
+ import { scanCsharpStructural } from './sast/csharp-structural.js';
95
+ import { scanJsFrameworkStructural } from './sast/js-framework-structural.js';
96
+ import { scanPythonStructural } from './sast/python-structural.js';
97
+ import { scanGoStructural } from './sast/go-structural.js';
98
+ import { scanSecretConcat } from './sast/secret-concat.js';
99
+ import { scanXssReflectedMultilang } from './sast/xss-reflected-multilang.js';
100
+ import { scanCodeInjectionMultilang } from './sast/code-injection-multilang.js';
101
+ import { scanRegexReDoS } from './sast/redos-nfa.js';
84
102
  import { scanResponseSplitting } from './sast/response-splitting.js';
85
103
  import { scanStoredPromptInjection, scanStoredPromptInjectionCrossFile } from './sast/llm-stored-prompt.js';
86
104
  import { scanRAGPoisoning } from './sast/rag-poisoning.js';
87
105
  import { scanAgentToolEscalation } from './sast/agent-tool-escalation.js';
106
+ import { scanAgentUntrustedFlow } from './sast/agent-untrusted-flow.js';
107
+ import { scanInstallScripts } from './sca/install-script-analysis.js';
108
+ import { scanEventEntrypoints } from './sast/event-entrypoint.js';
88
109
  import { scanDbTaint, scanDbTaintCrossFile } from './sast/db-taint.js';
89
110
  import { scanSSRFCloudMetadata } from './sast/ssrf-cloud-metadata.js';
90
111
  import { scanMutationXSS } from './sast/mutation-xss.js';
@@ -129,6 +150,8 @@ import { annotateFeatureFlagGating } from './posture/feature-flags.js';
129
150
  import { annotatePersonaScores } from './posture/persona-prioritization.js';
130
151
  import { annotateMitigationComposite } from './posture/mitigation-composite.js';
131
152
  import { annotateCompositeRisk } from './posture/composite-risk.js';
153
+ import { annotateScaVerdicts } from './posture/sca-verdict.js';
154
+ import { annotateFindingProvenance } from './posture/provenance.js';
132
155
 
133
156
  // ── Integration block: world-class scaffolded modules ──────────────────────
134
157
  // Each module is opt-in via its own env var so partial adoption is safe.
@@ -148,6 +171,8 @@ import { annotateRuntimeCorrelation } from './posture/runtime-correlation.js';
148
171
  import { applyLearnedCalibration } from './posture/triage-learning.js';
149
172
  import { annotateFormalVerification } from './dataflow/formal-verify.js';
150
173
  import { annotatePathFeasibility } from './dataflow/smt-feasibility.js';
174
+ import { annotateProofGate } from './dataflow/proof-gate.js';
175
+ import { computeAnalysisTiers, countUnmodeledSinkCandidates } from './posture/coverage-report.js';
151
176
  import { annotatePrivacyTaint, emitDpiaArtifact } from './dataflow/privacy-taint.js';
152
177
  import { buildThreatModel as buildAutoThreatModel, persistThreatModel as persistAutoThreatModel } from './posture/threat-model-auto.js';
153
178
  import { runApiContractScan } from './posture/api-contract.js';
@@ -1187,6 +1212,111 @@ function _hasPostLookupOwnershipCheck(lines, sinkLine){
1187
1212
  return false;
1188
1213
  }
1189
1214
 
1215
+ // An SSRF host allow/deny guard near the request sink: a hostname checked
1216
+ // against an allow/deny-list, an RFC1918 / loopback / link-local / metadata
1217
+ // prefix check, or a known SSRF-filter helper. Control-flow (not assignment),
1218
+ // so it isn't caught by the sanitizer-on-assignment path. Recall-safe: an
1219
+ // unguarded fetch still has no guard in its window.
1220
+ const _SSRF_HOST_GUARD_RE = /\b(?:DENY|ALLOW(?:ED)?|allow_?list|deny_?list|block_?list|allowed_?hosts?|blocked_?hosts?|ALLOWED_HOSTS)\b|\.(?:hostname|host|getHost\s*\(\s*\))\b[\s\S]{0,60}?(?:\bin\b|\.has\s*\(|\.includes\s*\(|\.contains\s*\(|[!=]=|\.startsWith\s*\(|\.startswith\s*\()|\b(?:startsWith|startswith)\s*\(\s*['"](?:10\.|192\.168\.|127\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.|::1|fc00|fd)|\bipaddress\b|\bis_private\b|\bis_loopback\b|getaddrinfo|gethostbyname|ssrf[-_]?req[-_]?filter|isSafeUrl|isPrivateIp|validateUrl|_is_metadata_or_private|urlparse\s*\([^)]*\)\s*\.\s*(?:hostname|netloc)/i;
1221
+
1222
+ // A comment-stripped window around the sink. Comments must not read as guards
1223
+ // (e.g. a "no host allow-list / 169.254.169.254" comment describing the vuln).
1224
+ function _guardWindow(ctx, before = 25, after = 5) {
1225
+ if (!ctx || !Array.isArray(ctx.lines)) return '';
1226
+ const line = ctx.line || 1;
1227
+ const lo = Math.max(0, line - before);
1228
+ const hi = Math.min(ctx.lines.length, line + after);
1229
+ return ctx.lines.slice(lo, hi).join('\n')
1230
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
1231
+ .replace(/\/\/[^\n]*/g, ' ')
1232
+ .replace(/(^|[^\w'"`])#[^\n]*/g, '$1 ');
1233
+ }
1234
+
1235
+ function _hasSsrfHostGuard(ctx) { return _SSRF_HOST_GUARD_RE.test(_guardWindow(ctx)); }
1236
+
1237
+ // A path-traversal containment guard near the file sink: a basename/strip
1238
+ // helper that removes directory components, a framework safe-join, or a
1239
+ // canonicalize-then-startsWith containment check.
1240
+ const _PATH_GUARD_RE = /\b(?:basename|GetFileName|secure_filename|sanitize_filename|send_from_directory|safe_join)\s*\(|\b(?:startsWith|startswith|StartsWith|HasPrefix)\s*\(|\bgetCanonicalPath\b|\btoRealPath\b|\bfilepath\s*\.\s*(?:Clean|Base|Abs)\b/;
1241
+ function _hasPathGuard(ctx) { return _PATH_GUARD_RE.test(_guardWindow(ctx)); }
1242
+
1243
+ // Reflected-XSS output-encoding guard: an HTML escaper applied near the sink.
1244
+ const _XSS_ESCAPER = String.raw`(?:escapeHtml|escape_html|escape-html|sanitizeHtml|sanitize_html|DOMPurify\.sanitize|he\.encode|he\.escape|_\.escape|validator\.escape|bleach\.clean|markupsafe|htmlspecialchars|htmlentities|html\.escape|escapeHTML|encodeURIComponent|escape)\s*\(`;
1245
+ const _XSS_ESCAPE_RE = new RegExp(_XSS_ESCAPER);
1246
+ // An escaper whose RESULT is captured (assigned / returned) — not a bare,
1247
+ // return-discarded `escapeHtml(s);` call, which does nothing for the reflected
1248
+ // value (FP-3 in smoke). Requires `… = <escaper>(` or `return <escaper>(`.
1249
+ const _XSS_ASSIGNED_ESCAPE_RE = new RegExp(String.raw`(?:[:=]|\breturn\b)\s*[^=;\n]*?` + _XSS_ESCAPER);
1250
+ // A raw request source appearing on the sink line itself (not via a prior
1251
+ // escaped var). If present, the reflection is unescaped — keep the finding.
1252
+ const _XSS_RAW_SOURCE_RE = /\breq(?:uest)?\s*\.|\bctx\s*\.\s*(?:query|params|request|body)\b|\$_(?:GET|POST|REQUEST|COOKIE)\b|\bparams\s*\[|\.(?:query|body|params)\s*\.[A-Za-z_$]/;
1253
+ function _hasXssOutputEncoding(ctx) {
1254
+ if (!ctx || !Array.isArray(ctx.lines)) return false;
1255
+ const line = ctx.lines[(ctx.line || 1) - 1] || '';
1256
+ // The reflected-XSS finding is anchored either on the escaped-assignment line
1257
+ // (`const safe = escape(req.query.x)`) or on the response sink line
1258
+ // (`res.send('…' + safe + '…')`).
1259
+ // - If the finding line itself captures the escaped value (`const safe =
1260
+ // escape(req.query.x)`), the reflection is encoded → safe. A bare,
1261
+ // return-discarded `escapeHtml(s);` does NOT count.
1262
+ if (_XSS_ASSIGNED_ESCAPE_RE.test(line)) return true;
1263
+ // - A raw, unescaped request source on the finding line is the real vuln
1264
+ // shape (`res.send('…' + req.query.x)`) → keep it.
1265
+ if (_XSS_RAW_SOURCE_RE.test(line)) return false;
1266
+ // - Otherwise the sink reflects a variable; drop only if that variable was
1267
+ // escaped (and the result captured) in the surrounding window. A bare
1268
+ // `escapeHtml(s);` with a discarded return does NOT count.
1269
+ return _XSS_ASSIGNED_ESCAPE_RE.test(_guardWindow(ctx, 8, 2));
1270
+ }
1271
+
1272
+ // Centralized guard-recognition pass (#1). SSRF (CWE-918) and path-traversal
1273
+ // (CWE-22) are emitted by several independent detectors (regex, structural,
1274
+ // per-language flow, PY-SAST, CSHARP, GO) — none of which shared a notion of
1275
+ // "the sink is hardened by a host allow/deny check or a path containment
1276
+ // guard." This single pass drops such findings uniformly, regardless of which
1277
+ // detector produced them, using the same comment-stripped window guards.
1278
+ // Recall-safe: a genuinely-unguarded sink has no guard in its window (the
1279
+ // cve-replay vulnerable-tier TP count is the invariant that proves this).
1280
+ export function dropGuardedFindings(findings, fileContents) {
1281
+ if (!Array.isArray(findings)) return findings;
1282
+ const cache = new Map();
1283
+ const linesOf = (file) => {
1284
+ if (cache.has(file)) return cache.get(file);
1285
+ const c = fileContents && fileContents[file];
1286
+ const arr = typeof c === 'string' ? c.split('\n') : null;
1287
+ cache.set(file, arr);
1288
+ return arr;
1289
+ };
1290
+ return findings.filter((f) => {
1291
+ if (!f || !f.file) return true;
1292
+ // Already-sanitized findings (isSanitized / info) are owned by the
1293
+ // sanitizer-dataflow pipeline, which records them in scan.suppressions.
1294
+ // Don't hard-drop them here or that bookkeeping is lost (smoke FP-3).
1295
+ if (f.isSanitized) return true;
1296
+ const cwe = f.cwe || '';
1297
+ const isXss = cwe === 'CWE-79' && /reflected xss/i.test(f.vuln || '');
1298
+ if (cwe !== 'CWE-918' && cwe !== 'CWE-22' && !isXss) return true;
1299
+ // Flow-pair findings (REGEX parser) carry the line on sink/source, not at
1300
+ // the top level; fall back to those before parsing it out of the id.
1301
+ let line = f.line;
1302
+ if (typeof line !== 'number') {
1303
+ line = (f.sink && f.sink.line) ?? (f.source && f.source.line);
1304
+ if (typeof line !== 'number') {
1305
+ const m = /:(\d+):/.exec(f.id || '');
1306
+ if (m) line = Number(m[1]);
1307
+ }
1308
+ }
1309
+ if (typeof line !== 'number') return true;
1310
+ const lines = linesOf(f.file);
1311
+ if (!lines) return true;
1312
+ const ctx = { lines, line };
1313
+ if (cwe === 'CWE-918' && _hasSsrfHostGuard(ctx)) return false;
1314
+ if (cwe === 'CWE-22' && _hasPathGuard(ctx)) return false;
1315
+ if (isXss && _hasXssOutputEncoding(ctx)) return false;
1316
+ return true;
1317
+ });
1318
+ }
1319
+
1190
1320
  function _detectSafeSinkShape(vuln, args, ctx){
1191
1321
  // 1.1: Reflected XSS / SSRF / Path Traversal / Open Redirect / Code Injection
1192
1322
  // sinks with empty or whitespace-only args cannot reflect user input — skip
@@ -1221,6 +1351,8 @@ function _detectSafeSinkShape(vuln, args, ctx){
1221
1351
  if (_isSafeExecFileCall(args)) return 'execFile-list';
1222
1352
  }
1223
1353
  if(/SSRF/.test(vuln) && _isStaticUrlFirstArg(args)) return 'static-url';
1354
+ if(/SSRF/.test(vuln) && _hasSsrfHostGuard(ctx)) return 'ssrf-host-guard';
1355
+ if(/Path Traversal/.test(vuln) && _hasPathGuard(ctx)) return 'path-contained';
1224
1356
  if(/Open Redirect/.test(vuln)) {
1225
1357
  // Static redirect targets (env var or string literal) are not user-controlled
1226
1358
  if (/^\s*process\.env\b/.test(args)) return 'static-redirect-target';
@@ -1959,6 +2091,17 @@ function scanStructuralVulns(fp, raw) {
1959
2091
  }
1960
2092
  effectiveSeverity = s;
1961
2093
  }
2094
+ // Guard recognition (precision, #1): a structural SSRF/path finding on
2095
+ // hardened code — an SSRF host allow/deny check, or a path
2096
+ // basename/containment guard near the sink — is a false positive.
2097
+ if (/SSRF/.test(pat.vuln) && _hasSsrfHostGuard({ lines, line })) {
2098
+ _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'ssrf-host-guard'});
2099
+ continue;
2100
+ }
2101
+ if (/Path Traversal/.test(pat.vuln) && _hasPathGuard({ lines, line })) {
2102
+ _suppressionLog.push({vuln:pat.vuln, file:fp, line, snippet, reason:'path-contained'});
2103
+ continue;
2104
+ }
1962
2105
  const id = `struct:${fp}:${line}:${pat.vuln.replace(/\s/g, '_')}`;
1963
2106
  if (!findings.find(f => f.id === id)) {
1964
2107
  findings.push({
@@ -5512,8 +5655,17 @@ function dedupeFindingsWithEvidence(findings){
5512
5655
  const key=`${file}:${sinkLine}:${fam}`;
5513
5656
  if(!buckets.has(key)){buckets.set(key,f);continue;}
5514
5657
  const kept=buckets.get(key);
5515
- // Keep highest-severity entry; preserve evidence from both.
5516
- const keepNew = (SEV_RANK[f.severity]??9) < (SEV_RANK[kept.severity]??9);
5658
+ // Winner selection: an interprocedural flow finding (carries source→sink
5659
+ // attribution) is the better carrier than a flat structural/regex match at
5660
+ // the same sink — keep its chain/source-line attribution. When neither or
5661
+ // both carry flow, fall back to severity. The winner keeps its own severity
5662
+ // (we must not resurrect a rating that ownership/reachability analysis
5663
+ // deliberately downgraded on one of the two findings).
5664
+ const fHasFlow = !!(f.source && f.sink);
5665
+ const kHasFlow = !!(kept.source && kept.sink);
5666
+ const keepNew = (fHasFlow !== kHasFlow)
5667
+ ? fHasFlow
5668
+ : (SEV_RANK[f.severity]??9) < (SEV_RANK[kept.severity]??9);
5517
5669
  const winner = keepNew ? f : kept;
5518
5670
  const loser = keepNew ? kept : f;
5519
5671
  if(!winner.evidence)winner.evidence=[winner.parser||"UNKNOWN"];
@@ -6548,6 +6700,42 @@ function _parseMavenDependencyTree(text, filePath){
6548
6700
  return out;
6549
6701
  }
6550
6702
 
6703
+ // R10 (PRD §5): Gradle transitive graph. `gradle dependencies` (or
6704
+ // `gradle :app:dependencies`) prints the FULL resolved tree; build.gradle alone
6705
+ // only declares direct deps. Canonical committed path: `gradle-dependencies.txt`.
6706
+ // Format (tree-drawing prefixes we strip), version may be conflict-resolved:
6707
+ // +--- group:artifact:1.0
6708
+ // | \--- group:artifact:1.0 -> 1.2 (resolved to 1.2)
6709
+ // Config headers ("compileClasspath - …"), project deps, and (*)/(c) markers skipped.
6710
+ function _parseGradleDependencies(text, filePath) {
6711
+ const out = [];
6712
+ const seen = new Set();
6713
+ for (const rawLine of String(text).split('\n')) {
6714
+ if (!rawLine.trim()) continue;
6715
+ const indent = rawLine.length - rawLine.replace(/^[\s|+\\-]+/, '').length;
6716
+ const stripped = rawLine.replace(/^[\s|+\\-]+/, '').trim();
6717
+ if (!stripped || stripped.startsWith('project ') || /\bproject\s+[':]/.test(stripped)) continue;
6718
+ // group:artifact:version, with optional `-> resolved` and trailing (*)/(c)/(n).
6719
+ const m = stripped.match(/^([A-Za-z0-9._-]+):([A-Za-z0-9._-]+):([^\s(]+?)(?:\s*->\s*([^\s(]+))?\s*(?:\([*cn]\))?\s*$/);
6720
+ if (!m) continue;
6721
+ const group = m[1], artifact = m[2];
6722
+ let ver = (m[4] || m[3] || '').replace(/[{}]/g, '');
6723
+ ver = ver.replace(/^(?:strictly|require|prefer)\s+/, '');
6724
+ if (!group || !artifact || !ver) continue;
6725
+ const key = `${group}:${artifact}:${ver}`;
6726
+ if (seen.has(key)) continue;
6727
+ seen.add(key);
6728
+ out.push({
6729
+ name: `${group}:${artifact}`, version: ver, group, scope: 'required',
6730
+ purl: _makePurl('maven', artifact, ver, group), ecosystem: 'maven', filePath,
6731
+ isUnpinned: false,
6732
+ isTransitive: indent > 6,
6733
+ pomSource: 'gradle-dependencies',
6734
+ });
6735
+ }
6736
+ return out;
6737
+ }
6738
+
6551
6739
  function _parseBuildGradle(text,filePath){
6552
6740
  const out=[];
6553
6741
  const pat=/(?:implementation|api|compile|runtimeOnly|testImplementation|testCompile|compileOnly|classpath)\s*(?:\(|['"])\s*['"]?([a-zA-Z0-9._\-]+):([a-zA-Z0-9._\-]+):([^'")\s,]+)/g;
@@ -6826,6 +7014,8 @@ function parseManifests(allFileContents){
6826
7014
  // `target/dependency-tree.txt` (per `mvn dependency:tree -DoutputFile=...`)
6827
7015
  // but users sometimes commit it as `dependency-tree.txt` at repo root.
6828
7016
  'dependency-tree.txt':_parseMavenDependencyTree,
7017
+ // R10: Gradle resolved transitive graph — `gradle dependencies > gradle-dependencies.txt`.
7018
+ 'gradle-dependencies.txt':_parseGradleDependencies,
6829
7019
  };
6830
7020
  const out=[],seen=new Set();
6831
7021
  for(const[fp,content]of Object.entries(allFileContents)){
@@ -7202,13 +7392,14 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7202
7392
  catch { _GLOBAL_JAVA_TAINTED_METHODS = new Set(); }
7203
7393
  const _perFileTimeoutMs = parseInt(process.env.AGENTIC_SECURITY_PER_FILE_TIMEOUT_MS || '10000', 10);
7204
7394
  const _fileTimings = [];
7205
- let _filesSkipped = 0, _filesTimedOut = 0;
7206
- const files=Object.keys(fileContents).filter(f=>shouldScan(f) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];let i=0;for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000)continue;fc[p]=c;aR.push(...scanRoutes(p,c));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,c));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,c));aF.push(...scanExtraStructural(p,c));aF.push(...scanAliasedSinks(p,c));aF.push(...scanJavaSAST(p,c));aF.push(...scanJavaBenchExtras(p,c));aLogic.push(...scanMiddlewareOrdering(p,c));aLogic.push(...scanReDoS(p,c));aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,c);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,c));aF.push(...scanIaC(p,c));
7395
+ let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
7396
+ const files=Object.keys(fileContents).filter(f=>shouldScan(f) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];let i=0;for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}fc[p]=c;aR.push(...scanRoutes(p,c));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,c));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,c));aF.push(...scanExtraStructural(p,c));aF.push(...scanAliasedSinks(p,c));aF.push(...scanJavaSAST(p,c));aF.push(...scanJavaBenchExtras(p,c));aLogic.push(...scanMiddlewareOrdering(p,c));aLogic.push(...scanReDoS(p,c));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,c));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,c);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,c));aF.push(...scanIaC(p,c));aF.push(...scanTerraform(p,c));
7207
7397
  aF.push(...scanLLM(p,c));
7208
7398
  aF.push(...scanLLMOwasp(p,c));
7209
7399
  aLogic.push(...scanBusinessLogic(p,c));
7210
7400
  aF.push(...scanPipeline(p,c));
7211
7401
  aF.push(...scanContainer(p,c));
7402
+ aF.push(...scanInstallScripts(p,c));
7212
7403
  aF.push(...scanMCP(p,c));
7213
7404
  aF.push(...scanClaudeSettings(p,c));
7214
7405
  aF.push(...scanClaudeMdPromptInjection(p,c));
@@ -7265,10 +7456,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7265
7456
  aF.push(...scanXPathInjection(p,c));
7266
7457
  aF.push(...scanSSTI(p,c));
7267
7458
  aF.push(...scanOpenRedirect(p,c));
7459
+ aF.push(...scanWrongContextSanitizer(p,c));
7460
+ aF.push(...scanSanitizerContextMismatch(p,c));
7461
+ aF.push(...scanFrontendHygiene(p,c));
7462
+ aF.push(...scanCsvInjection(p,c));
7463
+ aF.push(...scanStoredTaint(p,c));
7464
+ aF.push(...scanJavaStructural(p,c));
7465
+ aF.push(...scanCsharpStructural(p,c));
7466
+ aF.push(...scanJsFrameworkStructural(p,c));
7467
+ aF.push(...scanPythonStructural(p,c));
7468
+ aF.push(...scanGoStructural(p,c));
7469
+ aF.push(...scanSecretConcat(p,c));
7470
+ aF.push(...scanXssReflectedMultilang(p,c));
7471
+ aF.push(...scanCodeInjectionMultilang(p,c));
7268
7472
  aF.push(...scanResponseSplitting(p,c));
7269
7473
  aF.push(...scanStoredPromptInjection(p,c));
7270
7474
  aF.push(...scanRAGPoisoning(p,c));
7271
7475
  aF.push(...scanAgentToolEscalation(p,c));
7476
+ aF.push(...scanAgentUntrustedFlow(p,c));
7477
+ aF.push(...scanEventEntrypoints(p,c));
7272
7478
  aF.push(...scanDbTaint(p,c));
7273
7479
  aF.push(...scanSSRFCloudMetadata(p,c));
7274
7480
  aF.push(...scanMutationXSS(p,c));
@@ -7430,6 +7636,12 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7430
7636
  setProgress({current:i,total:files.length,file:"Stored taint...",phase:"Linking"});const storedRegistry=buildStoredTaintRegistry(fc);const stf=crossStoredTaint(fc,storedRegistry);aF.push(...stf);
7431
7637
  setProgress({current:i,total:files.length,file:"Session taint...",phase:"Linking"});const sess=crossSessionTaint(fc);aF.push(...sess);
7432
7638
  setProgress({current:i,total:files.length,file:"Call graph...",phase:"Linking"});const callGraph=buildCallGraph(fc);
7639
+ // R19 (PRD §5): cross-route BOLA/BFLA over the aggregated route inventory.
7640
+ try{aF.push(...scanApiBrokenAuthz(aR));}catch(_){}
7641
+ // R22 (PRD §5): cross-service edges inferred from code (client call → matched route).
7642
+ try{aF.push(...scanCrossService(aR,fc));}catch(_){}
7643
+ // R21 (PRD §5): RBAC role-tier consistency over the route inventory.
7644
+ try{aF.push(...scanRbacConsistency(aR,fc));}catch(_){}
7433
7645
  setProgress({current:i,total:files.length,file:"Reachability + guards...",phase:"Linking"});annotateReachability(aF,aR,callGraph,fc);aF.forEach(f=>detectGuardsForFinding(f,fc));
7434
7646
  setProgress({current:i,total:files.length,file:"Inferring sanitizers...",phase:"Linking"});const learned=inferSanitizers(fc);applyLearnedSanitizers(aF,learned,fc);
7435
7647
  setProgress({current:i,total:files.length,file:"Sanitizer effectiveness...",phase:"Linking"});applySanitizerEffectiveness(aF);
@@ -7438,6 +7650,9 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7438
7650
  setProgress({current:i,total:files.length,file:"OSV vulnerability database...",phase:"SCA"});
7439
7651
  const allFileContents={...fc, ...depFileContents};
7440
7652
  const components=parseManifests(allFileContents);
7653
+ // R8 (PRD §5): OS packages from an extracted container image's package DBs
7654
+ // (dpkg/apk) — baked-in deps the Dockerfile never names. Feed the OSV/SBOM pipeline.
7655
+ try{const{extractImagePackages}=await import('./sca/image-packages.js');for(const ip of extractImagePackages(allFileContents)){if(!components.some(c=>c.ecosystem===ip.ecosystem&&c.name===ip.name&&c.version===ip.version))components.push(ip);}}catch(_){}
7441
7656
  try{const{detectVendoredLibraries}=await import('./sca/vendor-detect.js');const vendored=detectVendoredLibraries(fc);for(const v of vendored){const key=`${v.ecosystem}:${v.name}:${v.version}`;if(!components.some(c=>`${c.ecosystem}:${c.name}:${c.version}`===key))components.push({...v,group:'',purl:`pkg:${v.ecosystem}/${v.name}@${v.version}`,filePath:v.file,isUnpinned:false,reachable:true});}}catch(_){}
7442
7657
  const reach=buildReachabilitySet(fc);
7443
7658
  const reachabilitySet=reach.imported;
@@ -7452,6 +7667,14 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7452
7667
  // 0.10.0: enrich SCA findings with CISA KEV (CISA KEV catalog)
7453
7668
  try{supplyChain=await _enrichWithKEV(supplyChain);}catch(_){}
7454
7669
  try{markUsedVulnFunctions(supplyChain,fc);}catch(_){}
7670
+ // R7 (PRD §5): import-aware call-site augmentation for npm/pypi deps. The regex
7671
+ // pass above misses aliased / namespace imports (import {merge as deepMerge};
7672
+ // deepMerge()) and over-matches bare names in files that don't import the
7673
+ // package. This parses each JS/TS + Python file's imports, resolves vuln-fn
7674
+ // calls THROUGH the alias map, and gates them to files that actually import the
7675
+ // package — then ADDS the recovered sites so the route-reachability classifier
7676
+ // below (which decides reachable/unreachable/unknown) sees them. Additive only.
7677
+ try{const{augmentReachabilityViaImports}=await import('./sca/import-reachability.js');augmentReachabilityViaImports(supplyChain,fc,VULN_FUNCTION_HINTS);}catch(_){}
7455
7678
  // Python AST-based function validation (deep mode only)
7456
7679
  if(process.env.AGENTIC_SECURITY_DEEP==='1'){try{const{validateOsvFunctionsExist}=await import('./sca/py-package-functions.js');for(const sc of supplyChain){if(sc.type!=='vulnerable_dep'||sc.ecosystem!=='pypi')continue;if(!sc.osvVulnFunctions||!sc.osvVulnFunctions.length)continue;const{validated,missing}=validateOsvFunctionsExist(sc.name,sc.osvVulnFunctions,scanRoot);if(validated.length)sc._pyAstValidated=validated;if(missing.length)sc._pyAstMissing=missing;}}catch(_){}}
7457
7680
  // LLM-assisted function extraction for CVEs without hints (opt-in: AGENTIC_SECURITY_LLM_SCA=1)
@@ -7552,7 +7775,14 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7552
7775
  const annotatedComponents=components.map(c=>{const key=`${c.ecosystem}:${c.name}:${c.version}`;const vulns=vulnsByKey[key]||[];const riKey=c.ecosystem==='maven'&&c.group?`maven:${c.group}/${c.name}`:`${c.ecosystem}:${c.name}`;const ri=registryInfo.get(riKey)||{};const latestVersion=ri.latestVersion||'';const vd=(ri.versions||{})[c.version]||{};const isDeprecated=typeof vd.deprecated==='string'&&vd.deprecated.length>0;const deprecationMessage=isDeprecated?vd.deprecated:'';const isOutdated=!isDeprecated&&typeof vd.outdated==='string'&&vd.outdated.length>0;const outdatedMessage=isOutdated?vd.outdated:'';const license=ri.license||vd.license||'';return{...c,vulns,hasVulns:vulns.length>0,hasAttackPath:attackResult.flagged.has(key),attackPaths:attackResult.pathsByKey.get(key)||[],latestVersion,isDeprecated,deprecationMessage,isOutdated,outdatedMessage,license};});
7553
7776
  try{aF.push(...scanDbTaintCrossFile(fc));}catch(_){}
7554
7777
  try{aF.push(...scanStoredPromptInjectionCrossFile(fc));}catch(_){}
7778
+ // Roadmap #8 — tree-sitter sinks for long-tail languages (opt-in,
7779
+ // AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
7780
+ if(process.env.AGENTIC_SECURITY_TREE_SITTER==='1'){try{aF.push(...await scanTreeSitterSinks(fc));}catch(_){}}
7555
7781
  let finalFindings;try{finalFindings=dedupeFindingsWithEvidence(aF);}catch(_){finalFindings=dd(aF,f=>f.id);}
7782
+ // #1 — centralized SSRF/path guard recognition: drop CWE-918/CWE-22 findings
7783
+ // on code hardened by a host allow/deny check or a path containment guard,
7784
+ // regardless of which detector emitted them. Opt out: AGENTIC_SECURITY_NO_GUARD_RECOGNITION=1.
7785
+ if(process.env.AGENTIC_SECURITY_NO_GUARD_RECOGNITION!=='1'){try{finalFindings=dropGuardedFindings(finalFindings,fc);}catch(_){}}
7556
7786
  // 0.34.6: filter out Java FPs where a sanitizer pattern (argv-form ProcessBuilder,
7557
7787
  // parameterized prepareStatement, constant-folded dead-branch) is present.
7558
7788
  // applyJavaBenchSuppressions is a no-op on non-.java files.
@@ -7670,6 +7900,14 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7670
7900
  _runAnnotator("annotateCalibratedConfidence", () => { annotateCalibratedConfidence(finalFindings, { scanRoot }); });
7671
7901
  const _projectCtx = (() => { try { return detectProjectContext(fc, aR); } catch { return {}; } })();
7672
7902
  _runAnnotator("annotateExploitability", () => { annotateExploitability(finalFindings, _projectCtx); });
7903
+ // Roadmap #6 — proof-gate precision pass. Runs AFTER confidence +
7904
+ // exploitability so it can demote their tiers, and BEFORE mitigation /
7905
+ // composite-risk so the demotion flows into the canonical ranking. Default
7906
+ // on; opt out with AGENTIC_SECURITY_NO_PROOF_GATE=1. Demotes proven-clean /
7907
+ // proven-infeasible flows (confidence + tiers only, never severity).
7908
+ if (process.env.AGENTIC_SECURITY_NO_PROOF_GATE !== '1') {
7909
+ _runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
7910
+ }
7673
7911
  // v3 next-gen: production-aware context ingest (Pillar 9). Must run BEFORE
7674
7912
  // the mitigation composite, persona prioritization, and final why-fired
7675
7913
  // record so those see the demotion signals.
@@ -7880,10 +8118,14 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7880
8118
  try { await annotateNarration(finalFindings); }
7881
8119
  catch (e) { _annotatorErrors.push({ phase: 'annotateNarration', err: String((e && e.message) || e) }); }
7882
8120
  // Phase 3 (Sentinel-parity FR-L1, FR-L2) — IR + interprocedural taint.
7883
- // Opt-in via AGENTIC_SECURITY_DEEP=1 because it's currently breadth-first,
7884
- // not benchmark-tuned. Findings ride through the standard dedup/cluster/
7885
- // confidence pipeline below; the LLM-validator stage above already ran but
7886
- // any deep-mode finding emitted here will be unvalidated.
8121
+ // R1 (PRD §5): the CLI entry (bin/agentic-security.js#cmdScan) now sets
8122
+ // AGENTIC_SECURITY_DEEP=1 by default for local/interactive scans, so deep mode
8123
+ // runs on the default `/scan --all` path. This gate is the enforcement +
8124
+ // CI-safety point: it honors an explicit opt-out (DEEP=0) and keeps deep off in
8125
+ // CI unless DEEP_IN_CI=1. In-process callers (tests, the cve-replay corpus) invoke
8126
+ // runScan()/runFullScan() directly without the CLI default, so they stay deep-off
8127
+ // and remain deterministic regression gates. Findings ride through the standard
8128
+ // dedup/cluster/confidence pipeline below and the LLM-validator stage that follows.
7887
8129
  //
7888
8130
  // SAFETY: Deep mode is gated for CI safety:
7889
8131
  // - Global timeout via AGENTIC_SECURITY_DEEP_TIMEOUT_MS (default 300_000 = 5 min)
@@ -8025,13 +8267,29 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
8025
8267
  // apply accept-risk / SLA / major-version-freeze rules. supplyChain
8026
8268
  // findings get suppressed/tagged in place.
8027
8269
  try { const sp = loadScaPolicy(scanRoot); if (sp && !sp._error) applyScaPolicy(supplyChain, sp); } catch (_) {}
8270
+ // R12 (PRD §5): deterministic decision-first verdict per dep, derived from the
8271
+ // signals enriched above (composite risk × KEV × EPSS × reachability × policy).
8272
+ // testsDetected is read from the UNFILTERED fileContents because shouldScan()
8273
+ // strips test files out of `fc`.
8274
+ try {
8275
+ const _testsDetected = Object.keys(fileContents).some(f =>
8276
+ /(?:^|\/)(?:tests?|spec|__tests__)\//i.test(f) ||
8277
+ /\.(?:test|spec)\.[cm]?[jt]sx?$/i.test(f) ||
8278
+ /(?:^|\/)test_[^/]*\.py$/i.test(f) || /_test\.(?:py|go)$/i.test(f));
8279
+ annotateScaVerdicts(supplyChain, { testsDetected: _testsDetected });
8280
+ } catch (_) {}
8028
8281
  // 0.9.0 Feat-15: dep confusion
8029
8282
  try{const dc=detectDepConfusion(annotatedComponents,scanRoot);aF.push(...dc);}catch(_){}
8030
8283
  // Deployment-platform security checklist
8031
8284
  try{const dpf=scanDeployPlatform(scanRoot);aLogic.push(...dpf);}catch(_){}
8032
8285
  // Stack-specific security playbook
8033
8286
  try{const sp=runStackPlaybook(scanRoot);if(sp&&sp.findings)aLogic.push(...sp.findings);}catch(_){}
8034
- finalFindings.sort((a,b)=>(b.toxicityScore||0)-(a.toxicityScore||0)||(b.triageScore||0)-(a.triageScore||0)||({critical:0,high:1,medium:2,low:3}[a.severity]??4)-({critical:0,high:1,medium:2,low:3}[b.severity]??4));
8287
+ // R17 (PRD §5): annotate per-finding corroboration ("one issue, many signals")
8288
+ // after evidence + validator verdicts are populated, then use it as a final
8289
+ // tiebreaker so multi-signal findings rank above single-signal ones at equal
8290
+ // severity. Does not alter calibrated confidence.
8291
+ try { annotateFindingProvenance(finalFindings); } catch (_) {}
8292
+ finalFindings.sort((a,b)=>(b.toxicityScore||0)-(a.toxicityScore||0)||(b.triageScore||0)-(a.triageScore||0)||({critical:0,high:1,medium:2,low:3}[a.severity]??4)-({critical:0,high:1,medium:2,low:3}[b.severity]??4)||((b.corroborationCount||1)-(a.corroborationCount||1)));
8035
8293
  // Auto-PoC filter: tag whether a concrete payload+test can be derived. When
8036
8294
  // AGENTIC_SECURITY_POC=1, demote ≥medium findings that fail this check.
8037
8295
  // Used as a precision lever for users who want to ship clean reports —
@@ -8150,6 +8408,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
8150
8408
  finalFindings = finalFindings.filter(_shouldKeep);
8151
8409
  _filterInPlace(aLogic);
8152
8410
  _filterInPlace(aSecrets);
8411
+ // Drop a generic High-Entropy Credential Candidate when a named/structural
8412
+ // secret detector (e.g. secret-concat, hardcoded-secret) already flagged the
8413
+ // SAME file:line — otherwise one split/hardcoded key is reported twice (the
8414
+ // named finding lives in `findings`, the entropy candidate in `secrets`).
8415
+ {
8416
+ const _namedSecretLines = new Set();
8417
+ for (const f of finalFindings) {
8418
+ const ln = f.line ?? f.source?.line;
8419
+ if (typeof ln === 'number' && (f.cwe === 'CWE-798' || /hardcoded|credential|secret/i.test(f.vuln || ''))) {
8420
+ _namedSecretLines.add(`${f.file}:${ln}`);
8421
+ }
8422
+ }
8423
+ const _kept = aSecrets.filter((s) => {
8424
+ if (!/High-Entropy|entropy/i.test(s.vuln || '')) return true;
8425
+ return !_namedSecretLines.has(`${s.file}:${s.line}`);
8426
+ });
8427
+ aSecrets.length = 0;
8428
+ aSecrets.push(..._kept);
8429
+ }
8153
8430
  _filterInPlace(supplyChain);
8154
8431
  classifyOrphans(aSrc,aSink,finalFindings,fc);
8155
8432
  // v3 next-gen: capture scan-level reports (counterfactual, threat model,
@@ -8255,7 +8532,12 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
8255
8532
  }
8256
8533
  }
8257
8534
 
8258
- const _scanMeta={filesScanned:files.length,filesSkipped:_filesSkipped,filesTimedOut:_filesTimedOut,fileTimings:_fileTimings.sort((a,b)=>b.ms-a.ms).slice(0,20),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length}};
8535
+ // Coverage-honesty report (#5 + #6): per-language analysis tier (IR-taint vs
8536
+ // pattern-only), dense/large/timeout skips, and unmodeled-sink candidates.
8537
+ let _analysisTier = null, _unmodeledSinks = null;
8538
+ try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
8539
+ try { _unmodeledSinks = countUnmodeledSinkCandidates(fc, finalFindings); } catch {}
8540
+ const _scanMeta={filesScanned:files.length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_fileTimings.sort((a,b)=>b.ms-a.ms).slice(0,20),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length}};
8259
8541
  return{routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,threatModel:_threatModel,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
8260
8542
 
8261
8543
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
package/src/ir/CLAUDE.md CHANGED
@@ -11,6 +11,7 @@ consumed by `scanner/src/dataflow/` for taint analysis.
11
11
  | Python | `parser-py-cst.js` | Python 3.8+ stdlib `ast` via subprocess (default when available) |
12
12
  | Python | `parser-py.js` | Hand-rolled regex parser (fallback when python3 missing) |
13
13
  | Java | `parser-java.js` | `java-parser` npm package (async) |
14
+ | Long-tail (rust/solidity/cpp/c/go/swift/dart) | `tree-sitter-loader.js` | **Optional** `web-tree-sitter` + `tree-sitter-wasms` (ABI-pinned 0.20.8 ↔ 0.1.13), lazy + degrades when absent. Powers `sast/tree-sitter-sinks.js` (opt-in via `AGENTIC_SECURITY_TREE_SITTER=1`). Marked `--external` in the build so the committed bundle never embeds WASM. |
14
15
 
15
16
  ## Python parser — dual-path with auto fallback
16
17
 
@@ -67,8 +67,19 @@ export function buildCallGraph(perFileIR, fileContents) {
67
67
  // the call graph for the callee's qid at the assign-from-call site.
68
68
  // Same precedence as the edge resolution above (same-file ident wins,
69
69
  // ClassName.method falls back).
70
- function resolve(name) {
70
+ function resolve(name, callerFile) {
71
71
  if (!name || typeof name !== 'string') return null;
72
+ // Roadmap #3: same-file preference. A bare name (`handler`, `save`,
73
+ // `query`) defined in several files would otherwise resolve to whichever
74
+ // file Map is iterated first, mis-targeting interprocedural taint to a
75
+ // same-named function in an unrelated file. When the caller's own file
76
+ // defines the name, that is overwhelmingly the intended callee — prefer
77
+ // it. Backward-compatible: with no callerFile (or no local match) the
78
+ // original resolution order is unchanged, so no edge is ever dropped.
79
+ if (callerFile) {
80
+ const local = byNameInFile.get(callerFile);
81
+ if (local && local.has(name)) return local.get(name);
82
+ }
72
83
  for (const m of byNameInFile.values()) {
73
84
  if (m.has(name)) return m.get(name);
74
85
  }
@@ -79,6 +79,14 @@ function _lowerExpr(text) {
79
79
  const parts = _splitTopLevelCommas(inner).map(_lowerExpr);
80
80
  return { kind: 'tpl', parts };
81
81
  }
82
+ // R3 (PRD §5): string concat with `+` MUST be checked before the bare-literal
83
+ // rule below — otherwise `"SELECT " + q` (starts with `"`) is captured whole as
84
+ // a single literal and taint cannot flow through the concatenation. Only fires
85
+ // when there's a top-level `+` (a pure literal still falls through to literal).
86
+ if (/["'`]/.test(s) && s.includes('+')) {
87
+ const plusParts = _splitTopLevelPlus(s);
88
+ if (plusParts.length > 1) return { kind: 'tpl', parts: plusParts.map(_lowerExpr) };
89
+ }
82
90
  if (/^"/.test(s) || /^`/.test(s)) return { kind: 'literal', value: s };
83
91
  if (/^\d/.test(s)) return { kind: 'literal', value: s };
84
92
  if (/^(true|false|nil)\b/.test(s)) return { kind: 'literal', value: s };