@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
@@ -11,8 +11,7 @@
11
11
  // The emitter is deterministic — sorts by ecosystem, name, version —
12
12
  // so commits don't churn between scans.
13
13
 
14
- import * as fs from 'node:fs';
15
- import * as path from 'node:path';
14
+ import { statePath, safeWriteState } from './state-dir.js';
16
15
 
17
16
  function _sortKey(c) {
18
17
  return `${c.ecosystem || 'zz'}:${c.name || ''}:${c.version || ''}`;
@@ -83,11 +82,10 @@ export function generateAttributions(components, options) {
83
82
 
84
83
  export function persistAttributions(scanRoot, result) {
85
84
  if (!result || !result.markdown) return null;
86
- try { fs.mkdirSync(path.join(scanRoot, '.agentic-security'), { recursive: true }); } catch {}
87
- try { fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'ATTRIBUTIONS.md'), result.markdown); } catch {}
88
- if (result.notice) {
89
- try { fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'NOTICE'), result.notice); } catch {}
90
- }
85
+ // Through the seam see the note in pqc-migration-plan.js. The result is
86
+ // still returned when writing is off; only the artifact is withheld.
87
+ safeWriteState(statePath(scanRoot, 'ATTRIBUTIONS.md'), result.markdown);
88
+ if (result.notice) safeWriteState(statePath(scanRoot, 'NOTICE'), result.notice);
91
89
  return result;
92
90
  }
93
91
 
@@ -37,6 +37,7 @@
37
37
  import * as fs from 'node:fs';
38
38
  import * as path from 'node:path';
39
39
 
40
+ import { statePath } from './state-dir.js';
40
41
  // ── License taxonomy ───────────────────────────────────────────────────────
41
42
 
42
43
  const LICENSE_FAMILIES = {
@@ -223,7 +224,7 @@ export function analyzeLicenseGraph(components, options) {
223
224
 
224
225
  export function loadLicenseGraphPolicy(scanRoot) {
225
226
  if (!scanRoot) return { distributionMode: DEFAULT_DIST_MODE };
226
- const fp = path.join(scanRoot, '.agentic-security', 'license-policy.yml');
227
+ const fp = statePath(scanRoot, 'license-policy.yml');
227
228
  if (!fs.existsSync(fp)) return { distributionMode: DEFAULT_DIST_MODE };
228
229
  try {
229
230
  const raw = fs.readFileSync(fp, 'utf8');
@@ -16,6 +16,7 @@ import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
  import * as yaml from '../util/yaml.js';
18
18
 
19
+ import { statePath } from './state-dir.js';
19
20
  const DEFAULT_POLICY = {
20
21
  allow: [],
21
22
  deny: [],
@@ -26,7 +27,7 @@ const DEFAULT_POLICY = {
26
27
  export function loadLicensePolicy(scanRoot) {
27
28
  if (!scanRoot) return null;
28
29
  for (const name of ['license-policy.yml', 'license-policy.yaml', 'license-policy.json']) {
29
- const p = path.join(scanRoot, '.agentic-security', name);
30
+ const p = statePath(scanRoot, name);
30
31
  if (!fs.existsSync(p)) continue;
31
32
  try {
32
33
  const raw = fs.readFileSync(p, 'utf8');
@@ -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';
18
+ import { statePath, stateWritesEnabled } from './state-dir.js';
19
19
 
20
20
  function _readJson(scanRoot, name) {
21
- try { return JSON.parse(fs.readFileSync(path.join(scanRoot, STATE, name), 'utf8')); } catch { return null; }
21
+ try { return JSON.parse(fs.readFileSync(statePath(scanRoot, name), 'utf8')); } catch { return null; }
22
22
  }
23
23
 
24
24
  /**
@@ -50,7 +50,8 @@ export function diffValidatorRuns(runA, runB) {
50
50
  * Persist a model-rescan report. Returns the file path.
51
51
  */
52
52
  export function persistRescanReport(scanRoot, from, to, changed) {
53
- const dir = path.join(scanRoot, STATE, 'model-rescan');
53
+ const dir = statePath(scanRoot, 'model-rescan');
54
+ if (!stateWritesEnabled()) return null;
54
55
  try { fs.mkdirSync(dir, { recursive: true }); } catch {}
55
56
  const safe = (s) => String(s || 'unknown').replace(/[^\w.-]/g, '-');
56
57
  const fp = path.join(dir, `${safe(from)}-vs-${safe(to)}.json`);
@@ -73,4 +74,69 @@ export function summarizeDelta(changed) {
73
74
  return lines.join('\n');
74
75
  }
75
76
 
77
+ // Stage 6 correctness audit: diffValidatorRuns/persistRescanReport/
78
+ // summarizeDelta above were fully built, but nothing in the codebase ever
79
+ // produced a `{model, results: {findingId: {verdict, reason}}}` run file
80
+ // for them to consume — commands/labs.md's `--model-rescan` mode was
81
+ // disclosed as genuinely unwired rather than fabricated. This is the
82
+ // missing producer: runs the SAME findings through the LLM validator twice
83
+ // — once under whatever model the environment currently resolves to
84
+ // ("from"), once under `toModel` ("to", via the existing per-role env
85
+ // override `AGENTIC_SECURITY_LLM_MODEL_VALIDATE` — no new plumbing needed,
86
+ // llm-validator/providers.js already supports it) — and turns the two runs
87
+ // into a real delta report. Reuses validateMany's own candidate filter
88
+ // (critical/high severity, low confidence, or AST parser) rather than
89
+ // re-validating every finding, matching normal validation scope. When no
90
+ // LLM endpoint is configured, validateMany degrades every finding to
91
+ // 'unvalidated' with no network call — this function inherits that
92
+ // no-network-by-default behavior rather than working around it.
93
+ export async function runModelRescan(scanRoot, { toModel } = {}) {
94
+ if (!toModel) return { ok: false, reason: 'no --model given to rescan with' };
95
+ const scan = _readJson(scanRoot, 'last-scan.json');
96
+ if (!scan) return { ok: false, reason: 'no .agentic-security/last-scan.json — run a scan first' };
97
+ const findings = Array.isArray(scan.findings) ? scan.findings : [];
98
+ if (!findings.length) return { ok: false, reason: 'last scan has no findings to re-validate' };
99
+
100
+ const fileContents = {};
101
+ for (const f of findings) {
102
+ if (!f.file || fileContents[f.file] !== undefined) continue;
103
+ try { fileContents[f.file] = fs.readFileSync(path.join(scanRoot, f.file), 'utf8'); }
104
+ catch { /* file may have moved/been deleted since the scan; validateMany skips it */ }
105
+ }
106
+
107
+ const { validateMany } = await import('../llm-validator/index.js');
108
+ const { resolveProvider } = await import('../llm-validator/providers.js');
109
+
110
+ const runFor = async (envKey, modelOverride) => {
111
+ const prev = envKey ? process.env[envKey] : undefined;
112
+ if (envKey) process.env[envKey] = modelOverride;
113
+ let resolvedModel = 'unvalidated';
114
+ try {
115
+ const r = resolveProvider({ role: 'validate' });
116
+ if (r.ok) resolvedModel = r.config.model;
117
+ const clones = findings.map(f => ({ ...f }));
118
+ await validateMany(clones, { fileContents, scanRoot });
119
+ const results = {};
120
+ for (const f of clones) {
121
+ const id = f.stableId || f.id;
122
+ if (!id) continue;
123
+ results[id] = { verdict: f.validator_verdict || 'unvalidated', reason: f.validator_reasoning || null };
124
+ }
125
+ return { model: resolvedModel, results };
126
+ } finally {
127
+ if (envKey) {
128
+ if (prev === undefined) delete process.env[envKey];
129
+ else process.env[envKey] = prev;
130
+ }
131
+ }
132
+ };
133
+
134
+ const runA = await runFor(null, null);
135
+ const runB = await runFor('AGENTIC_SECURITY_LLM_MODEL_VALIDATE', toModel);
136
+
137
+ const changed = diffValidatorRuns(runA, runB);
138
+ const reportPath = persistRescanReport(scanRoot, runA.model, runB.model, changed);
139
+ return { ok: true, from: runA.model, to: runB.model, changed, reportPath, summary: summarizeDelta(changed) };
140
+ }
141
+
76
142
  export const _internals = {};
@@ -10,6 +10,11 @@
10
10
  import * as crypto from 'node:crypto';
11
11
 
12
12
  // Stable fingerprint for cross-scan finding identity. Mirrors the dedupe key.
13
+ // Exported so a caller can compute the "removed since baseline" (i.e. fixed)
14
+ // set that computeMTTR needs, using the exact same identity function
15
+ // buildBaselineMap uses internally — a caller-side reimplementation would
16
+ // risk drifting from this one and silently under/over-counting fixes.
17
+ export function fingerprintFinding(f) { return _fingerprint(f); }
13
18
  function _fingerprint(f) {
14
19
  const file = (f.file || '').split(' -> ').pop();
15
20
  const line = f.line || f.source?.line || f.sink?.line || 0;
@@ -27,7 +27,8 @@
27
27
  import * as fs from 'node:fs';
28
28
  import * as path from 'node:path';
29
29
 
30
- const CANDIDATE_DIGEST = '.agentic-security/network-policy.json';
30
+
31
+ import { statePath } from './state-dir.js';
31
32
  const K8S_DIRS = ['k8s', 'infra/k8s', 'deploy/k8s', 'kubernetes', 'manifests'];
32
33
 
33
34
  const INTERNAL_CIDRS = [
@@ -81,7 +82,7 @@ function parseKubeManifests(scanRoot) {
81
82
 
82
83
  export function loadNetworkPosture(scanRoot) {
83
84
  const root = scanRoot || process.cwd();
84
- const digestPath = path.join(root, CANDIDATE_DIGEST);
85
+ const digestPath = statePath(root, 'network-policy.json');
85
86
  if (fs.existsSync(digestPath)) {
86
87
  try {
87
88
  const data = JSON.parse(fs.readFileSync(digestPath, 'utf8'));
@@ -119,14 +119,33 @@ function _binding(finding, call) {
119
119
  // The request property the handler reads. Anchored to the request identifier
120
120
  // the export actually binds, so a file that reads `req.query` while exporting
121
121
  // `(request, response)` does not produce a PoC built on the wrong name.
122
- function _requestSource(content, reqIdent) {
122
+ //
123
+ // A handler that reads more than one request property (extremely common —
124
+ // e.g. a harmless query param for pagination alongside the actual body
125
+ // param used in the sink) used to always get the first property found in a
126
+ // fixed query>body>params priority, with no relationship to which one
127
+ // actually reaches the sink — silently building a PoC against an inert
128
+ // parameter while the real injection point went untouched. When sinkLine
129
+ // is known, prefer whichever match sits closest to it (line proximity is a
130
+ // cheap, effective proxy for "this is the value that flows into the sink a
131
+ // few lines below/above it"); otherwise fall back to the old first-found
132
+ // behavior for callers that can't supply a line.
133
+ function _requestSource(content, reqIdent, sinkLine) {
123
134
  const esc = reqIdent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
135
+ const candidates = [];
124
136
  for (const prop of ['query', 'body', 'params']) {
125
- const re = new RegExp(`\\b${esc}\\.${prop}\\.(\\w+)`);
126
- const m = content.match(re);
127
- if (m) return { prop, key: m[1] };
137
+ const re = new RegExp(`\\b${esc}\\.${prop}\\.(\\w+)`, 'g');
138
+ let m;
139
+ while ((m = re.exec(content))) {
140
+ const line = content.slice(0, m.index).split('\n').length;
141
+ candidates.push({ prop, key: m[1], line });
142
+ }
128
143
  }
129
- return null;
144
+ if (!candidates.length) return null;
145
+ if (typeof sinkLine === 'number') {
146
+ candidates.sort((a, b) => Math.abs(a.line - sinkLine) - Math.abs(b.line - sinkLine));
147
+ }
148
+ return { prop: candidates[0].prop, key: candidates[0].key };
130
149
  }
131
150
 
132
151
  // The sink must interpolate into a SHELL, not an argv array. `exec`/`execSync`
@@ -184,7 +203,7 @@ export function synthesizeInProcessPoc(finding, fileContent) {
184
203
  return { ok: false, reason: 'no exported two-argument (req, res) handler found — nothing to call without inventing an interface' };
185
204
  }
186
205
 
187
- const src = _requestSource(fileContent, reqIdent);
206
+ const src = _requestSource(fileContent, reqIdent, finding.line);
188
207
  if (!src) {
189
208
  return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the injection point is unknown` };
190
209
  }
@@ -392,7 +411,7 @@ function _sqlInjectionPoc(finding, fileContent) {
392
411
  if (!found) return NO_HANDLER;
393
412
  const { call, reqIdent } = found;
394
413
 
395
- const src = _requestSource(fileContent, reqIdent);
414
+ const src = _requestSource(fileContent, reqIdent, finding.line);
396
415
  if (!src) {
397
416
  return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the injection point is unknown` };
398
417
  }
@@ -490,7 +509,7 @@ function _pathTraversalPoc(finding, fileContent) {
490
509
  if (!READ_SINK.test(fileContent)) {
491
510
  return { ok: false, reason: 'no readFile/sendFile sink in the file, so there is no served content to observe coming back' };
492
511
  }
493
- const src = _requestSource(fileContent, reqIdent);
512
+ const src = _requestSource(fileContent, reqIdent, finding.line);
494
513
  if (!src) {
495
514
  return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the traversal point is unknown` };
496
515
  }
@@ -18,8 +18,7 @@
18
18
  // suggestions; JSON-LD-shaped structured output is consumable by Vanta /
19
19
  // Drata / SecureFrame or any custom rollup dashboard.
20
20
 
21
- import * as fs from 'node:fs';
22
- import * as path from 'node:path';
21
+ import { statePath, safeWriteState } from './state-dir.js';
23
22
 
24
23
  function _byHndl(findings) {
25
24
  return {
@@ -117,9 +116,12 @@ export function buildMigrationPlan(allFindings) {
117
116
 
118
117
  export function persistMigrationPlan(scanRoot, plan) {
119
118
  if (!plan) return null;
120
- try { fs.mkdirSync(path.join(scanRoot, '.agentic-security'), { recursive: true }); } catch {}
121
- try { fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'pqc-migration-plan.json'), JSON.stringify(plan, null, 2)); } catch {}
122
- try { fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'pqc-migration-plan.md'), _markdown(plan)); } catch {}
119
+ // Through the seam: creates the directory, enforces the project-root check,
120
+ // and honours the read-only scan switch. The plan is still RETURNED when
121
+ // writing is off a scan must report the same result whether or not it is
122
+ // allowed to leave files behind.
123
+ safeWriteState(statePath(scanRoot, 'pqc-migration-plan.json'), JSON.stringify(plan, null, 2));
124
+ safeWriteState(statePath(scanRoot, 'pqc-migration-plan.md'), _markdown(plan));
123
125
  return plan;
124
126
  }
125
127
 
@@ -18,6 +18,7 @@ import * as fs from 'node:fs';
18
18
  import * as path from 'node:path';
19
19
  import { diffScans, summarizeDiff } from './baseline-compare.js';
20
20
 
21
+ import { statePath, safeWriteState } from './state-dir.js';
21
22
  const REVIEWER_TRIGGERS = [
22
23
  { family: /^auth/, team: 'security', why: 'Auth-related findings' },
23
24
  { family: /^crypto/, team: 'security', why: 'Cryptography findings' },
@@ -29,7 +30,7 @@ const REVIEWER_TRIGGERS = [
29
30
  ];
30
31
 
31
32
  function _stateFile(scanRoot, name) {
32
- return path.join(scanRoot, '.agentic-security', name);
33
+ return statePath(scanRoot, name);
33
34
  }
34
35
 
35
36
  function _readJson(fp) {
@@ -38,7 +39,7 @@ function _readJson(fp) {
38
39
 
39
40
  function _baselinePath(scanRoot, ref) {
40
41
  const safe = String(ref || 'main').replace(/[^\w.-]/g, '-');
41
- return path.join(scanRoot, '.agentic-security', 'scan-baselines', `${safe}.json`);
42
+ return statePath(scanRoot, 'scan-baselines', `${safe}.json`);
42
43
  }
43
44
 
44
45
  /**
@@ -47,8 +48,10 @@ function _baselinePath(scanRoot, ref) {
47
48
  */
48
49
  export function persistBaseline(scanRoot, ref, scan) {
49
50
  const fp = _baselinePath(scanRoot, ref);
50
- try { fs.mkdirSync(path.dirname(fp), { recursive: true }); } catch {}
51
- try { fs.writeFileSync(fp, JSON.stringify({ ref, ts: new Date().toISOString(), findings: scan.findings || [] }, null, 2)); } catch {}
51
+ // safeWriteState creates the directory, enforces the project-root check, and
52
+ // honours the read-only switch. The path is still returned either way, so
53
+ // callers that only report where the baseline WOULD live are unaffected.
54
+ safeWriteState(fp, JSON.stringify({ ref, ts: new Date().toISOString(), findings: scan.findings || [] }, null, 2));
52
55
  return fp;
53
56
  }
54
57
 
@@ -109,7 +112,7 @@ function _artifactLinks(scanRoot) {
109
112
  ];
110
113
  const out = [];
111
114
  for (const c of candidates) {
112
- const fp = path.join(scanRoot, '.agentic-security', c.file);
115
+ const fp = statePath(scanRoot, c.file);
113
116
  if (fs.existsSync(fp)) out.push({ name: c.name, path: `.agentic-security/${c.file}` });
114
117
  }
115
118
  return out;
@@ -0,0 +1,262 @@
1
+ // NIST Privacy Framework 1.1 — assessment + remediation planning.
2
+ //
3
+ // The bundled control set (`compliance-frameworks/nist-privacy-1-1.json`) is
4
+ // rendered by `auditor-walkthrough.js` like every other framework. This module
5
+ // adds the half a walkthrough cannot give you: a gap becomes a FINDING carrying
6
+ // a concrete remediation, so it flows through the same triage and `/fix` path
7
+ // as any other finding rather than sitting in a narrative nobody actions.
8
+ //
9
+ // THE INTEGRITY RULE THIS MODULE EXISTS TO ENFORCE
10
+ // -----------------------------------------------
11
+ // NIST ships a `Code Testable?` rating per control — yes / partial / no. On the
12
+ // 104 controls: 23 yes, 33 partial, **48 no**.
13
+ //
14
+ // That rating says whether a control COULD be assessed from source code. It does
15
+ // NOT say this engine assesses it. Conflating the two is how a compliance tool
16
+ // starts reporting governance controls — "the organization's privacy values are
17
+ // communicated" — as PASSED because no scanner rule fired against them. Silence
18
+ // is not evidence, and a clean report over 48 controls nobody measured is worse
19
+ // than no report: it is a false assurance that someone will hand to an auditor.
20
+ //
21
+ // So every control lands in exactly one of four buckets, and the bucket is
22
+ // always stated:
23
+ //
24
+ // satisfied mapped to a real engine signal, and that signal is clean
25
+ // gap mapped to a real engine signal, and that signal is dirty
26
+ // → this is the only bucket that produces a finding
27
+ // engine-gap NIST rates it code-testable, but this engine has no signal
28
+ // → disclosed BY NAME, never counted as satisfied
29
+ // manual NIST rates it not code-testable (governance, policy, process)
30
+ // → out of scope for any scanner, stated as such
31
+ //
32
+ // `engine-gap` is the bucket that keeps this honest. It is the difference
33
+ // between "we checked and it is fine" and "nobody checked", and it is reported
34
+ // with the same prominence as a failure because to a reader relying on the
35
+ // output they are the same thing.
36
+
37
+ import { loadFramework, evaluateFramework } from './auditor-walkthrough.js';
38
+ import { statePath, safeWriteState } from './state-dir.js';
39
+
40
+ export const PRIVACY_FRAMEWORK_ID = 'nist-privacy-1-1';
41
+
42
+ /** Bucket names, in report order (worst first). */
43
+ export const BUCKETS = Object.freeze(['gap', 'engine-gap', 'manual', 'satisfied']);
44
+
45
+ /**
46
+ * Remediation per control family. Deliberately ACTIONABLE — a command or a
47
+ * concrete artifact — rather than a restatement of the control text, which is
48
+ * what a compliance report usually offers and what makes them ignorable.
49
+ *
50
+ * Keyed by the control-id prefix so a new control in an existing category
51
+ * inherits sensible guidance instead of silently getting none.
52
+ */
53
+ const REMEDIATION = [
54
+ [/^PR\.DS-P1$/, 'Encrypt data at rest with an AEAD cipher. Replace ECB mode and static IVs; run `/fix --family crypto-weak-cipher` to apply the deterministic swaps.'],
55
+ [/^PR\.DS-P2$/, 'Enforce TLS 1.2+ and stop disabling certificate verification. `/fix --family crypto-tls-no-verify` patches the verify-off call sites.'],
56
+ [/^PR\.AA-P1$/, 'Remove hardcoded credentials and add authentication to unauthenticated routes. `/fix --rotate-secret` handles the secrets; auth gaps need a route-level decision.'],
57
+ [/^PR\.AA-P4$/, 'Sign and verify identity assertions with a modern algorithm — no `none` alg, no weak-hash HMAC.'],
58
+ [/^PR\.AA-05$/, 'Add object-level authorization on endpoints that read or mutate personal data. Review the IDOR and authz findings individually; these are not safely auto-patchable.'],
59
+ [/^PR\.PS-P1$/, 'Tighten IaC: drop privileged pod security contexts and over-permissive IAM policies.'],
60
+ [/^PR\.PS-P2$/, 'Upgrade dependencies carrying known advisories. `/fix --sca` proposes the version bumps.'],
61
+ [/^PR\.PS-P4$/, 'Resolve dependency-confusion exposure by pinning internal scopes to your private registry.'],
62
+ [/^PR\.IR-P1$/, 'Restrict network and cluster access — no cluster-admin bindings, no wildcard IAM.'],
63
+ [/^PR\.DS-P8$/, 'Record an SBOM snapshot per release so dependency substitution is detectable.'],
64
+ [/^CT\.DP-P/, 'Reduce what is observable: minimise PII reaching logs, responses, and third parties; de-identify where the data action does not need identity.'],
65
+ [/^CT\.DM-P6$/, 'Transmit over TLS with a standard, current cipher suite.'],
66
+ [/^CT\.DM-P9$/, 'Retain scan history and the MCP audit log so data-processing activity is reviewable.'],
67
+ [/^CT\.DM-P10$/, 'Keep fix history so the technical measures you applied are demonstrably tested, not asserted.'],
68
+ [/^CT\.PO-P/, 'Declare the policy in `.agentic-security/compliance.policy.yml` so it is verified on every scan rather than remembered.'],
69
+ [/^CM\.AW-P4$/, 'Preserve the audit log — it is the record of disclosure this control asks for.'],
70
+ [/^CM\.AW-P6$/, 'Keep SBOM snapshots and signed scan state so provenance and lineage survive review.'],
71
+ [/^ID\.IM-P/, 'Produce the inventory artifacts: run a scan so the SBOM, threat model, and PII field map exist and are current.'],
72
+ [/^ID\.RA-P3$/, 'Triage the PII-exposure findings — each is a problematic data action this control asks you to identify.'],
73
+ [/^ID\.BE-P3$/, 'Generate the threat model so priority systems and their requirements are recorded.'],
74
+ [/^GV\.PO-P2$/, 'Commit a compliance policy file so privacy expectations are enforced in CI, not just documented.'],
75
+ ];
76
+
77
+ /** The remediation for a control, or null when none is defined. */
78
+ export function remediationFor(controlId) {
79
+ for (const [re, text] of REMEDIATION) if (re.test(controlId)) return text;
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Bucket one evaluated control.
85
+ *
86
+ * `evaluateFramework` returns 'present' | 'partial' | 'manual'. That vocabulary
87
+ * cannot distinguish "no mapping because it is a governance control" from "no
88
+ * mapping because we never built the check" — which is exactly the distinction
89
+ * that decides whether a reader should worry. `codeTestable` supplies it.
90
+ */
91
+ export function bucketOf(result, { assessable = true } = {}) {
92
+ const c = result.control || {};
93
+ const mapped = Array.isArray(c.mapsTo) && c.mapsTo.length > 0;
94
+ if (mapped) {
95
+ // VACUOUS SATISFACTION GUARD. `evaluateFramework` clears a `family:`
96
+ // mapping when no findings of that family are open — which is also true of
97
+ // a scan that examined nothing at all. Without this, pointing the tool at
98
+ // an empty directory reports privacy controls as SATISFIED, on the strength
99
+ // of having looked at zero files. That is the same false assurance the
100
+ // manual bucket exists to prevent, arriving by a different route, and it
101
+ // was caught by this module's own test rather than in review.
102
+ if (!assessable) return 'engine-gap';
103
+ return result.status === 'present' ? 'satisfied' : 'gap';
104
+ }
105
+ return String(c.codeTestable || 'no').toLowerCase() === 'no' ? 'manual' : 'engine-gap';
106
+ }
107
+
108
+ /** Severity for a gap. Privacy controls are graded by what the gap exposes. */
109
+ function severityFor(controlId) {
110
+ if (/^PR\.(DS|AA)/.test(controlId)) return 'high'; // encryption, identity, access
111
+ if (/^CT\.DP/.test(controlId)) return 'medium'; // data minimisation
112
+ return 'low';
113
+ }
114
+
115
+ /**
116
+ * Assess a scan against NIST Privacy Framework 1.1.
117
+ *
118
+ * Returns `{ frameworkId, controls[], summary, findings[] }`. Never throws:
119
+ * posture modules degrade to a null result rather than failing a scan.
120
+ */
121
+ export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
122
+ const fw = loadFramework(scanRoot, PRIVACY_FRAMEWORK_ID);
123
+ if (!fw) return null;
124
+
125
+ let evaluation;
126
+ try { evaluation = evaluateFramework(scanRoot, fw, scan || {}); } catch { return null; }
127
+
128
+ // Did the scan actually examine anything? A clean signal from a run that read
129
+ // no files is not evidence — see the guard in bucketOf.
130
+ const filesScanned = Number(scan && (scan.filesScanned ?? scan._scanMeta?.filesScanned)) || 0;
131
+ const assessable = filesScanned > 0
132
+ || (Array.isArray(scan?.findings) && scan.findings.length > 0)
133
+ || (Array.isArray(scan?.components) && scan.components.length > 0);
134
+
135
+ const controls = [];
136
+ const findings = [];
137
+ const summary = { gap: 0, 'engine-gap': 0, manual: 0, satisfied: 0, total: 0 };
138
+
139
+ for (const r of evaluation) {
140
+ const c = r.control || {};
141
+ const bucket = bucketOf(r, { assessable });
142
+ summary[bucket] += 1;
143
+ summary.total += 1;
144
+
145
+ const row = {
146
+ id: c.id,
147
+ function: c.function || null,
148
+ category: c.category || null,
149
+ summary: c.summary || '',
150
+ codeTestable: c.codeTestable || 'no',
151
+ bucket,
152
+ observations: r.observations || [],
153
+ };
154
+ if (bucket === 'engine-gap') {
155
+ // Named, not counted as a pass. See the header.
156
+ row.disclosure = (Array.isArray(c.mapsTo) && c.mapsTo.length && !assessable)
157
+ ? 'This control is mapped, but the scan examined no files — a clean signal from a run that read nothing is not evidence. NOT assessed.'
158
+ : `NIST rates this control code-testable (${row.codeTestable}), but this engine has no signal for it. It was NOT assessed.`;
159
+ }
160
+ if (bucket === 'manual') {
161
+ row.disclosure = 'NIST rates this control not code-testable — it is a governance, policy, or process control and is outside any scanner\'s reach.';
162
+ }
163
+ controls.push(row);
164
+
165
+ if (bucket !== 'gap') continue;
166
+ const remediation = remediationFor(c.id)
167
+ || 'Review the observations for this control and close the underlying findings.';
168
+ findings.push({
169
+ id: `privacy-framework:${c.id}`,
170
+ severity: severityFor(c.id),
171
+ // A compliance gap is a property of the project, not of one line. The
172
+ // framework file is cited so the finding still has a real, openable
173
+ // location rather than a fabricated one.
174
+ file: '.agentic-security/compliance/nist-privacy-1-1',
175
+ line: 0,
176
+ vuln: `NIST Privacy Framework ${c.id} not satisfied — ${c.summary}`,
177
+ cwe: 'CWE-359',
178
+ description: [
179
+ `Control ${c.id} (${c.category || c.function || 'privacy'}) is mapped to engine signals that are currently failing.`,
180
+ ...(r.observations || []),
181
+ ].join(' '),
182
+ remediation,
183
+ parser: 'COMPLIANCE',
184
+ family: 'privacy-compliance',
185
+ complianceControl: { framework: PRIVACY_FRAMEWORK_ID, id: c.id, codeTestable: c.codeTestable || 'no' },
186
+ });
187
+ }
188
+
189
+ return {
190
+ frameworkId: PRIVACY_FRAMEWORK_ID,
191
+ frameworkName: fw.name,
192
+ controls,
193
+ summary,
194
+ findings,
195
+ // Carried in the result so a consumer cannot present the numbers without
196
+ // it. `satisfied` is a share of the ASSESSED controls, never of all 104.
197
+ assessable,
198
+ interpretation:
199
+ (!assessable ? 'The scan examined no files, so NO control was assessed. ' : '') +
200
+ `${summary.satisfied} of ${summary.satisfied + summary.gap} assessed controls satisfied. ` +
201
+ `${summary['engine-gap']} controls NIST rates code-testable were NOT assessed by this engine, and ` +
202
+ `${summary.manual} are governance controls outside any scanner's reach. ` +
203
+ 'Neither group is evidence of compliance.',
204
+ ...(opts.includeFindings === false ? { findings: [] } : {}),
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Render the assessment as Markdown, worst bucket first.
210
+ *
211
+ * Order is deliberate: gaps, then the controls nobody assessed, then governance,
212
+ * then satisfied. A report that opens with what passed invites the reader to
213
+ * stop there, which is precisely the wrong reading of a document where most
214
+ * controls carry no engine evidence at all.
215
+ */
216
+ function renderPrivacyMarkdown(result) {
217
+ if (!result) return '';
218
+ const L = [];
219
+ L.push(`# ${result.frameworkName} — assessment`);
220
+ L.push('');
221
+ L.push(result.interpretation);
222
+ L.push('');
223
+ L.push('| Bucket | Controls | Meaning |');
224
+ L.push('| --- | --- | --- |');
225
+ L.push(`| Gap | ${result.summary.gap} | Mapped to an engine signal, and that signal is failing |`);
226
+ L.push(`| Not assessed | ${result.summary['engine-gap']} | Code-testable, but this engine has no signal — **not** a pass |`);
227
+ L.push(`| Manual | ${result.summary.manual} | Governance/policy control, outside any scanner's reach |`);
228
+ L.push(`| Satisfied | ${result.summary.satisfied} | Mapped, and the signal is clean |`);
229
+ L.push('');
230
+ L.push('> A control in *Not assessed* or *Manual* is not evidence of compliance.');
231
+ L.push('> This document organizes scanner evidence; it does not certify anything.');
232
+ L.push('> A licensed assessor is responsible for the attestation.');
233
+ L.push('');
234
+ for (const bucket of BUCKETS) {
235
+ const rows = result.controls.filter(c => c.bucket === bucket);
236
+ if (!rows.length) continue;
237
+ L.push(`## ${bucket} (${rows.length})`);
238
+ L.push('');
239
+ for (const c of rows) {
240
+ L.push(`### ${c.id} — ${c.summary}`);
241
+ L.push(`- NIST code-testable: **${c.codeTestable}**`);
242
+ if (c.disclosure) L.push(`- ${c.disclosure}`);
243
+ for (const o of c.observations || []) L.push(`- ${o}`);
244
+ const rem = bucket === 'gap' ? remediationFor(c.id) : null;
245
+ if (rem) L.push(`- **Remediation:** ${rem}`);
246
+ L.push('');
247
+ }
248
+ }
249
+ return L.join('\n');
250
+ }
251
+
252
+ /**
253
+ * Persist the assessment. Through the seam, so a read-only scan writes nothing
254
+ * while still RETURNING the assessment — the switch changes what is written,
255
+ * never what is reported.
256
+ */
257
+ export function persistPrivacyFramework(scanRoot, result) {
258
+ if (!result) return null;
259
+ safeWriteState(statePath(scanRoot, 'privacy-framework.json'), JSON.stringify(result, null, 2));
260
+ safeWriteState(statePath(scanRoot, 'privacy-framework.md'), renderPrivacyMarkdown(result));
261
+ return result;
262
+ }
@@ -103,18 +103,30 @@ test('${finding.vuln.replace(/'/g, "\\'")}', async () => {
103
103
  }
104
104
 
105
105
  function _renderPytest(finding, poc) {
106
+ // Same finding-specific extraction _renderJest uses, not the hardcoded
107
+ // SQL-injection payload every call used to emit regardless of the actual
108
+ // vulnerability class. poc.code is assumed to follow this project's own
109
+ // URL_/METHOD/PAYLOAD synthesis convention (poc-generator.js), same as
110
+ // the JS PoCs this extraction already works against.
111
+ const url = _extractUrl(poc.code) || 'http://localhost:3000';
112
+ const method = (_extractMethod(poc.code) || 'POST').toLowerCase();
113
+ const payload = _extractPayloadValue(poc.code) || 'PoC';
106
114
  return `# Regression test for ${finding.vuln} (${finding.cwe || 'unknown CWE'}).
107
- # Auto-generated from P1.1 PoC.
115
+ # Auto-generated from P1.1 PoC; fails on vulnerable code, passes after the fix.
108
116
 
109
117
  import requests
110
118
 
111
119
 
112
120
  def test_${(finding.stableId || finding.id || 'finding').replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 40)}():
113
- url = 'http://localhost:3000'
114
- r = requests.post(url, json={'input': "' UNION SELECT username, password FROM users--"})
115
- # After the fix, the response should not contain SQL-syntax-error evidence.
121
+ url = ${JSON.stringify(url)}
122
+ payload = ${JSON.stringify(payload)}
123
+ r = requests.${method}(url, json={'input': payload}) if ${JSON.stringify(method)} != 'get' \\
124
+ else requests.get(url, params={'input': payload})
125
+ # The assertion below is the "exploit demonstrated" signal — when this
126
+ # test PASSES, the vuln is still present. After the fix, this assertion
127
+ # SHOULD fail. Inverse the assertion in a green build.
116
128
  text = r.text
117
- demonstrated = 'syntax error' in text.lower() or '<script>' in text
129
+ demonstrated = 'PoC' in text or 'syntax error' in text.lower() or '<script>' in text
118
130
  assert not demonstrated, f'Vulnerability still present: {text[:200]}'
119
131
  `;
120
132
  }
@@ -127,10 +139,13 @@ function _extractMethod(code) {
127
139
  const m = String(code || '').match(/METHOD = (['"])([A-Z]+)\1/);
128
140
  return m ? m[2] : null;
129
141
  }
130
- function _extractPayloadLine(code) {
142
+ function _extractPayloadValue(code) {
131
143
  const m = String(code || '').match(/PAYLOAD = `([^`]+)`/);
132
- if (m) return `const PAYLOAD = ${JSON.stringify(m[1])};`;
133
- return `const PAYLOAD = 'PoC';`;
144
+ return m ? m[1] : null;
145
+ }
146
+ function _extractPayloadLine(code) {
147
+ const v = _extractPayloadValue(code);
148
+ return `const PAYLOAD = ${JSON.stringify(v || 'PoC')};`;
134
149
  }
135
150
 
136
151
  /**