@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.
- package/CHANGELOG.md +555 -0
- package/bin/agentic-security.js +770 -49
- package/dist/1.index.js +223 -0
- package/dist/113.index.js +117 -18
- package/dist/144.index.js +163 -0
- package/dist/178.index.js +24 -6
- package/dist/238.index.js +3 -2
- package/dist/265.index.js +191 -0
- package/dist/271.index.js +165 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +187 -52
- package/dist/444.index.js +11 -2
- package/dist/449.index.js +76 -12
- package/dist/526.index.js +117 -18
- package/dist/552.index.js +97 -0
- package/dist/637.index.js +27 -5
- package/dist/730.index.js +311 -0
- package/dist/736.index.js +301 -0
- package/dist/824.index.js +7 -0
- package/dist/905.index.js +88 -22
- package/dist/920.index.js +491 -0
- package/dist/970.index.js +173 -0
- package/dist/agentic-security.mjs +13 -13
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/calibration-seed.json +2 -0
- package/package.json +23 -9
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/index.js +18 -0
- package/src/dataflow/privacy-catalog.js +290 -0
- package/src/dataflow/privacy-deep-walker.js +515 -0
- package/src/dataflow/privacy-governance.js +126 -0
- package/src/dataflow/privacy-inventory.js +154 -0
- package/src/dataflow/privacy-sink-policy.js +125 -0
- package/src/dataflow/privacy-taint.js +115 -54
- package/src/dataflow/privacy-taxonomy.js +233 -0
- package/src/discovery/disprove.js +7 -3
- package/src/discovery/hunter.js +9 -5
- package/src/discovery/index.js +2 -2
- package/src/discovery/llm-invoke.js +69 -13
- package/src/egress/audit.js +147 -0
- package/src/egress/policy.js +313 -0
- package/src/egress/redact.js +180 -0
- package/src/engine.js +1048 -302
- package/src/fix/apply-fix-service.js +404 -0
- package/src/fix/approver-registry.js +157 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +1 -1
- package/src/llm-validator/index.js +86 -9
- package/src/llm-validator/model-status.js +66 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/tools.js +177 -50
- package/src/pipeline/analyzer-supervisor.js +93 -0
- package/src/pipeline/analyzer-worker.js +26 -0
- package/src/pipeline/annotator-runner.js +33 -0
- package/src/pipeline/assurance-mode.js +154 -0
- package/src/pipeline/cascade-worker-pool.js +172 -0
- package/src/pipeline/cascade-worker.js +43 -0
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/detector-runner.js +51 -0
- package/src/pipeline/enrichment-completion.js +58 -0
- package/src/pipeline/evidence-provenance.js +91 -0
- package/src/pipeline/finding-schema.js +108 -0
- package/src/pipeline/legacy-compat.js +101 -0
- package/src/pipeline/producer-collector.js +48 -0
- package/src/pipeline/producer-registry.js +112 -0
- package/src/pipeline/scan-health.js +144 -0
- package/src/posture/CLAUDE.md +123 -0
- package/src/posture/accuracy-scorecard.js +156 -1
- package/src/posture/adversary-agent.js +15 -3
- package/src/posture/artifact-registry.js +241 -0
- package/src/posture/auditor-walkthrough.js +186 -21
- package/src/posture/calibration-feedback.js +201 -0
- package/src/posture/calibration-seed.json +2 -0
- package/src/posture/calibration.js +25 -0
- package/src/posture/compliance-evidence-signing.js +131 -0
- package/src/posture/compliance-policy.js +324 -17
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/custom-rules.js +36 -0
- package/src/posture/deterministic.js +8 -1
- package/src/posture/encryption-provider.js +205 -0
- package/src/posture/evidence-grade-wording.js +71 -0
- package/src/posture/fix-history.js +137 -20
- package/src/posture/fix-honesty-gate.js +47 -6
- package/src/posture/fix-verify.js +65 -8
- package/src/posture/fleet.js +0 -0
- package/src/posture/flow-narration.js +7 -2
- package/src/posture/git-history.js +13 -5
- package/src/posture/legal-hold.js +140 -0
- package/src/posture/llm-redteam.js +10 -1
- package/src/posture/material-change.js +111 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/policy-bundle.js +274 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +47 -6
- package/src/posture/production-feedback.js +179 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/retention-policy.js +132 -0
- package/src/posture/risk-dollars.js +216 -26
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/scan-checkpoint.js +176 -31
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +64 -5
- package/src/posture/state-lifecycle-report.js +77 -0
- package/src/posture/suppressions.js +59 -3
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/privacy/ir-adapter.js +380 -0
- package/src/report/index.js +248 -5
- package/src/report/oscal.js +7 -2
- package/src/runScan.js +34 -5
- package/src/sast/cpp.js +3 -14
- package/src/sast/rate-limit.js +33 -3
- package/src/sca/llm-function-extract.js +6 -0
- 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
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
|
30
|
-
//
|
|
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
|
-
|
|
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,
|
|
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(
|
|
160
|
-
return JSON.stringify({ v: FORMAT,
|
|
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
|
-
//
|
|
165
|
-
|
|
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
|
|
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 `
|
|
199
|
-
* A handle whose `enabled` is false silently no-ops through the rest
|
|
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, {
|
|
202
|
-
if (!scanRoot || !
|
|
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.
|
|
333
|
+
handle.globalKey = globalKey;
|
|
216
334
|
|
|
217
|
-
const keepBytes = _recover(handle, file,
|
|
335
|
+
const { offset: keepBytes, discardReason } = _recover(handle, file, globalKey, meta, handle.fileContents);
|
|
218
336
|
if (keepBytes <= 0) {
|
|
219
|
-
// Absent, foreign, or
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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 = [];
|
package/src/posture/state-dir.js
CHANGED
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
import * as fs from 'node:fs';
|
|
17
17
|
import * as path from 'node:path';
|
|
18
18
|
|
|
19
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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;
|