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

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 (93) hide show
  1. package/CHANGELOG.md +196 -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 +269 -44
  5. package/dist/113.index.js +6 -6
  6. package/dist/117.index.js +3 -1
  7. package/dist/178.index.js +1 -1
  8. package/dist/220.index.js +5 -3
  9. package/dist/238.index.js +4 -4
  10. package/dist/317.index.js +270 -0
  11. package/dist/384.index.js +1 -1
  12. package/dist/435.index.js +13 -7
  13. package/dist/513.index.js +7 -3
  14. package/dist/526.index.js +6 -6
  15. package/dist/637.index.js +1 -1
  16. package/dist/675.index.js +7 -5
  17. package/dist/839.index.js +4 -3
  18. package/dist/905.index.js +1173 -0
  19. package/dist/agentic-security.mjs +14 -14
  20. package/dist/agentic-security.mjs.sha256 +1 -1
  21. package/dist/compliance-frameworks/ccpa.json +32 -0
  22. package/dist/compliance-frameworks/eu-ai-act.json +51 -0
  23. package/dist/compliance-frameworks/gdpr.json +45 -0
  24. package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
  25. package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
  26. package/dist/compliance-frameworks/nist-csf-2.json +73 -0
  27. package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
  28. package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
  29. package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
  30. package/package.json +9 -5
  31. package/src/badge.js +2 -1
  32. package/src/dataflow/cross-service-taint.js +2 -1
  33. package/src/dataflow/ifds-precise.js +6 -4
  34. package/src/dataflow/incremental.js +7 -5
  35. package/src/discovery/CLAUDE.md +10 -0
  36. package/src/discovery/index.js +175 -3
  37. package/src/discovery/llm-invoke.js +90 -1
  38. package/src/discovery/memory.js +163 -0
  39. package/src/engine.js +45 -8
  40. package/src/integrations/tickets.js +7 -6
  41. package/src/ir/ir-stats.js +1 -1
  42. package/src/ir/parser-cpp.js +1 -1
  43. package/src/leaderboard.js +3 -2
  44. package/src/llm-validator/consistency.js +6 -2
  45. package/src/llm-validator/index.js +1 -2
  46. package/src/mcp/audit.js +2 -1
  47. package/src/mcp/tools.js +4 -3
  48. package/src/posture/CLAUDE.md +26 -1
  49. package/src/posture/accuracy-scorecard.js +59 -1
  50. package/src/posture/agents-memory.js +5 -3
  51. package/src/posture/auditor-walkthrough.js +9 -7
  52. package/src/posture/auth-posture-import.js +5 -4
  53. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
  54. package/src/posture/compliance-policy.js +7 -9
  55. package/src/posture/custom-rules.js +7 -5
  56. package/src/posture/cve-alert-daemon.js +6 -5
  57. package/src/posture/dep-add-guard.js +2 -1
  58. package/src/posture/deterministic.js +3 -2
  59. package/src/posture/evidence-bundle.js +246 -0
  60. package/src/posture/exploitability-probability.js +2 -1
  61. package/src/posture/feature-flags.js +3 -2
  62. package/src/posture/findings-memory.js +3 -3
  63. package/src/posture/fix-history.js +5 -2
  64. package/src/posture/fix-metrics.js +5 -5
  65. package/src/posture/fix-plan.js +2 -1
  66. package/src/posture/grader-calibration.js +3 -4
  67. package/src/posture/intent-context.js +2 -1
  68. package/src/posture/learning.js +4 -3
  69. package/src/posture/license-attributions.js +5 -7
  70. package/src/posture/license-graph.js +2 -1
  71. package/src/posture/license-policy.js +2 -1
  72. package/src/posture/model-rescan.js +4 -3
  73. package/src/posture/network-policy-import.js +3 -2
  74. package/src/posture/pqc-migration-plan.js +7 -5
  75. package/src/posture/pr-augment.js +8 -5
  76. package/src/posture/privacy-framework.js +262 -0
  77. package/src/posture/risk-dollars.js +2 -2
  78. package/src/posture/router.js +5 -4
  79. package/src/posture/ruleset-version.js +2 -2
  80. package/src/posture/runtime-correlation.js +2 -1
  81. package/src/posture/sbom-diff.js +12 -3
  82. package/src/posture/sca-policy.js +7 -4
  83. package/src/posture/scan-checkpoint.js +15 -0
  84. package/src/posture/state-dir.js +34 -0
  85. package/src/posture/telemetry-ingest.js +4 -3
  86. package/src/posture/threat-model-auto.js +4 -1
  87. package/src/posture/threat-model-grounding.js +11 -1
  88. package/src/posture/time-to-fix.js +3 -2
  89. package/src/posture/triage-memory.js +3 -2
  90. package/src/posture/waf-ingest.js +6 -5
  91. package/src/posture/watch-mode.js +4 -3
  92. package/src/sast/code-injection-multilang.js +29 -0
  93. package/src/sca/dep-confusion.js +2 -1
@@ -13,7 +13,11 @@ export async function defaultLlmInvoke(prompt, opts = {}) {
13
13
  // variable they set. Reaching it is this module's entire purpose; no
14
14
  // request-controlled input exists anywhere on this path, and an operator who
15
15
  // can set this variable can already run code.
16
- const res = await fetch(process.env.AGENTIC_SECURITY_LLM_ENDPOINT, { // agentic-security-ignore: CWE-918
16
+ // `opts.endpoint` lets the consensus caller target one specific provider.
17
+ // Absent, it falls back to the single configured endpoint — so the ordinary
18
+ // single-model path is byte-identical to what it was before consensus existed.
19
+ const endpoint = opts.endpoint || process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
20
+ const res = await fetch(endpoint, { // agentic-security-ignore: CWE-918
17
21
  method: 'POST',
18
22
  headers: { 'content-type': 'application/json' },
19
23
  body: JSON.stringify({ prompt }),
@@ -24,8 +28,93 @@ export async function defaultLlmInvoke(prompt, opts = {}) {
24
28
  return typeof body === 'string' ? body : (body?.text ?? JSON.stringify(body));
25
29
  }
26
30
 
31
+ // --- PRD Phase 3 / C2: multi-model consensus --------------------------------
32
+ //
33
+ // One model's opinion is one model's opinion. Asking several INDEPENDENT
34
+ // endpoints the same question and keeping only what a majority agree on
35
+ // collapses the idiosyncratic failures of any single one — a model that
36
+ // hallucinates a sink, or that is simply having a bad day on a prompt shape.
37
+ //
38
+ // WHY IT LIVES HERE AND NOWHERE ELSE. Every LLM call in the discovery layer
39
+ // already funnels through `resolveLlmInvoke`. Consensus is therefore a property
40
+ // of the seam, not of the hunter or the panel, and adding a provider cannot
41
+ // require touching either.
42
+ //
43
+ // WHAT CONSENSUS DOES AND DOES NOT MEAN. It reduces variance. It does NOT make
44
+ // the answer true — three models can agree and all be wrong, which is precisely
45
+ // why the deterministic confirmation gate still runs afterwards and still sets
46
+ // severity. Consensus is a noise filter in front of the real check, never a
47
+ // replacement for it.
48
+ //
49
+ // A provider that errors is EXCLUDED from the vote, not counted as dissent —
50
+ // the same rule `disprove.js` applies to its voters, for the same reason: an
51
+ // outage must never look like disagreement.
52
+ // Internal: read by resolveLlmInvoke below. Exporting it with no external
53
+ // caller is shipped dead code by the dead-module guard's definition.
54
+ const DEFAULT_CONSENSUS_ENV = 'AGENTIC_SECURITY_LLM_ENDPOINTS';
55
+
56
+ /** Split a comma-separated endpoint list into distinct URLs. */
57
+ export function parseEndpoints(raw) {
58
+ return String(raw || '')
59
+ .split(',')
60
+ .map(s => s.trim())
61
+ .filter(Boolean)
62
+ .filter((v, i, a) => a.indexOf(v) === i); // duplicates would fake agreement
63
+ }
64
+
65
+ /**
66
+ * Combine N responses into one, keeping the most common answer.
67
+ *
68
+ * Ties are resolved towards the FIRST endpoint listed, deterministically, rather
69
+ * than arbitrarily — a caller ordering their endpoints by trust should get the
70
+ * behaviour that ordering implies, and a random tie-break would make the whole
71
+ * pipeline non-reproducible.
72
+ */
73
+ export function consensusOf(responses) {
74
+ const usable = (responses || []).filter(r => typeof r === 'string' && r.trim());
75
+ if (usable.length === 0) return { value: null, agreement: 0, voters: 0 };
76
+ const counts = new Map();
77
+ for (const r of usable) counts.set(r, (counts.get(r) || 0) + 1);
78
+ let best = usable[0];
79
+ let bestCount = counts.get(best);
80
+ for (const r of usable) {
81
+ const c = counts.get(r);
82
+ if (c > bestCount) { best = r; bestCount = c; }
83
+ }
84
+ return { value: best, agreement: bestCount / usable.length, voters: usable.length };
85
+ }
86
+
87
+ /**
88
+ * An llmInvoke that queries several endpoints and returns the consensus answer.
89
+ * Returns null when no endpoint answered — the callers already treat a null or
90
+ * a throw as degradation, so an all-providers-down run degrades honestly.
91
+ */
92
+ function makeConsensusInvoke(endpoints, { timeoutMs } = {}) {
93
+ const list = parseEndpoints(endpoints);
94
+ if (list.length === 0) return null;
95
+ return async (prompt) => {
96
+ const answers = await Promise.all(list.map(async (url) => {
97
+ try { return await defaultLlmInvoke(prompt, { timeoutMs, endpoint: url }); }
98
+ catch { return null; } // excluded from the vote, never counted as dissent
99
+ }));
100
+ const { value } = consensusOf(answers);
101
+ if (value === null) throw new Error('no LLM endpoint answered');
102
+ return value;
103
+ };
104
+ }
105
+
27
106
  export function resolveLlmInvoke(opts = {}) {
107
+ // Precedence, most explicit first: an injected callback beats configuration,
108
+ // and a multi-endpoint list beats a single endpoint. A caller who supplied
109
+ // their own function must always get exactly that function.
28
110
  if (opts.llmInvoke) return opts.llmInvoke;
111
+
112
+ const multi = opts.endpoints || process.env[DEFAULT_CONSENSUS_ENV];
113
+ if (multi) {
114
+ const consensus = makeConsensusInvoke(multi, { timeoutMs: opts.timeoutMs });
115
+ if (consensus) return consensus;
116
+ }
117
+
29
118
  if (!process.env.AGENTIC_SECURITY_LLM_ENDPOINT) return null;
30
119
  return (prompt) => defaultLlmInvoke(prompt, { timeoutMs: opts.timeoutMs });
31
120
  }
@@ -0,0 +1,163 @@
1
+ // Cross-run discovery memory — PRD Phase 3 / C4.
2
+ //
3
+ // WHAT WAS MISSING
4
+ // ----------------
5
+ // `judge.js` dedupes a hunt against `last-scan.json` and the triage ledger, so
6
+ // it knows what the RULE ENGINE found and what a human dismissed. It has never
7
+ // known what a PREVIOUS HUNT found. Two consequences, both bad:
8
+ //
9
+ // 1. Every run re-proposes, re-confirms and re-refutes the same candidates.
10
+ // That is three LLM calls per candidate per run, spent to rediscover
11
+ // something already judged — the exact waste the Phase 0 budget exists to
12
+ // bound, being incurred deliberately.
13
+ // 2. A second run cannot be *additive*. Without a record of what was already
14
+ // examined, "hunt again" means "hunt the same thing again" rather than
15
+ // "hunt what we missed".
16
+ //
17
+ // This is that record. It turns a sequence of independent runs into a campaign.
18
+ //
19
+ // WHAT IS AND IS NOT REMEMBERED
20
+ // -----------------------------
21
+ // Remembered: every candidate ever JUDGED, with the verdict and the run that
22
+ // produced it. Also every focus area ever hunted, so coverage can become a plan
23
+ // instead of a report.
24
+ //
25
+ // NOT remembered: refuted candidates as if they were settled forever. A
26
+ // refutation is a majority opinion from three prompts on one day, not a proof.
27
+ // It suppresses re-reporting, and `--forget-refuted` exists precisely because a
28
+ // verdict made by a weaker model, or before a sanitiser was removed, must be
29
+ // re-openable. A memory you cannot clear is a memory that eventually lies.
30
+ //
31
+ // THE PRECEDENT THIS FOLLOWS
32
+ // --------------------------
33
+ // `judge.js` deliberately suppresses only `fp` triage verdicts and re-reports
34
+ // `tp` ones, because a prior true positive that is still in the code is still a
35
+ // bug. The same asymmetry holds here: a candidate previously judged FRESH is
36
+ // re-reported (it was never fixed), while one previously REFUTED is held back
37
+ // until something changes.
38
+
39
+ import * as fs from 'node:fs';
40
+ import * as path from 'node:path';
41
+
42
+ import { stateWritesEnabled } from '../posture/state-dir.js';
43
+ const MEMORY_SCHEMA = 'agentic-security/discovery-memory@1';
44
+ export const MEMORY_FILE = path.join('.agentic-security', 'discovery-memory.json');
45
+
46
+ function emptyMemory() {
47
+ return { schema: MEMORY_SCHEMA, runs: 0, candidates: {}, areas: {} };
48
+ }
49
+
50
+ /** A stable identity for a candidate across runs. */
51
+ export function memoryKey(candidate) {
52
+ // Location + family, matching judge.js's PRIMARY duplicate key. Deliberately
53
+ // NOT stableId: that is location-fuzzy by design and collides across distinct
54
+ // findings in one file, which is tolerable for a single scan's dedupe and
55
+ // corrosive when it accumulates across every run ever made.
56
+ const file = candidate?.file ?? '?';
57
+ const line = candidate?.line ?? '?';
58
+ const family = candidate?.family ?? candidate?.lens ?? '?';
59
+ return `${file}:${line}:${family}`;
60
+ }
61
+
62
+ /** Read the memory. Anything unreadable or unrecognised yields an empty one. */
63
+ export function loadMemory(scanRoot) {
64
+ try {
65
+ const doc = JSON.parse(fs.readFileSync(path.join(scanRoot, MEMORY_FILE), 'utf8'));
66
+ if (doc?.schema !== MEMORY_SCHEMA) return emptyMemory();
67
+ return { ...emptyMemory(), ...doc };
68
+ } catch {
69
+ // A corrupt memory must degrade to "remember nothing", never to a crash and
70
+ // never to a partially-trusted record. Re-hunting is cheap next to acting on
71
+ // a half-read ledger.
72
+ return emptyMemory();
73
+ }
74
+ }
75
+
76
+ /** Persist. Failure is non-fatal — the run still produced its report. */
77
+ export function saveMemory(scanRoot, memory) {
78
+ try {
79
+ const p = path.join(scanRoot, MEMORY_FILE);
80
+ if (!stateWritesEnabled()) return;
81
+ fs.mkdirSync(path.dirname(p), { recursive: true });
82
+ fs.writeFileSync(p, JSON.stringify(memory, null, 2) + '\n');
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Should this candidate be held back because a previous run already judged it?
91
+ *
92
+ * Only a REFUTED verdict suppresses. Everything else — fresh, duplicate,
93
+ * suppressed-by-triage — is re-evaluated, because those states are about the
94
+ * code and the code may have changed.
95
+ */
96
+ export function previouslyRefuted(memory, candidate) {
97
+ const rec = memory?.candidates?.[memoryKey(candidate)];
98
+ return Boolean(rec && rec.verdict === 'refuted');
99
+ }
100
+
101
+ /** Fold this run's outcome into the memory. Returns a NEW memory object. */
102
+ export function rememberRun(memory, { fresh = [], refutedCandidates = [], areas = [], at }) {
103
+ const next = {
104
+ ...emptyMemory(),
105
+ ...memory,
106
+ candidates: { ...(memory?.candidates || {}) },
107
+ areas: { ...(memory?.areas || {}) },
108
+ };
109
+ next.runs = (memory?.runs || 0) + 1;
110
+ const stamp = at || new Date().toISOString();
111
+
112
+ for (const f of fresh) {
113
+ next.candidates[memoryKey(f)] = { verdict: 'fresh', run: next.runs, at: stamp };
114
+ }
115
+ for (const c of refutedCandidates) {
116
+ next.candidates[memoryKey(c)] = { verdict: 'refuted', run: next.runs, at: stamp };
117
+ }
118
+ for (const a of areas) {
119
+ const prev = next.areas[a.id] || { hunts: 0 };
120
+ next.areas[a.id] = {
121
+ label: a.label,
122
+ hunts: prev.hunts + (a.hunted ? 1 : 0),
123
+ lastRun: a.hunted ? next.runs : (prev.lastRun ?? null),
124
+ files: a.files ?? prev.files ?? null,
125
+ };
126
+ }
127
+ return next;
128
+ }
129
+
130
+ /**
131
+ * Turn the memory into a PLAN: which areas have never been successfully hunted.
132
+ *
133
+ * This is the half that makes a second run additive rather than repetitive. A
134
+ * coverage report says what happened; this says what to do next.
135
+ */
136
+ export function nextWavePlan(memory, areas) {
137
+ const unhunted = [];
138
+ const stale = [];
139
+ for (const a of areas || []) {
140
+ const rec = memory?.areas?.[a.id];
141
+ if (!rec || rec.hunts === 0) unhunted.push(a.label || a.id);
142
+ else if (rec.lastRun !== memory.runs) stale.push(a.label || a.id);
143
+ }
144
+ return {
145
+ unhunted,
146
+ stale,
147
+ // Stated as a sentence because this lands in a report a human reads, and
148
+ // "3 areas" without saying which ones is not actionable.
149
+ summary: unhunted.length
150
+ ? `${unhunted.length} focus area(s) have NEVER been successfully hunted: ${unhunted.slice(0, 5).join(', ')}` +
151
+ (unhunted.length > 5 ? `, +${unhunted.length - 5} more` : '')
152
+ : 'every focus area has been hunted at least once',
153
+ };
154
+ }
155
+
156
+ /** Drop refuted verdicts so they can be re-examined. */
157
+ export function forgetRefuted(memory) {
158
+ const candidates = {};
159
+ for (const [k, v] of Object.entries(memory?.candidates || {})) {
160
+ if (v?.verdict !== 'refuted') candidates[k] = v;
161
+ }
162
+ return { ...emptyMemory(), ...memory, candidates };
163
+ }
package/src/engine.js CHANGED
@@ -71,6 +71,8 @@ import { scanWebhook } from './sast/webhook.js';
71
71
  import { scanClientSide } from './sast/client-side.js';
72
72
  import { scanPromptFirewall } from './sast/prompt-firewall.js';
73
73
  import { scanLlmRedteam } from './posture/llm-redteam.js';
74
+ import { assessPrivacyFramework, persistPrivacyFramework } from './posture/privacy-framework.js';
75
+ import { safeWriteState as _safeWriteState, statePath, statePath as _statePath } from './posture/state-dir.js';
74
76
  import { scanContainer } from './sca/container.js';
75
77
  import { detectDepConfusion } from './sca/dep-confusion.js';
76
78
  import { loadLicensePolicy, evaluateLicensePolicy } from './posture/license-policy.js';
@@ -585,7 +587,16 @@ const SANITIZER_PATTERNS=[{regex:/(?:escape|escapeHtml|htmlspecialchars|encodeUR
585
587
  {regex:/(?:re\.escape|preg_quote|Regexp\.escape)\s*\(/g,type:"Regex Escaping"}];
586
588
  const ROUTE_PATTERNS=[{regex:/(?:app|router)\s*\.\s*(get|post|put|patch|delete|all|options|head)\s*\(\s*['"`]([^'"`]+)['"`]/g,fw:"Express",mI:1,pI:2},{regex:/@(?:app|blueprint|bp)\s*\.\s*route\s*\(\s*['"]([^'"]+)['"]\s*(?:,\s*methods\s*=\s*\[([^\]]+)\])?/g,fw:"Flask",pI:1,mtI:2},{regex:/path\s*\(\s*['"]([^'"]+)['"]/g,fw:"Django",pI:1},{regex:/@(?:app|router)\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/g,fw:"FastAPI",mI:1,pI:2},{regex:/Route\s*::\s*(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/g,fw:"Laravel",mI:1,pI:2},{regex:/router\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/g,fw:"Koa/Express",mI:1,pI:2},{regex:/\[Http(Get|Post|Put|Delete|Patch)\s*\(\s*["']?([^"'\]]*)/g,fw:"ASP.NET",mI:1,pI:2},{regex:/['"`](\/api\/[a-zA-Z0-9\/:_\-{}]+)['"`]/g,fw:"API",pI:1}];
587
589
  const AUTH_PATTERNS=[/(?:authenticate|isAuthenticated|requireAuth|passport\.authenticate|jwt\.verify|verifyToken|authMiddleware|checkAuth|protect|authorize)\s*[\(,]/gi,/(?:middleware|use)\s*\(\s*(?:auth|jwt|token|session)/gi,/(?:isAuthorized|expressJwt|security\.isAuthorized|denyAll)\s*[\(]/gi,/passport\.(?:authenticate|initialize|session)\s*\(/gi];
588
- const IGNORE_DIRS=new Set(["node_modules",".git","__pycache__","vendor","dist","build",".next","venv","env",".venv","target","bin","obj",".cache","coverage","bower_components","tests","test","__tests__","spec","mocks"]);
590
+ // `.agentic-security` is OUR OWN OUTPUT and must never be scanned input.
591
+ // (NON_MUTATING_SCAN_PRD S4.) A scan writes threat-model.json,
592
+ // exploit-bundles.json and scan-history.json into the tree it scanned, and those
593
+ // files contain CWE identifiers. Without this, the second scan of any directory
594
+ // reads the first scan's conclusions as source code — measured on the
595
+ // independent benchmark as 220 polluted trees and 544 state files carrying
596
+ // `CWE-` strings, which silently turned an accuracy measurement into the engine
597
+ // grading itself. Scanning our own state is never useful and is exactly how
598
+ // output becomes input.
599
+ const IGNORE_DIRS=new Set(["node_modules",".git","__pycache__","vendor","dist","build",".next","venv","env",".venv","target","bin","obj",".cache","coverage","bower_components","tests","test","__tests__","spec","mocks",".agentic-security"]);
589
600
  const CODE_EXTS=new Set(["js","jsx","ts","tsx","mjs","cjs","py","rb","php","java","go","cs","rs","vue","svelte","html","htm","ejs","hbs","pug","erb","twig","graphql","gql","kt","scala","swift","dart","ex","exs","tf","tfvars","dockerfile","c","cc","cpp","cxx","h","hh","hpp","hxx","sol"]);
590
601
  // Feat-2: IaC manifest filenames that aren't extension-based.
591
602
  const IAC_FILENAMES = new Set(['Dockerfile', 'Containerfile', 'docker-compose.yml', 'docker-compose.yaml', 'Chart.yaml']);
@@ -2401,7 +2412,7 @@ async function _loadCustomRules(scanRoot){
2401
2412
  _customIgnorePaths = [];
2402
2413
  let raw = null, parsedObj = null;
2403
2414
  for (const ext of ['rules.yml', 'rules.yaml', 'rules.json']) {
2404
- const p = path.join(scanRoot, '.agentic-security', ext);
2415
+ const p = statePath(scanRoot, ext);
2405
2416
  try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
2406
2417
  try {
2407
2418
  if (ext.endsWith('.json')) parsedObj = JSON.parse(raw);
@@ -8211,7 +8222,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8211
8222
  if (r && r.piiFields) {
8212
8223
  try {
8213
8224
  const dpia = emitDpiaArtifact(r.piiFields, r.findings || []);
8214
- fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'dpia.md'), dpia);
8225
+ _safeWriteState(_statePath(scanRoot, 'dpia.md'), dpia);
8215
8226
  } catch (_) {}
8216
8227
  }
8217
8228
  });
@@ -8574,7 +8585,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8574
8585
  // second party could corroborate it.
8575
8586
  try {
8576
8587
  if (scanRoot) {
8577
- const raw = fs.readFileSync(path.join(scanRoot, '.agentic-security', 'logic-claims.json'), 'utf8');
8588
+ const raw = fs.readFileSync(statePath(scanRoot, 'logic-claims.json'), 'utf8');
8578
8589
  const parsed = JSON.parse(raw);
8579
8590
  const incoming = Array.isArray(parsed) ? parsed : (parsed && parsed.claims) || [];
8580
8591
  if (incoming.length) {
@@ -8770,6 +8781,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8770
8781
  // Each is opt-in via env var. They produce machine-readable artifacts
8771
8782
  // (threat-model.json/.md, dpia.md, compliance-evidence.json/.md,
8772
8783
  // sbom-history/<sha>.json, exploit-bundles/) under .agentic-security/.
8784
+ let _privacyFramework = null;
8773
8785
  let _threatModel = null, _apiContractFindings = [], _sbomDiff = null,
8774
8786
  _complianceReport = null, _exploitBundles = null, _pqcPlan = null,
8775
8787
  _licenseGraph = null, _attributions = null, _taxonomySummary = null;
@@ -8837,6 +8849,29 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8837
8849
  if (_pqcPlan) persistPqcPlan(scanRoot, _pqcPlan);
8838
8850
  } catch (_) {}
8839
8851
  }
8852
+ // NIST Privacy Framework 1.1 assessment.
8853
+ //
8854
+ // The ASSESSMENT is default-on and lands on `scan.privacyFramework`, like
8855
+ // every other posture artifact. Its FINDINGS are opt-in
8856
+ // (AGENTIC_SECURITY_PRIVACY_FRAMEWORK=1), because appending them to
8857
+ // scan.findings would change every severity count, gate verdict and
8858
+ // baseline in every downstream consumer — a compliance opinion should not
8859
+ // silently become a build failure for projects that never asked for it.
8860
+ // Turn them on and they flow through triage and /fix like any finding.
8861
+ if (process.env.AGENTIC_SECURITY_NO_PRIVACY_FRAMEWORK !== '1') {
8862
+ try {
8863
+ _privacyFramework = assessPrivacyFramework(scanRoot, {
8864
+ findings: finalFindings, components: annotatedComponents,
8865
+ // filesScanned feeds the vacuous-satisfaction guard: a clean signal
8866
+ // from a run that read no files is not evidence of compliance.
8867
+ filesScanned: files.length,
8868
+ });
8869
+ if (_privacyFramework) persistPrivacyFramework(scanRoot, _privacyFramework);
8870
+ if (_privacyFramework && process.env.AGENTIC_SECURITY_PRIVACY_FRAMEWORK === '1') {
8871
+ finalFindings.push(..._privacyFramework.findings);
8872
+ }
8873
+ } catch (_) {}
8874
+ }
8840
8875
  // Exploit bundles — per-family PoC + Jest + pytest + remediation for
8841
8876
  // top-N critical/high findings.
8842
8877
  if (process.env.AGENTIC_SECURITY_NO_EXPLOIT_BUNDLES !== '1') {
@@ -8845,9 +8880,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8845
8880
  if (bundles.size) {
8846
8881
  _exploitBundles = {};
8847
8882
  for (const [id, b] of bundles) _exploitBundles[id] = b;
8848
- const bundlePath = path.join(scanRoot, '.agentic-security', 'exploit-bundles.json');
8849
- try { fs.mkdirSync(path.dirname(bundlePath), { recursive: true }); } catch {}
8850
- try { fs.writeFileSync(bundlePath, JSON.stringify(_exploitBundles, null, 2)); } catch {}
8883
+ // Through the seam, so `--no-state` withholds the artifact. The
8884
+ // bundles stay on the scan result either way — a read-only scan must
8885
+ // report the same thing, it just must not leave it behind.
8886
+ _safeWriteState(_statePath(scanRoot, 'exploit-bundles.json'),
8887
+ JSON.stringify(_exploitBundles, null, 2));
8851
8888
  }
8852
8889
  } catch (_) {}
8853
8890
  }
@@ -8881,7 +8918,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8881
8918
  // Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
8882
8919
  // detectors missed, with total-count accounting. Confirmed-only (cheap by default).
8883
8920
  let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
8884
- return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
8921
+ return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
8885
8922
 
8886
8923
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
8887
8924
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
@@ -19,17 +19,18 @@ import * as cp from 'node:child_process';
19
19
  import { buildJiraIssue } from './index.js';
20
20
  import { escapeMarkdown } from '../util/untrusted.js';
21
21
 
22
- function statePath(scanRoot) {
23
- return path.join(scanRoot, '.agentic-security', 'tickets.json');
22
+ import { statePath } from '../posture/state-dir.js';
23
+ function _ticketsPath(scanRoot) {
24
+ return statePath(scanRoot, 'tickets.json');
24
25
  }
25
26
  export function readState(scanRoot) {
26
- const fp = statePath(scanRoot);
27
+ const fp = _ticketsPath(scanRoot);
27
28
  if (!fs.existsSync(fp)) return {};
28
29
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return {}; }
29
30
  }
30
31
  function writeState(scanRoot, state) {
31
- fs.mkdirSync(path.dirname(statePath(scanRoot)), { recursive: true });
32
- fs.writeFileSync(statePath(scanRoot), JSON.stringify(state, null, 2));
32
+ fs.mkdirSync(path.dirname(_ticketsPath(scanRoot)), { recursive: true });
33
+ fs.writeFileSync(_ticketsPath(scanRoot), JSON.stringify(state, null, 2));
33
34
  }
34
35
 
35
36
  function findingTitle(f) {
@@ -148,7 +149,7 @@ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
148
149
 
149
150
  export async function syncTickets({ scanRoot, provider, severity = 'high', repo, teamId, dryRun = false }) {
150
151
  const minRank = SEV_RANK[severity] ?? 3;
151
- const lastScanPath = path.join(scanRoot, '.agentic-security', 'last-scan.json');
152
+ const lastScanPath = statePath(scanRoot, 'last-scan.json');
152
153
  if (!fs.existsSync(lastScanPath)) return { ok: false, error: 'no last-scan.json — run a scan first' };
153
154
  const last = JSON.parse(fs.readFileSync(lastScanPath, 'utf8'));
154
155
  const allFindings = [...(last.findings || []), ...(last.secrets || []), ...(last.supplyChain || [])];
@@ -4,7 +4,7 @@
4
4
  // language, how many of the files we claim to support did we actually turn
5
5
  // into IR? That is the difference between recognising an extension and
6
6
  // supporting a language, and it is the headline metric of the proof corpus
7
- // bench (docs/PROOF_CORPUS_PRD.md §5.4).
7
+ // bench (the Proof Corpus PRD §5.4 (removed post-implementation)).
8
8
  //
9
9
  // Enable by setting AGENTIC_SECURITY_IR_STATS to an output path. The sidecar
10
10
  // deliberately contains NO timestamp so two runs over identical input produce
@@ -1,7 +1,7 @@
1
1
  // C / C++ IR frontend.
2
2
  //
3
3
  // Hand-rolled, following the parser-cs.js / parser-go.js template. See
4
- // docs/PROOF_CORPUS_PRD.md §6.3 for why this is not tree-sitter or libclang:
4
+ // the Proof Corpus PRD §6.3 (removed post-implementation) for why this is not tree-sitter or libclang:
5
5
  // the build excludes the tree-sitter deps from the bundle, and libclang would
6
6
  // require native bindings plus a compile database we deliberately never build.
7
7
  //
@@ -27,6 +27,7 @@ import * as fs from 'node:fs';
27
27
  import * as path from 'node:path';
28
28
  import { summarizeForBadge } from './badge.js';
29
29
 
30
+ import { statePath } from './posture/state-dir.js';
30
31
  // Grade thresholds. Critical findings dominate; high/medium contribute
31
32
  // secondarily. These numbers are heuristic — calibrate against the
32
33
  // public leaderboard corpus once data lands.
@@ -90,7 +91,7 @@ function _deltaTrend(history) {
90
91
  */
91
92
  export function leaderboardRowFor({ scanRoot, repo, badgeBase = 'https://agentic-security.dev/badge' } = {}) {
92
93
  if (!repo) throw new Error('leaderboardRowFor: repo slug is required');
93
- const lastScanPath = path.join(scanRoot || '.', '.agentic-security', 'last-scan.json');
94
+ const lastScanPath = statePath(scanRoot || '.', 'last-scan.json');
94
95
  let scan = null;
95
96
  try { scan = JSON.parse(fs.readFileSync(lastScanPath, 'utf8')); } catch {}
96
97
  const summary = summarizeForBadge(scan);
@@ -98,7 +99,7 @@ export function leaderboardRowFor({ scanRoot, repo, badgeBase = 'https://agentic
98
99
  const topCwe = _topCwe(scan);
99
100
 
100
101
  // Optional scan history for the trend signal.
101
- const historyPath = path.join(scanRoot || '.', '.agentic-security', 'scan-history.jsonl');
102
+ const historyPath = statePath(scanRoot || '.', 'scan-history.jsonl');
102
103
  let history = [];
103
104
  if (fs.existsSync(historyPath)) {
104
105
  try {
@@ -24,6 +24,7 @@ import * as fs from 'node:fs';
24
24
  import * as path from 'node:path';
25
25
  import { validateOne } from './index.js';
26
26
 
27
+ import { statePath, stateWritesEnabled } from '../posture/state-dir.js';
27
28
  // Build a deterministic test finding from the project's last-scan.json,
28
29
  // or accept a hand-crafted one. Returns a clone safe to mutate per trial.
29
30
  export function makeTrialFinding(template) {
@@ -60,9 +61,12 @@ export async function measureConsistency({
60
61
  // If useCache is false, we want each trial to bypass the cache; we
61
62
  // simulate by mutating the finding's file in a way the cache key
62
63
  // hashes over. Simpler: clear the per-scanRoot cache before each trial.
63
- if (!useCache && scanRoot) {
64
+ // `stateWritesEnabled()` gates the DELETE as well as any write: removing
65
+ // files from the scanned tree is a mutation like any other, and under a
66
+ // read-only scan there is no cache to clear anyway. (PRD M1)
67
+ if (!useCache && scanRoot && stateWritesEnabled()) {
64
68
  try {
65
- const cacheDir = path.join(scanRoot, '.agentic-security', 'llm-cache');
69
+ const cacheDir = statePath(scanRoot, 'llm-cache');
66
70
  if (fs.existsSync(cacheDir)) {
67
71
  for (const e of fs.readdirSync(cacheDir)) fs.unlinkSync(path.join(cacheDir, e));
68
72
  }
@@ -64,7 +64,7 @@
64
64
  import * as fs from 'node:fs';
65
65
  import * as path from 'node:path';
66
66
  import * as crypto from 'node:crypto';
67
- import { statePath, ensureStateDir, safeWriteState } from '../posture/state-dir.js';
67
+ import { ensureStateDir, safeWriteState, statePath } from '../posture/state-dir.js';
68
68
  import { redactSecrets } from './redact.js';
69
69
  import { signLastScan } from '../posture/integrity.js';
70
70
 
@@ -84,7 +84,6 @@ const MAX_OUTPUT_TOKENS = 512;
84
84
  let _localPresetRefusal = null;
85
85
 
86
86
  export const PROMPT_VERSION = 'v2.0-hardened';
87
- const CACHE_DIR = '.agentic-security/llm-cache';
88
87
 
89
88
  // System preamble — embeds a per-request challenge token the model MUST
90
89
  // echo, and a strict instruction-priority frame. {{challenge}} and {{nonce}}
package/src/mcp/audit.js CHANGED
@@ -27,6 +27,7 @@ import * as path from 'node:path';
27
27
  import * as crypto from 'node:crypto';
28
28
  import { redactArgsBlob } from './redact.js';
29
29
 
30
+ import { stateDir } from '../posture/state-dir.js';
30
31
  const MAX_ARG_BYTES = 1024;
31
32
  const GENESIS = 'GENESIS';
32
33
  const REMOTE_TIMEOUT_MS = 1500;
@@ -87,7 +88,7 @@ export function auditCall({ sessionRoot, tool, args, outcome, reason }) {
87
88
  let hasMarker = false;
88
89
  for (const m of MARKERS) { try { if (fs.existsSync(path.join(sessionRoot, m))) { hasMarker = true; break; } } catch {} }
89
90
  if (!hasMarker) return;
90
- const dir = path.join(sessionRoot, '.agentic-security');
91
+ const dir = stateDir(sessionRoot);
91
92
  fs.mkdirSync(dir, { recursive: true });
92
93
  const logFile = path.join(dir, 'mcp-audit.log');
93
94
  const entry = {
package/src/mcp/tools.js CHANGED
@@ -157,7 +157,7 @@ function _scratchpadAbs(sessionRoot, relPath) {
157
157
  }
158
158
 
159
159
  function _scratchpadTotalBytes(sessionRoot) {
160
- const base = path.join(sessionRoot, '.agentic-security', 'agent-scratchpad');
160
+ const base = statePath(sessionRoot, 'agent-scratchpad');
161
161
  if (!fs.existsSync(base)) return 0;
162
162
  let total = 0;
163
163
  const walk = (dir) => {
@@ -222,8 +222,8 @@ function _confine(sessionRoot, candidate, label) {
222
222
  }
223
223
 
224
224
  function _readLastScanVerified(sessionRoot, { allowUnsigned = false } = {}) {
225
- const stateDir = path.join(sessionRoot, '.agentic-security');
226
- const scanFile = path.join(stateDir, 'last-scan.json');
225
+ const stateDirPath = stateDir(sessionRoot);
226
+ const scanFile = path.join(stateDirPath, 'last-scan.json');
227
227
  const sigFile = scanFile + '.sig';
228
228
  if (!fs.existsSync(scanFile)) return { scan: null, status: 'missing' };
229
229
  const body = fs.readFileSync(scanFile, 'utf8');
@@ -1013,6 +1013,7 @@ export const read_scratchpad = {
1013
1013
  import { appendAgentsMemory as _appendAgentsMemory, readAgentsMemory as _readAgentsMemory } from '../posture/agents-memory.js';
1014
1014
  import { lookupCve as _lookupCve } from '../posture/cve-lookup.js';
1015
1015
 
1016
+ import { stateDir, statePath } from '../posture/state-dir.js';
1016
1017
  export const append_agents_memory = {
1017
1018
  name: 'append_agents_memory',
1018
1019
  description: 'Append a short narrative entry to AGENTS.md — agent-authored continual-learning notes. Use at session end to record "what worked / what didn\'t / what I\'d try differently next time" so the next agent can pick up the lesson. Bounded: 2 KB per entry, 20 KB total before rotation to AGENTS.md.archive. Use sparingly — narrative, not structured data.',
@@ -44,7 +44,7 @@ rather than creating a stray state dir outside a project.
44
44
 
45
45
  **Agentic verification** — `verifier.js`, `verifier-target.js`, `verifier-ephemeral.js`, `harness-discovery.js`, `adversary-agent.js`, `defender-agent.js`, `auditor-agent.js`, `three-agent-pipeline.js`.
46
46
 
47
- **Methodology additions (`docs/AGENTIC_METHODOLOGY_PRD.md`)** — default-on annotators/artifacts that layer the agentic-hunter methodology on the deterministic engine:
47
+ **Methodology additions (Agentic Methodology PRD, removed post-implementation)** — default-on annotators/artifacts that layer the agentic-hunter methodology on the deterministic engine:
48
48
  - `falsification.js` — default falsification pass. For each taint-style finding, tries to DISPROVE it (locate a context-matched control on the path, reusing `dataflow/sanitizer-proof.js`'s shape rules read-only); a blocked finding is demoted + `quarantined`, never removed and never severity-touched (recall-preserving, like `proof-gate`). Wired after `annotateProofGate`. Opt out: `AGENTIC_SECURITY_NO_FALSIFICATION=1`. Optional LLM tier over survivors when an endpoint is configured.
49
49
  - `entrypoint-inventory.js` — attack-surface completeness ledger. Enumerates every entry point (HTTP/queue/cron/CLI/env/upload/webhook) with a disposition each; on `scan.entrypointInventory`.
50
50
  - `root-cause-sweep.js` — from confirmed findings, finds sibling instances detectors missed with total-count accounting (`found === candidates + mitigated`); on `scan.rootCauseSweep`. Searches the corpus **once per distinct sink pattern**, not once per finding — findings deriving the same pattern share one walk and one set of (read-only) match records. The counts are always exact; the materialised `instances` list is a bounded sample (`INSTANCE_SAMPLE_LIMIT`, 100) and says so via `instancesTruncated`. Both properties are load-bearing on large corpora: the per-finding walk was O(findings × corpus-bytes) and the instance records were O(findings × matches), which together exhausted a 6 GB heap on a 40k-file suite. If you touch this module, keep the own-site exclusion **per pattern group** — resolving it globally makes a group subtract an exclusion it never matched and drives counts negative.
@@ -80,6 +80,31 @@ Wired in `bin/agentic-security.js` after every filter and after `makeDeterminist
80
80
 
81
81
  **Rule lifecycle** — `custom-rules.js` (YAML pattern DSL), `rule-overrides.js` (`disable:` gated on signature), `rule-packs.js`, `rule-synthesis.js` (proposes suppressions from triage feedback), `ruleset-version.js`.
82
82
 
83
+ **NIST Privacy Framework 1.1 (`privacy-framework.js`)** — assessment + remediation
84
+ over the bundled `compliance-frameworks/nist-privacy-1-1.json` (all 104 controls).
85
+ Sits on top of `auditor-walkthrough.js`'s evaluator and adds the half a narrative
86
+ cannot give you: a gap becomes a FINDING (`family: privacy-compliance`,
87
+ `CWE-359`) carrying an actionable remediation, so it flows through triage and
88
+ `/fix`.
89
+
90
+ Four buckets, and the bucket is always stated: `gap` (mapped signal failing —
91
+ the ONLY bucket that emits a finding), `engine-gap` (NIST rates it code-testable
92
+ but this engine has no signal — disclosed by name, never a pass), `manual` (NIST
93
+ rates it not code-testable), `satisfied`. NIST's own `codeTestable` rating is
94
+ carried per control and is what separates "nobody checked" from "we checked and
95
+ it is fine" — 48 of 104 are governance controls no scanner can assess, and
96
+ reporting those as passed is the failure mode the module exists to prevent.
97
+
98
+ Two guards are load-bearing. **Findings are opt-in**
99
+ (`AGENTIC_SECURITY_PRIVACY_FRAMEWORK=1`); the assessment always lands on
100
+ `scan.privacyFramework` and at `.agentic-security/privacy-framework.{json,md}`,
101
+ but appending to `scan.findings` by default would change every severity count
102
+ and gate verdict downstream. And the **vacuous-satisfaction guard**: a
103
+ `family:`-mapped control clears when no findings of that family are open, which
104
+ is also true of a scan that read zero files — so when nothing was examined every
105
+ mapped control degrades to `engine-gap` instead of reporting as satisfied. That
106
+ one was caught by the module's own test, not in review.
107
+
83
108
  **Posture artifacts** — `sbom.js`, `aibom.js`, `api-inventory.js`, `threat-model.js`, `trust-boundary-diagram.js`, `stack-playbook.js`, `deploy-platform.js`, `license-policy.js`, `material-change.js`, `mttr.js`, `streak.js`, `scorecard.js`, `security-trend.js`.
84
109
 
85
110
  **Why this fired** — `why-fired.js`. Runs LAST so it reflects every annotation. Customer-facing provenance.