@clear-capabilities/agentic-security-scanner 0.143.0 → 0.145.0

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 (135) hide show
  1. package/CHANGELOG.md +555 -0
  2. package/bin/agentic-security.js +770 -49
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +117 -18
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +24 -6
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/271.index.js +165 -0
  10. package/dist/384.index.js +1 -1
  11. package/dist/435.index.js +187 -52
  12. package/dist/444.index.js +11 -2
  13. package/dist/449.index.js +76 -12
  14. package/dist/526.index.js +117 -18
  15. package/dist/552.index.js +97 -0
  16. package/dist/637.index.js +27 -5
  17. package/dist/730.index.js +311 -0
  18. package/dist/736.index.js +301 -0
  19. package/dist/824.index.js +7 -0
  20. package/dist/905.index.js +88 -22
  21. package/dist/920.index.js +491 -0
  22. package/dist/970.index.js +173 -0
  23. package/dist/agentic-security.mjs +13 -13
  24. package/dist/agentic-security.mjs.sha256 +1 -1
  25. package/dist/calibration-seed.json +2 -0
  26. package/package.json +23 -9
  27. package/src/compare.js +6 -1
  28. package/src/dataflow/CLAUDE.md +1 -1
  29. package/src/dataflow/index.js +18 -0
  30. package/src/dataflow/privacy-catalog.js +290 -0
  31. package/src/dataflow/privacy-deep-walker.js +515 -0
  32. package/src/dataflow/privacy-governance.js +126 -0
  33. package/src/dataflow/privacy-inventory.js +154 -0
  34. package/src/dataflow/privacy-sink-policy.js +125 -0
  35. package/src/dataflow/privacy-taint.js +115 -54
  36. package/src/dataflow/privacy-taxonomy.js +233 -0
  37. package/src/discovery/disprove.js +7 -3
  38. package/src/discovery/hunter.js +9 -5
  39. package/src/discovery/index.js +2 -2
  40. package/src/discovery/llm-invoke.js +69 -13
  41. package/src/egress/audit.js +147 -0
  42. package/src/egress/policy.js +313 -0
  43. package/src/egress/redact.js +180 -0
  44. package/src/engine.js +1048 -302
  45. package/src/fix/apply-fix-service.js +404 -0
  46. package/src/fix/approver-registry.js +157 -0
  47. package/src/history-scan.js +22 -5
  48. package/src/ir/CLAUDE.md +1 -1
  49. package/src/llm-validator/index.js +86 -9
  50. package/src/llm-validator/model-status.js +66 -0
  51. package/src/lsp/server.js +49 -2
  52. package/src/mcp/tools.js +177 -50
  53. package/src/pipeline/analyzer-supervisor.js +93 -0
  54. package/src/pipeline/analyzer-worker.js +26 -0
  55. package/src/pipeline/annotator-runner.js +33 -0
  56. package/src/pipeline/assurance-mode.js +154 -0
  57. package/src/pipeline/cascade-worker-pool.js +172 -0
  58. package/src/pipeline/cascade-worker.js +43 -0
  59. package/src/pipeline/coverage-ledger.js +0 -0
  60. package/src/pipeline/detector-runner.js +51 -0
  61. package/src/pipeline/enrichment-completion.js +58 -0
  62. package/src/pipeline/evidence-provenance.js +91 -0
  63. package/src/pipeline/finding-schema.js +108 -0
  64. package/src/pipeline/legacy-compat.js +101 -0
  65. package/src/pipeline/producer-collector.js +48 -0
  66. package/src/pipeline/producer-registry.js +112 -0
  67. package/src/pipeline/scan-health.js +144 -0
  68. package/src/posture/CLAUDE.md +123 -0
  69. package/src/posture/accuracy-scorecard.js +156 -1
  70. package/src/posture/adversary-agent.js +15 -3
  71. package/src/posture/artifact-registry.js +241 -0
  72. package/src/posture/auditor-walkthrough.js +186 -21
  73. package/src/posture/calibration-feedback.js +201 -0
  74. package/src/posture/calibration-seed.json +2 -0
  75. package/src/posture/calibration.js +25 -0
  76. package/src/posture/compliance-evidence-signing.js +131 -0
  77. package/src/posture/compliance-policy.js +324 -17
  78. package/src/posture/cross-repo-memory.js +7 -2
  79. package/src/posture/custom-rules.js +36 -0
  80. package/src/posture/deterministic.js +8 -1
  81. package/src/posture/encryption-provider.js +205 -0
  82. package/src/posture/evidence-grade-wording.js +71 -0
  83. package/src/posture/fix-history.js +137 -20
  84. package/src/posture/fix-honesty-gate.js +47 -6
  85. package/src/posture/fix-verify.js +65 -8
  86. package/src/posture/fleet.js +0 -0
  87. package/src/posture/flow-narration.js +7 -2
  88. package/src/posture/git-history.js +13 -5
  89. package/src/posture/legal-hold.js +140 -0
  90. package/src/posture/llm-redteam.js +10 -1
  91. package/src/posture/material-change.js +111 -2
  92. package/src/posture/mttr.js +75 -12
  93. package/src/posture/policy-bundle.js +274 -0
  94. package/src/posture/pre-incident-archaeology.js +39 -7
  95. package/src/posture/privacy-framework.js +47 -6
  96. package/src/posture/production-feedback.js +179 -0
  97. package/src/posture/provenance/ai-authorship.js +68 -0
  98. package/src/posture/provenance/branch-entry.js +80 -0
  99. package/src/posture/provenance/cache.js +143 -0
  100. package/src/posture/provenance/confidence.js +36 -0
  101. package/src/posture/provenance/coordinator.js +786 -0
  102. package/src/posture/provenance/dag-walk.js +249 -0
  103. package/src/posture/provenance/evidence-attribution.js +59 -0
  104. package/src/posture/provenance/git-evidence.js +310 -0
  105. package/src/posture/provenance/lifecycle.js +208 -0
  106. package/src/posture/provenance/missing-control-resolver.js +137 -0
  107. package/src/posture/provenance/origin-resolver.js +342 -0
  108. package/src/posture/provenance/predicate-replay.js +133 -0
  109. package/src/posture/provenance/providers/config.js +39 -0
  110. package/src/posture/provenance/providers/github.js +62 -0
  111. package/src/posture/provenance/providers/gitlab.js +58 -0
  112. package/src/posture/provenance/repo-lineage.js +74 -0
  113. package/src/posture/provenance/sca-origin.js +139 -0
  114. package/src/posture/provenance/schema.js +255 -0
  115. package/src/posture/provenance/transitive-sca.js +147 -0
  116. package/src/posture/provenance/validate.js +30 -0
  117. package/src/posture/provenance-evidence-bundle.js +144 -0
  118. package/src/posture/retention-policy.js +132 -0
  119. package/src/posture/risk-dollars.js +216 -26
  120. package/src/posture/sbom-diff.js +15 -2
  121. package/src/posture/scan-checkpoint.js +176 -31
  122. package/src/posture/secret-history.js +10 -2
  123. package/src/posture/state-dir.js +64 -5
  124. package/src/posture/state-lifecycle-report.js +77 -0
  125. package/src/posture/suppressions.js +59 -3
  126. package/src/posture/vuln-archaeology.js +8 -2
  127. package/src/pr-delta.js +25 -4
  128. package/src/privacy/ir-adapter.js +380 -0
  129. package/src/report/index.js +248 -5
  130. package/src/report/oscal.js +7 -2
  131. package/src/runScan.js +34 -5
  132. package/src/sast/cpp.js +3 -14
  133. package/src/sast/rate-limit.js +33 -3
  134. package/src/sca/llm-function-extract.js +6 -0
  135. package/src/util/git-hardening.js +128 -0
@@ -18,22 +18,40 @@
18
18
  // 2. Only the per-file loop is checkpointed. Every cross-file pass and the
19
19
  // whole annotation pipeline re-runs from scratch on resume, so nothing that
20
20
  // depends on the global picture can be stale by construction.
21
- // 3. Invalidation is conservative to the point of being blunt. The run key
22
- // covers the engine version, the ruleset version, the bundle SHA, a content
23
- // hash of every file in the scan (which subsumes mtime), and the scanner's
24
- // own environment switches. If any of it moved, the checkpoint is discarded
25
- // and the scan starts clean. Redoing work is merely slow; resuming stale
26
- // work is a correctness bug.
21
+ // 3. Invalidation is split into a GLOBAL identity and a PER-FILE identity
22
+ // (assurance-hardening PRD FR-208 "changed inputs invalidate only
23
+ // affected checkpoints and record the invalidation reason"). The engine
24
+ // version, ruleset version, bundle SHA, dependency-manifest contents, and
25
+ // the scanner's own environment switches form the GLOBAL key: any of them
26
+ // changing affects how EVERY file would be analysed (decision #2 above —
27
+ // cross-file/dependency-derived results aren't checkpointed per-file at
28
+ // all), so a global-key mismatch still discards the whole checkpoint,
29
+ // exactly as before. A single scanned file's own content is NOT part of
30
+ // the global key: only THAT file's record is invalidated when its content
31
+ // changes, because a single file's own per-file analysis result depends
32
+ // only on that file's content plus the (unchanged) global identity — the
33
+ // cross-file passes that COULD make it depend on other files always
34
+ // re-run from scratch per decision #2. This was a real, deliberate
35
+ // widening from the original "any change discards everything" design,
36
+ // not a relaxation of the correctness property: it only reuses a
37
+ // per-file result when nothing that result could possibly depend on has
38
+ // moved. Redoing work is merely slow; resuming stale work is a
39
+ // correctness bug, and that bar has not moved.
27
40
  //
28
41
  // CRASH SAFETY: append-and-fsync. The file is a JSONL log — one header line
29
- // pinning the run key, then one self-describing record per completed file,
30
- // each carrying a SHA-256 of its own payload. Every record is written with a
42
+ // pinning the global key (plus a plaintext meta summary used only to explain a
43
+ // mismatch, never to decide one), then one self-describing record per
44
+ // completed file, each carrying a SHA-256 of its own payload AND a SHA-256 of
45
+ // the source content it was computed from. Every record is written with a
31
46
  // single `writeSync` and immediately `fsyncSync`'d before the next file is
32
47
  // analysed, so a process killed at any instant leaves either a complete record
33
48
  // or a torn tail. On recovery we read forward while records verify and truncate
34
49
  // the file at the last byte offset that did, so a torn tail is discarded rather
35
50
  // than resumed into. Nothing is ever rewritten in place, so there is no window
36
- // in which the file is neither the old state nor the new one.
51
+ // in which the file is neither the old state nor the new one. A record whose
52
+ // OWN content hash no longer matches the current file is a separate, later
53
+ // check from tamper/tear detection — it is structurally intact, so it is not
54
+ // truncated away, just excluded from what gets replayed (see `_recover`).
37
55
  //
38
56
  // Everything here follows the posture convention of never throwing: a failure to
39
57
  // open, read or append degrades to "no checkpoint", which just means a full scan.
@@ -54,7 +72,12 @@ import { stateWritesEnabled } from './state-dir.js';
54
72
  // exists to prevent. The read-only switch above is still honoured.
55
73
  const STATE_DIR = '.agentic-security';
56
74
  const FILE_NAME = 'scan-checkpoint.jsonl';
57
- const FORMAT = 'agentic-security-scan-checkpoint/1';
75
+ // Bumped from /1: the header now stores a GLOBAL-only key (FR-208) instead of
76
+ // one that folds in every scanned file's content, plus a plaintext meta
77
+ // summary. A /1 checkpoint on disk must never be half-interpreted under /2
78
+ // semantics, so the format bump alone is enough to force a clean discard of
79
+ // anything written before this change — the safest possible migration.
80
+ const FORMAT = 'agentic-security-scan-checkpoint/2';
58
81
 
59
82
  // Env switches that change what the engine emits are part of the run identity.
60
83
  // These three are deliberately excluded: they change how the run is driven, not
@@ -92,6 +115,14 @@ export function bundleShaForRunKey() {
92
115
  * Everything that would invalidate previously-completed per-file work, reduced
93
116
  * to one hex digest. Content hashes rather than mtimes: strictly stronger, and
94
117
  * immune to filesystems with coarse or non-monotonic timestamps.
118
+ *
119
+ * Retained as its own function (still folding in `fileContents`) because it is
120
+ * a useful, independently-tested "did ANYTHING about this scan change"
121
+ * fingerprint, and because `computeGlobalKey` below is defined in terms of it
122
+ * (fileContents forced empty) — changing this function's hash would silently
123
+ * change that one too. Production checkpointing itself uses `computeGlobalKey`,
124
+ * not this function directly; see FR-208's note in the module header for why
125
+ * file content is no longer part of what invalidates the WHOLE checkpoint.
95
126
  */
96
127
  export function computeRunKey({
97
128
  engineVersion, rulesetVersion, bundleSha,
@@ -119,6 +150,50 @@ export function computeRunKey({
119
150
  return h.digest('hex');
120
151
  }
121
152
 
153
+ /**
154
+ * FR-208: the GLOBAL portion of run identity — everything that would affect
155
+ * how EVERY scanned file is analysed. Deliberately excludes `fileContents`
156
+ * (a single file's own content is checked per-record in `_recover` instead)
157
+ * but keeps `depFileContents`: dependency-manifest-derived findings are not
158
+ * part of any per-file checkpoint record (they're recomputed fresh every run,
159
+ * same as cross-file taint — decision #2 in the module header), so there is
160
+ * no per-file granularity to offer there; any manifest change invalidates
161
+ * everything, same as before this change.
162
+ */
163
+ export function computeGlobalKey({ engineVersion, rulesetVersion, bundleSha, depFileContents = {}, env = process.env } = {}) {
164
+ return computeRunKey({ engineVersion, rulesetVersion, bundleSha, fileContents: {}, depFileContents, env });
165
+ }
166
+
167
+ /**
168
+ * A plaintext (non-hashed) summary of the same inputs `computeGlobalKey` folds
169
+ * in, stored alongside the key in the checkpoint header. Used ONLY to explain
170
+ * a global-key mismatch after the fact (`_explainGlobalMismatch`) — never to
171
+ * decide whether one occurred; the key comparison remains the sole source of
172
+ * truth for that, so a caller cannot bypass invalidation by supplying
173
+ * mismatched meta.
174
+ */
175
+ export function globalKeyMeta({ engineVersion, rulesetVersion, bundleSha, depFileContents = {}, env = process.env } = {}) {
176
+ return {
177
+ engineVersion: String(engineVersion ?? ''),
178
+ rulesetVersion: String(rulesetVersion ?? ''),
179
+ bundleSha: String(bundleSha ?? 'unavailable'),
180
+ depFingerprint: computeRunKey({ engineVersion: '', rulesetVersion: '', bundleSha: '', fileContents: {}, depFileContents, env: {} }),
181
+ envFingerprint: computeRunKey({ engineVersion: '', rulesetVersion: '', bundleSha: '', fileContents: {}, depFileContents: {}, env }),
182
+ };
183
+ }
184
+
185
+ function _explainGlobalMismatch(oldMeta, newMeta) {
186
+ oldMeta = oldMeta || {};
187
+ newMeta = newMeta || {};
188
+ const parts = [];
189
+ if (oldMeta.engineVersion !== newMeta.engineVersion) parts.push(`engine version changed (${oldMeta.engineVersion || 'unknown'} -> ${newMeta.engineVersion || 'unknown'})`);
190
+ if (oldMeta.rulesetVersion !== newMeta.rulesetVersion) parts.push(`ruleset version changed (${oldMeta.rulesetVersion || 'unknown'} -> ${newMeta.rulesetVersion || 'unknown'})`);
191
+ if (oldMeta.bundleSha !== newMeta.bundleSha) parts.push('the running bundle changed');
192
+ if (oldMeta.depFingerprint !== newMeta.depFingerprint) parts.push('a dependency manifest file changed');
193
+ if (oldMeta.envFingerprint !== newMeta.envFingerprint) parts.push('an AGENTIC_SECURITY_* environment switch changed');
194
+ return parts.length ? `global scan identity changed: ${parts.join(', ')}` : 'global scan identity changed';
195
+ }
196
+
122
197
  // A value is safe to checkpoint only if JSON can carry it back unchanged. Dates,
123
198
  // regexes, Maps, Sets, functions and BigInts all survive `JSON.stringify` in a
124
199
  // lossy or throwing way; recording one would mean the resumed run sees different
@@ -150,28 +225,39 @@ function _jsonSafe(v, depth = 0, seen = new Set()) {
150
225
 
151
226
  function _emptyHandle(reason) {
152
227
  return {
153
- enabled: false, file: null, fd: null, runKey: null,
228
+ enabled: false, file: null, fd: null, globalKey: null,
154
229
  recovered: new Map(), order: [], written: new Set(),
155
- discarded: false, reason,
230
+ discarded: false, reason, invalidatedFiles: [], fileContents: {},
156
231
  };
157
232
  }
158
233
 
159
- function _headerLine(runKey) {
160
- return JSON.stringify({ v: FORMAT, runKey }) + '\n';
234
+ function _headerLine(globalKey, meta) {
235
+ return JSON.stringify({ v: FORMAT, gk: globalKey, meta: meta || {} }) + '\n';
161
236
  }
162
237
 
163
238
  // Read forward from a byte offset, keeping records while they verify. Returns
164
- // the offset of the first byte that did NOT verify, so the caller can truncate.
165
- function _recover(handle, file, runKey) {
239
+ // {offset, discardReason}: offset is the byte position the caller should keep
240
+ // (or truncate to); discardReason is set only when offset===0 (the whole
241
+ // checkpoint is being discarded) and explains why, per FR-208.
242
+ //
243
+ // A record whose payload hash (`c`) doesn't verify is torn/tampered and ends
244
+ // recovery right there, same as before FR-208. A record that verifies
245
+ // structurally but whose OWN content hash (`h`) no longer matches the
246
+ // CURRENT content of that file is a different, later check — it is not
247
+ // corrupt, so it does not truncate the file; it is simply excluded from
248
+ // `handle.recovered` and named in `handle.invalidatedFiles`, so only that
249
+ // file gets re-analysed this run.
250
+ function _recover(handle, file, globalKey, meta, fileContents) {
166
251
  let buf;
167
252
  try { buf = fs.readFileSync(file); }
168
- catch { return -1; } // no file yet
253
+ catch { return { offset: -1, discardReason: null }; } // no file yet
169
254
  const text = buf.toString('utf8');
170
255
  const nl = text.indexOf('\n');
171
- if (nl < 0) return 0;
256
+ if (nl < 0) return { offset: 0, discardReason: 'empty or corrupt checkpoint file' };
172
257
  let header = null;
173
- try { header = JSON.parse(text.slice(0, nl)); } catch { return 0; }
174
- if (!header || header.v !== FORMAT || header.runKey !== runKey) return 0;
258
+ try { header = JSON.parse(text.slice(0, nl)); } catch { return { offset: 0, discardReason: 'corrupt checkpoint header' }; }
259
+ if (!header || header.v !== FORMAT) return { offset: 0, discardReason: 'checkpoint format changed' };
260
+ if (header.gk !== globalKey) return { offset: 0, discardReason: _explainGlobalMismatch(header.meta, meta) };
175
261
 
176
262
  let offset = Buffer.byteLength(text.slice(0, nl + 1), 'utf8');
177
263
  let cursor = nl + 1;
@@ -187,19 +273,50 @@ function _recover(handle, file, runKey) {
187
273
  if (rec.c !== _sha(rec.d)) break; // tampered or torn-then-patched
188
274
  let payload;
189
275
  try { payload = JSON.parse(rec.d); } catch { break; }
276
+ // Structurally intact and parseable — advance past it regardless of what
277
+ // the per-file content check below decides; a stale-but-intact record is
278
+ // not a torn or tampered one and must not be truncated away.
279
+ offset = Buffer.byteLength(text.slice(0, cursor), 'utf8');
280
+ // FR-208: per-file content check. A record written with no tracked
281
+ // source hash (`h` absent — e.g. a caller that never supplied
282
+ // fileContents) has nothing to compare against, so it is trusted exactly
283
+ // as it was before this per-file check existed.
284
+ if (rec.h != null) {
285
+ const current = fileContents ? fileContents[rec.f] : undefined;
286
+ if (typeof current !== 'string') {
287
+ handle.invalidatedFiles.push({ file: rec.f, reason: 'no longer part of this scan' });
288
+ continue;
289
+ }
290
+ if (rec.h !== _sha(current)) {
291
+ handle.invalidatedFiles.push({ file: rec.f, reason: 'content changed since it was checkpointed' });
292
+ continue;
293
+ }
294
+ }
190
295
  if (!handle.recovered.has(rec.f)) handle.order.push(rec.f);
191
296
  handle.recovered.set(rec.f, payload);
192
- offset = Buffer.byteLength(text.slice(0, cursor), 'utf8');
193
297
  }
194
- return offset;
298
+ return { offset, discardReason: null };
195
299
  }
196
300
 
197
301
  /**
198
- * Open (or start) the checkpoint for `scanRoot` under `runKey`. Never throws.
199
- * A handle whose `enabled` is false silently no-ops through the rest of the API.
302
+ * Open (or start) the checkpoint for `scanRoot` under `globalKey`. Never
303
+ * throws. A handle whose `enabled` is false silently no-ops through the rest
304
+ * of the API.
305
+ *
306
+ * `fileContents` (FR-208): the CURRENT content of every file this scan would
307
+ * analyse, keyed by the same relative path `recordFileDone` is called with.
308
+ * Passed through to per-file content comparison on recovery, and to
309
+ * `recordFileDone` for hashing each new record's own content. Omitting it
310
+ * (or passing `{}`) degrades every record to trust-on-structural-validity
311
+ * only — the pre-FR-208 behaviour — which is exactly what a caller testing
312
+ * the checkpoint PROTOCOL itself (not per-file invalidation) wants, and
313
+ * exactly what the low-level tests in this module's test file rely on.
314
+ *
315
+ * `meta`: a plaintext summary of the inputs behind `globalKey` (see
316
+ * `globalKeyMeta`), stored in the header purely to explain a mismatch later.
200
317
  */
201
- export function openCheckpoint(scanRoot, { runKey } = {}) {
202
- if (!scanRoot || !runKey) return _emptyHandle('no-run-key');
318
+ export function openCheckpoint(scanRoot, { globalKey, meta = {}, fileContents = {} } = {}) {
319
+ if (!scanRoot || !globalKey) return _emptyHandle('no-global-key');
203
320
  // A read-only scan cannot checkpoint, and must not try. Resume is purely an
204
321
  // optimisation — without it the scan recomputes, which is slower and
205
322
  // identical — so `--no-state` wins over `AGENTIC_SECURITY_RESUME=1` rather
@@ -207,20 +324,25 @@ export function openCheckpoint(scanRoot, { runKey } = {}) {
207
324
  // the API, so no caller needs a new branch. (PRD M1)
208
325
  if (!stateWritesEnabled()) return _emptyHandle('state-writes-disabled');
209
326
  const handle = _emptyHandle(null);
327
+ handle.fileContents = fileContents || {};
210
328
  try {
211
329
  const dir = path.join(scanRoot, STATE_DIR);
212
330
  fs.mkdirSync(dir, { recursive: true });
213
331
  const file = checkpointPath(scanRoot);
214
332
  handle.file = file;
215
- handle.runKey = runKey;
333
+ handle.globalKey = globalKey;
216
334
 
217
- const keepBytes = _recover(handle, file, runKey);
335
+ const { offset: keepBytes, discardReason } = _recover(handle, file, globalKey, meta, handle.fileContents);
218
336
  if (keepBytes <= 0) {
219
- // Absent, foreign, or unreadable — start clean. Conservative by design.
337
+ // Absent, foreign, or globally invalidated — start clean. A byte offset
338
+ // of -1 (no file yet) is a fresh checkpoint, not a discard, so it gets
339
+ // no reason; 0 (format/global-key mismatch, or corrupt header) does.
220
340
  handle.recovered.clear();
221
341
  handle.order.length = 0;
342
+ handle.invalidatedFiles = [];
222
343
  handle.discarded = keepBytes === 0;
223
- fs.writeFileSync(file, _headerLine(runKey));
344
+ handle.reason = keepBytes === 0 ? discardReason : null;
345
+ fs.writeFileSync(file, _headerLine(globalKey, meta));
224
346
  } else {
225
347
  // Drop any torn tail so appends land after the last verified record.
226
348
  try {
@@ -243,6 +365,13 @@ export function openCheckpoint(scanRoot, { runKey } = {}) {
243
365
  * analysis produced. `findings` is the per-file payload object (see the engine
244
366
  * call site); it must be plain JSON data. Returns true only if the record is on
245
367
  * disk and fsync'd.
368
+ *
369
+ * Also records a SHA-256 of `relPath`'s current content (from the
370
+ * `fileContents` the handle was opened with), so a later run's `_recover` can
371
+ * tell whether this specific file has changed since — the FR-208 per-file
372
+ * half of invalidation. A handle opened without `fileContents` (or one that
373
+ * doesn't include this path) records no hash, which `_recover` treats as
374
+ * "nothing to compare," matching this module's pre-FR-208 behaviour exactly.
246
375
  */
247
376
  export function recordFileDone(handle, relPath, findings) {
248
377
  if (!handle || !handle.enabled || handle.fd === null || typeof relPath !== 'string') return false;
@@ -250,7 +379,9 @@ export function recordFileDone(handle, relPath, findings) {
250
379
  if (!_jsonSafe(findings)) return false;
251
380
  const d = JSON.stringify(findings === undefined ? null : findings);
252
381
  if (typeof d !== 'string') return false;
253
- const line = JSON.stringify({ f: relPath, c: _sha(d), d }) + '\n';
382
+ const src = handle.fileContents ? handle.fileContents[relPath] : undefined;
383
+ const h = typeof src === 'string' ? _sha(src) : null;
384
+ const line = JSON.stringify({ f: relPath, h, c: _sha(d), d }) + '\n';
254
385
  fs.writeSync(handle.fd, line);
255
386
  fs.fsyncSync(handle.fd);
256
387
  handle.written.add(relPath);
@@ -260,6 +391,20 @@ export function recordFileDone(handle, relPath, findings) {
260
391
  }
261
392
  }
262
393
 
394
+ /**
395
+ * FR-208: individually-invalidated files from a checkpoint whose GLOBAL
396
+ * identity still matched — each entry names the file and why (content
397
+ * changed, or the file is no longer part of this scan). Deliberately does
398
+ * NOT enumerate every file when the whole checkpoint was discarded instead
399
+ * (`handle.discarded`/`handle.reason` already say why, once, for all of
400
+ * them — reading the rest of a large journal just to repeat the same reason
401
+ * per file would cost real I/O for no new information). Always `[]` on a
402
+ * disabled handle, a brand-new checkpoint, or a global discard.
403
+ */
404
+ export function invalidatedFiles(handle) {
405
+ return (handle && Array.isArray(handle.invalidatedFiles)) ? handle.invalidatedFiles : [];
406
+ }
407
+
263
408
  /** Files already analysed — recovered from a prior run plus written by this one. */
264
409
  export function completedFiles(handle) {
265
410
  const out = new Set();
@@ -12,6 +12,7 @@
12
12
  // needs network and is deferred — see the rollup.
13
13
 
14
14
  import { execFileSync } from 'node:child_process';
15
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
15
16
 
16
17
  // Pull the post-image (added) lines out of a unified diff: lines starting with
17
18
  // a single '+' (not the '+++' file header). Returns reconstructed text.
@@ -62,8 +63,15 @@ export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs
62
63
  if (!scanRoot || typeof detectFn !== 'function') return [];
63
64
  let out;
64
65
  try {
65
- out = execFileSync('git', ['-C', scanRoot, 'log', '-p', '-n', String(maxCommits), '--no-color', '--no-merges', '--no-textconv'],
66
- { encoding: 'utf8', maxBuffer: 96 * 1024 * 1024, timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'] });
66
+ // Second independent Finding Provenance PRD audit (FR-PROV-024): this
67
+ // scanRoot is a scanned repository, not this project's own trusted
68
+ // checkout. `--no-textconv` alone (the pre-existing hardening here) closes
69
+ // the .gitattributes textconv surface but NOT `core.fsmonitor` /
70
+ // `core.hooksPath` — this `log -p` call renders every historical commit's
71
+ // diff content, the same shape verified exploitable in
72
+ // provenance/git-evidence.js, so it gets the full hardening too.
73
+ out = execFileSync('git', hardenGitArgs(['-C', scanRoot, 'log', '-p', '-n', String(maxCommits), '--no-color', '--no-merges', '--no-textconv']),
74
+ { encoding: 'utf8', maxBuffer: 96 * 1024 * 1024, timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() });
67
75
  } catch { return []; }
68
76
  const parts = out.split(/^commit ([0-9a-f]{7,40})/m); // [pre, sha, body, sha, body, ...]
69
77
  const findings = [];
@@ -16,7 +16,12 @@
16
16
  import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
18
 
19
- const STATE_DIR_NAME = '.agentic-security';
19
+ // Exported so a caller that needs the state dir NAME for display purposes
20
+ // (e.g. a human-readable "this file lives at X/Y" string, not an actual
21
+ // path construction) can go through the seam too, rather than hardcoding
22
+ // the literal — see test/no-stray-state.test.js's own detector, which
23
+ // flags a quoted '.agentic-security' literal anywhere else in the tree.
24
+ export const STATE_DIR_NAME = '.agentic-security';
20
25
 
21
26
  const PROJECT_MARKERS = [
22
27
  '.git',
@@ -125,6 +130,14 @@ export function isSafeStateDir(dir) {
125
130
  // per-caller discipline already failed once: its header records a user who
126
131
  // uninstalled the plugin after stray state directories broke their build.
127
132
  let _stateWritesEnabled = true;
133
+ // Category-scoped override (M2 §2.4 performance fix): when the blanket
134
+ // switch above is OFF, a category listed here still writes. Exists for
135
+ // lsp/server.js, which needs the provenance disk cache live on every
136
+ // keystroke-save while every OTHER state write (dpia.md, lifecycle.json,
137
+ // ...) stays suppressed — see the withStateWritesDisabled call site in
138
+ // lsp/server.js for why the blanket switch alone made every LSP scan pay
139
+ // the FULL uncached provenance-resolution cost on every single save.
140
+ let _enabledCategories = new Set();
128
141
 
129
142
  /** Turn all state writing off (or back on) for this process. */
130
143
  export function setStateWritesEnabled(enabled) {
@@ -143,10 +156,56 @@ export function stateWritesEnabled() {
143
156
  return _stateWritesEnabled;
144
157
  }
145
158
 
159
+ // Category-aware check used by ensureStateDir/safeWriteState below. A
160
+ // caller that never passes `category` behaves EXACTLY as before: it only
161
+ // ever consults the blanket switch.
162
+ function _categoryEnabled(category) {
163
+ if (process.env.AGENTIC_SECURITY_NO_STATE === '1') return false;
164
+ if (_stateWritesEnabled) return true;
165
+ return !!category && _enabledCategories.has(category);
166
+ }
167
+
168
+ /**
169
+ * Run `fn` with state writes forced off, restoring the PRIOR flag value
170
+ * afterward — for a caller (assurance-hardening PRD FR-704) that must
171
+ * guarantee ITS scan does not mutate the tree, without having to remember
172
+ * to call setStateWritesEnabled(true) again itself. A caller that disables
173
+ * writes and forgets to re-enable them silently breaks every LATER write in
174
+ * the same process — exactly `apply_fix`'s failure mode this wrapper exists
175
+ * to prevent, via `finally` rather than caller discipline.
176
+ *
177
+ * `exceptCategories` lets a caller keep ONE narrow category of write alive
178
+ * while everything else stays suppressed — see lsp/server.js. Restored via
179
+ * the same `finally` as the blanket flag, for the same reason.
180
+ *
181
+ * KNOWN LIMITATION: `_stateWritesEnabled` and `_enabledCategories` are both
182
+ * process-global, not per-call. Two overlapping calls to this function (or
183
+ * one overlapping a direct setStateWritesEnabled() call) can race and leave
184
+ * the flags in the wrong state for one of them once both finish. mcp/CLAUDE.md
185
+ * already documents an accepted concurrency limitation of the same shape for
186
+ * fix-history.js ("concurrent apply_fix calls can race... today benign... a
187
+ * future stateful tool needs serialization") — this is the same class of
188
+ * risk, not a new one, and this wrapper is still a strict improvement over
189
+ * the alternative it replaces (a caller that writes state UNCONDITIONALLY on
190
+ * every call, with no opt-out at all).
191
+ */
192
+ export async function withStateWritesDisabled(fn, { exceptCategories = [] } = {}) {
193
+ const prior = _stateWritesEnabled;
194
+ const priorCategories = new Set(_enabledCategories);
195
+ _stateWritesEnabled = false;
196
+ for (const c of exceptCategories) _enabledCategories.add(c);
197
+ try {
198
+ return await fn();
199
+ } finally {
200
+ _stateWritesEnabled = prior;
201
+ _enabledCategories = priorCategories;
202
+ }
203
+ }
204
+
146
205
  // Safe mkdir: only creates .agentic-security/ if the parent has a project marker.
147
206
  // Returns the dir on success, null if refused. Logs a warning when refused.
148
- export function ensureStateDir(scanRoot) {
149
- if (!stateWritesEnabled()) return null;
207
+ export function ensureStateDir(scanRoot, { category } = {}) {
208
+ if (!_categoryEnabled(category)) return null;
150
209
  const dir = stateDir(scanRoot);
151
210
  if (!isSafeStateDir(dir)) {
152
211
  if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
@@ -164,8 +223,8 @@ export function ensureStateDir(scanRoot) {
164
223
 
165
224
  // Safe write: only writes if isSafeStateDir(parent) returns true.
166
225
  // Returns true on success, false if refused or errored.
167
- export function safeWriteState(filePath, content) {
168
- if (!stateWritesEnabled()) return false;
226
+ export function safeWriteState(filePath, content, { category } = {}) {
227
+ if (!_categoryEnabled(category)) return false;
169
228
  const dir = path.dirname(filePath);
170
229
  if (!isSafeStateDir(dir)) {
171
230
  if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
@@ -0,0 +1,77 @@
1
+ // FR-706 (assurance-hardening PRD): manifest-based export and deletion
2
+ // reports — "Operators can prove what was exported, deleted, retained, or
3
+ // failed."
4
+ //
5
+ // Two report kinds, one shared per-item shape ({name, status, ...}), each
6
+ // written by its one real caller in bin/agentic-security.js:
7
+ // - `cmdReset` builds a deletion report from the SAME target/preserved
8
+ // data it already computes for its console output (see FR-702/FR-703),
9
+ // reshaped into a durable, structured artifact instead of only ever
10
+ // printed and lost. Written on BOTH a dry run (items carry status
11
+ // 'planned') and a real run (status 'deleted'/'failed'), so an operator
12
+ // can prove either "what would happen" or "what did happen" depending
13
+ // on which they asked for — `dryRun` on the report says which.
14
+ // - `cmdExport` (new) builds an export report after copying every
15
+ // currently-present registered artifact to an operator-chosen
16
+ // destination, naming what was copied (with a hash) and what failed.
17
+ //
18
+ // Each report is a SINGLE OVERWRITTEN file per kind — the same "last
19
+ // action" precedent as last-scan.json, not an ever-growing log. An
20
+ // operator who needs a persistent audit trail across many runs should
21
+ // retain these externally (commit them, forward to their own SIEM); this
22
+ // module's job is only to make the LAST action's outcome durable and
23
+ // provable, not to be the audit log itself.
24
+
25
+ import { statePath, safeWriteState } from './state-dir.js';
26
+
27
+ export const DELETION_REPORT_FILE = 'deletion-report.json';
28
+ export const EXPORT_REPORT_FILE = 'export-report.json';
29
+
30
+ export function buildDeletionReport({ mode, dryRun, root, items, preserved }) {
31
+ const list = Array.isArray(items) ? items : [];
32
+ const preservedList = Array.isArray(preserved) ? preserved : [];
33
+ return {
34
+ schema: 'agentic-security/deletion-report@1',
35
+ generatedAt: new Date().toISOString(),
36
+ mode: mode || 'reset',
37
+ dryRun: !!dryRun,
38
+ root: root || null,
39
+ items: list,
40
+ preserved: preservedList,
41
+ summary: {
42
+ planned: list.filter(i => i.status === 'planned').length,
43
+ deleted: list.filter(i => i.status === 'deleted').length,
44
+ failed: list.filter(i => i.status === 'failed').length,
45
+ preserved: preservedList.length,
46
+ },
47
+ };
48
+ }
49
+
50
+ export function writeDeletionReport(scanRoot, report) {
51
+ try {
52
+ const fp = statePath(scanRoot, DELETION_REPORT_FILE);
53
+ return safeWriteState(fp, JSON.stringify(report, null, 2) + '\n') ? fp : null;
54
+ } catch { return null; }
55
+ }
56
+
57
+ export function buildExportReport({ root, outDir, items }) {
58
+ const list = Array.isArray(items) ? items : [];
59
+ return {
60
+ schema: 'agentic-security/export-report@1',
61
+ generatedAt: new Date().toISOString(),
62
+ root: root || null,
63
+ outDir: outDir || null,
64
+ items: list,
65
+ summary: {
66
+ exported: list.filter(i => i.status === 'exported').length,
67
+ failed: list.filter(i => i.status === 'failed').length,
68
+ },
69
+ };
70
+ }
71
+
72
+ export function writeExportReport(scanRoot, report) {
73
+ try {
74
+ const fp = statePath(scanRoot, EXPORT_REPORT_FILE);
75
+ return safeWriteState(fp, JSON.stringify(report, null, 2) + '\n') ? fp : null;
76
+ } catch { return null; }
77
+ }
@@ -4,11 +4,41 @@
4
4
  // reviewer + expiry + rule-version pin)
5
5
  // One function `applySuppressions(findings, scanRoot, profile)` filters in
6
6
  // place. Loaders accept malformed input gracefully (skip bad entries, log).
7
+ //
8
+ // FR-1004 (assurance-hardening PRD): "exception owner, reason, scope,
9
+ // compensating control, and expiry." The pro schema already had reason
10
+ // (`reason`) and expiry (`expires_at`); `owner`, `scope`, and
11
+ // `compensating_control` are added here as required fields on the SAME
12
+ // entry, validated by the SAME `validateProSuppression` gate that already
13
+ // enforces the two-person rule. This is a deliberate tightening: an
14
+ // existing suppressions.yml entry written before this field set was
15
+ // required will now fail validation and REOPEN its finding, same as any
16
+ // other invalid pro suppression already does — an operator must add the
17
+ // three new fields to keep the exception honored. `owner` is who is
18
+ // accountable for eventually resolving or renewing the exception (may or
19
+ // may not be the same person as `reviewer`); `scope` is a short,
20
+ // human-readable statement of what the exception covers (this module's
21
+ // matching remains per-finding — `scope` documents intent, it does not
22
+ // widen what gets suppressed); `compensating_control` is the specific
23
+ // alternative mitigation that justifies leaving the underlying finding
24
+ // unfixed (a required field, not inferred from `reason` — "we'll fix it
25
+ // later" is a reason, not a compensating control, and conflating the two
26
+ // was the actual gap this PRD item exists to close).
27
+ //
28
+ // "Expired exceptions automatically reopen findings or fail the gate":
29
+ // `applySuppressions` already returns an expired suppression's finding to
30
+ // the KEPT (open) set rather than the suppressed set (see the `exp < now`
31
+ // branch below) — an expired exception was always self-reopening by
32
+ // construction, this was just never covered by a test until FR-1004.
33
+ // Reopening feeds the existing severity-based CI gate (`--fail-on`)
34
+ // exactly like any other open finding; no second, parallel expiry-gate
35
+ // mechanism is needed or built here.
7
36
 
8
37
  import * as fs from 'node:fs';
9
38
  import * as path from 'node:path';
10
39
  import * as yaml from '../util/yaml.js';
11
40
  import { statePath, safeWriteState } from './state-dir.js';
41
+ import { loadApproverRegistry, verifyApprover, requiredRolesFor } from '../fix/approver-registry.js';
12
42
 
13
43
  const MS_PER_DAY = 86400000;
14
44
  const SOFT_TTL_DAYS = 30;
@@ -59,14 +89,31 @@ export function loadProSuppressions(scanRoot) {
59
89
  }
60
90
 
61
91
  // Validate one entry. Returns { ok, errors }.
62
- export function validateProSuppression(entry) {
92
+ //
93
+ // FR-1002: "bind approvals, EXCEPTIONS, AND SUPPRESSIONS to verified
94
+ // identities and roles... anonymous or unauthorized high-risk exceptions
95
+ // fail policy." A pro suppression's `justification_signed_by` is exactly
96
+ // such an exception-approval identity, but until this fix it was pure
97
+ // self-reported free text -- FR-307/FR-1002's registry existed and was
98
+ // wired into apply_fix, but never consulted here. `opts.registry` is
99
+ // optional and NO-OP when absent (loadApproverRegistry(scanRoot) returns
100
+ // null until an operator opts in, matching every other gate in this
101
+ // registry), so an existing project with no registry file sees zero
102
+ // behavior change. `opts.requiredRoles` lets the caller scope role
103
+ // requirements to the SUPPRESSED FINDING's own family/category, the same
104
+ // way FR-307 scopes them to a change's material-change category.
105
+ export function validateProSuppression(entry, opts = {}) {
63
106
  const errors = [];
64
- for (const k of ['finding_id', 'file', 'reason', 'justification_signed_by', 'reviewer', 'expires_at']) {
107
+ for (const k of ['finding_id', 'file', 'reason', 'justification_signed_by', 'reviewer', 'expires_at', 'owner', 'scope', 'compensating_control']) {
65
108
  if (!entry[k] || (typeof entry[k] === 'string' && !entry[k].trim())) errors.push(`missing: ${k}`);
66
109
  }
67
110
  if (entry.justification_signed_by && entry.reviewer && entry.justification_signed_by === entry.reviewer) {
68
111
  errors.push('justification_signed_by must differ from reviewer (two-person rule)');
69
112
  }
113
+ if (entry.justification_signed_by && opts.registry !== undefined) {
114
+ const v = verifyApprover(opts.registry, entry.justification_signed_by, opts.requiredRoles || []);
115
+ if (!v.verified) errors.push(`justification_signed_by not authorized: ${v.reason}`);
116
+ }
70
117
  if (entry.expires_at) {
71
118
  const t = _dateOnly(entry.expires_at);
72
119
  if (!Number.isFinite(t)) errors.push('expires_at must be ISO date');
@@ -84,6 +131,11 @@ export function applySuppressions(findings, scanRoot, profile) {
84
131
  const items = isPro ? loadProSuppressions(scanRoot) : loadSoftAccepted(scanRoot);
85
132
  if (!items.length) return findings;
86
133
 
134
+ // FR-1002: loaded once per call, not per entry — a missing registry file
135
+ // (the common, not-opted-in case) makes verifyApprover() a no-op below,
136
+ // exactly like every other gate built on this registry.
137
+ const approverRegistry = isPro ? loadApproverRegistry(scanRoot) : null;
138
+
87
139
  const now = _now();
88
140
  const kept = [];
89
141
  const suppressed = [];
@@ -106,7 +158,11 @@ export function applySuppressions(findings, scanRoot, profile) {
106
158
  }
107
159
  // Pro: validate the entry still passes
108
160
  if (isPro) {
109
- const v = validateProSuppression({ ...matched, severity: f.severity });
161
+ // Same registry/category shape FR-307 already uses for high-impact
162
+ // fixes: an operator opts a finding family into role-gating via
163
+ // requiredRolesByCategory, keyed by that family's name.
164
+ const requiredRoles = requiredRolesFor(approverRegistry, [f.family].filter(Boolean));
165
+ const v = validateProSuppression({ ...matched, severity: f.severity }, { registry: approverRegistry, requiredRoles });
110
166
  if (!v.ok) { kept.push({ ...f, _suppressionInvalid: v.errors }); continue; }
111
167
  }
112
168
  suppressed.push({ ...f, _suppressed: matched });
@@ -34,6 +34,7 @@
34
34
  // timeout, degrading to an empty result in a non-repository.
35
35
 
36
36
  import { execFileSync } from 'node:child_process';
37
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
37
38
 
38
39
  const CVE_RE = /\b(?:CVE-\d{4}-\d{4,7}|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4})\b/i;
39
40
 
@@ -87,14 +88,19 @@ export function classifyCommit(subject) {
87
88
  return { tier: 'mentioned', evidence: `mentions "${noun[0]}" with no fix verb` };
88
89
  }
89
90
 
91
+ // `scanRoot` is the scanned project's repository, not this project's own
92
+ // trusted checkout — hardened per FR-PROV-024 / the second Finding
93
+ // Provenance PRD audit (same exposure class as
94
+ // provenance/git-evidence.js's `_run`). No `--no-textconv` needed: this
95
+ // invocation has no `-p`/`-L`, so no diff/blob content is rendered.
90
96
  function _gitLog(scanRoot, { maxCommits, timeoutMs }) {
91
97
  // NUL-delimited records so subjects containing newlines cannot split a
92
98
  // record — a commit message is arbitrary user text and must not be able to
93
99
  // forge a record boundary.
94
- const out = execFileSync('git', [
100
+ const out = execFileSync('git', hardenGitArgs([
95
101
  '-C', scanRoot, 'log', '-n', String(maxCommits), '--no-merges', '--no-color',
96
102
  '--name-only', '--format=%x00%H%x1f%aI%x1f%s',
97
- ], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
103
+ ]), { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() });
98
104
  const commits = [];
99
105
  for (const block of out.split('\0')) {
100
106
  if (!block.trim()) continue;