@clear-capabilities/agentic-security-scanner 0.133.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 (111) hide show
  1. package/CHANGELOG.md +299 -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 +348 -40
  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/499.index.js +86 -0
  14. package/dist/513.index.js +7 -3
  15. package/dist/526.index.js +6 -6
  16. package/dist/609.index.js +741 -0
  17. package/dist/637.index.js +1 -1
  18. package/dist/675.index.js +7 -5
  19. package/dist/839.index.js +4 -3
  20. package/dist/905.index.js +1173 -0
  21. package/dist/agentic-security.mjs +56 -56
  22. package/dist/agentic-security.mjs.sha256 +1 -1
  23. package/dist/compliance-frameworks/ccpa.json +32 -0
  24. package/dist/compliance-frameworks/eu-ai-act.json +51 -0
  25. package/dist/compliance-frameworks/gdpr.json +45 -0
  26. package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
  27. package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
  28. package/dist/compliance-frameworks/nist-csf-2.json +73 -0
  29. package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
  30. package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
  31. package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
  32. package/package.json +14 -6
  33. package/src/badge.js +2 -1
  34. package/src/dataflow/cross-service-taint.js +2 -1
  35. package/src/dataflow/ifds-precise.js +6 -4
  36. package/src/dataflow/incremental.js +7 -5
  37. package/src/discovery/CLAUDE.md +48 -0
  38. package/src/discovery/confirm.js +47 -0
  39. package/src/discovery/disprove.js +79 -0
  40. package/src/discovery/hunter.js +116 -0
  41. package/src/discovery/index.js +331 -0
  42. package/src/discovery/judge.js +97 -0
  43. package/src/discovery/lenses.js +69 -0
  44. package/src/discovery/llm-invoke.js +120 -0
  45. package/src/discovery/memory.js +163 -0
  46. package/src/discovery/partition.js +92 -0
  47. package/src/engine.js +163 -7
  48. package/src/integrations/tickets.js +7 -6
  49. package/src/ir/ir-stats.js +1 -1
  50. package/src/ir/parser-cpp.js +1 -1
  51. package/src/leaderboard.js +3 -2
  52. package/src/llm-validator/consistency.js +6 -2
  53. package/src/llm-validator/index.js +30 -41
  54. package/src/llm-validator/providers.js +227 -0
  55. package/src/mcp/audit.js +2 -1
  56. package/src/mcp/tools.js +4 -3
  57. package/src/posture/CLAUDE.md +102 -1
  58. package/src/posture/accuracy-scorecard.js +59 -1
  59. package/src/posture/agents-memory.js +5 -3
  60. package/src/posture/auditor-walkthrough.js +9 -7
  61. package/src/posture/auth-posture-import.js +5 -4
  62. package/src/posture/autopilot.js +225 -0
  63. package/src/posture/comparison.js +181 -0
  64. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
  65. package/src/posture/compliance-policy.js +7 -9
  66. package/src/posture/custom-rules.js +7 -5
  67. package/src/posture/cve-alert-daemon.js +6 -5
  68. package/src/posture/dep-add-guard.js +2 -1
  69. package/src/posture/deterministic.js +3 -2
  70. package/src/posture/evidence-bundle.js +246 -0
  71. package/src/posture/execution-proof.js +25 -1
  72. package/src/posture/exploitability-probability.js +2 -1
  73. package/src/posture/feature-flags.js +3 -2
  74. package/src/posture/findings-memory.js +3 -3
  75. package/src/posture/fix-history.js +5 -2
  76. package/src/posture/fix-metrics.js +5 -5
  77. package/src/posture/fix-plan.js +2 -1
  78. package/src/posture/fleet.js +0 -0
  79. package/src/posture/grader-calibration.js +3 -4
  80. package/src/posture/intent-context.js +2 -1
  81. package/src/posture/learning.js +4 -3
  82. package/src/posture/license-attributions.js +5 -7
  83. package/src/posture/license-graph.js +2 -1
  84. package/src/posture/license-policy.js +2 -1
  85. package/src/posture/logic-claims.js +266 -0
  86. package/src/posture/model-rescan.js +4 -3
  87. package/src/posture/network-policy-import.js +3 -2
  88. package/src/posture/poc-inprocess.js +404 -2
  89. package/src/posture/pqc-migration-plan.js +7 -5
  90. package/src/posture/pr-augment.js +8 -5
  91. package/src/posture/privacy-framework.js +262 -0
  92. package/src/posture/proof-artifact.js +101 -0
  93. package/src/posture/prove-findings.js +28 -4
  94. package/src/posture/risk-dollars.js +2 -2
  95. package/src/posture/router.js +5 -4
  96. package/src/posture/ruleset-version.js +2 -2
  97. package/src/posture/runtime-correlation.js +2 -1
  98. package/src/posture/sbom-diff.js +12 -3
  99. package/src/posture/sca-policy.js +7 -4
  100. package/src/posture/scan-checkpoint.js +15 -0
  101. package/src/posture/state-dir.js +34 -0
  102. package/src/posture/telemetry-ingest.js +4 -3
  103. package/src/posture/threat-model-auto.js +4 -1
  104. package/src/posture/threat-model-grounding.js +11 -1
  105. package/src/posture/time-to-fix.js +3 -2
  106. package/src/posture/triage-memory.js +3 -2
  107. package/src/posture/waf-ingest.js +6 -5
  108. package/src/posture/watch-mode.js +4 -3
  109. package/src/report/index.js +9 -0
  110. package/src/sast/code-injection-multilang.js +29 -0
  111. package/src/sca/dep-confusion.js +2 -1
@@ -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
+ }
@@ -0,0 +1,92 @@
1
+ //
2
+ // Split the codebase into disjoint focus areas so parallel hunters cannot
3
+ // converge on the same code.
4
+ //
5
+ // WHY THE CALL GRAPH AND NOT DIRECTORIES: a directory split hands one
6
+ // subsystem to several hunters whenever a feature spans folders, and hands
7
+ // unrelated code to one hunter whenever a folder is a grab bag. Weakly-
8
+ // connected components over call edges group code that actually talks to
9
+ // itself, which is the unit a hunter can reason about end to end.
10
+ //
11
+ // FILES, NOT FUNCTIONS, ARE THE ATOM. A hunter reads whole files. If two
12
+ // components share a file they are merged, otherwise the same source lands in
13
+ // two hunters' context and the convergence this module exists to prevent
14
+ // comes straight back.
15
+ import * as crypto from 'node:crypto';
16
+
17
+ export function focusAreaId(files) {
18
+ const canon = [...new Set(files || [])].sort().join('\n');
19
+ return crypto.createHash('sha256').update(canon).digest('hex').slice(0, 12);
20
+ }
21
+
22
+ // Union-find over file paths.
23
+ function makeDSU() {
24
+ const parent = new Map();
25
+ const find = (x) => {
26
+ if (!parent.has(x)) parent.set(x, x);
27
+ let r = x;
28
+ while (parent.get(r) !== r) r = parent.get(r);
29
+ while (parent.get(x) !== r) { const n = parent.get(x); parent.set(x, r); x = n; }
30
+ return r;
31
+ };
32
+ const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra, rb); };
33
+ return { find, union };
34
+ }
35
+
36
+ function labelFor(files) {
37
+ if (files.length === 1) return files[0];
38
+ const parts = files[0].split('/');
39
+ for (let i = parts.length - 1; i > 0; i--) {
40
+ const prefix = parts.slice(0, i).join('/') + '/';
41
+ if (files.every(f => f.startsWith(prefix))) return prefix;
42
+ }
43
+ return files[0] + ` (+${files.length - 1})`;
44
+ }
45
+
46
+ export function partitionCallGraph(callGraph, opts = {}) {
47
+ const fns = callGraph?.functions;
48
+ if (!fns || typeof fns.get !== 'function' || fns.size === 0) return [];
49
+ const maxAreas = Number.isInteger(opts.maxAreas) && opts.maxAreas > 0 ? opts.maxAreas : 8;
50
+
51
+ const dsu = makeDSU();
52
+ for (const fn of fns.values()) if (fn?.file) dsu.find(fn.file);
53
+ for (const e of callGraph.edges || []) {
54
+ const a = fns.get(e?.caller)?.file;
55
+ const b = fns.get(e?.callee)?.file;
56
+ if (a && b) dsu.union(a, b);
57
+ }
58
+
59
+ const filesByRoot = new Map();
60
+ for (const fn of fns.values()) {
61
+ if (!fn?.file) continue;
62
+ const root = dsu.find(fn.file);
63
+ if (!filesByRoot.has(root)) filesByRoot.set(root, new Set());
64
+ filesByRoot.get(root).add(fn.file);
65
+ }
66
+
67
+ const fnsByFile = new Map();
68
+ for (const fn of fns.values()) {
69
+ if (!fn?.file) continue;
70
+ if (!fnsByFile.has(fn.file)) fnsByFile.set(fn.file, []);
71
+ fnsByFile.get(fn.file).push(fn.qid);
72
+ }
73
+
74
+ const build = (files, label) => {
75
+ const sorted = [...files].sort();
76
+ const functions = sorted.flatMap(f => (fnsByFile.get(f) || [])).sort();
77
+ return { id: focusAreaId(sorted), label: label ?? labelFor(sorted), files: sorted, functions, size: functions.length };
78
+ };
79
+
80
+ let areas = [...filesByRoot.values()].map(s => build(s));
81
+ // Deterministic ranking: biggest first, ties broken by id so two runs on the
82
+ // same graph produce the same order.
83
+ areas.sort((a, b) => b.size - a.size || (a.id < b.id ? -1 : 1));
84
+
85
+ if (areas.length > maxAreas) {
86
+ const kept = areas.slice(0, maxAreas - 1);
87
+ const tail = areas.slice(maxAreas - 1);
88
+ kept.push(build(tail.flatMap(a => a.files), 'misc'));
89
+ areas = kept;
90
+ }
91
+ return areas;
92
+ }
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';
@@ -142,6 +144,7 @@ import { scanIacReachability } from './posture/iac-reachability.js';
142
144
  import { scanIamPolicies } from './posture/iam-policy.js';
143
145
  import { scanContainerRuntime } from './posture/container-runtime.js';
144
146
  import { scanBusinessLogic as scanBusinessLogicV2 } from './posture/business-logic.js';
147
+ import { ingestLogicClaims } from './posture/logic-claims.js';
145
148
  import { annotateNarration } from './posture/flow-narration.js';
146
149
  import { applyPathConstraints } from './posture/path-predicates.js';
147
150
  // Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
@@ -584,7 +587,16 @@ const SANITIZER_PATTERNS=[{regex:/(?:escape|escapeHtml|htmlspecialchars|encodeUR
584
587
  {regex:/(?:re\.escape|preg_quote|Regexp\.escape)\s*\(/g,type:"Regex Escaping"}];
585
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}];
586
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];
587
- 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"]);
588
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"]);
589
601
  // Feat-2: IaC manifest filenames that aren't extension-based.
590
602
  const IAC_FILENAMES = new Set(['Dockerfile', 'Containerfile', 'docker-compose.yml', 'docker-compose.yaml', 'Chart.yaml']);
@@ -2293,6 +2305,80 @@ function _resetSuppressions(){ _suppressionLog.length = 0; }
2293
2305
  function _pfrMetaOnly(ta){ if(!ta||typeof ta!=='object')return {}; const o={}; for(const k of Object.keys(ta)){ if(k==='findings'||k==='sources'||k==='sinks'||k==='sanitizers')continue; o[k]=ta[k]; } return o; }
2294
2306
  function _getSuppressions(){ return [..._suppressionLog]; }
2295
2307
 
2308
+ // ── inline suppression pragma ───────────────────────────────────────────────
2309
+ //
2310
+ // `// agentic-security-ignore: <rule-id>` on the offending line. This is
2311
+ // documented in the root CLAUDE.md and `pr-comment.js` tells every reviewer to
2312
+ // use it — and until now NOTHING implemented it. A suppression mechanism that
2313
+ // silently does nothing is worse than not having one: a developer writes the
2314
+ // pragma, sees the finding again, and concludes the scanner is noisy rather
2315
+ // than that the pragma is dead.
2316
+ //
2317
+ // MATCHED ON THE LINE, AND ON THE RULE. A bare pragma with no rule id
2318
+ // suppresses every finding on that line; with an id it suppresses only findings
2319
+ // whose id, vuln or CWE contains it. Line-scoped rather than file-scoped on
2320
+ // purpose — a file-wide opt-out is how a whole module quietly leaves coverage.
2321
+ //
2322
+ // EVERY SUPPRESSION IS LOGGED to the same ledger custom rules use, so
2323
+ // `--include-suppressed` and the suppression summary show them. A suppression
2324
+ // nobody can see is indistinguishable from a finding that never fired.
2325
+ const _IGNORE_PRAGMA_RE = /(?:\/\/|#|\/\*|<!--)\s*agentic-security-ignore\s*:?\s*([^\n*]*?)\s*(?:\*\/|-->)?\s*$/;
2326
+
2327
+ function _pragmaOnLine(content, line){
2328
+ if (typeof content !== 'string' || !Number.isInteger(line) || line < 1) return null;
2329
+ const lines = content.split('\n');
2330
+ if (line > lines.length) return null;
2331
+ const m = lines[line - 1].match(_IGNORE_PRAGMA_RE);
2332
+ if (!m) return null;
2333
+ return { rule: (m[1] || '').trim() };
2334
+ }
2335
+
2336
+ function _pragmaSuppresses(pragma, f){
2337
+ if (!pragma) return false;
2338
+ if (!pragma.rule) return true; // bare pragma: this line, any rule
2339
+ const want = pragma.rule.toLowerCase();
2340
+ const hay = `${f.id || ''} ${f.vuln || ''} ${f.cwe || ''} ${f.family || ''}`.toLowerCase();
2341
+ return hay.includes(want);
2342
+ }
2343
+
2344
+ // Filter one findings array in place. Returns the number removed.
2345
+ // Filter one findings array in place against inline pragmas. Returns the count.
2346
+ //
2347
+ // CALLED TWICE per scan, deliberately: once after the cross-file passes and
2348
+ // again after deep-mode IR findings are appended. See both call sites.
2349
+ //
2350
+ // KNOWN LIMITATION — a finding with no integer `line` can never be suppressed.
2351
+ // The guard below skips it, because a line-scoped pragma has nothing to match
2352
+ // against. This is not hypothetical: `struct:` detectors emit findings with no
2353
+ // `line` property at all (the line survives only inside the id string, e.g.
2354
+ // `struct:app.js:22:Mass_Assignment`), so a false positive from one of those
2355
+ // cannot be silenced by a pragma and has to be fixed at the source instead.
2356
+ //
2357
+ // A file-scoped fallback was considered and REJECTED. Widening a line pragma to
2358
+ // a whole file would silently suppress findings the author never looked at, and
2359
+ // silent over-suppression in a security tool is worse than the gap it closes.
2360
+ // The real fix is for struct detectors to carry a `line`; that changes finding
2361
+ // output repo-wide, moves the self-scan baseline, and belongs in its own change.
2362
+ function _applyIgnorePragmas(arr, fc){
2363
+ if (!Array.isArray(arr)) return 0;
2364
+ let removed = 0;
2365
+ for (let i = arr.length - 1; i >= 0; i--) {
2366
+ const f = arr[i];
2367
+ const file = f && (f.file || f.sink?.file);
2368
+ const line = f && Number(f.line ?? f.sink?.line);
2369
+ if (!file || !Number.isInteger(line)) continue;
2370
+ const pragma = _pragmaOnLine(fc[file], line);
2371
+ if (!_pragmaSuppresses(pragma, f)) continue;
2372
+ _suppressionLog.push({
2373
+ vuln: f.vuln, file, line, snippet: f.snippet || '',
2374
+ reason: `inline pragma: agentic-security-ignore${pragma.rule ? ': ' + pragma.rule : ''}`,
2375
+ });
2376
+ arr.splice(i, 1);
2377
+ removed++;
2378
+ }
2379
+ return removed;
2380
+ }
2381
+
2296
2382
  // FP-9 / Feat-4: custom rules loaded from .agentic-security/rules.{yml,yaml,json}
2297
2383
  // at scan root. Mutates SOURCE/SINK/SANITIZER pattern arrays in place when active;
2298
2384
  // snapshot lengths from the first call so subsequent scans can restore baseline.
@@ -2326,7 +2412,7 @@ async function _loadCustomRules(scanRoot){
2326
2412
  _customIgnorePaths = [];
2327
2413
  let raw = null, parsedObj = null;
2328
2414
  for (const ext of ['rules.yml', 'rules.yaml', 'rules.json']) {
2329
- const p = path.join(scanRoot, '.agentic-security', ext);
2415
+ const p = statePath(scanRoot, ext);
2330
2416
  try { raw = fs.readFileSync(p, 'utf8'); } catch { continue; }
2331
2417
  try {
2332
2418
  if (ext.endsWith('.json')) parsedObj = JSON.parse(raw);
@@ -7894,6 +7980,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7894
7980
  // AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
7895
7981
  if(process.env.AGENTIC_SECURITY_TREE_SITTER==='1'){try{aF.push(...await scanTreeSitterSinks(fc));}catch(_){}}
7896
7982
  let finalFindings;try{finalFindings=dedupeFindingsWithEvidence(aF);}catch(_){finalFindings=dd(aF,f=>f.id);}
7983
+ // Inline `agentic-security-ignore` pragmas, pass 1 of 2. This covers every
7984
+ // finding that exists BY THIS POINT — the pattern detectors, the cross-file
7985
+ // passes, and the logic and secrets buckets, which are the ones a developer
7986
+ // is most likely to want silenced on a specific line.
7987
+ //
7988
+ // It does NOT cover deep-mode IR findings: those are appended much further
7989
+ // down (search `finalFindings.push(...irFindings)`), so a second pass runs
7990
+ // there. For years this call carried a comment claiming it ran "after every
7991
+ // cross-file pass has appended", which was false for the deep path — a
7992
+ // correctly-formed pragma on the exact line of an ir-taint finding did
7993
+ // nothing, silently, in the mode the CLI actually uses.
7994
+ try{ _applyIgnorePragmas(finalFindings, fc); _applyIgnorePragmas(aLogic, fc); _applyIgnorePragmas(aSecrets, fc); }catch(_){}
7897
7995
  // #1 — centralized SSRF/path guard recognition: drop CWE-918/CWE-22 findings
7898
7996
  // on code hardened by a host allow/deny check or a path containment guard,
7899
7997
  // regardless of which detector emitted them. Opt out: AGENTIC_SECURITY_NO_GUARD_RECOGNITION=1.
@@ -7995,6 +8093,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7995
8093
  // can tell "didn't run" from "ran cleanly." The array is surfaced as
7996
8094
  // scan.annotatorErrors in the report; an empty array means clean.
7997
8095
  let _executionProofSummary = null, _vulnHistory = null;
8096
+ let _logicClaims = null;
7998
8097
  const _annotatorErrors = [];
7999
8098
  const _runAnnotator = (phase, fn) => {
8000
8099
  try { return fn(); }
@@ -8123,7 +8222,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8123
8222
  if (r && r.piiFields) {
8124
8223
  try {
8125
8224
  const dpia = emitDpiaArtifact(r.piiFields, r.findings || []);
8126
- fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'dpia.md'), dpia);
8225
+ _safeWriteState(_statePath(scanRoot, 'dpia.md'), dpia);
8127
8226
  } catch (_) {}
8128
8227
  }
8129
8228
  });
@@ -8372,6 +8471,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8372
8471
  f.validator_verdict = 'unvalidated';
8373
8472
  }
8374
8473
  finalFindings.push(...irFindings);
8474
+ // Pragma pass 2 of 2 — see the pass-1 comment far above. Deep-mode IR
8475
+ // findings land here, long after pass 1 ran, so without this an
8476
+ // `agentic-security-ignore` on an ir-taint finding is inert. Deep mode is
8477
+ // what the CLI uses outside CI and taint findings are the ones users most
8478
+ // want to silence, so the documented feature did nothing in the case that
8479
+ // mattered most.
8480
+ //
8481
+ // Re-running over the already-filtered array is safe and does not
8482
+ // double-log: pass 1's removals are gone from `finalFindings`, so only the
8483
+ // newly-appended IR findings can match here, and each suppression reaches
8484
+ // the ledger exactly once.
8485
+ try{ _applyIgnorePragmas(finalFindings, fc); }catch(_){}
8375
8486
  // Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
8376
8487
  try {
8377
8488
  for (const sc of supplyChain) {
@@ -8465,6 +8576,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8465
8576
  catch (e) { _annotatorErrors.push({ phase: '_enrichWithScorecard', err: String((e && e.message) || e) }); }
8466
8577
  // 0.8.0 Feat-10: license policy
8467
8578
  try{const lp=loadLicensePolicy(scanRoot);if(lp){const lv=evaluateLicensePolicy(annotatedComponents,lp);aLogic.push(...lv);}}catch(_){}
8579
+ // PRD Epic 6: business-logic claims from a reviewing agent, put through the
8580
+ // deterministic refutation lenses before they are allowed anywhere near the
8581
+ // report. A claim citing a file that was never scanned, misquoting the code,
8582
+ // or contradicted by the handler it names comes back quarantined. Refuted
8583
+ // claims are KEPT — the tier's contract is recall-preserving, same as
8584
+ // falsification's — so the reader can see what the reviewer said and why no
8585
+ // second party could corroborate it.
8586
+ try {
8587
+ if (scanRoot) {
8588
+ const raw = fs.readFileSync(statePath(scanRoot, 'logic-claims.json'), 'utf8');
8589
+ const parsed = JSON.parse(raw);
8590
+ const incoming = Array.isArray(parsed) ? parsed : (parsed && parsed.claims) || [];
8591
+ if (incoming.length) {
8592
+ const r = ingestLogicClaims(incoming, { fileContents: fc });
8593
+ _logicClaims = r.summary;
8594
+ aLogic.push(...r.claims);
8595
+ }
8596
+ }
8597
+ } catch(_) { /* absent or unreadable → the tier simply contributes nothing */ }
8468
8598
  // Phase 4 / Item 7 of the SCA improvement plan: load sca-policy.yml and
8469
8599
  // apply accept-risk / SLA / major-version-freeze rules. supplyChain
8470
8600
  // findings get suppressed/tagged in place.
@@ -8651,6 +8781,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8651
8781
  // Each is opt-in via env var. They produce machine-readable artifacts
8652
8782
  // (threat-model.json/.md, dpia.md, compliance-evidence.json/.md,
8653
8783
  // sbom-history/<sha>.json, exploit-bundles/) under .agentic-security/.
8784
+ let _privacyFramework = null;
8654
8785
  let _threatModel = null, _apiContractFindings = [], _sbomDiff = null,
8655
8786
  _complianceReport = null, _exploitBundles = null, _pqcPlan = null,
8656
8787
  _licenseGraph = null, _attributions = null, _taxonomySummary = null;
@@ -8718,6 +8849,29 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8718
8849
  if (_pqcPlan) persistPqcPlan(scanRoot, _pqcPlan);
8719
8850
  } catch (_) {}
8720
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
+ }
8721
8875
  // Exploit bundles — per-family PoC + Jest + pytest + remediation for
8722
8876
  // top-N critical/high findings.
8723
8877
  if (process.env.AGENTIC_SECURITY_NO_EXPLOIT_BUNDLES !== '1') {
@@ -8726,9 +8880,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8726
8880
  if (bundles.size) {
8727
8881
  _exploitBundles = {};
8728
8882
  for (const [id, b] of bundles) _exploitBundles[id] = b;
8729
- const bundlePath = path.join(scanRoot, '.agentic-security', 'exploit-bundles.json');
8730
- try { fs.mkdirSync(path.dirname(bundlePath), { recursive: true }); } catch {}
8731
- 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));
8732
8888
  }
8733
8889
  } catch (_) {}
8734
8890
  }
@@ -8762,7 +8918,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8762
8918
  // Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
8763
8919
  // detectors missed, with total-count accounting. Confirmed-only (cheap by default).
8764
8920
  let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
8765
- 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,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};}
8766
8922
 
8767
8923
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
8768
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
  }