@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
@@ -0,0 +1,276 @@
1
+ // Per-finding evidence bundles, signed so a THIRD PARTY can verify them.
2
+ // PRD Phase 2 / D2.
3
+ //
4
+ // WHAT WAS MISSING
5
+ // ----------------
6
+ // `attestation.js` already turns a run into a checkable digest, and it is
7
+ // explicit about its own ceiling: the signature is a per-install SYMMETRIC HMAC,
8
+ // so it is "tamper-evidence for the operator, not third-party non-repudiation."
9
+ // Anyone who can verify it can also forge it. That is fine for detecting local
10
+ // corruption and useless for handing a finding to somebody else.
11
+ //
12
+ // This module closes that gap for the artefact that actually travels: a single
13
+ // finding plus the evidence behind it. A buyer, an auditor, or a downstream
14
+ // consumer can check the bundle with the PUBLIC key alone, having never had
15
+ // access to ours.
16
+ //
17
+ // WHY A SEPARATE MODULE RATHER THAN EXTENDING attestation.js
18
+ // ----------------------------------------------------------
19
+ // They answer different questions and must not be conflated. A run attestation
20
+ // says "this set of findings came from this engine, ruleset and bundle". An
21
+ // evidence bundle says "THIS finding is backed by THIS evidence, and here is
22
+ // proof the claim was not edited after the fact." One is about reproducibility,
23
+ // the other about portability. Merging them would produce an artefact that is
24
+ // worse at both.
25
+ //
26
+ // WHAT A BUNDLE PROVES, AND WHAT IT DOES NOT
27
+ // -------------------------------------------
28
+ // PROVES — the bundle's contents are exactly what the holder of the signing key
29
+ // attested, unmodified. Nothing in it has been added, removed, or edited since.
30
+ //
31
+ // DOES NOT PROVE — that the finding is real. A signature is an integrity claim,
32
+ // never a correctness claim. An `unproven` finding in a signed bundle is still
33
+ // an unproven finding; the signature only stops someone silently upgrading the
34
+ // word `unproven` to `execution-proven` in transit. Both statements are carried
35
+ // INSIDE the bundle so they travel with it and cannot be dropped by a
36
+ // summariser.
37
+ //
38
+ // The evidence chain is deliberately the honest one this engine already
39
+ // computes: the proof tier, what the sandbox observed, which backend ran it, the
40
+ // taint path if there is one. A bundle for a regex finding says so plainly
41
+ // rather than dressing it up.
42
+
43
+ import * as fs from 'node:fs';
44
+ import * as os from 'node:os';
45
+ import * as path from 'node:path';
46
+ import * as crypto from 'node:crypto';
47
+
48
+ export const BUNDLE_SCHEMA = 'agentic-security/finding-evidence@1';
49
+
50
+ /** Where the Ed25519 signing key lives. Mirrors integrity.js's key handling. */
51
+ function keyDir() {
52
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
53
+ return path.join(xdg, 'agentic-security');
54
+ }
55
+ const PRIVATE_KEY_FILE = 'attest-key.pem';
56
+ const PUBLIC_KEY_FILE = 'attest-key.pub.pem';
57
+
58
+ export function keyPaths(dir = keyDir()) {
59
+ return {
60
+ privateKey: path.join(dir, PRIVATE_KEY_FILE),
61
+ publicKey: path.join(dir, PUBLIC_KEY_FILE),
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Load the signing key pair, generating one on first use.
67
+ *
68
+ * Ed25519 from node:crypto — no new dependency, and deliberately not a
69
+ * transparency-log scheme: those need a network round trip, and this project
70
+ * does not make runtime cloud calls. A log-backed option can layer on later
71
+ * without changing the bundle format.
72
+ */
73
+ export function ensureKeyPair(dir = keyDir()) {
74
+ const p = keyPaths(dir);
75
+
76
+ // NO existsSync-then-read. This project's own scanner flagged the first
77
+ // version of this function for TOCTOU (CWE-367) and it was right: between an
78
+ // existence check and the read, an attacker who can write this directory can
79
+ // swap the file, and the thing being swapped is a SIGNING KEY. Winning that
80
+ // race means we sign with a key the attacker controls, which defeats the
81
+ // entire point of the module. Read first and treat absence as the exceptional
82
+ // case — there is then no window between the check and the use, because there
83
+ // is no check.
84
+ try {
85
+ const privateKeyPem = fs.readFileSync(p.privateKey, 'utf8');
86
+ const publicKeyPem = fs.readFileSync(p.publicKey, 'utf8');
87
+ if (privateKeyPem && publicKeyPem) {
88
+ return { privateKeyPem, publicKeyPem, created: false, ...p };
89
+ }
90
+ } catch { /* missing or unreadable — fall through and generate */ }
91
+
92
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
93
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
94
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' });
95
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
96
+
97
+ // `wx` — exclusive create. If another process generated a key between our
98
+ // failed read and this write, we must NOT clobber it: every bundle already
99
+ // signed with that key would silently stop verifying. On collision, re-read
100
+ // and use the winner's key.
101
+ try {
102
+ fs.writeFileSync(p.privateKey, privateKeyPem, { mode: 0o600, flag: 'wx' });
103
+ fs.writeFileSync(p.publicKey, publicKeyPem, { mode: 0o644, flag: 'w' });
104
+ return { privateKeyPem, publicKeyPem, created: true, ...p };
105
+ } catch (e) {
106
+ if (e.code !== 'EEXIST') throw e;
107
+ // We lost the race on the private key — someone else's 'wx' won.
108
+ // Their public-key write follows immediately after their private-key
109
+ // write but is not itself atomic with it, so it may not have landed
110
+ // yet: a bare read here would throw an uncaught ENOENT on a genuinely
111
+ // transient state, not a real error. Retry briefly rather than crash.
112
+ const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
113
+ for (let attempt = 0; ; attempt++) {
114
+ try {
115
+ return {
116
+ privateKeyPem: fs.readFileSync(p.privateKey, 'utf8'),
117
+ publicKeyPem: fs.readFileSync(p.publicKey, 'utf8'),
118
+ created: false,
119
+ ...p,
120
+ };
121
+ } catch (readErr) {
122
+ if (readErr.code !== 'ENOENT' || attempt >= 50) throw readErr;
123
+ Atomics.wait(sleepBuf, 0, 0, 10); // 10ms; ~500ms total budget
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Canonical bytes for signing.
131
+ *
132
+ * An ALLOWLIST, exactly as attestation.js canonicalises: a field that is not
133
+ * named here is not signed, so a future addition cannot silently enter the
134
+ * signed surface. Sorted keys, so serialisation order cannot change the
135
+ * signature.
136
+ */
137
+ export function canonicalBytes(bundle) {
138
+ const signed = {
139
+ schema: bundle.schema,
140
+ finding: bundle.finding,
141
+ evidence: bundle.evidence,
142
+ engine: bundle.engine,
143
+ proves: bundle.proves,
144
+ doesNotProve: bundle.doesNotProve,
145
+ };
146
+ return Buffer.from(canonicalJson(signed), 'utf8');
147
+ }
148
+
149
+ /**
150
+ * Deterministic JSON: keys sorted at EVERY level, arrays order-preserving.
151
+ *
152
+ * Written by hand rather than using JSON.stringify's replacer argument, and the
153
+ * reason matters. The first version of this function did
154
+ * `JSON.stringify(signed, Object.keys(signed).sort())`, believing the array
155
+ * argument was a top-level field allowlist. It is not — a replacer ARRAY is a
156
+ * key filter applied at every nesting depth, so every nested object serialised
157
+ * as `{}` and the signature covered nothing but the two prose strings. A bundle
158
+ * whose severity was edited from `high` to `critical` verified as authentic.
159
+ *
160
+ * That is the worst possible failure for this module: a security feature that
161
+ * appears to work, produces a valid-looking signature, and protects nothing. It
162
+ * was caught by tampering with a bundle and expecting verification to fail —
163
+ * which is why the tamper cases in the test file are not optional extras.
164
+ */
165
+ export function canonicalJson(value) {
166
+ if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
167
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
168
+ const keys = Object.keys(value).sort();
169
+ return `{${keys.map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
170
+ }
171
+
172
+ const PROVES =
173
+ 'The contents of this bundle are exactly what the signer attested, unmodified.';
174
+ const DOES_NOT_PROVE =
175
+ 'That the finding is real. A signature is an integrity claim, never a correctness ' +
176
+ 'claim — read `evidence.proofTier` for how well-supported the finding actually is. ' +
177
+ 'An unproven finding in a signed bundle is still an unproven finding.';
178
+
179
+ /**
180
+ * Build an unsigned bundle from a finding.
181
+ *
182
+ * Everything here is copied from what the engine already computed. Nothing is
183
+ * inferred, and a missing field stays missing rather than being defaulted into
184
+ * something more confident-looking than the truth.
185
+ */
186
+ export function buildEvidenceBundle(finding, { engineVersion, rulesetVersion, bundleSha, commit } = {}) {
187
+ if (!finding || typeof finding !== 'object') return null;
188
+ return {
189
+ schema: BUNDLE_SCHEMA,
190
+ finding: {
191
+ id: finding.id ?? null,
192
+ stableId: finding.stableId ?? null,
193
+ severity: finding.severity ?? null,
194
+ file: finding.file ?? null,
195
+ line: finding.line ?? null,
196
+ vuln: finding.vuln ?? null,
197
+ cwe: finding.cwe ?? null,
198
+ family: finding.family ?? null,
199
+ parser: finding.parser ?? null,
200
+ },
201
+ evidence: {
202
+ // The honest chain, in the vocabulary the engine already uses.
203
+ proofTier: finding.proofTier ?? null,
204
+ proofEvidence: finding.proofEvidence ?? null,
205
+ confidence: finding.confidence ?? null,
206
+ exploitability: finding.exploitability ?? null,
207
+ unreachable: finding.unreachable ?? null,
208
+ // Present only for the layers that produce them; absent means absent.
209
+ taintPath: finding.pathSteps ?? finding.exampleFlows ?? null,
210
+ discovery: finding.discovery ?? null,
211
+ },
212
+ engine: {
213
+ engineVersion: engineVersion ?? null,
214
+ rulesetVersion: rulesetVersion ?? null,
215
+ bundleSha: bundleSha ?? null,
216
+ commit: commit ?? null,
217
+ },
218
+ proves: PROVES,
219
+ doesNotProve: DOES_NOT_PROVE,
220
+ };
221
+ }
222
+
223
+ /** Sign a bundle. Returns a new object; the input is not mutated. */
224
+ export function signEvidenceBundle(bundle, privateKeyPem) {
225
+ const sig = crypto.sign(null, canonicalBytes(bundle), privateKeyPem);
226
+ return {
227
+ ...bundle,
228
+ signature: {
229
+ algorithm: 'ed25519',
230
+ canonicalisation: BUNDLE_SCHEMA,
231
+ value: sig.toString('base64'),
232
+ },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Verify a bundle with a PUBLIC key. This is the whole point: the verifier
238
+ * never needs, and never had, the signing key.
239
+ *
240
+ * Returns {ok, reason}. Never throws — a malformed bundle from an untrusted
241
+ * source is an expected input, not an exceptional one.
242
+ */
243
+ // The complete set of top-level keys a legitimately-built, signed bundle can
244
+ // carry — buildEvidenceBundle's six plus signEvidenceBundle's `signature`.
245
+ // EA-03 (Stage-0 audit, 2026): canonicalBytes SIGNS an allowlist of fields;
246
+ // verifyEvidenceBundle never checked for keys OUTSIDE that allowlist, so a
247
+ // bundle with a fabricated `verdict`/`proofLevel`/anything-else stapled on
248
+ // after signing verified as authentic — the signature simply never covered
249
+ // those bytes. Rejecting unknown keys here closes that; it must exactly match
250
+ // what buildEvidenceBundle+signEvidenceBundle actually produce, or a
251
+ // legitimate bundle would start failing verification.
252
+ const BUNDLE_TOP_LEVEL_KEYS = new Set([
253
+ 'schema', 'finding', 'evidence', 'engine', 'proves', 'doesNotProve', 'signature',
254
+ ]);
255
+
256
+ export function verifyEvidenceBundle(bundle, publicKeyPem) {
257
+ if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
258
+ if (bundle.schema !== BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
259
+ const unknownKeys = Object.keys(bundle).filter(k => !BUNDLE_TOP_LEVEL_KEYS.has(k));
260
+ if (unknownKeys.length) {
261
+ return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
262
+ }
263
+ const sig = bundle.signature;
264
+ if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
265
+ if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
266
+ if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
267
+ let ok = false;
268
+ try {
269
+ ok = crypto.verify(null, canonicalBytes(bundle), publicKeyPem, Buffer.from(sig.value, 'base64'));
270
+ } catch (e) {
271
+ return { ok: false, reason: `verification error: ${e.message}` };
272
+ }
273
+ return ok
274
+ ? { ok: true, reason: null }
275
+ : { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
276
+ }
@@ -35,6 +35,7 @@ import * as fs from 'node:fs';
35
35
  import * as path from 'node:path';
36
36
  import { wilsonInterval } from './calibration.js';
37
37
 
38
+ import { statePath } from './state-dir.js';
38
39
  // CISA KEV-derived base rate per CWE family. These are rough mid-2025
39
40
  // observations of "actually exploited in the wild" rates among findings
40
41
  // of the family, not academic numbers. Refresh annually.
@@ -138,7 +139,7 @@ function _clamp01(x) { return Math.max(0.001, Math.min(0.999, x)); }
138
139
  // (cwe × language × framework) grain.
139
140
  function _loadHistory(scanRoot) {
140
141
  if (!scanRoot) return [];
141
- const fp = path.join(scanRoot, '.agentic-security', 'exploit-history.jsonl');
142
+ const fp = statePath(scanRoot, 'exploit-history.jsonl');
142
143
  if (!fs.existsSync(fp)) return [];
143
144
  const out = [];
144
145
  try {
@@ -188,9 +189,21 @@ export function annotateExploitProbability(findings, ctx = {}) {
188
189
  p = _clamp01(p);
189
190
  // Wilson CI: prefer historical CI when we have one, otherwise derive
190
191
  // a wider CI from the prior (n=10 implied sample at base rate).
192
+ //
193
+ // The point estimate (exploitProbability) is ALWAYS the per-finding,
194
+ // factor-adjusted `p` computed above — never the population-level
195
+ // historical hit rate. This module's own header scopes historical data
196
+ // explicitly to the CI ("Wilson CI computed from operator's historical
197
+ // hit rate... when enough samples exist"); overwriting the point
198
+ // estimate too collapsed every finding in a CWE×language slice with
199
+ // enough history to the SAME number regardless of its own reachability/
200
+ // sanitizer/auth signals — a maximally-dangerous finding and a
201
+ // maximally-safe one scored identically, while exploitProbabilityWhy
202
+ // kept listing factors that, in that branch, had zero effect on the
203
+ // reported number.
191
204
  const hist = _historicalCi(history, cwe, f.language || (f.file || '').split('.').pop());
192
205
  if (hist) {
193
- f.exploitProbability = hist.p;
206
+ f.exploitProbability = p;
194
207
  f.exploitProbabilityCI95 = hist.ci;
195
208
  f.exploitProbabilitySlice = hist.slice;
196
209
  f.exploitProbabilityN = hist.n;
@@ -22,7 +22,19 @@ import {
22
22
  } from './verification-separation.js';
23
23
 
24
24
  const DEMOTE_FACTOR = 0.4; // mirror proof-gate.js
25
- const TIERS = ['low', 'medium', 'high']; // confidence / exploitability tier order
25
+ // Stage 3 correctness audit (detection depth): this ladder was missing
26
+ // 'critical' — exploitability.js sets f.exploitabilityTier = 'critical' at
27
+ // score >= 0.80 (the tier falsification most needs to demote, since it's
28
+ // exactly the findings a false "survives" verdict would most overstate).
29
+ // _dropTier('critical') hit the `i <= 0` "unknown tier, leave unchanged"
30
+ // branch (indexOf returns -1 for an unrecognized value), so a falsified
31
+ // finding at the critical exploitability tier kept its full tier — the
32
+ // demotion silently no-op'd for the highest tier in the system. 'very-low'
33
+ // (confidence.js's own floor tier) is included too, for the same reason
34
+ // confidenceTier is demoted by this same function — it was already
35
+ // unchanged-at-floor by the same `i <= 0` fallback, so this is a
36
+ // completeness fix there, not a behavior change.
37
+ const TIERS = ['very-low', 'low', 'medium', 'high', 'critical']; // confidence / exploitability tier order
26
38
 
27
39
  function _dropTier(tier) {
28
40
  const i = TIERS.indexOf(tier);
@@ -66,7 +78,16 @@ export function classifyFinding(finding, fileContents) {
66
78
  }
67
79
  // A sanitizer that doesn't match the sink context does NOT block the flow —
68
80
  // the finding survives (this is a real bug, not a mitigation).
69
- if (finding.sanitizerMismatch === true) {
81
+ //
82
+ // Stage 3 correctness audit (detection depth): this was `=== true`, but
83
+ // the field's real producer (engine.js's applySanitizerEffectiveness)
84
+ // sets `f.sanitizerMismatch = f.sanitizerType` — a STRING sanitizer-type
85
+ // label ("Type Guard", "JWT Algo Pinning", ...), never the literal
86
+ // boolean `true`. Every OTHER consumer of this field (confidence.js,
87
+ // exploitability.js, engine.js's own scoring) checks it via plain
88
+ // truthiness; this strict-equality check could never match a real
89
+ // finding, making the whole branch dead code.
90
+ if (finding.sanitizerMismatch) {
70
91
  return { verdict: 'survived', reasons: ['wrong-context sanitizer does not neutralize this sink'] };
71
92
  }
72
93
  const window = _pathWindow(finding, fileContents);
@@ -21,6 +21,7 @@
21
21
  import * as fs from 'node:fs';
22
22
  import * as path from 'node:path';
23
23
 
24
+ import { statePath } from './state-dir.js';
24
25
  const FLAG_PATTERNS = [
25
26
  // LaunchDarkly
26
27
  [/\bldClient\.variation\s*\(\s*['"`]([^'"`]+)['"`]/g, 'launchdarkly'],
@@ -44,8 +45,8 @@ const FLAG_PATTERNS = [
44
45
 
45
46
  function loadRollouts(scanRoot) {
46
47
  const candidates = [
47
- '.agentic-security/feature-flag-rollouts.json',
48
- '.agentic-security/feature-flags.json',
48
+ 'feature-flag-rollouts.json',
49
+ 'feature-flags.json',
49
50
  ];
50
51
  for (const rel of candidates) {
51
52
  const fp = path.join(scanRoot || process.cwd(), rel);
@@ -15,10 +15,10 @@
15
15
  import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
 
18
- const STATE = '.agentic-security';
19
18
 
19
+ import { statePath } from './state-dir.js';
20
20
  function _read(scanRoot, name) {
21
- try { return fs.readFileSync(path.join(scanRoot, STATE, name), 'utf8'); } catch { return null; }
21
+ try { return fs.readFileSync(statePath(scanRoot, name), 'utf8'); } catch { return null; }
22
22
  }
23
23
 
24
24
  function _readJson(scanRoot, name) {
@@ -99,7 +99,7 @@ export function queryFindingsMemory(scanRoot, query) {
99
99
 
100
100
  // 3. Scan history.
101
101
  try {
102
- const histDir = path.join(scanRoot, STATE, 'scan-history');
102
+ const histDir = statePath(scanRoot, 'scan-history');
103
103
  if (fs.existsSync(histDir)) {
104
104
  const files = fs.readdirSync(histDir).filter(f => f.endsWith('.json')).slice(-10);
105
105
  for (const f of files) {
@@ -12,7 +12,7 @@ import * as fs from 'node:fs';
12
12
  import * as fsp from 'node:fs/promises';
13
13
  import * as path from 'node:path';
14
14
  import * as crypto from 'node:crypto';
15
- import { isSafeStateDir, statePath } from './state-dir.js';
15
+ import { isSafeStateDir, statePath, stateWritesEnabled } from './state-dir.js';
16
16
 
17
17
  function historyDir(scanRoot) {
18
18
  return statePath(scanRoot, 'fix-history');
@@ -20,6 +20,9 @@ function historyDir(scanRoot) {
20
20
  function logPath(scanRoot) { return path.join(historyDir(scanRoot), 'log.json'); }
21
21
 
22
22
  function ensure(scanRoot) {
23
+ // Read-only scan: callers already treat `false` as "history unavailable",
24
+ // so the switch needs no new branch anywhere else.
25
+ if (!stateWritesEnabled()) return false;
23
26
  const dir = historyDir(scanRoot);
24
27
  if (!isSafeStateDir(path.dirname(dir))) return false;
25
28
  fs.mkdirSync(dir, { recursive: true });
@@ -65,7 +68,7 @@ function sha(s) { return crypto.createHash('sha256').update(s).digest('hex').sli
65
68
  // findings by `id` (the finding's canonical key from the engine) so we can
66
69
  // stash the corresponding stableId on the fix entry and verify in recover().
67
70
  function _lastScanPath(scanRoot) {
68
- return path.join(scanRoot, '.agentic-security', 'last-scan.json');
71
+ return statePath(scanRoot, 'last-scan.json');
69
72
  }
70
73
  function _readLastScan(scanRoot) {
71
74
  const fp = _lastScanPath(scanRoot);
@@ -34,9 +34,8 @@
34
34
 
35
35
  import fs from 'node:fs';
36
36
  import path from 'node:path';
37
- import { isSafeStateDir } from './state-dir.js';
37
+ import { isSafeStateDir, stateDir, statePath, stateWritesEnabled } from './state-dir.js';
38
38
 
39
- const STATE_DIR = '.agentic-security';
40
39
  const LOG_FILE = 'fix-metrics.jsonl';
41
40
 
42
41
  // Below this many samples a percentile is an artifact of the sample, not a
@@ -49,7 +48,7 @@ const RELIABLE_N = 10;
49
48
  export const FIX_STAGES = Object.freeze(['rescan', 'lint', 'tests', 'honesty', 'poc']);
50
49
 
51
50
  function _logPath(scanRoot) {
52
- return path.join(scanRoot, STATE_DIR, LOG_FILE);
51
+ return statePath(scanRoot, LOG_FILE);
53
52
  }
54
53
 
55
54
  /**
@@ -61,9 +60,10 @@ function _logPath(scanRoot) {
61
60
  export function recordFixAttempt(scanRoot, record) {
62
61
  if (!scanRoot || !record || typeof record !== 'object') return false;
63
62
  try {
64
- const dir = path.join(scanRoot, STATE_DIR);
63
+ const dir = stateDir(scanRoot);
65
64
  if (!isSafeStateDir(dir)) return false;
66
- fs.mkdirSync(dir, { recursive: true });
65
+ if (!stateWritesEnabled()) return false;
66
+ fs.mkdirSync(dir, { recursive: true });
67
67
  // One writeSync of one newline-terminated line: a concurrent reader sees
68
68
  // whole records or nothing, and a torn tail is dropped on read.
69
69
  fs.appendFileSync(_logPath(scanRoot), JSON.stringify(record) + '\n', 'utf8');
@@ -12,6 +12,7 @@
12
12
  import * as fs from 'node:fs';
13
13
  import * as path from 'node:path';
14
14
 
15
+ import { statePath } from './state-dir.js';
15
16
  const MAX_FILES = 3;
16
17
  const MAX_LOC = 100;
17
18
 
@@ -103,7 +104,7 @@ export function renderFixPlan(finding, opts = {}) {
103
104
  // Returns the absolute path of the written file, or null on error.
104
105
  export function emitFixPlanFile(scanRoot, finding, opts = {}) {
105
106
  if (!scanRoot || !finding) return null;
106
- const dir = path.join(scanRoot, '.agentic-security', 'fix-plans');
107
+ const dir = statePath(scanRoot, 'fix-plans');
107
108
  try { fs.mkdirSync(dir, { recursive: true }); } catch { return null; }
108
109
  const id = finding.stableId || finding.id || `unknown-${Date.now().toString(36)}`;
109
110
  const fp = path.join(dir, `${id}.md`);
@@ -38,7 +38,16 @@ function _detectRunner(scanRoot) {
38
38
  } catch { return null; }
39
39
  })();
40
40
  if (pkg && pkg.scripts && pkg.scripts.test && !/no test specified/.test(String(pkg.scripts.test))) {
41
- return { runner: 'npm', cmd: 'npm', args: ['test', '--silent', '--', '--passWithNoTests'] };
41
+ // --passWithNoTests is Jest-specific CLI syntax appending it
42
+ // unconditionally broke every non-Jest npm test script (mocha, vitest,
43
+ // ava, tap, or a plain node script) with an "unrecognized option" exit,
44
+ // failing verification for a reason that has nothing to do with
45
+ // whether the patch actually broke anything. Only add it when Jest is
46
+ // actually the configured runner.
47
+ const usesJest = /\bjest\b/.test(String(pkg.scripts.test))
48
+ || Boolean(pkg.devDependencies?.jest) || Boolean(pkg.dependencies?.jest);
49
+ const args = usesJest ? ['test', '--silent', '--', '--passWithNoTests'] : ['test', '--silent'];
50
+ return { runner: 'npm', cmd: 'npm', args };
42
51
  }
43
52
  if (has('pytest.ini') || has('pyproject.toml') || has('setup.cfg')) {
44
53
  return { runner: 'pytest', cmd: 'pytest', args: ['-q', '--no-header', '-x'] };
@@ -26,18 +26,17 @@
26
26
  import * as fs from 'node:fs';
27
27
  import * as path from 'node:path';
28
28
 
29
- const TRIAGE_FILE = '.agentic-security/triage-feedback.json';
30
- const SCAN_FILE = '.agentic-security/last-scan.json';
31
29
 
30
+ import { statePath } from './state-dir.js';
32
31
  function _loadTriageFeedback(scanRoot) {
33
- const fp = path.join(scanRoot, TRIAGE_FILE);
32
+ const fp = statePath(scanRoot, 'triage-feedback.json');
34
33
  if (!fs.existsSync(fp)) return [];
35
34
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')).entries || []; }
36
35
  catch { return []; }
37
36
  }
38
37
 
39
38
  function _loadScanVerdicts(scanRoot) {
40
- const fp = path.join(scanRoot, SCAN_FILE);
39
+ const fp = statePath(scanRoot, 'last-scan.json');
41
40
  if (!fs.existsSync(fp)) return [];
42
41
  try {
43
42
  const scan = JSON.parse(fs.readFileSync(fp, 'utf8'));
@@ -1,22 +1,28 @@
1
1
  // IaC → application code reachability bridge (Sentinel-parity FR-DET-4).
2
2
  //
3
- // Detects publicly-exposed cloud resources in IaC (Terraform / CloudFormation
4
- // / Kubernetes) and correlates them with application-code references to the
5
- // same resource (by name, ARN, or hostname). Application-code findings on
6
- // resources that IaC has exposed get a severity bump and an explicit
7
- // "exposed-via-iac" tag.
3
+ // Detects publicly-exposed cloud resources in IaC and correlates them with
4
+ // application-code references to the same resource (by name, ARN, or
5
+ // hostname). Application-code findings on resources that IaC has exposed get
6
+ // a severity bump and an explicit "exposed-via-iac" tag.
8
7
  //
9
- // Patterns detected:
8
+ // **Terraform only.** Only `parseTerraform` exists in this file — this header
9
+ // previously also listed CloudFormation and five Kubernetes patterns as
10
+ // detected; neither has any parser function anywhere here (found via
11
+ // Stage-0 doc audit, 2026; confirmed by grep for `function parse*` in this
12
+ // file, which returns exactly one hit). The pattern list below is therefore
13
+ // the Terraform-only reality, not the originally-documented superset:
10
14
  //
11
15
  // S3 bucket with public-read ACL / public-access-block disabled
12
16
  // RDS / DocumentDB / Redshift with publicly_accessible = true
13
17
  // Security group with 0.0.0.0/0 ingress on a sensitive port
14
18
  // ALB / NLB / API Gateway with internet-facing scheme
15
- // K8s Service of type LoadBalancer with no NetworkPolicy
16
- // K8s Ingress with no auth annotation
17
19
  // Lambda function URL with auth_type = NONE
18
20
  // ECS task with assignPublicIp = ENABLED
19
21
  //
22
+ // NOT IMPLEMENTED despite being previously documented here: CloudFormation
23
+ // support, K8s Service/LoadBalancer + NetworkPolicy correlation, K8s Ingress
24
+ // auth-annotation checking.
25
+ //
20
26
  // Output: { exposedResources: [{name, kind, file, line, severity}], findings: [...new findings] }
21
27
 
22
28
  const SENSITIVE_PORTS = new Set([22, 23, 25, 110, 143, 3306, 3389, 5432, 6379, 27017, 9200, 9300, 1521, 5984, 11211]);
@@ -52,19 +52,37 @@ function _readOrGenerateKey() {
52
52
  if (/^[0-9a-fA-F]{32,}$/.test(hex)) { _keySource = 'per-install'; return Buffer.from(hex, 'hex'); }
53
53
  }
54
54
  } catch { /* fall through to generate */ }
55
- // Generate, mode 0600.
55
+ // Generate, mode 0600. `wx` — exclusive create, same TOCTOU fix
56
+ // evidence-bundle.js's ensureKeyPair() already applies to its own key
57
+ // material: on first use, two concurrent processes can both pass the
58
+ // existsSync check above as false and both reach here. Without exclusive
59
+ // create, the last writer's key silently wins on disk while every OTHER
60
+ // process keeps signing with the key it generated and lost — a key that
61
+ // now exists nowhere, so every signature made under it fails to verify
62
+ // forever after, indistinguishable from real tampering.
56
63
  const buf = crypto.randomBytes(32);
57
64
  try {
58
65
  fs.mkdirSync(_keyDir(), { recursive: true, mode: 0o700 });
59
- fs.writeFileSync(fp, buf.toString('hex') + '\n', { mode: 0o600 });
66
+ fs.writeFileSync(fp, buf.toString('hex') + '\n', { mode: 0o600, flag: 'wx' });
60
67
  _keySource = 'per-install-new';
61
- } catch {
62
- // Could not persist — this key lives for this process only, so nothing
63
- // signed with it will verify on any later run. Callers must be able to see
64
- // that, or a permanently-unverifiable signature looks like a valid one.
68
+ return buf;
69
+ } catch (e) {
70
+ if (e.code === 'EEXIST') {
71
+ // Another process won the race and persisted its key first — use
72
+ // THAT key instead of the one we generated, or we'd return a key
73
+ // that matches nothing on disk.
74
+ try {
75
+ const hex = fs.readFileSync(fp, 'utf8').trim();
76
+ if (/^[0-9a-fA-F]{32,}$/.test(hex)) { _keySource = 'per-install'; return Buffer.from(hex, 'hex'); }
77
+ } catch { /* fall through to ephemeral */ }
78
+ }
79
+ // Could not persist (or the winner's key was unreadable/malformed) —
80
+ // this key lives for this process only, so nothing signed with it will
81
+ // verify on any later run. Callers must be able to see that, or a
82
+ // permanently-unverifiable signature looks like a valid one.
65
83
  _keySource = 'ephemeral';
84
+ return buf;
66
85
  }
67
- return buf;
68
86
  }
69
87
 
70
88
  // REMOVED (2026-08-08): the legacy hostname-derived key.
@@ -29,6 +29,7 @@
29
29
  import * as fs from 'node:fs';
30
30
  import * as path from 'node:path';
31
31
 
32
+ import { statePath } from './state-dir.js';
32
33
  const INTENT_PATH_RE = /(?:^|\/)(?:examples?|demos?|tutorials?|sandbox|playground|challenges?|ctf)(?:\/|$)/i;
33
34
 
34
35
  const FILE_HEADER_MARKERS = [
@@ -49,7 +50,7 @@ function _readSafely(fp) {
49
50
  }
50
51
 
51
52
  function _readIntentDeclaration(scanRoot) {
52
- const fp = path.join(scanRoot, '.agentic-security', 'current-intent.md');
53
+ const fp = statePath(scanRoot, 'current-intent.md');
53
54
  if (!fs.existsSync(fp)) return null;
54
55
  const body = _readSafely(fp);
55
56
  if (!body) return null;
@@ -22,11 +22,11 @@
22
22
  import * as fs from 'node:fs';
23
23
  import * as path from 'node:path';
24
24
 
25
- const FILE = '.agentic-security/triage-feedback.json';
25
+ import { statePath, stateWritesEnabled } from './state-dir.js';
26
26
 
27
27
  export function loadFeedback(scanRoot) {
28
28
  if (!scanRoot) return { entries: [] };
29
- const fp = path.join(scanRoot, FILE);
29
+ const fp = statePath(scanRoot, 'triage-feedback.json');
30
30
  if (!fs.existsSync(fp)) return { entries: [] };
31
31
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')) || { entries: [] }; }
32
32
  catch { return { entries: [] }; }
@@ -34,7 +34,8 @@ export function loadFeedback(scanRoot) {
34
34
 
35
35
  export function saveFeedback(scanRoot, data) {
36
36
  if (!scanRoot) return;
37
- const fp = path.join(scanRoot, FILE);
37
+ const fp = statePath(scanRoot, 'triage-feedback.json');
38
+ if (!stateWritesEnabled()) return;
38
39
  fs.mkdirSync(path.dirname(fp), { recursive: true });
39
40
  fs.writeFileSync(fp, JSON.stringify(data, null, 2));
40
41
  }