@clear-capabilities/agentic-security-scanner 0.136.2 → 0.137.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 (117) hide show
  1. package/CHANGELOG.md +880 -0
  2. package/bin/agentic-security.js +189 -37
  3. package/dist/113.index.js +13 -4
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +5 -4
  6. package/dist/238.index.js +1 -1
  7. package/dist/317.index.js +36 -6
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +192 -15
  10. package/dist/444.index.js +20 -11
  11. package/dist/449.index.js +8 -1
  12. package/dist/526.index.js +3 -3
  13. package/dist/637.index.js +1 -1
  14. package/dist/agentic-security.mjs +15 -15
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
  17. package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
  18. package/package.json +21 -13
  19. package/src/dataflow/CLAUDE.md +12 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/catalog-expanded.js +1 -0
  22. package/src/dataflow/catalog.js +157 -31
  23. package/src/dataflow/engine.js +639 -112
  24. package/src/dataflow/implicit-flow.js +68 -36
  25. package/src/dataflow/incremental.js +18 -3
  26. package/src/dataflow/index.js +17 -1
  27. package/src/dataflow/points-to.js +19 -6
  28. package/src/dataflow/proven-clean.js +41 -0
  29. package/src/dataflow/sanitizer-gate.js +35 -9
  30. package/src/dataflow/sanitizer-proof.js +21 -3
  31. package/src/dataflow/stub-aware-filter.js +36 -13
  32. package/src/dataflow/summaries.js +21 -2
  33. package/src/engine.js +430 -196
  34. package/src/ir/CLAUDE.md +16 -2
  35. package/src/ir/balanced-call.js +55 -0
  36. package/src/ir/class-hierarchy.js +57 -11
  37. package/src/ir/index.js +14 -2
  38. package/src/ir/parser-cs.js +513 -40
  39. package/src/ir/parser-go.js +29 -11
  40. package/src/ir/parser-java.js +300 -20
  41. package/src/ir/parser-js.js +300 -22
  42. package/src/ir/parser-kt.js +436 -18
  43. package/src/ir/parser-php.js +631 -38
  44. package/src/ir/parser-py.helper.py +32 -2
  45. package/src/ir/parser-py.js +31 -4
  46. package/src/ir/parser-rb.js +161 -26
  47. package/src/ir/ssa.js +6 -1
  48. package/src/lsp/server.js +35 -3
  49. package/src/mcp/CLAUDE.md +9 -2
  50. package/src/mcp/redact.js +26 -0
  51. package/src/mcp/tools.js +164 -15
  52. package/src/posture/CLAUDE.md +19 -7
  53. package/src/posture/accuracy-scorecard.js +9 -1
  54. package/src/posture/aibom.js +12 -8
  55. package/src/posture/auditor-walkthrough.js +102 -3
  56. package/src/posture/autopilot.js +8 -1
  57. package/src/posture/calibration-drift.js +11 -5
  58. package/src/posture/calibration.js +24 -2
  59. package/src/posture/clustering.js +12 -1
  60. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  61. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  62. package/src/posture/compliance-policy.js +33 -1
  63. package/src/posture/confidence.js +44 -10
  64. package/src/posture/corpus-enroll.js +9 -5
  65. package/src/posture/corpus-match.js +19 -0
  66. package/src/posture/csharp-analysis.js +62 -3
  67. package/src/posture/deploy-platform.js +4 -1
  68. package/src/posture/drift.js +7 -1
  69. package/src/posture/epss.js +13 -1
  70. package/src/posture/evidence-bundle.js +36 -6
  71. package/src/posture/exploitability-probability.js +13 -1
  72. package/src/posture/falsification.js +23 -2
  73. package/src/posture/fix-metrics.js +1 -1
  74. package/src/posture/fix-verify-loop.js +10 -1
  75. package/src/posture/iac-reachability.js +14 -8
  76. package/src/posture/integrity.js +25 -7
  77. package/src/posture/model-rescan.js +65 -0
  78. package/src/posture/mttr.js +5 -0
  79. package/src/posture/poc-inprocess.js +27 -8
  80. package/src/posture/regression-test-gen.js +23 -8
  81. package/src/posture/reverse-blast-radius.js +5 -1
  82. package/src/posture/risk-dollars.js +18 -1
  83. package/src/posture/sbom.js +2 -2
  84. package/src/posture/secret-history.js +20 -11
  85. package/src/posture/security-trend.js +7 -1
  86. package/src/posture/stack-playbook.js +22 -1
  87. package/src/posture/threat-model-grounding.js +2 -2
  88. package/src/posture/validator-metrics.js +10 -3
  89. package/src/posture/verifier.js +32 -57
  90. package/src/report/index.js +183 -14
  91. package/src/runScan.js +1 -1
  92. package/src/sast/_comment-strip.js +15 -4
  93. package/src/sast/_secret-entropy.js +1 -1
  94. package/src/sast/authz.js +6 -4
  95. package/src/sast/bench-shape/index.js +2 -7
  96. package/src/sast/claude-md-prompt-injection.js +14 -3
  97. package/src/sast/cloud-iam.js +60 -7
  98. package/src/sast/cpp-bench-extras.js +1 -1
  99. package/src/sast/csrf.js +7 -5
  100. package/src/sast/env-hygiene.js +5 -2
  101. package/src/sast/iac-terraform.js +25 -0
  102. package/src/sast/java-bench-extras.js +1 -1
  103. package/src/sast/java-constant-fold.js +5 -5
  104. package/src/sast/llm-owasp.js +4 -2
  105. package/src/sast/mcp-audit.js +7 -0
  106. package/src/sast/pipeline.js +8 -0
  107. package/src/sast/prompt-template.js +8 -6
  108. package/src/sast/prototype-pollution.js +6 -2
  109. package/src/sast/redos-nfa.js +6 -6
  110. package/src/sast/secret-concat.js +13 -2
  111. package/src/sast/ssrf-cloud-metadata.js +6 -3
  112. package/src/sast/xss-reflected-multilang.js +1 -1
  113. package/src/sast/xxe.js +1 -1
  114. package/src/sca/CLAUDE.md +3 -4
  115. package/src/sca/container.js +35 -3
  116. package/src/sca/dep-confusion.js +7 -0
  117. package/src/sca/sarif-ingest.js +0 -187
package/src/engine.js CHANGED
@@ -121,7 +121,7 @@ import { scanRuby } from './sast/ruby.js';
121
121
  import { scanPhp } from './sast/php.js';
122
122
  import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secret-entropy.js';
123
123
  // Phase 1 — precision-engineering posture modules.
124
- import { annotateConfidence } from './posture/confidence.js';
124
+ import { annotateConfidence, applyUnvalidatedPenalty } from './posture/confidence.js';
125
125
  import { backfillFindingDefaults } from './posture/finding-defaults.js';
126
126
  import { annotatePocs } from './posture/poc-generator.js';
127
127
  import { annotateExecutionProofs } from './posture/prove-findings.js';
@@ -148,7 +148,7 @@ import { ingestLogicClaims } from './posture/logic-claims.js';
148
148
  import { annotateNarration } from './posture/flow-narration.js';
149
149
  import { applyPathConstraints } from './posture/path-predicates.js';
150
150
  // Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
151
- import { buildProjectIR } from './ir/index.js';
151
+ import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
152
152
  import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
153
153
  import { runDeepAnalysis } from './dataflow/index.js';
154
154
  // v3 next-gen — Pillars 1, 4, 5, 6, 8, 9.
@@ -1271,13 +1271,75 @@ function _guardWindow(ctx, before = 25, after = 5) {
1271
1271
  .replace(/(^|[^\w'"`])#[^\n]*/g, '$1 ');
1272
1272
  }
1273
1273
 
1274
- function _hasSsrfHostGuard(ctx) { return _SSRF_HOST_GUARD_RE.test(_guardWindow(ctx)); }
1274
+ // PRD R15: a guard-shaped token anywhere in the -25/+5 window used to be
1275
+ // sufficient — an allow-list built for an unrelated purpose, or even the
1276
+ // tainted variable's OWN declaration line, sitting in the same screenful of
1277
+ // code as a genuinely-unguarded sink, silently killed a real finding. This
1278
+ // does not require full dataflow correlation, only a cheap positional one:
1279
+ // the sink's own argument identifier(s) must appear WITHIN A FEW LINES of
1280
+ // the specific line the guard-shaped text actually matched on — not merely
1281
+ // somewhere in the whole window, which is true of almost any variable used
1282
+ // nearby (its own declaration, an unrelated helper, the sink line itself).
1283
+ // Falls back to permissive (unable to correlate → don't break existing
1284
+ // recall protection) when the sink line yields no usable identifier.
1285
+ const _GUARD_STOPWORDS = new Set(['var', 'let', 'const', 'function', 'return', 'new', 'await', 'async',
1286
+ 'if', 'else', 'for', 'while', 'require', 'import', 'from', 'true', 'false', 'null', 'undefined',
1287
+ 'this', 'self', 'req', 'res', 'request', 'response']);
1288
+ function _sinkLineIdentifiers(ctx) {
1289
+ const line = (ctx && Array.isArray(ctx.lines) && ctx.lines[(ctx.line || 1) - 1]) || '';
1290
+ const ids = new Set();
1291
+ // Excludes call-target identifiers (name immediately followed by `(`) —
1292
+ // `fetch(target)` must correlate on the ARGUMENT `target`, not on `fetch`
1293
+ // itself, which incidentally appears anywhere the module imports fetch
1294
+ // (e.g. `const fetch = require('node-fetch')`) and would trivially
1295
+ // "correlate" with any guard window in the same file.
1296
+ const re = /\b[A-Za-z_$][\w$]*\b(?!\s*\()/g;
1297
+ let m;
1298
+ while ((m = re.exec(line))) {
1299
+ const id = m[0];
1300
+ // No minimum length: short variable names (`u`, `p`, `f`) are common,
1301
+ // legitimate taint carriers in real code — excluding them left the
1302
+ // ONLY correlating identifier out entirely for sinks like
1303
+ // `File.ReadAllText(p)` or `axios.get(u.toString())`, defeating
1304
+ // correlation with a guard that genuinely protects that exact
1305
+ // variable (a corpus regression caught this: CVE-2021-22054-ssrf-shape,
1306
+ // CVE-2022-26049-cs-path).
1307
+ if (id.length >= 1 && !_GUARD_STOPWORDS.has(id)) ids.add(id);
1308
+ }
1309
+ return ids;
1310
+ }
1311
+ // Runs guardRe against the window and, for each match, checks whether any
1312
+ // sink-line identifier appears within `span` lines of that match's own line
1313
+ // (excluding the sink line itself, which trivially contains its own
1314
+ // argument). Tries every match, not just the first, since a window can
1315
+ // contain several guard-shaped lines and only one need actually correlate.
1316
+ function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
1317
+ const w = _guardWindow(ctx);
1318
+ const re = new RegExp(guardRe.source, guardRe.flags.includes('g') ? guardRe.flags : guardRe.flags + 'g');
1319
+ const ids = _sinkLineIdentifiers(ctx);
1320
+ if (!ids.size) return re.test(w); // can't correlate — fall back to the old shape-only check
1321
+ const wLines = w.split('\n');
1322
+ const sinkLineText = (ctx.lines && ctx.lines[(ctx.line || 1) - 1]) || '';
1323
+ let m;
1324
+ while ((m = re.exec(w))) {
1325
+ const guardLineIdx = w.slice(0, m.index).split('\n').length - 1; // 0-based within window
1326
+ const lo = Math.max(0, guardLineIdx - span);
1327
+ const hi = Math.min(wLines.length, guardLineIdx + span + 1);
1328
+ const local = wLines.slice(lo, hi).filter((l) => l !== sinkLineText).join('\n');
1329
+ for (const id of ids) {
1330
+ if (new RegExp(`\\b${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(local)) return true;
1331
+ }
1332
+ }
1333
+ return false;
1334
+ }
1335
+
1336
+ function _hasSsrfHostGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _SSRF_HOST_GUARD_RE); }
1275
1337
 
1276
1338
  // A path-traversal containment guard near the file sink: a basename/strip
1277
1339
  // helper that removes directory components, a framework safe-join, or a
1278
1340
  // canonicalize-then-startsWith containment check.
1279
1341
  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/;
1280
- function _hasPathGuard(ctx) { return _PATH_GUARD_RE.test(_guardWindow(ctx)); }
1342
+ function _hasPathGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _PATH_GUARD_RE); }
1281
1343
 
1282
1344
  // Reflected-XSS output-encoding guard: an HTML escaper applied near the sink.
1283
1345
  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*\(`;
@@ -2214,7 +2276,13 @@ const IAC_PATTERNS = [
2214
2276
  { match: /\$\{\{\s*github\.event\.(?:issue|pull_request)\.title|\$\{\{\s*github\.event\.comment\.body/i,
2215
2277
  fileTypes: /\.github\/workflows\/.*\.ya?ml$/i,
2216
2278
  severity: 'high', cwe: 'CWE-78', vuln: 'GitHub Actions: untrusted github.event input interpolated into shell',
2217
- fix: 'Pass user-controlled fields via env vars and reference them as $VARNAME in the script body, not via ${{ }} interpolation.' },
2279
+ fix: 'Pass user-controlled fields via env vars and reference them as $VARNAME in the script body, not via ${{ }} interpolation.',
2280
+ // Same false-positive as src/sast/pipeline.js's overlapping rule: a pure
2281
+ // `KEY: ${{ github.event.… }}` mapping line assigns to an env var at the
2282
+ // workflow-engine level (the fix's own recommended pattern), not into a
2283
+ // shell command string — only a match embedded in a larger line (a run:
2284
+ // script body) is actually dangerous.
2285
+ lineSafeRe: /^[\w.-]+\s*:\s*\$\{\{[^}]*\}\}\s*$/ },
2218
2286
  ];
2219
2287
 
2220
2288
  function scanIaC(fp, raw){
@@ -2227,6 +2295,7 @@ function scanIaC(fp, raw){
2227
2295
  let m;
2228
2296
  while ((m = re.exec(raw))) {
2229
2297
  const line = raw.substring(0, m.index).split('\n').length;
2298
+ if (p.lineSafeRe && p.lineSafeRe.test((lines[line - 1] || '').trim())) continue;
2230
2299
  findings.push({
2231
2300
  id: `iac:${fp}:${line}:${p.vuln.replace(/\s/g, '_').slice(0, 60)}`,
2232
2301
  kind: 'iac', severity: p.severity, vuln: p.vuln,
@@ -2747,10 +2816,24 @@ function scanLogicVulns(fp,raw){
2747
2816
  while((m=re.exec(haystack))){
2748
2817
  const line=lineAt(haystack,m.index);
2749
2818
  const snippet=lines[line-1]?.trim()||"";
2819
+ let outSnippet=snippet;
2750
2820
  // FP-2: credential FP filter
2751
2821
  if(pat.vuln==='Hardcoded Secret'||pat.vuln==='Hardcoded Credential Check'){
2752
2822
  const fpCheck=_isFalsePositiveCredential(fp,snippet,m[0]);
2753
2823
  if(fpCheck.skip){_suppressionLog.push({vuln:pat.vuln,file:fp,line,snippet,reason:fpCheck.reason});continue;}
2824
+ // Stage 4 correctness audit (coverage breadth, secrets): same
2825
+ // unredacted-snippet leak found in engine.js's scanEntropySecrets/
2826
+ // scanCredentials and sast/secret-concat.js — this is a THIRD,
2827
+ // separate detector (LOGIC_PATTERNS' own "Hardcoded Secret" rule)
2828
+ // that also stored the raw source line, with no masking at all.
2829
+ // The regex captures the quoted value inside `m[0]`; mask just
2830
+ // that value within the reported snippet.
2831
+ const valMatch=m[0].match(/['"]([^'"]{3,})['"]/);
2832
+ if(valMatch){
2833
+ const val=valMatch[1];
2834
+ const masked=val.length>8?val.substring(0,4)+"…"+val.substring(val.length-4):"••••";
2835
+ outSnippet=snippet.split(val).join(masked);
2836
+ }
2754
2837
  }
2755
2838
  // FP-6: operational-context gate for selected logic patterns
2756
2839
  if (predicate) {
@@ -2760,7 +2843,7 @@ function scanLogicVulns(fp,raw){
2760
2843
  continue;
2761
2844
  }
2762
2845
  }
2763
- results.push({vuln:pat.vuln,severity:pat.severity,cwe:pat.cwe,stride:pat.stride,kind:pat.kind,fix:pat.fix,code:pat.code,file:fp,line,snippet});
2846
+ results.push({vuln:pat.vuln,severity:pat.severity,cwe:pat.cwe,stride:pat.stride,kind:pat.kind,fix:pat.fix,code:pat.code,file:fp,line,snippet:outSnippet});
2764
2847
  }
2765
2848
  }
2766
2849
  const routeRe=/(?:app|router)\s*\.\s*(?:get|post|all)\s*\(\s*['"`](\/(?:debug|admin|test|internal|__)[^'"`]*)/gi;let rm;
@@ -2987,15 +3070,17 @@ const JAVA_FAMILY_RULES = [
2987
3070
  return !isWeak(resolved); // strong → suppress; weak → fire
2988
3071
  }
2989
3072
  // 2) OWASP Benchmark fallback — hardcoded answer-key for OWASP's own
2990
- // benchmark.properties file. Pure label leakage; disabled under
2991
- // blind bench so the F1 reflects the production engine alone.
2992
- const _blindHere = process.env.AGENTIC_SECURITY_BLIND_BENCH === '1';
2993
- const OWASP_BENCH_PROPS = _blindHere ? {} : {
3073
+ // benchmark.properties file. Pure label leakage. PRD R5: was
3074
+ // opt-out (disabled only under BLIND_BENCH=1) inverted to opt-in,
3075
+ // enabled only under explicit BENCH_SHAPE=1, matching the
3076
+ // documented default every other bench-shape mechanism follows.
3077
+ const _benchShapeHere = process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1' && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1';
3078
+ const OWASP_BENCH_PROPS = _benchShapeHere ? {
2994
3079
  cryptoAlg1: 'DES/ECB/PKCS5Padding',
2995
3080
  cryptoAlg2: 'AES/CCM/NoPadding',
2996
3081
  hashAlg1: 'MD5',
2997
3082
  hashAlg2: 'SHA-256',
2998
- };
3083
+ } : {};
2999
3084
  if (OWASP_BENCH_PROPS[propKey]) {
3000
3085
  return !isWeak(OWASP_BENCH_PROPS[propKey]);
3001
3086
  }
@@ -4443,8 +4528,10 @@ function scanJavaSAST(fp, raw) {
4443
4528
  // 72/73/74/.../82 where the receiving file has no local source — the
4444
4529
  // tainted Vector/List/Map arrives via a method parameter from a sibling
4445
4530
  // file. Gated tightly to avoid FPs on real apps.
4446
- if (!hasSource && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1') {
4447
- // Juliet-shape signal disabled under blind bench (answer-key leakage).
4531
+ if (!hasSource && process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1' && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1') {
4532
+ // PRD R5: was opt-out (disabled only under BLIND_BENCH=1) inverted to
4533
+ // opt-in, matching the documented default every other bench-shape
4534
+ // mechanism in this file follows. Juliet-shape signal.
4448
4535
  const _isJulietShape = /\bjuliet\.(?:testcases|support)\b/.test(cleaned)
4449
4536
  || /\b(?:badSink|badSource|goodG2B|goodB2G)\s*\(/.test(cleaned);
4450
4537
  if (_isJulietShape && /\b(?:Vector|ArrayList|LinkedList|List|Set|HashSet|Map|HashMap|Hashtable|Properties|Queue|Deque|Stack|Optional)\s*<[^>]*>\s+[A-Za-z_]\w*\s*[,)]/.test(cleaned)) {
@@ -4464,15 +4551,18 @@ function scanJavaSAST(fp, raw) {
4464
4551
  let pm;
4465
4552
  // OWASP_BENCH_PROPS is the OWASP Benchmark answer-key for its own
4466
4553
  // benchmark.properties file (hashAlg1 → MD5, cryptoAlg1 → DES/ECB). Pure
4467
- // label leakage. Disabled under blind bench; real apps use the
4468
- // properties index loaded from the filesystem instead.
4469
- const _blindHere = process.env.AGENTIC_SECURITY_BLIND_BENCH === '1';
4470
- const OWASP_BENCH_PROPS = _blindHere ? {} : {
4554
+ // label leakage. PRD R5: was opt-out (disabled only under
4555
+ // BLIND_BENCH=1) inverted to opt-in, enabled only under explicit
4556
+ // BENCH_SHAPE=1; real apps use the properties index loaded from the
4557
+ // filesystem instead (the `resolved`/`getJavaProperty` path above this
4558
+ // fallback).
4559
+ const _benchShapeHere = process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1' && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1';
4560
+ const OWASP_BENCH_PROPS = _benchShapeHere ? {
4471
4561
  cryptoAlg1: 'DES/ECB/PKCS5Padding',
4472
4562
  cryptoAlg2: 'AES/CCM/NoPadding',
4473
4563
  hashAlg1: 'MD5',
4474
4564
  hashAlg2: 'SHA-256',
4475
- };
4565
+ } : {};
4476
4566
  const isWeak = (v) =>
4477
4567
  /\b(?:MD2|MD4|MD5|SHA-?1|SHA1|DES|DESede|3DES|RC2|RC4|Blowfish|HmacMD5|HmacSHA1)\b|AES\s*\/\s*ECB/i.test(v || '');
4478
4568
  while ((pm = propUseRe.exec(cleaned)) !== null) {
@@ -4803,7 +4893,13 @@ function annotateReachability(findings,routes,callGraph,fc){
4803
4893
  // Within 60 lines of a route declaration we consider this source route-rooted
4804
4894
  const routeRooted=rl.some(l=>Math.abs(l-srcLine)<60);
4805
4895
  f.routeRooted=routeRooted;
4806
- // Cheap function-of-source lookup via callGraph
4896
+ // Cheap function-of-source lookup via callGraph. buildCallGraph only
4897
+ // ever populates entries for .js/.jsx/.ts/.tsx/.mjs/.cjs files (PRD
4898
+ // R15) — callGraph[fp] being absent for every other language is an
4899
+ // ABSENCE OF EVIDENCE, not evidence the finding is unreachable, and
4900
+ // must not be conflated with a JS file whose call graph genuinely has
4901
+ // no incoming edge.
4902
+ const hasCallGraphData=Object.prototype.hasOwnProperty.call(callGraph,fp);
4807
4903
  const funcs=callGraph[fp]||{};
4808
4904
  let enclosing=null;
4809
4905
  for(const[fn,info] of Object.entries(funcs))
@@ -4812,6 +4908,7 @@ function annotateReachability(findings,routes,callGraph,fc){
4812
4908
  // Reachable when route-rooted OR enclosingFunction is called from any function
4813
4909
  // declared near a route in the same file
4814
4910
  if(routeRooted){f.reachable=true;continue;}
4911
+ if(!hasCallGraphData){f.reachable=null;continue;}
4815
4912
  let reachable=false;
4816
4913
  if(enclosing){
4817
4914
  for(const rLine of rl){
@@ -5409,17 +5506,29 @@ function scanEntropySecrets(fp,raw){
5409
5506
  // FP-5: structural / doc-context suppression
5410
5507
  const surrounding=lines.slice(Math.max(0,line-3),Math.min(lines.length,line+1)).join("\n");
5411
5508
  const nonSecretReason=_isLikelyNonSecret(v, ctx, surrounding);
5509
+ const masked=v.substring(0,4)+"…"+v.substring(v.length-4);
5412
5510
  if (nonSecretReason) {
5413
- _suppressionLog.push({vuln:"High-Entropy Credential Candidate",file:fp,line,snippet:ctx.trim(),reason:'entropy-'+nonSecretReason});
5511
+ // Same redaction concern as the main finding below applies to the
5512
+ // suppression log — it's exposed via --include-suppressed, and a
5513
+ // heuristic "probably not a real secret" call can be wrong.
5514
+ _suppressionLog.push({vuln:"High-Entropy Credential Candidate",file:fp,line,snippet:ctx.trim().split(v).join(masked),reason:'entropy-'+nonSecretReason});
5414
5515
  continue;
5415
5516
  }
5416
- const masked=v.substring(0,4)+"…"+v.substring(v.length-4);
5517
+ // Stage 4 correctness audit (coverage breadth, secrets): `snippet` used
5518
+ // to carry the RAW, unmasked source line — including the full secret
5519
+ // value — even though `masked` right next to it was correctly
5520
+ // redacted. Nothing downstream (normalizeFindings, toHTML, toCSV,
5521
+ // toJUnit) ever redacts `snippet` again, so the plaintext credential
5522
+ // this detector exists to find flowed straight into every report
5523
+ // format this scanner emits, including last-scan.json. Redact the
5524
+ // exact matched value out of the snippet at the source, the same way
5525
+ // `masked` already is.
5417
5526
  out.push({
5418
5527
  vuln:"High-Entropy Credential Candidate",
5419
5528
  severity:"high",cwe:"CWE-798",stride:"Information Disclosure",
5420
5529
  fix:"Replace with environment variable or secrets manager reference; rotate the value immediately.",
5421
5530
  code:`// BEFORE\nconst secret = "${masked}";\n\n// AFTER\nconst secret = process.env.APP_SECRET;`,
5422
- file:fp,line,snippet:ctx.trim(),masked,entropy:e.toFixed(2)
5531
+ file:fp,line,snippet:ctx.trim().split(v).join(masked),masked,entropy:e.toFixed(2)
5423
5532
  });
5424
5533
  }
5425
5534
  return out;
@@ -5459,12 +5568,21 @@ function scanConfigFiles(fc){
5459
5568
  const[,k,v]=m;
5460
5569
  if(!v||v==='""'||v==="''"||/^(?:change.?me|your[_-]|placeholder|example|xxx+|todo|<|\$\{)/i.test(v))continue;
5461
5570
  if(/(?:password|secret|key|token|api)/i.test(k)){
5571
+ // Stage 6 correctness audit: this was the one secrets-adjacent
5572
+ // detector still shipping the RAW, unmasked source line as
5573
+ // `snippet` — its siblings (scanCredentials/scanEntropySecrets)
5574
+ // were already fixed for this exact leak in the Stage 4 audit.
5575
+ // Nothing downstream re-redacts `snippet`, and the MCP redact.js
5576
+ // catch-all requires a QUOTED value, which standard `.env`
5577
+ // KEY=value syntax never has — so the plaintext committed
5578
+ // secret flowed straight through explain_finding unredacted.
5579
+ const masked=v.length>8?v.substring(0,4)+"…"+v.substring(v.length-4):"…";
5462
5580
  out.push({
5463
5581
  vuln:`Committed .env with Real-Looking ${k}`,
5464
5582
  severity:"high",cwe:"CWE-538",stride:"Information Disclosure",
5465
5583
  fix:`Remove ${k} from committed env files. Use .env.example with placeholders and ignore .env in VCS.`,
5466
5584
  code:`# In .gitignore\n.env\n\n# .env.example (commit)\n${k}=<your-${k.toLowerCase()}>`,
5467
- file:fp,line:i+1,snippet:ln
5585
+ file:fp,line:i+1,snippet:ln.split(v).join(masked),masked
5468
5586
  });
5469
5587
  }
5470
5588
  }
@@ -5780,15 +5898,26 @@ function dedupeFindingsWithEvidence(findings){
5780
5898
  const key=`${file}:${sinkLine}:${fam}`;
5781
5899
  if(!buckets.has(key)){buckets.set(key,f);continue;}
5782
5900
  const kept=buckets.get(key);
5783
- // Winner selection: an interprocedural flow finding (carries source→sink
5784
- // attribution) is the better carrier than a flat structural/regex match at
5785
- // the same sinkkeep its chain/source-line attribution. When neither or
5786
- // both carry flow, fall back to severity. The winner keeps its own severity
5787
- // (we must not resurrect a rating that ownership/reachability analysis
5788
- // deliberately downgraded on one of the two findings).
5901
+ // Winner selection: an IR-TAINT finding (the deep engine's real
5902
+ // interprocedural taint walk) is the best carrier at a shared sink,
5903
+ // ahead of everything else PRD R3: it carries taint-walk-only evidence
5904
+ // (sanitizer observations keyed off the actual value reaching the sink,
5905
+ // chain, LLM-validation state) that a flat pattern/AST match has no
5906
+ // equivalent for, and that evidence would silently vanish if a
5907
+ // same-severity pattern-layer duplicate won the tie instead. Below that,
5908
+ // an interprocedural flow finding (carries source→sink attribution) is
5909
+ // the better carrier than a flat structural/regex match — keep its
5910
+ // chain/source-line attribution. When neither or both carry flow, fall
5911
+ // back to severity. The winner keeps its own severity (we must not
5912
+ // resurrect a rating that ownership/reachability analysis deliberately
5913
+ // downgraded on one of the two findings).
5914
+ const fIsIrTaint = f.parser === 'IR-TAINT';
5915
+ const kIsIrTaint = kept.parser === 'IR-TAINT';
5789
5916
  const fHasFlow = !!(f.source && f.sink);
5790
5917
  const kHasFlow = !!(kept.source && kept.sink);
5791
- const keepNew = (fHasFlow !== kHasFlow)
5918
+ const keepNew = (fIsIrTaint !== kIsIrTaint)
5919
+ ? fIsIrTaint
5920
+ : (fHasFlow !== kHasFlow)
5792
5921
  ? fHasFlow
5793
5922
  : (SEV_RANK[f.severity]??9) < (SEV_RANK[kept.severity]??9);
5794
5923
  const winner = keepNew ? f : kept;
@@ -6369,7 +6498,11 @@ function scanCredentials(fp,raw){
6369
6498
  if(seen.has(key))continue;seen.add(key);
6370
6499
  const severity=pat.s==="c"?"critical":pat.s==="h"?"high":"medium";
6371
6500
  const masked=val.length>12?val.substring(0,6)+"••••••"+val.substring(val.length-4):val.substring(0,3)+"•••";
6372
- results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,snippet,masked,fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
6501
+ // Stage 4 correctness audit (coverage breadth, secrets): same
6502
+ // unredacted-snippet leak as scanEntropySecrets — `snippet` carried
6503
+ // the raw source line (full credential value) straight through to
6504
+ // every report format. Redact the exact matched value here too.
6505
+ results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,snippet:snippet.split(val).join(masked),masked,fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
6373
6506
  }
6374
6507
  }
6375
6508
  return results;
@@ -6534,7 +6667,7 @@ function _makePurl(ecosystem,name,version,group){
6534
6667
  const t={npm:'npm',pypi:'pypi'}[ecosystem]||ecosystem;
6535
6668
  if(!t)return'';
6536
6669
  const ns=group?`${encodeURIComponent(group)}/`:'';
6537
- return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+version:''}`;
6670
+ return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+encodeURIComponent(version):''}`;
6538
6671
  }
6539
6672
 
6540
6673
  function _parsePackageJson(text,filePath){
@@ -7508,7 +7641,7 @@ async function queryRegistries(components){
7508
7641
 
7509
7642
  // Node port: takes { fileContents, depFileContents } maps directly instead of a JSZip object.
7510
7643
  // fileContents = code files keyed by relative path; depFileContents = manifest/lockfiles keyed by relative path.
7511
- async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
7644
+ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
7512
7645
  // Pre-pass: build cross-file Java tainted-method index so per-file taint
7513
7646
  // analysis can recognize calls to user-input-returning helper methods
7514
7647
  // defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
@@ -7771,7 +7904,15 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7771
7904
  const c = fc[p];
7772
7905
  if (!c) continue;
7773
7906
  // Path-based category for Juliet test cases: `juliet-cweN/.../...java`.
7774
- const julietMatch = p.match(/(?:^|\/)juliet-cwe(\d+)\//i);
7907
+ // PRD R5: this reads a path-embedded answer key (the directory name
7908
+ // declares the CWE) exactly like _javaWebServletCategory's @WebServlet
7909
+ // annotation reading below — off by default, enabled only when
7910
+ // BENCH_SHAPE=1. This branch previously had no gate at all, so a real
7911
+ // repository with a directory that happens to be named `juliet-cweNN/`
7912
+ // would silently lose off-family findings in the default pipeline.
7913
+ const julietMatch = (process.env.AGENTIC_SECURITY_BENCH_SHAPE === '1'
7914
+ && process.env.AGENTIC_SECURITY_BLIND_BENCH !== '1')
7915
+ ? p.match(/(?:^|\/)juliet-cwe(\d+)\//i) : null;
7775
7916
  if (julietMatch && _JULIET_CWE_TO_FAMILY[julietMatch[1]]) {
7776
7917
  _benchCategoryByFile.set(p, _JULIET_CWE_TO_FAMILY[julietMatch[1]]);
7777
7918
  continue;
@@ -7854,6 +7995,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7854
7995
  // R8 (PRD §5): OS packages from an extracted container image's package DBs
7855
7996
  // (dpkg/apk) — baked-in deps the Dockerfile never names. Feed the OSV/SBOM pipeline.
7856
7997
  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(_){}
7998
+ // Dockerfile-declared apt/apk install-line packages — complementary to
7999
+ // extractImagePackages above (which reads an actually scanned filesystem's
8000
+ // installed-package DB, a different signal: build-time-declared vs.
8001
+ // actually-installed).
8002
+ try{const{extractContainerPackages}=await import('./sca/container.js');for(const cp of extractContainerPackages(allFileContents)){if(!components.some(c=>c.ecosystem===cp.ecosystem&&c.name===cp.name&&c.version===cp.version))components.push(cp);}}catch(_){}
7857
8003
  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(_){}
7858
8004
  const reach=buildReachabilitySet(fc);
7859
8005
  const reachabilitySet=reach.imported;
@@ -7979,6 +8125,169 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7979
8125
  // Roadmap #8 — tree-sitter sinks for long-tail languages (opt-in,
7980
8126
  // AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
7981
8127
  if(process.env.AGENTIC_SECURITY_TREE_SITTER==='1'){try{aF.push(...await scanTreeSitterSinks(fc));}catch(_){}}
8128
+ // Phase 3 (Sentinel-parity FR-L1, FR-L2) — IR + interprocedural taint.
8129
+ // R1 (PRD §5): the CLI entry (bin/agentic-security.js#cmdScan) now sets
8130
+ // AGENTIC_SECURITY_DEEP=1 by default for local/interactive scans, so deep mode
8131
+ // runs on the default `/scan --all` path. This gate is the enforcement +
8132
+ // CI-safety point: it honors an explicit opt-out (DEEP=0) and keeps deep off in
8133
+ // CI unless DEEP_IN_CI=1. In-process callers (tests, the cve-replay corpus) invoke
8134
+ // runScan()/runFullScan() directly without the CLI default, so they stay deep-off
8135
+ // and remain deterministic regression gates.
8136
+ //
8137
+ // PRD R3: IR-TAINT findings are appended into `aF` HERE, before dedup, so
8138
+ // they dedupe against a pattern-layer duplicate of the same sink and then
8139
+ // ride through the exact same annotator pipeline every other finding does
8140
+ // (stable IDs, clustering, reachability, family backfill, confidence,
8141
+ // calibration, exploitability, sanitizer/proof gate, mitigation, composite
8142
+ // risk, LLM validation...) below. Previously this block ran AFTER that
8143
+ // entire pipeline and pushed straight into the post-dedup `finalFindings`
8144
+ // array, so a sink caught by both the regex layer and deep mode produced
8145
+ // two findings (one with no family and no calibrated confidence) instead
8146
+ // of one deduped, fully-annotated finding.
8147
+ //
8148
+ // SAFETY: Deep mode is gated for CI safety:
8149
+ // - Global timeout via AGENTIC_SECURITY_DEEP_TIMEOUT_MS (default 300_000 = 5 min)
8150
+ // - Auto-disabled in CI unless AGENTIC_SECURITY_DEEP_IN_CI=1 is also set,
8151
+ // so a pathological file can't hang the whole pipeline.
8152
+ // ── IR parse-coverage sidecar (proof-corpus instrumentation, default off) ──
8153
+ // Built ahead of the deep-mode gate so coverage is measurable without paying
8154
+ // for taint analysis, and stashed in _sharedIR so the deep block below reuses
8155
+ // it rather than parsing the project twice.
8156
+ //
8157
+ // NOTE (affects instrumented runs only, i.e. AGENTIC_SECURITY_IR_STATS set):
8158
+ // when this block runs, buildProjectIR() happens here, BEFORE the deep-mode
8159
+ // budget timer (t0) below is started. On an uninstrumented run, IR
8160
+ // construction instead happens inside the timed block via the
8161
+ // `_sharedIR || (_sharedIR = buildProjectIR(fc))` line, so its cost counts
8162
+ // against AGENTIC_SECURITY_DEEP_TIMEOUT_MS. That means the deep budget does
8163
+ // NOT account for parse time when stats are enabled — an instrumented run
8164
+ // gets strictly more wall-clock for the taint analysis itself than an
8165
+ // uninstrumented run with the same budget.
8166
+ let _sharedIR = null;
8167
+ // Java IR requires the ASYNC builder. `parser-java.js` exports an async
8168
+ // `parseJavaFile` (java-parser needs a dynamic import), so the sync
8169
+ // `buildProjectIR` has no Java branch at all — and both deep-path call sites
8170
+ // used it. The result was that no .java file had ever produced an IR function
8171
+ // in deep mode: `bench/layer-recall` measured java at 0/25 while the catalog
8172
+ // carried 7 Java sources and 15 Java sinks that had nothing to run against.
8173
+ // `buildProjectIRAsync` is a full mirror plus Java and had zero callers.
8174
+ //
8175
+ // Gated on the presence of .java rather than always awaiting: the async
8176
+ // builder is a superset, but switching every scan in the product to it to fix
8177
+ // one language would change the execution shape (and attempt the java-parser
8178
+ // import) for projects that contain no Java. `runFullScan` is already async,
8179
+ // so the await costs nothing structurally.
8180
+ const _hasJava = Object.keys(fc || {}).some(f => /\.java$/i.test(f));
8181
+ const _buildIR = async () => (_hasJava ? await buildProjectIRAsync(fc) : buildProjectIR(fc));
8182
+ const _irStatsTarget = irStatsTarget();
8183
+ if (_irStatsTarget) {
8184
+ try {
8185
+ _sharedIR = await _buildIR();
8186
+ writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
8187
+ } catch (e) {
8188
+ // Instrumentation must never fail a scan. Surface only when debugging.
8189
+ if (process.env.AGENTIC_SECURITY_IR_STATS_DEBUG === '1') {
8190
+ process.stderr.write(`ir-stats: ${e && e.message}\n`);
8191
+ }
8192
+ }
8193
+ }
8194
+ // `deep`/`deepInCi` come from runScan()'s options object (threaded through
8195
+ // unchanged from runScan.js) — an explicit-opt-in override alongside the
8196
+ // env vars, not a replacement for them. Added because `runScan(dir,
8197
+ // {deep:true})` was a silent, total no-op: this options object was
8198
+ // destructured for fileContents/depFileContents/scanRoot/resume only, so
8199
+ // `deep` was dropped on the floor and deep mode stayed off regardless.
8200
+ // Several interprocedural test files (interproc-k2.test.js,
8201
+ // parser-cs-kt.test.js, points-to.test.js) pass exactly this option
8202
+ // believing it enables deep mode — it never did, so those tests were
8203
+ // exercising whatever coincidentally fires without the deep engine, not
8204
+ // the interprocedural machinery they're named for.
8205
+ const _deepRequested = deep === true || process.env.AGENTIC_SECURITY_DEEP === '1';
8206
+ const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
8207
+ process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
8208
+ const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
8209
+ const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
8210
+ let _deepCallGraph = null;
8211
+ if (_deepEnabled) {
8212
+ const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
8213
+ const t0 = Date.now();
8214
+ try {
8215
+ const { perFile, callGraph } = _sharedIR || (_sharedIR = await _buildIR());
8216
+ _deepCallGraph = callGraph;
8217
+ // The runDeepAnalysis call is synchronous in this codebase; we can't
8218
+ // truly interrupt it without re-architecting the worklist. We pass a
8219
+ // deadlineMs hint that the inner loops check; if absent, we still cap
8220
+ // function count via fnLimit. Operators who suspect a hung run can
8221
+ // kill the process and re-run with AGENTIC_SECURITY_DEEP=0.
8222
+ const irFindings = runDeepAnalysis(perFile, callGraph, {
8223
+ fnLimit: parseInt(process.env.AGENTIC_SECURITY_DEEP_FN_LIMIT || '5000', 10),
8224
+ deadlineMs: t0 + budgetMs,
8225
+ // v0.69 — incremental cache inputs (used when AGENTIC_SECURITY_INCREMENTAL=1).
8226
+ scanRoot,
8227
+ fileContents: fc,
8228
+ });
8229
+ const elapsed = Date.now() - t0;
8230
+ if (elapsed > budgetMs) {
8231
+ // We exceeded budget — surface a single info finding so operators see it.
8232
+ aF.push({
8233
+ id: `ir-taint-timeout:${scanRoot || ''}`,
8234
+ file: '(deep-engine)', line: 0,
8235
+ vuln: `IR-TAINT deep mode exceeded ${budgetMs}ms budget (${elapsed}ms used) — results may be incomplete`,
8236
+ severity: 'info',
8237
+ parser: 'IR-TAINT',
8238
+ confidence: 0.5,
8239
+ });
8240
+ }
8241
+ for (const f of irFindings) {
8242
+ f.unvalidated = true;
8243
+ f.validator_verdict = 'unvalidated';
8244
+ }
8245
+ aF.push(...irFindings);
8246
+ } catch (e) {
8247
+ // Deep mode is best-effort. A parser blowup in one file shouldn't kill
8248
+ // the scan — fall back to the pattern-only result.
8249
+ }
8250
+ } else if (_deepRequested && _inCi) {
8251
+ // Operator asked for deep but we're in CI — emit a non-blocking notice
8252
+ // so they know it was skipped and how to override.
8253
+ aF.push({
8254
+ id: 'ir-taint-ci-skipped',
8255
+ file: '(deep-engine)', line: 0,
8256
+ vuln: 'IR-TAINT deep mode skipped in CI environment (set AGENTIC_SECURITY_DEEP_IN_CI=1 to opt in)',
8257
+ severity: 'info',
8258
+ parser: 'IR-TAINT',
8259
+ confidence: 1.0,
8260
+ });
8261
+ }
8262
+ // Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
8263
+ if (_deepCallGraph) {
8264
+ try {
8265
+ for (const sc of supplyChain) {
8266
+ if (sc.type !== 'vulnerable_dep' || sc.ecosystem !== 'maven') continue;
8267
+ if (sc.functionReachable === 'reachable') continue;
8268
+ const allFns = [...(sc.osvVulnFunctions || []), ...(VULN_FUNCTION_HINTS[sc.name] || [])];
8269
+ if (!allFns.length) continue;
8270
+ for (const fn of _deepCallGraph.functions ? _deepCallGraph.functions.values() : []) {
8271
+ if (!fn.cfg || !fn.cfg.nodes) continue;
8272
+ for (const node of Object.values(fn.cfg.nodes)) {
8273
+ if (node.kind !== 'call') continue;
8274
+ const callee = typeof node.callee === 'string' ? node.callee : null;
8275
+ if (!callee) continue;
8276
+ const shortCallee = callee.includes('.') ? callee.split('.').pop() : callee;
8277
+ if (allFns.some(f => f === shortCallee || f === callee)) {
8278
+ sc.functionReachable = 'reachable';
8279
+ sc.reachabilityTier = 'function-reachable';
8280
+ if (!sc.vulnerableFunctionCallSites) sc.vulnerableFunctionCallSites = [];
8281
+ sc.vulnerableFunctionCallSites.push({ pkg: sc.name, fn: shortCallee, file: fn.file, line: node.line });
8282
+ sc._javaIrEnriched = true;
8283
+ break;
8284
+ }
8285
+ }
8286
+ if (sc.functionReachable === 'reachable') break;
8287
+ }
8288
+ }
8289
+ } catch { /* Java SCA enrichment is best-effort */ }
8290
+ }
7982
8291
  let finalFindings;try{finalFindings=dedupeFindingsWithEvidence(aF);}catch(_){finalFindings=dd(aF,f=>f.id);}
7983
8292
  // Inline `agentic-security-ignore` pragmas, pass 1 of 2. This covers every
7984
8293
  // finding that exists BY THIS POINT — the pattern detectors, the cross-file
@@ -8104,7 +8413,13 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8104
8413
  };
8105
8414
  _runAnnotator('annotateStableIds', () => annotateStableIds(finalFindings));
8106
8415
  _runAnnotator("clusterByRootCause", () => { finalFindings = clusterByRootCause(finalFindings); });
8107
- _runAnnotator("demoteUnreachable", () => { demoteUnreachable(finalFindings, { routes: aR }); });
8416
+ _runAnnotator("demoteUnreachable", () => {
8417
+ demoteUnreachable(finalFindings, { routes: aR });
8418
+ // `type: 'vulnerable_dep'` findings live in supplyChain, not finalFindings
8419
+ // (src/sca/CLAUDE.md) — demoteUnreachable's SCA-tier branch needs this
8420
+ // array passed explicitly or it never sees an SCA finding at all.
8421
+ demoteUnreachable(supplyChain, { routes: aR });
8422
+ });
8108
8423
  // Premortem #8: backfill parser/family BEFORE confidence and calibration,
8109
8424
  // because both consume those fields and silently no-op when they're null.
8110
8425
  _runAnnotator("backfillFindingDefaults", () => { backfillFindingDefaults(finalFindings); });
@@ -8124,22 +8439,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8124
8439
  // Generalised sanitizer consumption (dataflow/sanitizer-gate.js): labels
8125
8440
  // findings whose flow passes a catalog sanitizer matching their family
8126
8441
  // (xss/url/cmd, not just sql) so the proof gate below can demote them the
8127
- // same way it demotes proven-clean SQL. `sanitizersOnPath` would need to
8128
- // be `{ [findingId]: string[] of sanitizer callees observed on that
8129
- // finding's flow }`. There is nothing to build that map from: the live
8130
- // taint walk in dataflow/engine.js does NOT consult sanitizer catalog
8131
- // entries at all. `matchSinkOrSanitizer()` returns every catalog hit for
8132
- // a callee, but every consumer in dataflow/*.js selects only
8133
- // `e.kind === 'sink'` there is no `'sanitizer'` branch anywhere in that
8134
- // tree. Taint is killed only by clean re-assignment of a variable
8135
- // (removePathAndDescendants, engine.js:374), which happens regardless of
8136
- // whether the RHS call is a catalog sanitizer.
8137
- // So this is `{}` and the gate below is INERT — not "awaiting plumbing"
8138
- // but awaiting the sanitizer walk itself. Making it live needs two things:
8139
- // (1) dataflow/engine.js honouring `kind === 'sanitizer'` at a call site,
8140
- // and (2) that call site's callee name threaded onto the finding
8141
- // alongside the trace/chain that proven-clean.js already reads.
8442
+ // same way it demotes proven-clean SQL.
8443
+ //
8444
+ // The taint walk now records the sanitizer callees observed on the value
8445
+ // reaching each sink argument (`dataflow/engine.js` `_sanitizersForExpr`)
8446
+ // and stamps them on the finding as `_sanitizersOnPath`. This rebuilds the
8447
+ // `{ [findingId]: string[] }` shape the gate wants. Both `id` and
8448
+ // `stableId` are keyed because the gate accepts either and stable ids are
8449
+ // assigned by an earlier annotator.
8450
+ //
8451
+ // The sanitizer never kills the taint in the walk itself: a mislabelled
8452
+ // sanitizer would then hide a real vulnerability outright, whereas a label
8453
+ // only demotes confidence here. Recall-preserving, on purpose.
8142
8454
  const sanitizersOnPath = {};
8455
+ for (const f of finalFindings) {
8456
+ const names = f && f._sanitizersOnPath;
8457
+ if (!Array.isArray(names) || !names.length) continue;
8458
+ if (f.id) sanitizersOnPath[f.id] = names;
8459
+ if (f.stableId) sanitizersOnPath[f.stableId] = names;
8460
+ }
8143
8461
  _runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath }); });
8144
8462
  _runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
8145
8463
  }
@@ -8288,8 +8606,10 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8288
8606
  // v3 next-gen: per-attacker-persona score matrix (FR-ADV-2). Must run AFTER
8289
8607
  // crown-jewels + mitigation composite so it sees those signals.
8290
8608
  _runAnnotator("annotatePersonaScores", () => { annotatePersonaScores(finalFindings); });
8291
- // v3 next-gen: SCA reverse-blast-radius enrichment (FR-ADV-5).
8292
- _runAnnotator("annotateScaReverseBlast", () => { annotateScaReverseBlast(finalFindings, fc); });
8609
+ // v3 next-gen: SCA reverse-blast-radius enrichment (FR-ADV-5). Annotates
8610
+ // SCA findings (package-name-keyed) must run against supplyChain, not
8611
+ // finalFindings (SAST), which has no package-name field at all.
8612
+ _runAnnotator("annotateScaReverseBlast", () => { annotateScaReverseBlast(supplyChain, fc); });
8293
8613
  // v3 next-gen: bug-bounty payout prediction (FR-ADV-3). Composes with the
8294
8614
  // mitigation composite — gated/unreachable findings get the bounty scaled
8295
8615
  // down rather than zeroed.
@@ -8391,146 +8711,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8391
8711
  // FR-LOGIC-6: LLM-driven flow narration (template fallback when no LLM endpoint).
8392
8712
  try { await annotateNarration(finalFindings); }
8393
8713
  catch (e) { _annotatorErrors.push({ phase: 'annotateNarration', err: String((e && e.message) || e) }); }
8394
- // Phase 3 (Sentinel-parity FR-L1, FR-L2) IR + interprocedural taint.
8395
- // R1 (PRD §5): the CLI entry (bin/agentic-security.js#cmdScan) now sets
8396
- // AGENTIC_SECURITY_DEEP=1 by default for local/interactive scans, so deep mode
8397
- // runs on the default `/scan --all` path. This gate is the enforcement +
8398
- // CI-safety point: it honors an explicit opt-out (DEEP=0) and keeps deep off in
8399
- // CI unless DEEP_IN_CI=1. In-process callers (tests, the cve-replay corpus) invoke
8400
- // runScan()/runFullScan() directly without the CLI default, so they stay deep-off
8401
- // and remain deterministic regression gates. Findings ride through the standard
8402
- // dedup/cluster/confidence pipeline below and the LLM-validator stage that follows.
8403
- //
8404
- // SAFETY: Deep mode is gated for CI safety:
8405
- // - Global timeout via AGENTIC_SECURITY_DEEP_TIMEOUT_MS (default 300_000 = 5 min)
8406
- // - Auto-disabled in CI unless AGENTIC_SECURITY_DEEP_IN_CI=1 is also set,
8407
- // so a pathological file can't hang the whole pipeline.
8408
- // ── IR parse-coverage sidecar (proof-corpus instrumentation, default off) ──
8409
- // Built ahead of the deep-mode gate so coverage is measurable without paying
8410
- // for taint analysis, and stashed in _sharedIR so the deep block below reuses
8411
- // it rather than parsing the project twice.
8412
- //
8413
- // NOTE (affects instrumented runs only, i.e. AGENTIC_SECURITY_IR_STATS set):
8414
- // when this block runs, buildProjectIR() happens here, BEFORE the deep-mode
8415
- // budget timer (t0) below is started. On an uninstrumented run, IR
8416
- // construction instead happens inside the timed block via the
8417
- // `_sharedIR || (_sharedIR = buildProjectIR(fc))` line, so its cost counts
8418
- // against AGENTIC_SECURITY_DEEP_TIMEOUT_MS. That means the deep budget does
8419
- // NOT account for parse time when stats are enabled — an instrumented run
8420
- // gets strictly more wall-clock for the taint analysis itself than an
8421
- // uninstrumented run with the same budget.
8422
- let _sharedIR = null;
8423
- const _irStatsTarget = irStatsTarget();
8424
- if (_irStatsTarget) {
8425
- try {
8426
- _sharedIR = buildProjectIR(fc);
8427
- writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
8428
- } catch (e) {
8429
- // Instrumentation must never fail a scan. Surface only when debugging.
8430
- if (process.env.AGENTIC_SECURITY_IR_STATS_DEBUG === '1') {
8431
- process.stderr.write(`ir-stats: ${e && e.message}\n`);
8432
- }
8433
- }
8434
- }
8435
- const _deepRequested = process.env.AGENTIC_SECURITY_DEEP === '1';
8436
- const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
8437
- process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
8438
- const _deepInCiAllowed = process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
8439
- const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
8440
- if (_deepEnabled) {
8441
- const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
8442
- const t0 = Date.now();
8443
- try {
8444
- const { perFile, callGraph } = _sharedIR || (_sharedIR = buildProjectIR(fc));
8445
- // The runDeepAnalysis call is synchronous in this codebase; we can't
8446
- // truly interrupt it without re-architecting the worklist. We pass a
8447
- // deadlineMs hint that the inner loops check; if absent, we still cap
8448
- // function count via fnLimit. Operators who suspect a hung run can
8449
- // kill the process and re-run with AGENTIC_SECURITY_DEEP=0.
8450
- const irFindings = runDeepAnalysis(perFile, callGraph, {
8451
- fnLimit: parseInt(process.env.AGENTIC_SECURITY_DEEP_FN_LIMIT || '5000', 10),
8452
- deadlineMs: t0 + budgetMs,
8453
- // v0.69 — incremental cache inputs (used when AGENTIC_SECURITY_INCREMENTAL=1).
8454
- scanRoot,
8455
- fileContents: fc,
8456
- });
8457
- const elapsed = Date.now() - t0;
8458
- if (elapsed > budgetMs) {
8459
- // We exceeded budget — surface a single info finding so operators see it.
8460
- finalFindings.push({
8461
- id: `ir-taint-timeout:${scanRoot || ''}`,
8462
- file: '(deep-engine)', line: 0,
8463
- vuln: `IR-TAINT deep mode exceeded ${budgetMs}ms budget (${elapsed}ms used) — results may be incomplete`,
8464
- severity: 'info',
8465
- parser: 'IR-TAINT',
8466
- confidence: 0.5,
8467
- });
8468
- }
8469
- for (const f of irFindings) {
8470
- f.unvalidated = true;
8471
- f.validator_verdict = 'unvalidated';
8472
- }
8473
- finalFindings.push(...irFindings);
8474
- // Pragma pass 2 of 2 — see the pass-1 comment far above. Deep-mode IR
8475
- // findings land here, long after pass 1 ran, so without this an
8476
- // `agentic-security-ignore` on an ir-taint finding is inert. Deep mode is
8477
- // what the CLI uses outside CI and taint findings are the ones users most
8478
- // want to silence, so the documented feature did nothing in the case that
8479
- // mattered most.
8480
- //
8481
- // Re-running over the already-filtered array is safe and does not
8482
- // double-log: pass 1's removals are gone from `finalFindings`, so only the
8483
- // newly-appended IR findings can match here, and each suppression reaches
8484
- // the ledger exactly once.
8485
- try{ _applyIgnorePragmas(finalFindings, fc); }catch(_){}
8486
- // Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
8487
- try {
8488
- for (const sc of supplyChain) {
8489
- if (sc.type !== 'vulnerable_dep' || sc.ecosystem !== 'maven') continue;
8490
- if (sc.functionReachable === 'reachable') continue;
8491
- const allFns = [...(sc.osvVulnFunctions || []), ...(VULN_FUNCTION_HINTS[sc.name] || [])];
8492
- if (!allFns.length) continue;
8493
- for (const fn of callGraph.functions ? callGraph.functions.values() : []) {
8494
- if (!fn.cfg || !fn.cfg.nodes) continue;
8495
- for (const node of Object.values(fn.cfg.nodes)) {
8496
- if (node.kind !== 'call') continue;
8497
- const callee = typeof node.callee === 'string' ? node.callee : null;
8498
- if (!callee) continue;
8499
- const shortCallee = callee.includes('.') ? callee.split('.').pop() : callee;
8500
- if (allFns.some(f => f === shortCallee || f === callee)) {
8501
- sc.functionReachable = 'reachable';
8502
- sc.reachabilityTier = 'function-reachable';
8503
- if (!sc.vulnerableFunctionCallSites) sc.vulnerableFunctionCallSites = [];
8504
- sc.vulnerableFunctionCallSites.push({ pkg: sc.name, fn: shortCallee, file: fn.file, line: node.line });
8505
- sc._javaIrEnriched = true;
8506
- break;
8507
- }
8508
- }
8509
- if (sc.functionReachable === 'reachable') break;
8510
- }
8511
- }
8512
- } catch { /* Java SCA enrichment is best-effort */ }
8513
- } catch (e) {
8514
- // Deep mode is best-effort. A parser blowup in one file shouldn't kill
8515
- // the scan — fall back to the pattern-only result.
8516
- }
8517
- } else if (_deepRequested && _inCi) {
8518
- // Operator asked for deep but we're in CI — emit a non-blocking notice
8519
- // so they know it was skipped and how to override.
8520
- finalFindings.push({
8521
- id: 'ir-taint-ci-skipped',
8522
- file: '(deep-engine)', line: 0,
8523
- vuln: 'IR-TAINT deep mode skipped in CI environment (set AGENTIC_SECURITY_DEEP_IN_CI=1 to opt in)',
8524
- severity: 'info',
8525
- parser: 'IR-TAINT',
8526
- confidence: 1.0,
8527
- });
8528
- }
8529
- // Phase 2 (Sentinel-parity): LLM validator stage. No-op unless the operator
8530
- // sets AGENTIC_SECURITY_LLM_VALIDATE=1 AND AGENTIC_SECURITY_LLM_ENDPOINT. When
8531
- // disabled, every finding gets unvalidated:true and the existing confidence
8532
- // pipeline accounts for that. When enabled, the validator emits accept/reject
8533
- // /escalate per finding; rejects are dropped into the suppression log.
8714
+ // Phase 2 (Sentinel-parity): LLM validator stage. DEFAULT-ON whenever
8715
+ // AGENTIC_SECURITY_LLM_ENDPOINT is configured not gated on
8716
+ // AGENTIC_SECURITY_LLM_VALIDATE=1 as this comment previously (and wrongly)
8717
+ // said. Opt OUT with AGENTIC_SECURITY_LLM_VALIDATE=0; the legacy
8718
+ // AGENTIC_SECURITY_LLM_VALIDATE=1 still works as an explicit-on no-op. With
8719
+ // no endpoint configured the validator stays a no-op regardless — no
8720
+ // surprise network calls from an unrelated env var. See
8721
+ // llm-validator/index.js's own header, which states this correctly; this
8722
+ // comment was the one that had drifted. When disabled, every finding gets
8723
+ // unvalidated:true and the existing confidence pipeline accounts for that.
8724
+ // When enabled, the validator emits accept/reject/escalate per finding;
8725
+ // rejects are dropped into the suppression log.
8534
8726
  try {
8535
8727
  // Concurrency defaults to 1 (the validator's deterministic-default).
8536
8728
  // Operators raise via AGENTIC_SECURITY_LLM_CONCURRENCY at the cost of
@@ -8543,7 +8735,23 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8543
8735
  vuln: d.vuln, file: d.file, line: d.line, snippet: d.snippet,
8544
8736
  reason: 'llm-validator:reject:' + (d.validator_reasoning || '').slice(0, 80),
8545
8737
  });
8738
+ // Re-run: annotateVerifierVerdicts ran (~8335) before validator_verdict
8739
+ // existed on any finding (set here, hundreds of lines later), so its
8740
+ // 'verified-by-llm' verdict — documented as one of five possible
8741
+ // outcomes in verifier.js's own header — could never be produced by the
8742
+ // real pipeline. Cheap and idempotent; re-running is the fix, not
8743
+ // moving the original call (confidence/exploitability annotators
8744
+ // upstream of it still need to run before validation, same as before).
8745
+ try { annotateVerifierVerdicts(finalFindings, { fileContents: fc }); } catch (_) {}
8546
8746
  } catch(_) {}
8747
+ // Same ordering fix as annotateVerifierVerdicts above: annotateConfidence
8748
+ // (~8111) computes f.confidence before f.unvalidated exists on any
8749
+ // finding, so its 0.85x "LLM validator unavailable" penalty could never
8750
+ // apply in the real pipeline. Runs unconditionally (outside the
8751
+ // llmValidateMany try-block above) because f.unvalidated is also set
8752
+ // directly on deep-mode IR findings appended earlier in this function,
8753
+ // independent of whether the LLM validator itself ran.
8754
+ try { applyUnvalidatedPenalty(finalFindings); } catch (_) {}
8547
8755
  try {
8548
8756
  const { kept, suppressed } = applyLearnedFeedback(scanRoot, finalFindings);
8549
8757
  finalFindings = kept;
@@ -8610,8 +8818,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8610
8818
  /(?:^|\/)test_[^/]*\.py$/i.test(f) || /_test\.(?:py|go)$/i.test(f));
8611
8819
  annotateScaVerdicts(supplyChain, { testsDetected: _testsDetected });
8612
8820
  } catch (_) {}
8613
- // 0.9.0 Feat-15: dep confusion
8614
- try{const dc=detectDepConfusion(annotatedComponents,scanRoot);aF.push(...dc);}catch(_){}
8821
+ // 0.9.0 Feat-15: dep confusion. These are supply-chain findings (kind:'sca',
8822
+ // derived from `components` not source code) and belong in supplyChain, not
8823
+ // aF — aF was already snapshotted into finalFindings at dedupeFindingsWithEvidence
8824
+ // above, so pushing into aF here silently discards every result.
8825
+ try{const dc=detectDepConfusion(annotatedComponents,scanRoot);supplyChain.push(...dc);}catch(_){}
8615
8826
  // Deployment-platform security checklist
8616
8827
  try{const dpf=scanDeployPlatform(scanRoot);aLogic.push(...dpf);}catch(_){}
8617
8828
  // Stack-specific security playbook
@@ -8814,8 +9025,22 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8814
9025
  if (process.env.AGENTIC_SECURITY_NO_COMPLIANCE !== '1') {
8815
9026
  try {
8816
9027
  const policy = loadCompliancePolicy(scanRoot);
8817
- if (policy && !policy._error) {
9028
+ if (policy && policy._error) {
9029
+ // CMP-5: a malformed policy used to be silently treated the same
9030
+ // as "no policy file" — the parse error (which names the exact
9031
+ // problem) was computed and then discarded. verifyPolicy now
9032
+ // reports it as a distinct 'error' status rather than staying
9033
+ // silent, and it is surfaced here too so an interactive scan
9034
+ // shows it.
8818
9035
  _complianceReport = verifyCompliancePolicy(policy, { scanRoot, findings: finalFindings });
9036
+ process.stderr.write(`[compliance] ${policy._error}\n`);
9037
+ } else if (policy) {
9038
+ // CMP-5: pass every channel a real scan produces, not just SAST —
9039
+ // a finding-family check for hardcoded-secret or vulnerable-dep
9040
+ // was previously invisible to secrets/SCA findings entirely.
9041
+ _complianceReport = verifyCompliancePolicy(policy, {
9042
+ scanRoot, findings: finalFindings, secrets: aSecrets, logicVulns: aLogic, supplyChain,
9043
+ });
8819
9044
  emitComplianceJsonLd(_complianceReport, scanRoot);
8820
9045
  emitComplianceMarkdown(_complianceReport, scanRoot);
8821
9046
  }
@@ -8895,7 +9120,16 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8895
9120
  let _analysisTier = null, _unmodeledSinks = null;
8896
9121
  try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
8897
9122
  try { _unmodeledSinks = countUnmodeledSinkCandidates(fc, finalFindings); } catch {}
8898
- 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},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length}};
9123
+ // filesScanned counts files actually analyzed (Object.keys(fc), the same
9124
+ // set computeAnalysisTiers above reads) — NOT files.length, the candidate
9125
+ // list before the per-file loop's size/density skips run. Using the
9126
+ // candidate count here double-counted skipped files: they were included
9127
+ // in filesScanned AND separately reported in filesSkipped/filesDenseSkipped,
9128
+ // so coverage-report.js's "scanned=N skipped=M" line implied N+M files were
9129
+ // seen when only N-of-those-candidates were actually analyzed.
9130
+ // checkpoint.total intentionally keeps files.length — that field means the
9131
+ // full candidate set for resume bookkeeping, a different, correct meaning.
9132
+ const _scanMeta={filesScanned:Object.keys(fc).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},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length}};
8899
9133
  // R8: the scan completed, so the checkpoint has been fully consumed — remove
8900
9134
  // it. Anything that threw before this point leaves it in place to resume from.
8901
9135
  try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}