@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.9

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 (170) hide show
  1. package/CHANGELOG.md +432 -0
  2. package/bin/agentic-security-audit.js +2 -1
  3. package/bin/agentic-security-consistency.js +2 -1
  4. package/bin/agentic-security.js +448 -74
  5. package/dist/113.index.js +16 -7
  6. package/dist/117.index.js +3 -1
  7. package/dist/178.index.js +1 -1
  8. package/dist/207.index.js +5 -4
  9. package/dist/220.index.js +5 -3
  10. package/dist/238.index.js +4 -4
  11. package/dist/317.index.js +300 -0
  12. package/dist/384.index.js +1 -1
  13. package/dist/435.index.js +196 -21
  14. package/dist/444.index.js +20 -11
  15. package/dist/449.index.js +8 -1
  16. package/dist/513.index.js +7 -3
  17. package/dist/526.index.js +6 -6
  18. package/dist/637.index.js +1 -1
  19. package/dist/675.index.js +7 -5
  20. package/dist/839.index.js +4 -3
  21. package/dist/905.index.js +1173 -0
  22. package/dist/agentic-security.mjs +14 -14
  23. package/dist/agentic-security.mjs.sha256 +1 -1
  24. package/dist/compliance-frameworks/ccpa.json +32 -0
  25. package/dist/compliance-frameworks/eu-ai-act.json +51 -0
  26. package/dist/compliance-frameworks/gdpr.json +45 -0
  27. package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
  28. package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
  29. package/dist/compliance-frameworks/nist-csf-2.json +73 -0
  30. package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
  31. package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
  32. package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
  33. package/package.json +24 -12
  34. package/src/badge.js +2 -1
  35. package/src/dataflow/CLAUDE.md +10 -4
  36. package/src/dataflow/builtin-summaries.js +1 -1
  37. package/src/dataflow/cross-service-taint.js +2 -1
  38. package/src/dataflow/engine.js +324 -60
  39. package/src/dataflow/ifds-precise.js +6 -4
  40. package/src/dataflow/implicit-flow.js +68 -36
  41. package/src/dataflow/incremental.js +25 -8
  42. package/src/dataflow/index.js +2 -1
  43. package/src/dataflow/proven-clean.js +41 -0
  44. package/src/dataflow/sanitizer-gate.js +35 -9
  45. package/src/dataflow/sanitizer-proof.js +21 -3
  46. package/src/dataflow/stub-aware-filter.js +36 -13
  47. package/src/dataflow/summaries.js +21 -2
  48. package/src/discovery/CLAUDE.md +10 -0
  49. package/src/discovery/index.js +175 -3
  50. package/src/discovery/llm-invoke.js +90 -1
  51. package/src/discovery/memory.js +163 -0
  52. package/src/engine.js +247 -50
  53. package/src/integrations/tickets.js +7 -6
  54. package/src/ir/CLAUDE.md +4 -1
  55. package/src/ir/balanced-call.js +55 -0
  56. package/src/ir/ir-stats.js +1 -1
  57. package/src/ir/parser-cpp.js +1 -1
  58. package/src/ir/parser-cs.js +62 -9
  59. package/src/ir/parser-go.js +29 -11
  60. package/src/ir/parser-java.js +96 -19
  61. package/src/ir/parser-js.js +151 -20
  62. package/src/ir/parser-php.js +44 -9
  63. package/src/ir/parser-rb.js +37 -7
  64. package/src/ir/ssa.js +6 -1
  65. package/src/leaderboard.js +3 -2
  66. package/src/llm-validator/consistency.js +6 -2
  67. package/src/llm-validator/index.js +1 -2
  68. package/src/lsp/server.js +28 -2
  69. package/src/mcp/CLAUDE.md +9 -2
  70. package/src/mcp/audit.js +2 -1
  71. package/src/mcp/redact.js +26 -0
  72. package/src/mcp/tools.js +159 -17
  73. package/src/posture/CLAUDE.md +45 -8
  74. package/src/posture/accuracy-scorecard.js +67 -1
  75. package/src/posture/agents-memory.js +5 -3
  76. package/src/posture/aibom.js +12 -8
  77. package/src/posture/auditor-walkthrough.js +111 -10
  78. package/src/posture/auth-posture-import.js +5 -4
  79. package/src/posture/autopilot.js +8 -1
  80. package/src/posture/calibration-drift.js +11 -5
  81. package/src/posture/calibration.js +24 -2
  82. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
  83. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  84. package/src/posture/compliance-policy.js +40 -10
  85. package/src/posture/confidence.js +44 -10
  86. package/src/posture/corpus-enroll.js +9 -5
  87. package/src/posture/corpus-match.js +19 -0
  88. package/src/posture/csharp-analysis.js +62 -3
  89. package/src/posture/custom-rules.js +7 -5
  90. package/src/posture/cve-alert-daemon.js +6 -5
  91. package/src/posture/dep-add-guard.js +2 -1
  92. package/src/posture/deploy-platform.js +4 -1
  93. package/src/posture/deterministic.js +3 -2
  94. package/src/posture/drift.js +7 -1
  95. package/src/posture/epss.js +13 -1
  96. package/src/posture/evidence-bundle.js +276 -0
  97. package/src/posture/exploitability-probability.js +15 -2
  98. package/src/posture/falsification.js +23 -2
  99. package/src/posture/feature-flags.js +3 -2
  100. package/src/posture/findings-memory.js +3 -3
  101. package/src/posture/fix-history.js +5 -2
  102. package/src/posture/fix-metrics.js +5 -5
  103. package/src/posture/fix-plan.js +2 -1
  104. package/src/posture/fix-verify-loop.js +10 -1
  105. package/src/posture/grader-calibration.js +3 -4
  106. package/src/posture/iac-reachability.js +14 -8
  107. package/src/posture/integrity.js +25 -7
  108. package/src/posture/intent-context.js +2 -1
  109. package/src/posture/learning.js +4 -3
  110. package/src/posture/license-attributions.js +5 -7
  111. package/src/posture/license-graph.js +2 -1
  112. package/src/posture/license-policy.js +2 -1
  113. package/src/posture/model-rescan.js +69 -3
  114. package/src/posture/mttr.js +5 -0
  115. package/src/posture/network-policy-import.js +3 -2
  116. package/src/posture/poc-inprocess.js +27 -8
  117. package/src/posture/pqc-migration-plan.js +7 -5
  118. package/src/posture/pr-augment.js +8 -5
  119. package/src/posture/privacy-framework.js +262 -0
  120. package/src/posture/regression-test-gen.js +23 -8
  121. package/src/posture/reverse-blast-radius.js +5 -1
  122. package/src/posture/risk-dollars.js +20 -3
  123. package/src/posture/router.js +5 -4
  124. package/src/posture/ruleset-version.js +2 -2
  125. package/src/posture/runtime-correlation.js +2 -1
  126. package/src/posture/sbom-diff.js +12 -3
  127. package/src/posture/sca-policy.js +7 -4
  128. package/src/posture/scan-checkpoint.js +15 -0
  129. package/src/posture/secret-history.js +20 -11
  130. package/src/posture/security-trend.js +7 -1
  131. package/src/posture/stack-playbook.js +22 -1
  132. package/src/posture/state-dir.js +34 -0
  133. package/src/posture/telemetry-ingest.js +4 -3
  134. package/src/posture/threat-model-auto.js +4 -1
  135. package/src/posture/threat-model-grounding.js +13 -3
  136. package/src/posture/time-to-fix.js +3 -2
  137. package/src/posture/triage-memory.js +3 -2
  138. package/src/posture/validator-metrics.js +10 -3
  139. package/src/posture/verifier.js +32 -57
  140. package/src/posture/waf-ingest.js +6 -5
  141. package/src/posture/watch-mode.js +4 -3
  142. package/src/report/index.js +183 -14
  143. package/src/runScan.js +1 -1
  144. package/src/sast/_comment-strip.js +15 -4
  145. package/src/sast/_secret-entropy.js +1 -1
  146. package/src/sast/authz.js +6 -4
  147. package/src/sast/bench-shape/index.js +2 -7
  148. package/src/sast/claude-md-prompt-injection.js +14 -3
  149. package/src/sast/cloud-iam.js +60 -7
  150. package/src/sast/code-injection-multilang.js +29 -0
  151. package/src/sast/cpp-bench-extras.js +1 -1
  152. package/src/sast/csrf.js +7 -5
  153. package/src/sast/env-hygiene.js +5 -2
  154. package/src/sast/iac-terraform.js +25 -0
  155. package/src/sast/java-bench-extras.js +1 -1
  156. package/src/sast/java-constant-fold.js +5 -5
  157. package/src/sast/llm-owasp.js +4 -2
  158. package/src/sast/mcp-audit.js +7 -0
  159. package/src/sast/pipeline.js +8 -0
  160. package/src/sast/prompt-template.js +8 -6
  161. package/src/sast/prototype-pollution.js +6 -2
  162. package/src/sast/redos-nfa.js +6 -6
  163. package/src/sast/secret-concat.js +13 -2
  164. package/src/sast/ssrf-cloud-metadata.js +6 -3
  165. package/src/sast/xss-reflected-multilang.js +1 -1
  166. package/src/sast/xxe.js +1 -1
  167. package/src/sca/CLAUDE.md +3 -4
  168. package/src/sca/container.js +35 -3
  169. package/src/sca/dep-confusion.js +9 -1
  170. package/src/sca/sarif-ingest.js +0 -187
package/src/engine.js CHANGED
@@ -71,6 +71,8 @@ import { scanWebhook } from './sast/webhook.js';
71
71
  import { scanClientSide } from './sast/client-side.js';
72
72
  import { scanPromptFirewall } from './sast/prompt-firewall.js';
73
73
  import { scanLlmRedteam } from './posture/llm-redteam.js';
74
+ import { assessPrivacyFramework, persistPrivacyFramework } from './posture/privacy-framework.js';
75
+ import { safeWriteState as _safeWriteState, statePath, statePath as _statePath } from './posture/state-dir.js';
74
76
  import { scanContainer } from './sca/container.js';
75
77
  import { detectDepConfusion } from './sca/dep-confusion.js';
76
78
  import { loadLicensePolicy, evaluateLicensePolicy } from './posture/license-policy.js';
@@ -119,7 +121,7 @@ import { scanRuby } from './sast/ruby.js';
119
121
  import { scanPhp } from './sast/php.js';
120
122
  import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secret-entropy.js';
121
123
  // Phase 1 — precision-engineering posture modules.
122
- import { annotateConfidence } from './posture/confidence.js';
124
+ import { annotateConfidence, applyUnvalidatedPenalty } from './posture/confidence.js';
123
125
  import { backfillFindingDefaults } from './posture/finding-defaults.js';
124
126
  import { annotatePocs } from './posture/poc-generator.js';
125
127
  import { annotateExecutionProofs } from './posture/prove-findings.js';
@@ -146,7 +148,7 @@ import { ingestLogicClaims } from './posture/logic-claims.js';
146
148
  import { annotateNarration } from './posture/flow-narration.js';
147
149
  import { applyPathConstraints } from './posture/path-predicates.js';
148
150
  // Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
149
- import { buildProjectIR } from './ir/index.js';
151
+ import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
150
152
  import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
151
153
  import { runDeepAnalysis } from './dataflow/index.js';
152
154
  // v3 next-gen — Pillars 1, 4, 5, 6, 8, 9.
@@ -585,7 +587,16 @@ const SANITIZER_PATTERNS=[{regex:/(?:escape|escapeHtml|htmlspecialchars|encodeUR
585
587
  {regex:/(?:re\.escape|preg_quote|Regexp\.escape)\s*\(/g,type:"Regex Escaping"}];
586
588
  const ROUTE_PATTERNS=[{regex:/(?:app|router)\s*\.\s*(get|post|put|patch|delete|all|options|head)\s*\(\s*['"`]([^'"`]+)['"`]/g,fw:"Express",mI:1,pI:2},{regex:/@(?:app|blueprint|bp)\s*\.\s*route\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*methods\s*=\s*\[([^\]]+)\])?/g,fw:"Flask",pI:1,mtI:2},{regex:/path\s*\(\s*['"]([^'"]+)['"]/g,fw:"Django",pI:1},{regex:/@(?:app|router)\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/g,fw:"FastAPI",mI:1,pI:2},{regex:/Route\s*::\s*(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/g,fw:"Laravel",mI:1,pI:2},{regex:/router\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/g,fw:"Koa/Express",mI:1,pI:2},{regex:/\[Http(Get|Post|Put|Delete|Patch)\s*\(\s*["']?([^"'\]]*)/g,fw:"ASP.NET",mI:1,pI:2},{regex:/['"`](\/api\/[a-zA-Z0-9\/:_\-{}]+)['"`]/g,fw:"API",pI:1}];
587
589
  const AUTH_PATTERNS=[/(?:authenticate|isAuthenticated|requireAuth|passport\.authenticate|jwt\.verify|verifyToken|authMiddleware|checkAuth|protect|authorize)\s*[\(,]/gi,/(?:middleware|use)\s*\(\s*(?:auth|jwt|token|session)/gi,/(?:isAuthorized|expressJwt|security\.isAuthorized|denyAll)\s*[\(]/gi,/passport\.(?:authenticate|initialize|session)\s*\(/gi];
588
- const IGNORE_DIRS=new Set(["node_modules",".git","__pycache__","vendor","dist","build",".next","venv","env",".venv","target","bin","obj",".cache","coverage","bower_components","tests","test","__tests__","spec","mocks"]);
590
+ // `.agentic-security` is OUR OWN OUTPUT and must never be scanned input.
591
+ // (NON_MUTATING_SCAN_PRD S4.) A scan writes threat-model.json,
592
+ // exploit-bundles.json and scan-history.json into the tree it scanned, and those
593
+ // files contain CWE identifiers. Without this, the second scan of any directory
594
+ // reads the first scan's conclusions as source code — measured on the
595
+ // independent benchmark as 220 polluted trees and 544 state files carrying
596
+ // `CWE-` strings, which silently turned an accuracy measurement into the engine
597
+ // grading itself. Scanning our own state is never useful and is exactly how
598
+ // output becomes input.
599
+ const IGNORE_DIRS=new Set(["node_modules",".git","__pycache__","vendor","dist","build",".next","venv","env",".venv","target","bin","obj",".cache","coverage","bower_components","tests","test","__tests__","spec","mocks",".agentic-security"]);
589
600
  const CODE_EXTS=new Set(["js","jsx","ts","tsx","mjs","cjs","py","rb","php","java","go","cs","rs","vue","svelte","html","htm","ejs","hbs","pug","erb","twig","graphql","gql","kt","scala","swift","dart","ex","exs","tf","tfvars","dockerfile","c","cc","cpp","cxx","h","hh","hpp","hxx","sol"]);
590
601
  // Feat-2: IaC manifest filenames that aren't extension-based.
591
602
  const IAC_FILENAMES = new Set(['Dockerfile', 'Containerfile', 'docker-compose.yml', 'docker-compose.yaml', 'Chart.yaml']);
@@ -2203,7 +2214,13 @@ const IAC_PATTERNS = [
2203
2214
  { match: /\$\{\{\s*github\.event\.(?:issue|pull_request)\.title|\$\{\{\s*github\.event\.comment\.body/i,
2204
2215
  fileTypes: /\.github\/workflows\/.*\.ya?ml$/i,
2205
2216
  severity: 'high', cwe: 'CWE-78', vuln: 'GitHub Actions: untrusted github.event input interpolated into shell',
2206
- fix: 'Pass user-controlled fields via env vars and reference them as $VARNAME in the script body, not via ${{ }} interpolation.' },
2217
+ fix: 'Pass user-controlled fields via env vars and reference them as $VARNAME in the script body, not via ${{ }} interpolation.',
2218
+ // Same false-positive as src/sast/pipeline.js's overlapping rule: a pure
2219
+ // `KEY: ${{ github.event.… }}` mapping line assigns to an env var at the
2220
+ // workflow-engine level (the fix's own recommended pattern), not into a
2221
+ // shell command string — only a match embedded in a larger line (a run:
2222
+ // script body) is actually dangerous.
2223
+ lineSafeRe: /^[\w.-]+\s*:\s*\$\{\{[^}]*\}\}\s*$/ },
2207
2224
  ];
2208
2225
 
2209
2226
  function scanIaC(fp, raw){
@@ -2216,6 +2233,7 @@ function scanIaC(fp, raw){
2216
2233
  let m;
2217
2234
  while ((m = re.exec(raw))) {
2218
2235
  const line = raw.substring(0, m.index).split('\n').length;
2236
+ if (p.lineSafeRe && p.lineSafeRe.test((lines[line - 1] || '').trim())) continue;
2219
2237
  findings.push({
2220
2238
  id: `iac:${fp}:${line}:${p.vuln.replace(/\s/g, '_').slice(0, 60)}`,
2221
2239
  kind: 'iac', severity: p.severity, vuln: p.vuln,
@@ -2401,7 +2419,7 @@ async function _loadCustomRules(scanRoot){
2401
2419
  _customIgnorePaths = [];
2402
2420
  let raw = null, parsedObj = null;
2403
2421
  for (const ext of ['rules.yml', 'rules.yaml', 'rules.json']) {
2404
- const p = path.join(scanRoot, '.agentic-security', ext);
2422
+ const p = statePath(scanRoot, ext);
2405
2423
  try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
2406
2424
  try {
2407
2425
  if (ext.endsWith('.json')) parsedObj = JSON.parse(raw);
@@ -2736,10 +2754,24 @@ function scanLogicVulns(fp,raw){
2736
2754
  while((m=re.exec(haystack))){
2737
2755
  const line=lineAt(haystack,m.index);
2738
2756
  const snippet=lines[line-1]?.trim()||"";
2757
+ let outSnippet=snippet;
2739
2758
  // FP-2: credential FP filter
2740
2759
  if(pat.vuln==='Hardcoded Secret'||pat.vuln==='Hardcoded Credential Check'){
2741
2760
  const fpCheck=_isFalsePositiveCredential(fp,snippet,m[0]);
2742
2761
  if(fpCheck.skip){_suppressionLog.push({vuln:pat.vuln,file:fp,line,snippet,reason:fpCheck.reason});continue;}
2762
+ // Stage 4 correctness audit (coverage breadth, secrets): same
2763
+ // unredacted-snippet leak found in engine.js's scanEntropySecrets/
2764
+ // scanCredentials and sast/secret-concat.js — this is a THIRD,
2765
+ // separate detector (LOGIC_PATTERNS' own "Hardcoded Secret" rule)
2766
+ // that also stored the raw source line, with no masking at all.
2767
+ // The regex captures the quoted value inside `m[0]`; mask just
2768
+ // that value within the reported snippet.
2769
+ const valMatch=m[0].match(/['"]([^'"]{3,})['"]/);
2770
+ if(valMatch){
2771
+ const val=valMatch[1];
2772
+ const masked=val.length>8?val.substring(0,4)+"…"+val.substring(val.length-4):"••••";
2773
+ outSnippet=snippet.split(val).join(masked);
2774
+ }
2743
2775
  }
2744
2776
  // FP-6: operational-context gate for selected logic patterns
2745
2777
  if (predicate) {
@@ -2749,7 +2781,7 @@ function scanLogicVulns(fp,raw){
2749
2781
  continue;
2750
2782
  }
2751
2783
  }
2752
- 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});
2784
+ 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});
2753
2785
  }
2754
2786
  }
2755
2787
  const routeRe=/(?:app|router)\s*\.\s*(?:get|post|all)\s*\(\s*['"`](\/(?:debug|admin|test|internal|__)[^'"`]*)/gi;let rm;
@@ -5398,17 +5430,29 @@ function scanEntropySecrets(fp,raw){
5398
5430
  // FP-5: structural / doc-context suppression
5399
5431
  const surrounding=lines.slice(Math.max(0,line-3),Math.min(lines.length,line+1)).join("\n");
5400
5432
  const nonSecretReason=_isLikelyNonSecret(v, ctx, surrounding);
5433
+ const masked=v.substring(0,4)+"…"+v.substring(v.length-4);
5401
5434
  if (nonSecretReason) {
5402
- _suppressionLog.push({vuln:"High-Entropy Credential Candidate",file:fp,line,snippet:ctx.trim(),reason:'entropy-'+nonSecretReason});
5435
+ // Same redaction concern as the main finding below applies to the
5436
+ // suppression log — it's exposed via --include-suppressed, and a
5437
+ // heuristic "probably not a real secret" call can be wrong.
5438
+ _suppressionLog.push({vuln:"High-Entropy Credential Candidate",file:fp,line,snippet:ctx.trim().split(v).join(masked),reason:'entropy-'+nonSecretReason});
5403
5439
  continue;
5404
5440
  }
5405
- const masked=v.substring(0,4)+"…"+v.substring(v.length-4);
5441
+ // Stage 4 correctness audit (coverage breadth, secrets): `snippet` used
5442
+ // to carry the RAW, unmasked source line — including the full secret
5443
+ // value — even though `masked` right next to it was correctly
5444
+ // redacted. Nothing downstream (normalizeFindings, toHTML, toCSV,
5445
+ // toJUnit) ever redacts `snippet` again, so the plaintext credential
5446
+ // this detector exists to find flowed straight into every report
5447
+ // format this scanner emits, including last-scan.json. Redact the
5448
+ // exact matched value out of the snippet at the source, the same way
5449
+ // `masked` already is.
5406
5450
  out.push({
5407
5451
  vuln:"High-Entropy Credential Candidate",
5408
5452
  severity:"high",cwe:"CWE-798",stride:"Information Disclosure",
5409
5453
  fix:"Replace with environment variable or secrets manager reference; rotate the value immediately.",
5410
5454
  code:`// BEFORE\nconst secret = "${masked}";\n\n// AFTER\nconst secret = process.env.APP_SECRET;`,
5411
- file:fp,line,snippet:ctx.trim(),masked,entropy:e.toFixed(2)
5455
+ file:fp,line,snippet:ctx.trim().split(v).join(masked),masked,entropy:e.toFixed(2)
5412
5456
  });
5413
5457
  }
5414
5458
  return out;
@@ -5448,12 +5492,21 @@ function scanConfigFiles(fc){
5448
5492
  const[,k,v]=m;
5449
5493
  if(!v||v==='""'||v==="''"||/^(?:change.?me|your[_-]|placeholder|example|xxx+|todo|<|\$\{)/i.test(v))continue;
5450
5494
  if(/(?:password|secret|key|token|api)/i.test(k)){
5495
+ // Stage 6 correctness audit: this was the one secrets-adjacent
5496
+ // detector still shipping the RAW, unmasked source line as
5497
+ // `snippet` — its siblings (scanCredentials/scanEntropySecrets)
5498
+ // were already fixed for this exact leak in the Stage 4 audit.
5499
+ // Nothing downstream re-redacts `snippet`, and the MCP redact.js
5500
+ // catch-all requires a QUOTED value, which standard `.env`
5501
+ // KEY=value syntax never has — so the plaintext committed
5502
+ // secret flowed straight through explain_finding unredacted.
5503
+ const masked=v.length>8?v.substring(0,4)+"…"+v.substring(v.length-4):"…";
5451
5504
  out.push({
5452
5505
  vuln:`Committed .env with Real-Looking ${k}`,
5453
5506
  severity:"high",cwe:"CWE-538",stride:"Information Disclosure",
5454
5507
  fix:`Remove ${k} from committed env files. Use .env.example with placeholders and ignore .env in VCS.`,
5455
5508
  code:`# In .gitignore\n.env\n\n# .env.example (commit)\n${k}=<your-${k.toLowerCase()}>`,
5456
- file:fp,line:i+1,snippet:ln
5509
+ file:fp,line:i+1,snippet:ln.split(v).join(masked),masked
5457
5510
  });
5458
5511
  }
5459
5512
  }
@@ -6358,7 +6411,11 @@ function scanCredentials(fp,raw){
6358
6411
  if(seen.has(key))continue;seen.add(key);
6359
6412
  const severity=pat.s==="c"?"critical":pat.s==="h"?"high":"medium";
6360
6413
  const masked=val.length>12?val.substring(0,6)+"••••••"+val.substring(val.length-4):val.substring(0,3)+"•••";
6361
- 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,"_")};`});
6414
+ // Stage 4 correctness audit (coverage breadth, secrets): same
6415
+ // unredacted-snippet leak as scanEntropySecrets — `snippet` carried
6416
+ // the raw source line (full credential value) straight through to
6417
+ // every report format. Redact the exact matched value here too.
6418
+ 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,"_")};`});
6362
6419
  }
6363
6420
  }
6364
6421
  return results;
@@ -6523,7 +6580,7 @@ function _makePurl(ecosystem,name,version,group){
6523
6580
  const t={npm:'npm',pypi:'pypi'}[ecosystem]||ecosystem;
6524
6581
  if(!t)return'';
6525
6582
  const ns=group?`${encodeURIComponent(group)}/`:'';
6526
- return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+version:''}`;
6583
+ return`pkg:${t}/${ns}${encodeURIComponent(name)}${version?'@'+encodeURIComponent(version):''}`;
6527
6584
  }
6528
6585
 
6529
6586
  function _parsePackageJson(text,filePath){
@@ -7497,7 +7554,7 @@ async function queryRegistries(components){
7497
7554
 
7498
7555
  // Node port: takes { fileContents, depFileContents } maps directly instead of a JSZip object.
7499
7556
  // fileContents = code files keyed by relative path; depFileContents = manifest/lockfiles keyed by relative path.
7500
- async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
7557
+ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined}, setProgress=()=>{}){_resetSuppressions();_buildProjectIndex(fileContents);await _loadCustomRules(scanRoot);
7501
7558
  // Pre-pass: build cross-file Java tainted-method index so per-file taint
7502
7559
  // analysis can recognize calls to user-input-returning helper methods
7503
7560
  // defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
@@ -7843,6 +7900,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7843
7900
  // R8 (PRD §5): OS packages from an extracted container image's package DBs
7844
7901
  // (dpkg/apk) — baked-in deps the Dockerfile never names. Feed the OSV/SBOM pipeline.
7845
7902
  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(_){}
7903
+ // Dockerfile-declared apt/apk install-line packages — complementary to
7904
+ // extractImagePackages above (which reads an actually scanned filesystem's
7905
+ // installed-package DB, a different signal: build-time-declared vs.
7906
+ // actually-installed).
7907
+ 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(_){}
7846
7908
  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(_){}
7847
7909
  const reach=buildReachabilitySet(fc);
7848
7910
  const reachabilitySet=reach.imported;
@@ -8093,7 +8155,13 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8093
8155
  };
8094
8156
  _runAnnotator('annotateStableIds', () => annotateStableIds(finalFindings));
8095
8157
  _runAnnotator("clusterByRootCause", () => { finalFindings = clusterByRootCause(finalFindings); });
8096
- _runAnnotator("demoteUnreachable", () => { demoteUnreachable(finalFindings, { routes: aR }); });
8158
+ _runAnnotator("demoteUnreachable", () => {
8159
+ demoteUnreachable(finalFindings, { routes: aR });
8160
+ // `type: 'vulnerable_dep'` findings live in supplyChain, not finalFindings
8161
+ // (src/sca/CLAUDE.md) — demoteUnreachable's SCA-tier branch needs this
8162
+ // array passed explicitly or it never sees an SCA finding at all.
8163
+ demoteUnreachable(supplyChain, { routes: aR });
8164
+ });
8097
8165
  // Premortem #8: backfill parser/family BEFORE confidence and calibration,
8098
8166
  // because both consume those fields and silently no-op when they're null.
8099
8167
  _runAnnotator("backfillFindingDefaults", () => { backfillFindingDefaults(finalFindings); });
@@ -8113,22 +8181,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8113
8181
  // Generalised sanitizer consumption (dataflow/sanitizer-gate.js): labels
8114
8182
  // findings whose flow passes a catalog sanitizer matching their family
8115
8183
  // (xss/url/cmd, not just sql) so the proof gate below can demote them the
8116
- // same way it demotes proven-clean SQL. `sanitizersOnPath` would need to
8117
- // be `{ [findingId]: string[] of sanitizer callees observed on that
8118
- // finding's flow }`. There is nothing to build that map from: the live
8119
- // taint walk in dataflow/engine.js does NOT consult sanitizer catalog
8120
- // entries at all. `matchSinkOrSanitizer()` returns every catalog hit for
8121
- // a callee, but every consumer in dataflow/*.js selects only
8122
- // `e.kind === 'sink'` there is no `'sanitizer'` branch anywhere in that
8123
- // tree. Taint is killed only by clean re-assignment of a variable
8124
- // (removePathAndDescendants, engine.js:374), which happens regardless of
8125
- // whether the RHS call is a catalog sanitizer.
8126
- // So this is `{}` and the gate below is INERT — not "awaiting plumbing"
8127
- // but awaiting the sanitizer walk itself. Making it live needs two things:
8128
- // (1) dataflow/engine.js honouring `kind === 'sanitizer'` at a call site,
8129
- // and (2) that call site's callee name threaded onto the finding
8130
- // alongside the trace/chain that proven-clean.js already reads.
8184
+ // same way it demotes proven-clean SQL.
8185
+ //
8186
+ // The taint walk now records the sanitizer callees observed on the value
8187
+ // reaching each sink argument (`dataflow/engine.js` `_sanitizersForExpr`)
8188
+ // and stamps them on the finding as `_sanitizersOnPath`. This rebuilds the
8189
+ // `{ [findingId]: string[] }` shape the gate wants. Both `id` and
8190
+ // `stableId` are keyed because the gate accepts either and stable ids are
8191
+ // assigned by an earlier annotator.
8192
+ //
8193
+ // The sanitizer never kills the taint in the walk itself: a mislabelled
8194
+ // sanitizer would then hide a real vulnerability outright, whereas a label
8195
+ // only demotes confidence here. Recall-preserving, on purpose.
8131
8196
  const sanitizersOnPath = {};
8197
+ for (const f of finalFindings) {
8198
+ const names = f && f._sanitizersOnPath;
8199
+ if (!Array.isArray(names) || !names.length) continue;
8200
+ if (f.id) sanitizersOnPath[f.id] = names;
8201
+ if (f.stableId) sanitizersOnPath[f.stableId] = names;
8202
+ }
8132
8203
  _runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath }); });
8133
8204
  _runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
8134
8205
  }
@@ -8211,7 +8282,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8211
8282
  if (r && r.piiFields) {
8212
8283
  try {
8213
8284
  const dpia = emitDpiaArtifact(r.piiFields, r.findings || []);
8214
- fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'dpia.md'), dpia);
8285
+ _safeWriteState(_statePath(scanRoot, 'dpia.md'), dpia);
8215
8286
  } catch (_) {}
8216
8287
  }
8217
8288
  });
@@ -8277,8 +8348,10 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8277
8348
  // v3 next-gen: per-attacker-persona score matrix (FR-ADV-2). Must run AFTER
8278
8349
  // crown-jewels + mitigation composite so it sees those signals.
8279
8350
  _runAnnotator("annotatePersonaScores", () => { annotatePersonaScores(finalFindings); });
8280
- // v3 next-gen: SCA reverse-blast-radius enrichment (FR-ADV-5).
8281
- _runAnnotator("annotateScaReverseBlast", () => { annotateScaReverseBlast(finalFindings, fc); });
8351
+ // v3 next-gen: SCA reverse-blast-radius enrichment (FR-ADV-5). Annotates
8352
+ // SCA findings (package-name-keyed) must run against supplyChain, not
8353
+ // finalFindings (SAST), which has no package-name field at all.
8354
+ _runAnnotator("annotateScaReverseBlast", () => { annotateScaReverseBlast(supplyChain, fc); });
8282
8355
  // v3 next-gen: bug-bounty payout prediction (FR-ADV-3). Composes with the
8283
8356
  // mitigation composite — gated/unreachable findings get the bounty scaled
8284
8357
  // down rather than zeroed.
@@ -8409,10 +8482,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8409
8482
  // gets strictly more wall-clock for the taint analysis itself than an
8410
8483
  // uninstrumented run with the same budget.
8411
8484
  let _sharedIR = null;
8485
+ // Java IR requires the ASYNC builder. `parser-java.js` exports an async
8486
+ // `parseJavaFile` (java-parser needs a dynamic import), so the sync
8487
+ // `buildProjectIR` has no Java branch at all — and both deep-path call sites
8488
+ // used it. The result was that no .java file had ever produced an IR function
8489
+ // in deep mode: `bench/layer-recall` measured java at 0/25 while the catalog
8490
+ // carried 7 Java sources and 15 Java sinks that had nothing to run against.
8491
+ // `buildProjectIRAsync` is a full mirror plus Java and had zero callers.
8492
+ //
8493
+ // Gated on the presence of .java rather than always awaiting: the async
8494
+ // builder is a superset, but switching every scan in the product to it to fix
8495
+ // one language would change the execution shape (and attempt the java-parser
8496
+ // import) for projects that contain no Java. `runFullScan` is already async,
8497
+ // so the await costs nothing structurally.
8498
+ const _hasJava = Object.keys(fc || {}).some(f => /\.java$/i.test(f));
8499
+ const _buildIR = async () => (_hasJava ? await buildProjectIRAsync(fc) : buildProjectIR(fc));
8412
8500
  const _irStatsTarget = irStatsTarget();
8413
8501
  if (_irStatsTarget) {
8414
8502
  try {
8415
- _sharedIR = buildProjectIR(fc);
8503
+ _sharedIR = await _buildIR();
8416
8504
  writeIrStats(_irStatsTarget, collectIrStats(fc, _sharedIR.perFile, _sharedIR.callGraph));
8417
8505
  } catch (e) {
8418
8506
  // Instrumentation must never fail a scan. Surface only when debugging.
@@ -8421,16 +8509,27 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8421
8509
  }
8422
8510
  }
8423
8511
  }
8424
- const _deepRequested = process.env.AGENTIC_SECURITY_DEEP === '1';
8512
+ // `deep`/`deepInCi` come from runScan()'s options object (threaded through
8513
+ // unchanged from runScan.js) — an explicit-opt-in override alongside the
8514
+ // env vars, not a replacement for them. Added because `runScan(dir,
8515
+ // {deep:true})` was a silent, total no-op: this options object was
8516
+ // destructured for fileContents/depFileContents/scanRoot/resume only, so
8517
+ // `deep` was dropped on the floor and deep mode stayed off regardless.
8518
+ // Several interprocedural test files (interproc-k2.test.js,
8519
+ // parser-cs-kt.test.js, points-to.test.js) pass exactly this option
8520
+ // believing it enables deep mode — it never did, so those tests were
8521
+ // exercising whatever coincidentally fires without the deep engine, not
8522
+ // the interprocedural machinery they're named for.
8523
+ const _deepRequested = deep === true || process.env.AGENTIC_SECURITY_DEEP === '1';
8425
8524
  const _inCi = !!(process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI ||
8426
8525
  process.env.BUILDKITE || process.env.CIRCLECI || process.env.JENKINS_URL);
8427
- const _deepInCiAllowed = process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
8526
+ const _deepInCiAllowed = deepInCi === true || process.env.AGENTIC_SECURITY_DEEP_IN_CI === '1';
8428
8527
  const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
8429
8528
  if (_deepEnabled) {
8430
8529
  const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
8431
8530
  const t0 = Date.now();
8432
8531
  try {
8433
- const { perFile, callGraph } = _sharedIR || (_sharedIR = buildProjectIR(fc));
8532
+ const { perFile, callGraph } = _sharedIR || (_sharedIR = await _buildIR());
8434
8533
  // The runDeepAnalysis call is synchronous in this codebase; we can't
8435
8534
  // truly interrupt it without re-architecting the worklist. We pass a
8436
8535
  // deadlineMs hint that the inner loops check; if absent, we still cap
@@ -8460,6 +8559,29 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8460
8559
  f.validator_verdict = 'unvalidated';
8461
8560
  }
8462
8561
  finalFindings.push(...irFindings);
8562
+ // Sanitizer + proof gate, pass 2 of 2 — same ordering trap as the
8563
+ // ignore-pragma double pass below, and for the same reason: pass 1 runs
8564
+ // ~2300 lines above, long before deep-mode IR findings exist, so a
8565
+ // sanitized IR-TAINT flow was never labelled and a proven-clean one was
8566
+ // never demoted. Deep mode is what the CLI uses outside CI, so that was
8567
+ // the case that mattered most.
8568
+ //
8569
+ // Scoped to `irFindings` rather than re-running over `finalFindings`:
8570
+ // annotateProofGate demotes confidence, so a second pass over findings
8571
+ // pass 1 already handled would demote them twice.
8572
+ if (process.env.AGENTIC_SECURITY_NO_PROOF_GATE !== '1') {
8573
+ const _irSanitizers = {};
8574
+ for (const f of irFindings) {
8575
+ const names = f && f._sanitizersOnPath;
8576
+ if (!Array.isArray(names) || !names.length) continue;
8577
+ if (f.id) _irSanitizers[f.id] = names;
8578
+ if (f.stableId) _irSanitizers[f.stableId] = names;
8579
+ }
8580
+ _runAnnotator("applySanitizerGate:deep", () => {
8581
+ applySanitizerGate(irFindings, { sanitizersOnPath: _irSanitizers });
8582
+ });
8583
+ _runAnnotator("annotateProofGate:deep", () => { annotateProofGate(irFindings); });
8584
+ }
8463
8585
  // Pragma pass 2 of 2 — see the pass-1 comment far above. Deep-mode IR
8464
8586
  // findings land here, long after pass 1 ran, so without this an
8465
8587
  // `agentic-security-ignore` on an ir-taint finding is inert. Deep mode is
@@ -8515,11 +8637,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8515
8637
  confidence: 1.0,
8516
8638
  });
8517
8639
  }
8518
- // Phase 2 (Sentinel-parity): LLM validator stage. No-op unless the operator
8519
- // sets AGENTIC_SECURITY_LLM_VALIDATE=1 AND AGENTIC_SECURITY_LLM_ENDPOINT. When
8520
- // disabled, every finding gets unvalidated:true and the existing confidence
8521
- // pipeline accounts for that. When enabled, the validator emits accept/reject
8522
- // /escalate per finding; rejects are dropped into the suppression log.
8640
+ // Phase 2 (Sentinel-parity): LLM validator stage. DEFAULT-ON whenever
8641
+ // AGENTIC_SECURITY_LLM_ENDPOINT is configured not gated on
8642
+ // AGENTIC_SECURITY_LLM_VALIDATE=1 as this comment previously (and wrongly)
8643
+ // said. Opt OUT with AGENTIC_SECURITY_LLM_VALIDATE=0; the legacy
8644
+ // AGENTIC_SECURITY_LLM_VALIDATE=1 still works as an explicit-on no-op. With
8645
+ // no endpoint configured the validator stays a no-op regardless — no
8646
+ // surprise network calls from an unrelated env var. See
8647
+ // llm-validator/index.js's own header, which states this correctly; this
8648
+ // comment was the one that had drifted. When disabled, every finding gets
8649
+ // unvalidated:true and the existing confidence pipeline accounts for that.
8650
+ // When enabled, the validator emits accept/reject/escalate per finding;
8651
+ // rejects are dropped into the suppression log.
8523
8652
  try {
8524
8653
  // Concurrency defaults to 1 (the validator's deterministic-default).
8525
8654
  // Operators raise via AGENTIC_SECURITY_LLM_CONCURRENCY at the cost of
@@ -8532,7 +8661,23 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8532
8661
  vuln: d.vuln, file: d.file, line: d.line, snippet: d.snippet,
8533
8662
  reason: 'llm-validator:reject:' + (d.validator_reasoning || '').slice(0, 80),
8534
8663
  });
8664
+ // Re-run: annotateVerifierVerdicts ran (~8335) before validator_verdict
8665
+ // existed on any finding (set here, hundreds of lines later), so its
8666
+ // 'verified-by-llm' verdict — documented as one of five possible
8667
+ // outcomes in verifier.js's own header — could never be produced by the
8668
+ // real pipeline. Cheap and idempotent; re-running is the fix, not
8669
+ // moving the original call (confidence/exploitability annotators
8670
+ // upstream of it still need to run before validation, same as before).
8671
+ try { annotateVerifierVerdicts(finalFindings, { fileContents: fc }); } catch (_) {}
8535
8672
  } catch(_) {}
8673
+ // Same ordering fix as annotateVerifierVerdicts above: annotateConfidence
8674
+ // (~8111) computes f.confidence before f.unvalidated exists on any
8675
+ // finding, so its 0.85x "LLM validator unavailable" penalty could never
8676
+ // apply in the real pipeline. Runs unconditionally (outside the
8677
+ // llmValidateMany try-block above) because f.unvalidated is also set
8678
+ // directly on deep-mode IR findings appended earlier in this function,
8679
+ // independent of whether the LLM validator itself ran.
8680
+ try { applyUnvalidatedPenalty(finalFindings); } catch (_) {}
8536
8681
  try {
8537
8682
  const { kept, suppressed } = applyLearnedFeedback(scanRoot, finalFindings);
8538
8683
  finalFindings = kept;
@@ -8574,7 +8719,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8574
8719
  // second party could corroborate it.
8575
8720
  try {
8576
8721
  if (scanRoot) {
8577
- const raw = fs.readFileSync(path.join(scanRoot, '.agentic-security', 'logic-claims.json'), 'utf8');
8722
+ const raw = fs.readFileSync(statePath(scanRoot, 'logic-claims.json'), 'utf8');
8578
8723
  const parsed = JSON.parse(raw);
8579
8724
  const incoming = Array.isArray(parsed) ? parsed : (parsed && parsed.claims) || [];
8580
8725
  if (incoming.length) {
@@ -8599,8 +8744,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8599
8744
  /(?:^|\/)test_[^/]*\.py$/i.test(f) || /_test\.(?:py|go)$/i.test(f));
8600
8745
  annotateScaVerdicts(supplyChain, { testsDetected: _testsDetected });
8601
8746
  } catch (_) {}
8602
- // 0.9.0 Feat-15: dep confusion
8603
- try{const dc=detectDepConfusion(annotatedComponents,scanRoot);aF.push(...dc);}catch(_){}
8747
+ // 0.9.0 Feat-15: dep confusion. These are supply-chain findings (kind:'sca',
8748
+ // derived from `components` not source code) and belong in supplyChain, not
8749
+ // aF — aF was already snapshotted into finalFindings at dedupeFindingsWithEvidence
8750
+ // above, so pushing into aF here silently discards every result.
8751
+ try{const dc=detectDepConfusion(annotatedComponents,scanRoot);supplyChain.push(...dc);}catch(_){}
8604
8752
  // Deployment-platform security checklist
8605
8753
  try{const dpf=scanDeployPlatform(scanRoot);aLogic.push(...dpf);}catch(_){}
8606
8754
  // Stack-specific security playbook
@@ -8770,6 +8918,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8770
8918
  // Each is opt-in via env var. They produce machine-readable artifacts
8771
8919
  // (threat-model.json/.md, dpia.md, compliance-evidence.json/.md,
8772
8920
  // sbom-history/<sha>.json, exploit-bundles/) under .agentic-security/.
8921
+ let _privacyFramework = null;
8773
8922
  let _threatModel = null, _apiContractFindings = [], _sbomDiff = null,
8774
8923
  _complianceReport = null, _exploitBundles = null, _pqcPlan = null,
8775
8924
  _licenseGraph = null, _attributions = null, _taxonomySummary = null;
@@ -8802,8 +8951,22 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8802
8951
  if (process.env.AGENTIC_SECURITY_NO_COMPLIANCE !== '1') {
8803
8952
  try {
8804
8953
  const policy = loadCompliancePolicy(scanRoot);
8805
- if (policy && !policy._error) {
8954
+ if (policy && policy._error) {
8955
+ // CMP-5: a malformed policy used to be silently treated the same
8956
+ // as "no policy file" — the parse error (which names the exact
8957
+ // problem) was computed and then discarded. verifyPolicy now
8958
+ // reports it as a distinct 'error' status rather than staying
8959
+ // silent, and it is surfaced here too so an interactive scan
8960
+ // shows it.
8806
8961
  _complianceReport = verifyCompliancePolicy(policy, { scanRoot, findings: finalFindings });
8962
+ process.stderr.write(`[compliance] ${policy._error}\n`);
8963
+ } else if (policy) {
8964
+ // CMP-5: pass every channel a real scan produces, not just SAST —
8965
+ // a finding-family check for hardcoded-secret or vulnerable-dep
8966
+ // was previously invisible to secrets/SCA findings entirely.
8967
+ _complianceReport = verifyCompliancePolicy(policy, {
8968
+ scanRoot, findings: finalFindings, secrets: aSecrets, logicVulns: aLogic, supplyChain,
8969
+ });
8807
8970
  emitComplianceJsonLd(_complianceReport, scanRoot);
8808
8971
  emitComplianceMarkdown(_complianceReport, scanRoot);
8809
8972
  }
@@ -8837,6 +9000,29 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8837
9000
  if (_pqcPlan) persistPqcPlan(scanRoot, _pqcPlan);
8838
9001
  } catch (_) {}
8839
9002
  }
9003
+ // NIST Privacy Framework 1.1 assessment.
9004
+ //
9005
+ // The ASSESSMENT is default-on and lands on `scan.privacyFramework`, like
9006
+ // every other posture artifact. Its FINDINGS are opt-in
9007
+ // (AGENTIC_SECURITY_PRIVACY_FRAMEWORK=1), because appending them to
9008
+ // scan.findings would change every severity count, gate verdict and
9009
+ // baseline in every downstream consumer — a compliance opinion should not
9010
+ // silently become a build failure for projects that never asked for it.
9011
+ // Turn them on and they flow through triage and /fix like any finding.
9012
+ if (process.env.AGENTIC_SECURITY_NO_PRIVACY_FRAMEWORK !== '1') {
9013
+ try {
9014
+ _privacyFramework = assessPrivacyFramework(scanRoot, {
9015
+ findings: finalFindings, components: annotatedComponents,
9016
+ // filesScanned feeds the vacuous-satisfaction guard: a clean signal
9017
+ // from a run that read no files is not evidence of compliance.
9018
+ filesScanned: files.length,
9019
+ });
9020
+ if (_privacyFramework) persistPrivacyFramework(scanRoot, _privacyFramework);
9021
+ if (_privacyFramework && process.env.AGENTIC_SECURITY_PRIVACY_FRAMEWORK === '1') {
9022
+ finalFindings.push(..._privacyFramework.findings);
9023
+ }
9024
+ } catch (_) {}
9025
+ }
8840
9026
  // Exploit bundles — per-family PoC + Jest + pytest + remediation for
8841
9027
  // top-N critical/high findings.
8842
9028
  if (process.env.AGENTIC_SECURITY_NO_EXPLOIT_BUNDLES !== '1') {
@@ -8845,9 +9031,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8845
9031
  if (bundles.size) {
8846
9032
  _exploitBundles = {};
8847
9033
  for (const [id, b] of bundles) _exploitBundles[id] = b;
8848
- const bundlePath = path.join(scanRoot, '.agentic-security', 'exploit-bundles.json');
8849
- try { fs.mkdirSync(path.dirname(bundlePath), { recursive: true }); } catch {}
8850
- try { fs.writeFileSync(bundlePath, JSON.stringify(_exploitBundles, null, 2)); } catch {}
9034
+ // Through the seam, so `--no-state` withholds the artifact. The
9035
+ // bundles stay on the scan result either way — a read-only scan must
9036
+ // report the same thing, it just must not leave it behind.
9037
+ _safeWriteState(_statePath(scanRoot, 'exploit-bundles.json'),
9038
+ JSON.stringify(_exploitBundles, null, 2));
8851
9039
  }
8852
9040
  } catch (_) {}
8853
9041
  }
@@ -8858,7 +9046,16 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8858
9046
  let _analysisTier = null, _unmodeledSinks = null;
8859
9047
  try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
8860
9048
  try { _unmodeledSinks = countUnmodeledSinkCandidates(fc, finalFindings); } catch {}
8861
- 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}};
9049
+ // filesScanned counts files actually analyzed (Object.keys(fc), the same
9050
+ // set computeAnalysisTiers above reads) — NOT files.length, the candidate
9051
+ // list before the per-file loop's size/density skips run. Using the
9052
+ // candidate count here double-counted skipped files: they were included
9053
+ // in filesScanned AND separately reported in filesSkipped/filesDenseSkipped,
9054
+ // so coverage-report.js's "scanned=N skipped=M" line implied N+M files were
9055
+ // seen when only N-of-those-candidates were actually analyzed.
9056
+ // checkpoint.total intentionally keeps files.length — that field means the
9057
+ // full candidate set for resume bookkeeping, a different, correct meaning.
9058
+ 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}};
8862
9059
  // R8: the scan completed, so the checkpoint has been fully consumed — remove
8863
9060
  // it. Anything that threw before this point leaves it in place to resume from.
8864
9061
  try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
@@ -8881,7 +9078,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8881
9078
  // Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
8882
9079
  // detectors missed, with total-count accounting. Confirmed-only (cheap by default).
8883
9080
  let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
8884
- return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,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,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
9081
+ return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,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,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
8885
9082
 
8886
9083
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
8887
9084
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
@@ -19,17 +19,18 @@ import * as cp from 'node:child_process';
19
19
  import { buildJiraIssue } from './index.js';
20
20
  import { escapeMarkdown } from '../util/untrusted.js';
21
21
 
22
- function statePath(scanRoot) {
23
- return path.join(scanRoot, '.agentic-security', 'tickets.json');
22
+ import { statePath } from '../posture/state-dir.js';
23
+ function _ticketsPath(scanRoot) {
24
+ return statePath(scanRoot, 'tickets.json');
24
25
  }
25
26
  export function readState(scanRoot) {
26
- const fp = statePath(scanRoot);
27
+ const fp = _ticketsPath(scanRoot);
27
28
  if (!fs.existsSync(fp)) return {};
28
29
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return {}; }
29
30
  }
30
31
  function writeState(scanRoot, state) {
31
- fs.mkdirSync(path.dirname(statePath(scanRoot)), { recursive: true });
32
- fs.writeFileSync(statePath(scanRoot), JSON.stringify(state, null, 2));
32
+ fs.mkdirSync(path.dirname(_ticketsPath(scanRoot)), { recursive: true });
33
+ fs.writeFileSync(_ticketsPath(scanRoot), JSON.stringify(state, null, 2));
33
34
  }
34
35
 
35
36
  function findingTitle(f) {
@@ -148,7 +149,7 @@ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
148
149
 
149
150
  export async function syncTickets({ scanRoot, provider, severity = 'high', repo, teamId, dryRun = false }) {
150
151
  const minRank = SEV_RANK[severity] ?? 3;
151
- const lastScanPath = path.join(scanRoot, '.agentic-security', 'last-scan.json');
152
+ const lastScanPath = statePath(scanRoot, 'last-scan.json');
152
153
  if (!fs.existsSync(lastScanPath)) return { ok: false, error: 'no last-scan.json — run a scan first' };
153
154
  const last = JSON.parse(fs.readFileSync(lastScanPath, 'utf8'));
154
155
  const allFindings = [...(last.findings || []), ...(last.secrets || []), ...(last.supplyChain || [])];
package/src/ir/CLAUDE.md CHANGED
@@ -10,7 +10,10 @@ consumed by `scanner/src/dataflow/` for taint analysis.
10
10
  | JS / TS | `parser-js.js` | `@babel/parser` |
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
- | Java | `parser-java.js` | `java-parser` npm package (async) |
13
+ | Java | `parser-java.js` | `java-parser` npm package (**async only** — the deep path in `engine.js` therefore awaits `buildProjectIRAsync` when any `.java` file is present, and uses the sync builder otherwise).
14
+ ⚠ Three defects made Java taint impossible until v0.136.3+: the sync-only call site; a CST walk looking for `blockStatement` on a `block` (java-parser nests `block → blockStatements → blockStatement`), which emptied every method CFG; and `exprFromCst` missing the `primary → primaryPrefix + primarySuffix` form that models **every** method call. Guarded by `test/java-taint-flow.test.js`. **Params are still not extracted** (`params: []`, marked "deferred" in the source), so Java interprocedural summaries are limited. |
15
+ | Ruby | `parser-rb.js` | Hand-rolled. **`DEF_RE` must not let `\s*` cross a newline** — it did, and the body slice then started after the method's first statement, silently dropping it from every method (a one-statement body became empty). Measured as Ruby 0/20 IR-TAINT recall in `bench/layer-recall`. Guarded by `test/parser-php-rb.test.js`. ⚠ Also emitted no `fn.calls` at all (every OTHER parser does) — `callgraph.js`'s edges/callersOf/resolveKnownCallee are built entirely from `fn.calls`, so this left dead-code demotion and any interprocedural signal that depends on real call-graph resolution (rather than engine.js's generic tainted-call-argument fallback) permanently blind to Ruby. Fixed by deriving `fn.calls` from the CFG via the shared `call-sites.js#callSitesFromCfg` (the same helper `parser-py-cst.js` uses) — Ruby's node shapes already matched its documented contract. Guarded by `test/parser-rb-calls.test.js`. |
16
+ | C# | `parser-cs.js` | Hand-rolled. ⚠ `_lowerExpr`'s string-concat branch **must** guard on `_splitTopLevelPlus` returning more than one part — when the `+` is nested inside parens the splitter returns the input unchanged and the branch recurses on the identical string (stack overflow, swallowed by `buildProjectIR`'s per-file catch, surfacing only as "no IR"). `new Type(args)` is lowered to a call so taint reaches constructor sinks such as `new SqlCommand`. Guarded by `test/parser-cs-kt.test.js`. |
14
17
  | C / C++ | `parser-cpp.js` | Hand-rolled parser (functions, qualified names, CFG lowering). Dispatched by extension (`c/cc/cpp/cxx/h/hh/hpp/hxx`) in both `buildProjectIR` and `buildProjectIRAsync`. |
15
18
  | Long-tail (rust/solidity/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. |
16
19