@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
@@ -66,7 +66,7 @@
66
66
  "category": "Malicious Code",
67
67
  "summary": "Verify the application does not include known-malicious or compromised dependencies.",
68
68
  "evidence": [".agentic-security/sbom-history snapshots clean.", "No dependency-confusion or dependency-drift findings."],
69
- "mapsTo": ["family:vulnerable-dependency", "family:dependency-confusion", "family:dependency-drift"]
69
+ "mapsTo": ["family:vulnerable-dep", "family:dependency-confusion", "family:dependency-drift"]
70
70
  },
71
71
  {
72
72
  "id": "V14.1",
@@ -40,11 +40,13 @@
40
40
  import * as fs from 'node:fs';
41
41
  import * as path from 'node:path';
42
42
  import * as yaml from '../util/yaml.js';
43
+ import { statePath, safeWriteState } from './state-dir.js';
44
+ import { SCANNER_VERSION } from './version.js';
43
45
 
44
46
  const POLICY_FILE = 'compliance.policy.yml';
45
47
 
46
48
  export function loadPolicy(scanRoot) {
47
- const fp = path.join(scanRoot, '.agentic-security', POLICY_FILE);
49
+ const fp = statePath(scanRoot, POLICY_FILE);
48
50
  if (!fs.existsSync(fp)) return null;
49
51
  try {
50
52
  const raw = fs.readFileSync(fp, 'utf8');
@@ -105,7 +107,7 @@ function _runCheck(check, ctx) {
105
107
  }
106
108
  if (check['sca-policy-has-entry']) {
107
109
  const type = check['sca-policy-has-entry'];
108
- const policyPath = path.join(ctx.scanRoot, '.agentic-security', 'sca-policy.yml');
110
+ const policyPath = statePath(ctx.scanRoot, 'sca-policy.yml');
109
111
  if (!fs.existsSync(policyPath)) return { passed: false, reason: 'sca-policy.yml not found' };
110
112
  try {
111
113
  const policy = yaml.load(fs.readFileSync(policyPath, 'utf8'));
@@ -127,7 +129,29 @@ function _runCheck(check, ctx) {
127
129
  * Run all controls in the policy and emit a verification report.
128
130
  */
129
131
  export function verifyPolicy(policy, ctx) {
132
+ // CMP-5: loadPolicy() reports a parse failure as { _error }, which has no
133
+ // `.controls` — the same shape as "no policy file at all". Distinguishing
134
+ // them matters: a customer who typo'd their YAML deserves a loud error,
135
+ // not a report that silently treats their (unparsed) policy as having no
136
+ // controls to check.
137
+ if (policy && policy._error) return { controls: [], status: 'error', error: policy._error };
130
138
  if (!policy || !policy.controls) return { controls: [], status: 'no-policy' };
139
+ // CMP-5: a finding-family check must see every channel a real scan
140
+ // produces (findings=SAST, secrets, logicVulns, supplyChain=SCA) — the
141
+ // caller (engine.js) hands last-scan.json's own channel split through
142
+ // ctx, so a control checking family:hardcoded-secret or
143
+ // family:vulnerable-dep isn't blind to 3 of the engine's 4 finding types.
144
+ // Family defaults mirror report/index.js's normalizeFindings so the two
145
+ // modules can't drift apart on what family an untagged finding belongs to.
146
+ ctx = {
147
+ ...ctx,
148
+ findings: [
149
+ ...(ctx.findings || []),
150
+ ...(ctx.secrets || []).map(s => ({ ...s, family: s.family || 'hardcoded-secret' })),
151
+ ...(ctx.logicVulns || []),
152
+ ...(ctx.supplyChain || []).map(sc => ({ ...sc, family: sc.family || 'vulnerable-dep' })),
153
+ ],
154
+ };
131
155
  const results = [];
132
156
  for (const control of policy.controls) {
133
157
  if (control.not_applicable) {
@@ -165,6 +189,13 @@ export function emitEvidenceJsonLd(report, scanRoot) {
165
189
  framework: report.framework,
166
190
  version: report.version,
167
191
  generatedAt: new Date().toISOString(),
192
+ // CMP-5: this artifact is fed to GRC tooling (Vanta/Drata/SecureFrame) and
193
+ // auditors largely unread by a human — the same reason
194
+ // auditor-walkthrough.js's narrative carries this disclaimer verbatim.
195
+ // Without it here, a machine-consumed "ComplianceEvidence" document reads
196
+ // as an attestation, not a scanner's automated observation.
197
+ disclaimer: 'This artifact organizes automated scanner evidence into a structured report. It does not certify compliance. A licensed assessor (CPA / auditor / DPO) is responsible for the final attestation.',
198
+ provenance: { engineVersion: SCANNER_VERSION },
168
199
  summary: report.summary,
169
200
  controls: report.controls.map(c => ({
170
201
  '@type': 'Control',
@@ -178,10 +209,9 @@ export function emitEvidenceJsonLd(report, scanRoot) {
178
209
  narrative_evidence: c.evidence || [],
179
210
  })),
180
211
  };
181
- try {
182
- fs.mkdirSync(path.join(scanRoot, '.agentic-security'), { recursive: true });
183
- fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'compliance-evidence.json'), JSON.stringify(jsonld, null, 2));
184
- } catch {}
212
+ // Through the seam — see the note in pqc-migration-plan.js. The report is
213
+ // still returned when writing is off; only the artifact is withheld.
214
+ safeWriteState(statePath(scanRoot, 'compliance-evidence.json'), JSON.stringify(jsonld, null, 2));
185
215
  return jsonld;
186
216
  }
187
217
 
@@ -192,7 +222,9 @@ export function emitEvidenceMarkdown(report, scanRoot) {
192
222
  const lines = [];
193
223
  lines.push(`# Compliance evidence — ${report.framework}`);
194
224
  lines.push('');
195
- lines.push(`Generated by agentic-security on ${new Date().toISOString().slice(0,10)}.`);
225
+ lines.push(`Generated by agentic-security (engine ${SCANNER_VERSION}) on ${new Date().toISOString().slice(0,10)}.`);
226
+ lines.push('');
227
+ lines.push('> This document organizes automated scanner evidence into a structured report. It does not certify compliance. A licensed assessor (CPA / auditor / DPO) is responsible for the final attestation.');
196
228
  lines.push('');
197
229
  lines.push(`Compliant: **${report.summary.compliant}** / Non-compliant: **${report.summary.nonCompliant}** / Not applicable: **${report.summary.notApplicable}** of ${report.summary.total} controls.`);
198
230
  lines.push('');
@@ -209,9 +241,7 @@ export function emitEvidenceMarkdown(report, scanRoot) {
209
241
  }
210
242
  lines.push('');
211
243
  }
212
- try {
213
- fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'compliance-evidence.md'), lines.join('\n'));
214
- } catch {}
244
+ safeWriteState(statePath(scanRoot, 'compliance-evidence.md'), lines.join('\n'));
215
245
  return lines.join('\n');
216
246
  }
217
247
 
@@ -47,19 +47,53 @@ export function annotateConfidence(findings) {
47
47
  if (f.routeRooted) conf = Math.min(1, conf + 0.05);
48
48
  if (f.guards && f.guards.length) conf *= 0.80;
49
49
  if (f.reachable === false) conf *= 0.55;
50
+ // f.unvalidated is set later in the pipeline (llm-validator/index.js,
51
+ // invoked well after this first confidence pass), so it's never true
52
+ // here on a finding's first computation — see applyUnvalidatedPenalty
53
+ // below, the real enforcement point, run after validation. Kept here
54
+ // too so a caller that builds a synthetic finding with
55
+ // unvalidated:true pre-set still gets it applied in one pass.
50
56
  if (f.unvalidated) conf *= 0.85; // LLM validator unavailable
51
- if (f.llmOnly) conf *= 0.70; // LLM-only finding, no Layer-2 path
57
+ // f.llmOnly: currently unreachable no producer anywhere in this
58
+ // codebase ever sets it (grepped). Documented as a no-op rather than
59
+ // silently deleted, since the intent ("LLM-only finding, no Layer-2
60
+ // path") is a real, plausible signal that just isn't wired yet.
61
+ if (f.llmOnly) conf *= 0.70;
52
62
  }
53
63
  conf = Math.max(0, Math.min(1, conf));
54
64
  f.confidence = Math.round(conf * 1000) / 1000;
55
- // Premortem 3R-15: derive tier from the 2-decimal display value so a
56
- // finding reported as "0.75" never lands in two tiers depending on the
57
- // viewer's rounding. Add a +0.005 epsilon to anchor cutoffs to the
58
- // displayed rounded value (3-decimal raw 0.745 → 2-decimal 0.75 → high).
59
- const display = Math.round(f.confidence * 100) / 100;
60
- if (display >= 0.75) f.confidenceTier = 'high';
61
- else if (display >= 0.50) f.confidenceTier = 'medium';
62
- else if (display >= 0.25) f.confidenceTier = 'low';
63
- else f.confidenceTier = 'very-low';
65
+ f.confidenceTier = _tierFor(f.confidence);
66
+ }
67
+ }
68
+
69
+ // Premortem 3R-15: derive tier from the 2-decimal display value so a
70
+ // finding reported as "0.75" never lands in two tiers depending on the
71
+ // viewer's rounding (3-decimal raw 0.745 → 2-decimal 0.75 high).
72
+ function _tierFor(confidence) {
73
+ const display = Math.round(confidence * 100) / 100;
74
+ if (display >= 0.75) return 'high';
75
+ if (display >= 0.50) return 'medium';
76
+ if (display >= 0.25) return 'low';
77
+ return 'very-low';
78
+ }
79
+
80
+ // Retroactively applies the unvalidated penalty to findings whose
81
+ // confidence was already computed by annotateConfidence BEFORE
82
+ // f.unvalidated could be known — annotateConfidence only computes from
83
+ // scratch when f.confidence is still null (so hand-tuned detector
84
+ // confidences survive untouched), and f.unvalidated is set by
85
+ // llm-validator/index.js, which runs well after the pipeline's first
86
+ // annotateConfidence pass. Call this once, immediately after LLM
87
+ // validation runs (or is skipped). Idempotent: a finding is only
88
+ // adjusted once, tracked via f._unvalidatedPenaltyApplied.
89
+ export function applyUnvalidatedPenalty(findings) {
90
+ if (!Array.isArray(findings)) return;
91
+ for (const f of findings) {
92
+ if (!f || typeof f !== 'object') continue;
93
+ if (f._unvalidatedPenaltyApplied) continue;
94
+ f._unvalidatedPenaltyApplied = true;
95
+ if (!f.unvalidated || typeof f.confidence !== 'number') continue;
96
+ f.confidence = Math.round(Math.max(0, Math.min(1, f.confidence * 0.85)) * 1000) / 1000;
97
+ f.confidenceTier = _tierFor(f.confidence);
64
98
  }
65
99
  }
@@ -296,8 +296,12 @@ function _stripState(dir) {
296
296
  }
297
297
  }
298
298
 
299
- // `scoreCandidate` is deliberately NOT exported: an external caller could
300
- // score a candidate and then write it by some other route, which is exactly
301
- // the unscored-write path this module exists to make unavailable. Enrolment
302
- // scores and writes as one operation or not at all.
303
- export const _internals = { DEFAULT_TIER, scoreCandidate, _languageOf, _escapeRegex, _stripState };
299
+ // `scoreCandidate` is deliberately NOT exported not even via _internals,
300
+ // the test-only-helpers convention used elsewhere in this codebase: an
301
+ // external caller could score a candidate and then write it by some other
302
+ // route, which is exactly the unscored-write path this module exists to
303
+ // make unavailable. Enrolment scores and writes as one operation or not at
304
+ // all. Keep it out of this object even though every other _internals
305
+ // export in this codebase is a harmless pure helper — this one specific
306
+ // function is the security boundary, not test plumbing.
307
+ export const _internals = { DEFAULT_TIER, _languageOf, _escapeRegex, _stripState };
@@ -54,6 +54,25 @@ function _matches(f, manifest, matcher) {
54
54
  (manifest?.cwe ? f.cwe === manifest.cwe || matcher.test(f.cwe || '') : true);
55
55
  }
56
56
 
57
+ /**
58
+ * Every finding, across every channel, that scores this entry.
59
+ *
60
+ * Third caller of the ONE predicate (gate, enrollment, and now the per-layer
61
+ * recall instrument in `bench/layer-recall/`). That instrument needs to know
62
+ * WHICH findings matched so it can attribute them to the analysis layer that
63
+ * produced them; re-deriving the predicate there is precisely the drift this
64
+ * module was extracted to prevent, so it reuses `_matches` instead.
65
+ */
66
+ export function matchingFindings(scan, manifest, matcher = matcherFor(manifest)) {
67
+ const out = [];
68
+ for (const channel of CHANNELS) {
69
+ const arr = scan?.[channel];
70
+ if (!Array.isArray(arr)) continue;
71
+ for (const f of arr) if (_matches(f, manifest, matcher)) out.push(f);
72
+ }
73
+ return out;
74
+ }
75
+
57
76
  /** Did the vulnerable (`pre/`) tree produce a matching finding? */
58
77
  export function preHit(scan, manifest, matcher = matcherFor(manifest)) {
59
78
  return _any(scan, f => _matches(f, manifest, matcher));
@@ -101,6 +101,49 @@ function isSanitizedExpr(text) {
101
101
  return false;
102
102
  }
103
103
 
104
+ // For each SANITIZER_PATTERNS match in `text`, find the nearest `(...)` call
105
+ // span after the match and return its [start, end) bounds. Used to tell
106
+ // "the sanitizer call actually wraps the tainted value" (HtmlEncode(x)) apart
107
+ // from "the sanitizer pattern matched something unrelated elsewhere in a
108
+ // compound expression" (comment + (IsNullOrEmpty(flag) ? ... : ...)) — a
109
+ // common .NET idiom that combines a tainted value with an unrelated validity
110
+ // check in the same expression.
111
+ function _sanitizerCallSpans(text) {
112
+ const spans = [];
113
+ for (const re of SANITIZER_PATTERNS) {
114
+ const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
115
+ let m;
116
+ while ((m = g.exec(text))) {
117
+ const openIdx = text.indexOf('(', g.lastIndex - 1);
118
+ if (openIdx !== -1 && openIdx - g.lastIndex < 3) {
119
+ let depth = 1, j = openIdx + 1;
120
+ while (j < text.length && depth > 0) {
121
+ if (text[j] === '(') depth++;
122
+ else if (text[j] === ')') depth--;
123
+ j++;
124
+ }
125
+ spans.push([openIdx, j]);
126
+ }
127
+ if (g.lastIndex === m.index) g.lastIndex++; // avoid infinite loop on zero-width matches
128
+ }
129
+ }
130
+ return spans;
131
+ }
132
+
133
+ // Is `ref` tainted-and-NOT-neutralized by an enclosing sanitizer call in
134
+ // `text`? True when every occurrence of `ref` as a whole word sits outside
135
+ // every sanitizer call span (i.e. no sanitizer actually wraps it).
136
+ function _refEscapesSanitizers(text, ref, spans) {
137
+ if (!spans.length) return true;
138
+ const re = new RegExp(`\\b${ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
139
+ let m, sawAny = false;
140
+ while ((m = re.exec(text))) {
141
+ sawAny = true;
142
+ if (!spans.some(([s, e]) => m.index >= s && m.index < e)) return true;
143
+ }
144
+ return !sawAny;
145
+ }
146
+
104
147
  // Walk a single method's body and compute per-variable type + taint.
105
148
  // Returns { typeMap, taintMap, sourceLines } where sourceLines records the
106
149
  // declaration line at which each variable first became tainted.
@@ -296,12 +339,22 @@ export function receiverIsType(method, flow, receiver, typePattern) {
296
339
  // for short expressions but unsafe for arbitrary string-containing text.
297
340
  export function expressionIsTainted(flow, text, idents = null) {
298
341
  if (!text && !idents) return false;
342
+ // Check known-tainted variable references that ESCAPE every sanitizer
343
+ // call span first — a whole-expression sanitizer-pattern match (below)
344
+ // must not clear a tainted variable the sanitizer doesn't actually wrap.
345
+ // `HtmlEncode(x)` genuinely neutralizes `x` (inside the call's parens);
346
+ // `comment + (string.IsNullOrEmpty(flag) ? ... : ...)` does not neutralize
347
+ // `comment` just because an unrelated sanitizer pattern matched `flag`
348
+ // elsewhere in the same compound expression.
349
+ const refs = idents || (text ? text.match(/\b[A-Za-z_]\w*\b/g) || [] : []);
350
+ const spans = text ? _sanitizerCallSpans(text) : [];
351
+ for (const r of refs) {
352
+ if (flow.taintMap.get(r) && (!text || _refEscapesSanitizers(text, r, spans))) return true;
353
+ }
299
354
  if (text) {
300
355
  if (isSourceExpr(text) && !isSanitizedExpr(text)) return true;
301
356
  if (isSanitizedExpr(text)) return false;
302
357
  }
303
- const refs = idents || (text ? text.match(/\b[A-Za-z_]\w*\b/g) || [] : []);
304
- for (const r of refs) if (flow.taintMap.get(r)) return true;
305
358
  return false;
306
359
  }
307
360
 
@@ -311,9 +364,15 @@ export function expressionIsTainted(flow, text, idents = null) {
311
364
  // not treated as code identifiers.
312
365
  export function argIsTainted(flow, arg) {
313
366
  if (!arg) return false;
367
+ // Same span-aware fix as expressionIsTainted: a tainted identifier that
368
+ // escapes every sanitizer call span wins over a whole-argument
369
+ // sanitizer-pattern match.
370
+ const spans = arg.text ? _sanitizerCallSpans(arg.text) : [];
371
+ for (const id of arg.idents || []) {
372
+ if (flow.taintMap.get(id) && (!arg.text || _refEscapesSanitizers(arg.text, id, spans))) return true;
373
+ }
314
374
  if (arg.text && isSanitizedExpr(arg.text)) return false;
315
375
  if (arg.text && isSourceExpr(arg.text)) return true;
316
- for (const id of arg.idents || []) if (flow.taintMap.get(id)) return true;
317
376
  return false;
318
377
  }
319
378
 
@@ -33,6 +33,7 @@ import * as yaml from '../util/yaml.js';
33
33
  import { globFiles } from '../util/glob.js';
34
34
  import { loadTrustedKeys, verifyRulePack } from './rule-pack-signing.js';
35
35
 
36
+ import { statePath, safeWriteState } from './state-dir.js';
36
37
  const LANG_EXTS = {
37
38
  javascript: ['.js', '.mjs', '.cjs', '.jsx'],
38
39
  typescript: ['.ts', '.tsx'],
@@ -48,7 +49,7 @@ const LANG_EXTS = {
48
49
  };
49
50
 
50
51
  function rulesDir(scanRoot) {
51
- return path.join(scanRoot, '.agentic-security', 'rules');
52
+ return statePath(scanRoot, 'rules');
52
53
  }
53
54
 
54
55
  export function loadCustomRules(scanRoot) {
@@ -318,10 +319,11 @@ export function applyCustomRules(scanRoot, fileContents) {
318
319
  }
319
320
  if (shadow.length) {
320
321
  try {
321
- const stateDir = path.join(scanRoot, '.agentic-security');
322
- fs.mkdirSync(stateDir, { recursive: true });
323
- fs.writeFileSync(
324
- path.join(stateDir, 'shadow-findings.json'),
322
+ // Through the seam, so a read-only scan cannot leave shadow findings
323
+ // behind. Shadow rules are excluded from gates by design, which made this
324
+ // the least likely write for anyone to notice. (PRD M1)
325
+ safeWriteState(
326
+ statePath(scanRoot, 'shadow-findings.json'),
325
327
  JSON.stringify({ generatedAt: new Date().toISOString(), findings: shadow }, null, 2),
326
328
  );
327
329
  } catch { /* non-fatal */ }
@@ -27,9 +27,9 @@
27
27
  import * as fs from 'node:fs';
28
28
  import * as path from 'node:path';
29
29
 
30
+ import { statePath, stateWritesEnabled } from './state-dir.js';
30
31
  const OSV_API = 'https://api.osv.dev/v1/query';
31
- const CFG_PATH = '.agentic-security/cve-alerts.json';
32
- const STATE_PATH = '.agentic-security/cve-alerts-state.json';
32
+
33
33
 
34
34
  // Multi-ecosystem dep extraction. Each entry: { manifest filename → ecosystem }
35
35
  const ECOSYSTEM_BY_MANIFEST = {
@@ -267,7 +267,7 @@ export async function _queryOsvForDep({ name, ecosystem }, { fetchImpl = globalT
267
267
  // ─── State persistence ───────────────────────────────────────────────────
268
268
 
269
269
  export function loadState(scanRoot) {
270
- const fp = path.join(scanRoot, STATE_PATH);
270
+ const fp = statePath(scanRoot, 'cve-alerts-state.json');
271
271
  if (!fs.existsSync(fp)) return { known: new Set(), lastRun: null };
272
272
  try {
273
273
  const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
@@ -276,7 +276,8 @@ export function loadState(scanRoot) {
276
276
  }
277
277
 
278
278
  export function persistState(scanRoot, state) {
279
- const fp = path.join(scanRoot, STATE_PATH);
279
+ const fp = statePath(scanRoot, 'cve-alerts-state.json');
280
+ if (!stateWritesEnabled()) return;
280
281
  fs.mkdirSync(path.dirname(fp), { recursive: true });
281
282
  fs.writeFileSync(fp, JSON.stringify({
282
283
  known: [...state.known].sort(),
@@ -285,7 +286,7 @@ export function persistState(scanRoot, state) {
285
286
  }
286
287
 
287
288
  export function loadConfig(scanRoot) {
288
- const fp = path.join(scanRoot, CFG_PATH);
289
+ const fp = statePath(scanRoot, 'cve-alerts.json');
289
290
  if (!fs.existsSync(fp)) return null;
290
291
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
291
292
  }
@@ -19,6 +19,7 @@
19
19
  import * as fs from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
 
22
+ import { statePath } from './state-dir.js';
22
23
  const CACHE = path.join(process.env.HOME || '/tmp', '.claude', 'agentic-security', 'osv-cache');
23
24
  const TYPOSQUAT_LEVENSHTEIN = 2;
24
25
  const NEW_PACKAGE_WINDOW_DAYS = 7;
@@ -61,7 +62,7 @@ function _loadPopular(ecosystem) {
61
62
  }
62
63
 
63
64
  function _loadPolicy(scanRoot) {
64
- const fp = path.join(scanRoot, '.agentic-security', 'sca-policy.yml');
65
+ const fp = statePath(scanRoot, 'sca-policy.yml');
65
66
  if (!fs.existsSync(fp)) return { deny: [] };
66
67
  try {
67
68
  const body = fs.readFileSync(fp, 'utf8');
@@ -4,7 +4,10 @@
4
4
  // returns platform-specific security findings: missing headers, public previews,
5
5
  // no health checks, unsafe infra settings.
6
6
  //
7
- // Platforms: Vercel, Railway, Fly.io, Render, Netlify, AWS Amplify, Cloudflare
7
+ // Platforms: Vercel, Railway, Fly.io, Netlify, Cloudflare.
8
+ // NOT implemented despite being previously listed here: Render, AWS Amplify
9
+ // (found via Stage-0 doc audit, 2026 — grepped for any render/amplify config
10
+ // path or platform key in this file; zero hits for either).
8
11
 
9
12
  import * as fs from 'node:fs';
10
13
  import * as path from 'node:path';
@@ -16,6 +16,7 @@ import * as path from 'node:path';
16
16
  import * as crypto from 'node:crypto';
17
17
  import { PACKS } from './rule-packs.js';
18
18
 
19
+ import { stateDir, statePath } from './state-dir.js';
19
20
  export const SCANNER_VERSION = '0.39.2';
20
21
  const LOCK_FILE = 'rules.lock.json';
21
22
 
@@ -40,7 +41,7 @@ export function buildLockfile() {
40
41
  }
41
42
 
42
43
  export function writeLockfile(scanRoot) {
43
- const dir = path.join(scanRoot, '.agentic-security');
44
+ const dir = stateDir(scanRoot);
44
45
  fs.mkdirSync(dir, { recursive: true });
45
46
  const fp = path.join(dir, LOCK_FILE);
46
47
  const lock = buildLockfile();
@@ -49,7 +50,7 @@ export function writeLockfile(scanRoot) {
49
50
  }
50
51
 
51
52
  export function readLockfile(scanRoot) {
52
- const fp = path.join(scanRoot, '.agentic-security', LOCK_FILE);
53
+ const fp = statePath(scanRoot, LOCK_FILE);
53
54
  if (!fs.existsSync(fp)) return null;
54
55
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
55
56
  }
@@ -8,7 +8,13 @@
8
8
 
9
9
  function _routeKey(r) { return `${r.method || 'ANY'} ${r.path || '(file)'} @ ${r.file}:${r.line}`; }
10
10
  function _depKey(c) { return `${c.ecosystem}:${c.name}@${c.version}`; }
11
- function _findingKey(f) { return `${f.kind}:${f.file}:${f.line}:${(f.vuln||'').slice(0,80)}`; }
11
+ // Prefer stableId (posture/stable-id.js) it omits the exact source line by
12
+ // design, so an unrelated edit that shifts a still-unfixed finding's line
13
+ // number doesn't register as one "removed" + one "added" finding, inflating
14
+ // drift tier and falsely flagging a PR as introducing/fixing something it
15
+ // didn't touch. Falls back to the line-sensitive key only for finding shapes
16
+ // that never got a stableId annotated.
17
+ function _findingKey(f) { return f.stableId || `${f.kind}:${f.file}:${f.line}:${(f.vuln||'').slice(0,80)}`; }
12
18
 
13
19
  function _toMap(arr, keyFn) {
14
20
  const m = new Map();
@@ -6,10 +6,19 @@
6
6
  // from those attackers are actively weaponizing.
7
7
  //
8
8
  // Decoration shape (added to each SCA finding with a CVE):
9
- // epss: 0.92345
9
+ // epssScore: 0.92345 (this comment previously said `epss:`; the code has
10
+ // always written `epssScore` — the comment was wrong,
11
+ // not the code)
10
12
  // epssPercentile: 0.987
11
13
  // exploitedNow: true ← percentile >= 0.95
12
14
  //
15
+ // ⚠ engine.js ALSO sets f.epssScore/f.epssPercentile directly (around line
16
+ // 6451), independently of this module's fetchEPSS/decorate path — two
17
+ // implementations of the same decoration. This module's own `fetchEPSS` has
18
+ // no in-tree caller (allowlisted in no-dead-modules.test.js as future-public
19
+ // API); the live path is the one in engine.js. Worth consolidating, not
20
+ // resolved here.
21
+ //
13
22
  // Source: https://api.first.org/data/v1/epss?cve=CVE-...,CVE-...
14
23
  // Cached on disk: ~/.claude/agentic-security/epss-cache/<sha256>.json
15
24
  // 24-hour TTL. Falls back gracefully when offline.
@@ -93,6 +102,9 @@ function cvesIn(finding) {
93
102
  const found = new Set();
94
103
  if (typeof finding.cve === 'string') found.add(finding.cve.toUpperCase());
95
104
  if (Array.isArray(finding.cves)) for (const c of finding.cves) found.add(String(c).toUpperCase());
105
+ // The actual field name every SCA finding in this codebase uses (src/sca/
106
+ // CLAUDE.md's documented shape; carried through by normalizeFindings).
107
+ if (Array.isArray(finding.cveAliases)) for (const c of finding.cveAliases) found.add(String(c).toUpperCase());
96
108
  if (Array.isArray(finding.vulnerabilities)) {
97
109
  for (const v of finding.vulnerabilities) {
98
110
  if (typeof v.id === 'string' && v.id.startsWith('CVE-')) found.add(v.id.toUpperCase());