@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
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// ApplyFixService (assurance-hardening PRD, FR-301/FR-302/FR-303/FR-304).
|
|
2
|
+
//
|
|
3
|
+
// Before this module, three write paths each re-implemented (or partly
|
|
4
|
+
// skipped) the same safety checks:
|
|
5
|
+
//
|
|
6
|
+
// - mcp/tools.js's apply_fix, caller-patch branch: confines paths, checks
|
|
7
|
+
// reserved-write paths, fails closed on bad last-scan.json integrity,
|
|
8
|
+
// and runs a fresh verifyFixCore before writing. The strongest of the
|
|
9
|
+
// three, and the model this module generalizes.
|
|
10
|
+
// - mcp/tools.js's apply_fix, stored-fix.replacement branch: confines
|
|
11
|
+
// paths and checks reserved-write paths, but WRITES WITHOUT any fresh
|
|
12
|
+
// verification (no rescan, no lint check) — verified missing by reading
|
|
13
|
+
// the handler directly (A-08).
|
|
14
|
+
// - bin/agentic-security.js's `cmdFix --apply`: WARNS (does not refuse) on
|
|
15
|
+
// failed last-scan.json integrity, has NO path confinement check at all
|
|
16
|
+
// (a relative path with `../../` or a pre-planted symlink is followed
|
|
17
|
+
// without question), and applies WITHOUT any fresh verification. This
|
|
18
|
+
// was a materially worse gap than FR-301's PRD evidence table (A-08)
|
|
19
|
+
// described — A-08 only flagged the missing verification, not the
|
|
20
|
+
// missing confinement or the fail-open integrity check.
|
|
21
|
+
//
|
|
22
|
+
// This module is the one place all of that lives now. It does NOT
|
|
23
|
+
// reimplement the transactional write itself — posture/fix-history.js's
|
|
24
|
+
// applyFix() already does backup+fsync, pending-log+fsync,
|
|
25
|
+
// write+fsync, promote-to-applied+fsync, with its own crash-recovery
|
|
26
|
+
// (`recover()`) and per-finding attempt-budget enforcement. That machinery
|
|
27
|
+
// was already sound and already shared; the gap was upstream of it.
|
|
28
|
+
//
|
|
29
|
+
// Confinement and reserved-write-path logic is copied verbatim from
|
|
30
|
+
// mcp/tools.js's `_confine`/`_isReservedWritePath` (not rewritten) so
|
|
31
|
+
// behavior does not drift between the two callers during migration —
|
|
32
|
+
// mcp/tools.js still owns its own copies for its scratchpad/other tools
|
|
33
|
+
// that need the same primitives without the full apply-fix flow; a future
|
|
34
|
+
// cleanup could have it import from here instead, out of scope for this
|
|
35
|
+
// change.
|
|
36
|
+
|
|
37
|
+
import * as fs from 'node:fs';
|
|
38
|
+
import * as fsp from 'node:fs/promises';
|
|
39
|
+
import * as path from 'node:path';
|
|
40
|
+
import * as crypto from 'node:crypto';
|
|
41
|
+
import { verifyLastScan } from '../posture/integrity.js';
|
|
42
|
+
import { stateDir } from '../posture/state-dir.js';
|
|
43
|
+
import { applyFix as applyFixHistory, revertEntryById as revertFixEntry } from '../posture/fix-history.js';
|
|
44
|
+
import { classifyFixMaterialRisk } from '../posture/material-change.js';
|
|
45
|
+
import { loadApproverRegistry, verifyApprover, requiredRolesFor, checkSeparationOfDuties } from './approver-registry.js';
|
|
46
|
+
|
|
47
|
+
const RESERVED_WRITE_PREFIXES = [
|
|
48
|
+
'.git/', '.github/', '.gitlab/', '.circleci/', '.buildkite/',
|
|
49
|
+
'.agentic-security/', 'node_modules/', '.terraform/', '.aws/', 'k8s/', 'kubernetes/',
|
|
50
|
+
];
|
|
51
|
+
const RESERVED_WRITE_BASENAMES = new Set([
|
|
52
|
+
'Dockerfile', 'Jenkinsfile', '.gitlab-ci.yml', '.gitlab-ci.yaml',
|
|
53
|
+
'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
54
|
+
'pyproject.toml', 'Pipfile', 'Pipfile.lock', 'poetry.lock', 'requirements.txt',
|
|
55
|
+
'go.mod', 'go.sum', 'Cargo.toml', 'Cargo.lock', 'composer.json', 'composer.lock',
|
|
56
|
+
'Gemfile', 'Gemfile.lock', 'pom.xml', 'build.gradle', 'build.gradle.kts',
|
|
57
|
+
]);
|
|
58
|
+
const RESERVED_WRITE_SUFFIXES = ['.tf', '.tfvars', 'docker-compose.yml', 'docker-compose.yaml', '.bak', '.lock'];
|
|
59
|
+
const RESERVED_WRITE_DIR_SEGMENTS = new Set(['dist', 'build', 'target']);
|
|
60
|
+
|
|
61
|
+
/** Lexical check + lstat symlink reject + realpath re-check. OWASP MCP05. */
|
|
62
|
+
export function confinePath(root, candidate, label = 'path') {
|
|
63
|
+
if (typeof candidate !== 'string' || !candidate) throw new Error(`${label}: not a string`);
|
|
64
|
+
const rootReal = fs.realpathSync(path.resolve(root));
|
|
65
|
+
const abs = path.isAbsolute(candidate) ? candidate : path.resolve(rootReal, candidate);
|
|
66
|
+
|
|
67
|
+
const relLex = path.relative(rootReal, path.resolve(abs));
|
|
68
|
+
if (relLex === '' || relLex.startsWith('..') || path.isAbsolute(relLex)) {
|
|
69
|
+
throw new Error(`${label}: path "${candidate}" escapes session root`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (fs.existsSync(abs)) {
|
|
73
|
+
if (fs.lstatSync(abs).isSymbolicLink()) {
|
|
74
|
+
throw new Error(`${label}: path "${candidate}" is a symbolic link (refused)`);
|
|
75
|
+
}
|
|
76
|
+
const real = fs.realpathSync(abs);
|
|
77
|
+
if (path.relative(rootReal, real).startsWith('..')) {
|
|
78
|
+
throw new Error(`${label}: path "${candidate}" resolves outside session root via symlink`);
|
|
79
|
+
}
|
|
80
|
+
return real;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let parent = path.dirname(abs);
|
|
84
|
+
while (parent !== path.dirname(parent) && !fs.existsSync(parent)) {
|
|
85
|
+
parent = path.dirname(parent);
|
|
86
|
+
}
|
|
87
|
+
const parentReal = fs.realpathSync(parent);
|
|
88
|
+
if (path.relative(rootReal, parentReal).startsWith('..')) {
|
|
89
|
+
throw new Error(`${label}: path "${candidate}" parent resolves outside session root`);
|
|
90
|
+
}
|
|
91
|
+
return path.resolve(parentReal, path.relative(parent, abs));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isReservedWritePath(root, absFile) {
|
|
95
|
+
const rootReal = fs.realpathSync(path.resolve(root));
|
|
96
|
+
const rel = path.relative(rootReal, absFile).replace(/\\/g, '/');
|
|
97
|
+
if (RESERVED_WRITE_PREFIXES.some(p => rel === p.replace(/\/$/, '') || rel.startsWith(p))) return true;
|
|
98
|
+
const segments = rel.split('/');
|
|
99
|
+
const base = segments[segments.length - 1] || '';
|
|
100
|
+
if (RESERVED_WRITE_BASENAMES.has(base)) return true;
|
|
101
|
+
if (RESERVED_WRITE_SUFFIXES.some(s => base === s || base.endsWith(s))) return true;
|
|
102
|
+
if (segments.slice(0, -1).some(seg => RESERVED_WRITE_DIR_SEGMENTS.has(seg))) return true;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** SHA-256 of a file's current content, or null if it does not exist. Shared by
|
|
107
|
+
* both the pre-verification baseline snapshot and the pre-write recheck below,
|
|
108
|
+
* so the two can never disagree about what "unchanged" means. Read first,
|
|
109
|
+
* classify ENOENT as "does not exist" — the same existsSync-then-readFileSync
|
|
110
|
+
* TOCTOU this codebase has already fixed twice this session (readVerifiedScan
|
|
111
|
+
* here, egress/policy.js's loadPolicyConfig) applies equally to this new
|
|
112
|
+
* function, so it uses the same read-first pattern from the start. */
|
|
113
|
+
export function hashFileContentSync(absPath) {
|
|
114
|
+
let body;
|
|
115
|
+
try {
|
|
116
|
+
body = fs.readFileSync(absPath, 'utf8');
|
|
117
|
+
} catch (e) {
|
|
118
|
+
if (e && e.code === 'ENOENT') return null;
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
return crypto.createHash('sha256').update(body).digest('hex');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* FR-304: does any file in `confined` (a map of rel -> {abs, baselineHash})
|
|
126
|
+
* no longer match the hash captured before verification started? Returns the
|
|
127
|
+
* first mismatched rel path's reason string, or null if every file still
|
|
128
|
+
* matches its baseline. A pure, synchronous, directly-testable check — no
|
|
129
|
+
* verification timing involved — so this exact race-window property can be
|
|
130
|
+
* proven deterministically instead of by racing real wall-clock verification
|
|
131
|
+
* time (which varies 10x+ between runs and under combined-suite load, the
|
|
132
|
+
* same contention class as this codebase's own documented D-0006 flake).
|
|
133
|
+
*/
|
|
134
|
+
export function detectConcurrentModification(confined) {
|
|
135
|
+
for (const [rel, v] of Object.entries(confined)) {
|
|
136
|
+
if (hashFileContentSync(v.abs) !== v.baselineHash) {
|
|
137
|
+
return `${rel} changed on disk after verification started — a stale approval must not be applied to a moved target. Re-run verification against the current file.`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Fail-closed read of last-scan.json. Unlike bin/agentic-security.js's old
|
|
145
|
+
* behavior (log a warning, apply anyway), a caller that does not explicitly
|
|
146
|
+
* pass `allowUnsigned: true` gets `{scan: null}` on any integrity problem —
|
|
147
|
+
* missing, tampered, or unsigned — and MUST refuse the write.
|
|
148
|
+
*/
|
|
149
|
+
export function readVerifiedScan(scanRoot, { allowUnsigned = false } = {}) {
|
|
150
|
+
const scanFile = path.join(stateDir(scanRoot), 'last-scan.json');
|
|
151
|
+
const sigFile = scanFile + '.sig';
|
|
152
|
+
// Self-scan gate (bench/self-scan): a check-then-read (existsSync then
|
|
153
|
+
// readFileSync) is a TOCTOU — the file can vanish between the two calls
|
|
154
|
+
// (a concurrent `reset`, another process). Read first and classify ENOENT
|
|
155
|
+
// as 'missing'; any other read error still propagates, same as before.
|
|
156
|
+
let body;
|
|
157
|
+
try {
|
|
158
|
+
body = fs.readFileSync(scanFile, 'utf8');
|
|
159
|
+
} catch (e) {
|
|
160
|
+
if (e && e.code === 'ENOENT') return { scan: null, status: 'missing' };
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
const ok = verifyLastScan(body, sigFile);
|
|
164
|
+
if (ok === false) return { scan: null, status: 'tampered' };
|
|
165
|
+
if (ok === null && !allowUnsigned) return { scan: null, status: 'unsigned' };
|
|
166
|
+
let parsed;
|
|
167
|
+
try { parsed = JSON.parse(body); } catch { return { scan: null, status: 'unparseable' }; }
|
|
168
|
+
return { scan: parsed, status: ok ? 'verified' : 'unsigned' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let _verifyFixCore;
|
|
172
|
+
async function getVerifyFixCore() {
|
|
173
|
+
if (!_verifyFixCore) _verifyFixCore = (await import('../posture/fix-verify.js')).verifyFix;
|
|
174
|
+
return _verifyFixCore;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The unified apply flow. Every public entry point (MCP's two apply_fix
|
|
179
|
+
* branches, the CLI's `--apply`) should reach a disk write only through
|
|
180
|
+
* this function.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} opts
|
|
183
|
+
* @param {string} opts.scanRoot
|
|
184
|
+
* @param {object} opts.finding - the finding being fixed (needs .file, .stableId, .id/.findingId)
|
|
185
|
+
* @param {Record<string,string>} opts.files - { relPath: newContent } — one or more candidate files
|
|
186
|
+
* @param {object} [opts.fixMeta] - optional agent-reported completeness/residual claims (fix-honesty-gate).
|
|
187
|
+
* FR-307: `fixMeta.approval` — `{approvedBy: string, reason: string}` — REQUIRED (else refused)
|
|
188
|
+
* when the candidate falls into a high-impact change class (auth/authZ/crypto/pii/schema/
|
|
189
|
+
* infra-privilege/public-api, per posture/material-change.js's classifyFixMaterialRisk). Absent
|
|
190
|
+
* for every other candidate, which is unaffected by this check. FR-1002: when
|
|
191
|
+
* .agentic-security/authorized-approvers.json is configured, `approvedBy` is ALSO checked
|
|
192
|
+
* against it (fix/approver-registry.js) — an approvedBy the registry doesn't recognize, or one
|
|
193
|
+
* missing a role a touched category requires, is refused. A no-op with no registry configured.
|
|
194
|
+
* @param {boolean} [opts.dryRun]
|
|
195
|
+
* @param {boolean} [opts.skipVerification] - escape hatch for callers that have ALREADY verified
|
|
196
|
+
* (none currently use this — present so a future caller cannot be forced into double verification
|
|
197
|
+
* cost without a documented way to opt out; using it without having independently verified defeats
|
|
198
|
+
* the entire point of this service).
|
|
199
|
+
* @returns {Promise<{ok:boolean, applied:boolean, reason?:string, verify?:object, written?:object[],
|
|
200
|
+
* verified?:boolean, verifiedFull?:boolean, materialClassification?:object}>} `verified` means
|
|
201
|
+
* verification was attempted (and passed, when true); `verifiedFull` (FR-305) means every required leg — lint when a linter is
|
|
202
|
+
* configured, tests when a runner is detected — genuinely ran and passed, not silently skipped.
|
|
203
|
+
* `verified:true, verifiedFull:false` is a real, honest state: applied, but on a degraded pass.
|
|
204
|
+
*/
|
|
205
|
+
export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = null, dryRun = false, skipVerification = false } = {}) {
|
|
206
|
+
if (!scanRoot) return { ok: false, applied: false, reason: 'scanRoot required' };
|
|
207
|
+
if (!finding) return { ok: false, applied: false, reason: 'finding required' };
|
|
208
|
+
if (!files || typeof files !== 'object' || !Object.keys(files).length) {
|
|
209
|
+
return { ok: false, applied: false, reason: 'no candidate file content provided' };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// FR-302: fail closed on invalid/missing signed scan-state integrity.
|
|
213
|
+
// Both current callers (bin/agentic-security.js's cmdFix, mcp/tools.js's
|
|
214
|
+
// apply_fix) already read+verify last-scan.json themselves before calling
|
|
215
|
+
// here — they need the scan CONTENT to look up the finding in the first
|
|
216
|
+
// place, not just a pass/fail signal, so this check is deliberately
|
|
217
|
+
// redundant with theirs. It stays here anyway: a shared service that is
|
|
218
|
+
// only safe because every CURRENT caller happens to check first is not
|
|
219
|
+
// actually safe — it is safe by convention, which is exactly the failure
|
|
220
|
+
// mode FR-301 was created to close for path confinement and verification.
|
|
221
|
+
// A future caller that forgets its own pre-check is still refused here.
|
|
222
|
+
const { status: integrityStatus } = readVerifiedScan(scanRoot);
|
|
223
|
+
if (integrityStatus !== 'verified') {
|
|
224
|
+
return { ok: false, applied: false, reason: `last-scan.json integrity check failed: ${integrityStatus}` };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const confined = {};
|
|
228
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
229
|
+
let abs;
|
|
230
|
+
try { abs = confinePath(scanRoot, rel, 'finding file'); }
|
|
231
|
+
catch (e) { return { ok: false, applied: false, reason: `path-escape refused: ${e.message}` }; }
|
|
232
|
+
if (isReservedWritePath(scanRoot, abs)) {
|
|
233
|
+
return { ok: false, applied: false, reason: `reserved path refused: ${rel}` };
|
|
234
|
+
}
|
|
235
|
+
// FR-304: "a changed file invalidates approval". Verification below can
|
|
236
|
+
// take real wall-clock time — verifyFixCore runs a rescan AND the
|
|
237
|
+
// project's own lint/test legs, easily seconds. Snapshotting each file's
|
|
238
|
+
// on-disk content NOW, before verification starts, and re-checking it
|
|
239
|
+
// immediately before the write (below) closes the window where the file
|
|
240
|
+
// changes between "this was approved" and "this gets written" — without
|
|
241
|
+
// this, that drift is invisible and the write silently clobbers
|
|
242
|
+
// whatever changed the file in between.
|
|
243
|
+
let baselineHash = null;
|
|
244
|
+
try {
|
|
245
|
+
baselineHash = hashFileContentSync(abs);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
return { ok: false, applied: false, reason: `could not read current content of ${rel} to detect concurrent changes: ${e.message}` };
|
|
248
|
+
}
|
|
249
|
+
confined[rel] = { abs, content: String(content), baselineHash };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// FR-303/FR-307: classify the candidate BEFORE verification (fail fast,
|
|
253
|
+
// same reasoning FR-304's baseline-hash check already applies — no point
|
|
254
|
+
// running a real rescan+lint+test verification pass on a change that is
|
|
255
|
+
// refused regardless of its outcome). "Auth, authZ, crypto, PII, schema,
|
|
256
|
+
// infrastructure privilege, and public API changes cannot auto-apply
|
|
257
|
+
// without approval evidence" — a no-op for every OTHER candidate (the
|
|
258
|
+
// overwhelming majority of fixes), matching this codebase's
|
|
259
|
+
// restricts-nothing-until-triggered convention for every other policy
|
|
260
|
+
// gate (egress/policy.js, dataflow/privacy-sink-policy.js).
|
|
261
|
+
const filesForMaterialClassification = {};
|
|
262
|
+
for (const [rel, v] of Object.entries(confined)) {
|
|
263
|
+
let before = '';
|
|
264
|
+
try { before = fs.existsSync(v.abs) ? await fsp.readFile(v.abs, 'utf8') : ''; } catch { /* new file — before stays '' */ }
|
|
265
|
+
filesForMaterialClassification[rel] = { before, after: v.content };
|
|
266
|
+
}
|
|
267
|
+
const materialClassification = classifyFixMaterialRisk(filesForMaterialClassification);
|
|
268
|
+
// A dry run never writes anything (`applied` is always false below), so it
|
|
269
|
+
// is allowed to preview past this gate WITHOUT approval evidence — an
|
|
270
|
+
// agent needs exactly that preview to learn a real apply will need
|
|
271
|
+
// approval before it goes and collects it. `materialClassification` is
|
|
272
|
+
// still attached to every return path below so the gap is never silent.
|
|
273
|
+
if (materialClassification.highImpactCategories.length && !dryRun) {
|
|
274
|
+
const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
|
|
275
|
+
const hasApprovalEvidence = !!(approval && typeof approval === 'object' &&
|
|
276
|
+
typeof approval.approvedBy === 'string' && approval.approvedBy.trim().length > 0 &&
|
|
277
|
+
typeof approval.reason === 'string' && approval.reason.trim().length > 0);
|
|
278
|
+
if (!hasApprovalEvidence) {
|
|
279
|
+
return {
|
|
280
|
+
ok: false, applied: false,
|
|
281
|
+
reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) requires approval evidence — pass fixMeta.approval: {approvedBy, reason} — before it can be applied`,
|
|
282
|
+
materialClassification,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
// FR-1002: bind the approval to a verified (operator-registered)
|
|
286
|
+
// identity and role, when an operator has opted in via
|
|
287
|
+
// .agentic-security/authorized-approvers.json. A no-op with no
|
|
288
|
+
// registry configured — approvedBy's mere presence (checked above)
|
|
289
|
+
// remains sufficient, unchanged from FR-307's own behavior. With a
|
|
290
|
+
// registry present, an approvedBy the registry doesn't recognize, or
|
|
291
|
+
// one recognized but missing a role a touched category requires, is
|
|
292
|
+
// refused: the literal "anonymous or unauthorized... fail policy"
|
|
293
|
+
// acceptance criterion.
|
|
294
|
+
const approverRegistry = loadApproverRegistry(scanRoot);
|
|
295
|
+
const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
|
|
296
|
+
const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
|
|
297
|
+
if (!identityCheck.verified) {
|
|
298
|
+
return {
|
|
299
|
+
ok: false, applied: false,
|
|
300
|
+
reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${identityCheck.reason}`,
|
|
301
|
+
materialClassification,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
// FR-1003: "a configurable policy can prevent the patch author from
|
|
305
|
+
// self-approving a high-impact fix." A no-op unless the SAME registry
|
|
306
|
+
// opts in via `separationOfDuties.enabled` — an operator who has not
|
|
307
|
+
// configured this is unaffected, matching every other dimension here.
|
|
308
|
+
const sodCheck = checkSeparationOfDuties(approverRegistry, fixMeta?.author, approval.approvedBy);
|
|
309
|
+
if (!sodCheck.ok) {
|
|
310
|
+
return {
|
|
311
|
+
ok: false, applied: false,
|
|
312
|
+
reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${sodCheck.reason}`,
|
|
313
|
+
materialClassification,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let verify = null;
|
|
319
|
+
if (!skipVerification) {
|
|
320
|
+
if (!finding.stableId) {
|
|
321
|
+
return { ok: false, applied: false, reason: 'finding has no stableId — cannot verify a candidate fix against it' };
|
|
322
|
+
}
|
|
323
|
+
let verdict;
|
|
324
|
+
try {
|
|
325
|
+
const verifyFixCore = await getVerifyFixCore();
|
|
326
|
+
verdict = await verifyFixCore({
|
|
327
|
+
scanRoot,
|
|
328
|
+
originalFindingStableId: finding.stableId,
|
|
329
|
+
files: Object.fromEntries(Object.entries(confined).map(([rel, v]) => [rel, v.content])),
|
|
330
|
+
fixMeta,
|
|
331
|
+
});
|
|
332
|
+
} catch (e) {
|
|
333
|
+
return { ok: false, applied: false, reason: `verification failed: ${e.message}` };
|
|
334
|
+
}
|
|
335
|
+
verify = verdict;
|
|
336
|
+
if (!verdict.ok) {
|
|
337
|
+
return {
|
|
338
|
+
ok: false, applied: false,
|
|
339
|
+
reason: `rejected by verifier: ${verdict.summary || verdict.rescan?.reason || 'did not verify'}`,
|
|
340
|
+
verify: { rescan: verdict.rescan, lint: verdict.lint ? { runner: verdict.lint.runner, ok: verdict.lint.ok } : null, honesty: verdict.honesty || null },
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (dryRun) {
|
|
346
|
+
return { ok: true, applied: false, dryRun: true, verified: !skipVerification, files: Object.keys(confined), verify, materialClassification };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// FR-304: re-check every file against its pre-verification baseline hash,
|
|
350
|
+
// ALL of them before writing ANY of them — a stale approval on file A must
|
|
351
|
+
// not let file B (also stale) get partially written before the drift on B
|
|
352
|
+
// is even checked.
|
|
353
|
+
const concurrentChangeReason = detectConcurrentModification(confined);
|
|
354
|
+
if (concurrentChangeReason) {
|
|
355
|
+
return { ok: false, applied: false, reason: concurrentChangeReason };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const written = [];
|
|
359
|
+
try {
|
|
360
|
+
for (const [rel, v] of Object.entries(confined)) {
|
|
361
|
+
const fileExisted = fs.existsSync(v.abs);
|
|
362
|
+
const originalContent = fileExisted ? await fsp.readFile(v.abs, 'utf8') : '';
|
|
363
|
+
const entry = await applyFixHistory({
|
|
364
|
+
scanRoot, file: rel, originalContent, newContent: v.content, fileExisted,
|
|
365
|
+
findingId: finding.id || finding.findingId,
|
|
366
|
+
stableId: finding.stableId || null,
|
|
367
|
+
ruleId: finding.ruleId || finding.cwe || finding.family || null,
|
|
368
|
+
vuln: finding.vuln || finding.title || null,
|
|
369
|
+
findingProvenance: finding.findingProvenance || null,
|
|
370
|
+
});
|
|
371
|
+
written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath, attemptOrdinal: entry.attemptOrdinal });
|
|
372
|
+
}
|
|
373
|
+
} catch (e) {
|
|
374
|
+
// FR-306: "injected write failure restores all files" — applyFixHistory
|
|
375
|
+
// already rolled back the ONE file that just failed; this rolls back
|
|
376
|
+
// every file THIS batch had already successfully written before that
|
|
377
|
+
// failure, so a multi-file fix never leaves some files patched and
|
|
378
|
+
// others not. Best-effort per file — a revert failure here is recorded
|
|
379
|
+
// on the entry itself (fix-history.js) and does not mask the original
|
|
380
|
+
// error, which is what the caller actually needs to see.
|
|
381
|
+
for (const w of written) {
|
|
382
|
+
try { await revertFixEntry(scanRoot, w.historyId); } catch { /* best-effort; original error still propagates below */ }
|
|
383
|
+
}
|
|
384
|
+
if (e && e.name === 'FixAttemptBudgetExceededError') {
|
|
385
|
+
return { ok: false, applied: false, reason: `budget-exceeded: ${e.message}`, budgetExceeded: true, attempts: e.attempts, maxAttempts: e.max, key: e.key };
|
|
386
|
+
}
|
|
387
|
+
throw e;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// FR-305: `verified: true` only ever meant "verification was attempted",
|
|
391
|
+
// not "every required leg genuinely ran and passed" — a caller that
|
|
392
|
+
// reported `verified` as "fully verified" would misrepresent a degraded
|
|
393
|
+
// pass (e.g. no linter installed, no test runner detected) as a complete
|
|
394
|
+
// one. `verifiedFull` is the honest, explicit answer to that question;
|
|
395
|
+
// `false` when verification was skipped entirely (skipVerification:true)
|
|
396
|
+
// just as much as when a required leg was skipped.
|
|
397
|
+
return {
|
|
398
|
+
ok: true, applied: true, verified: !skipVerification,
|
|
399
|
+
verifiedFull: !skipVerification && verify ? !!verify.verifiedFull : false,
|
|
400
|
+
written,
|
|
401
|
+
verify: verify ? { summary: verify.summary, verifiedFull: verify.verifiedFull, degradedLegs: verify.degradedLegs || [] } : null,
|
|
402
|
+
materialClassification,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Approver identity registry (assurance-hardening PRD FR-1002).
|
|
2
|
+
//
|
|
3
|
+
// FR-307 already refuses to auto-apply a high-impact change (auth/authZ/
|
|
4
|
+
// crypto/pii/schema/infra-privilege/public-api) without approval evidence
|
|
5
|
+
// — but that evidence (`fixMeta.approval: {approvedBy, reason}`) is
|
|
6
|
+
// entirely self-reported: any caller can write ANY string into
|
|
7
|
+
// `approvedBy` and satisfy the gate. FR-1002's acceptance criterion is
|
|
8
|
+
// "anonymous or unauthorized high-risk exceptions fail policy" — closing
|
|
9
|
+
// that gap needs the approver's identity checked against something the
|
|
10
|
+
// caller does not control.
|
|
11
|
+
//
|
|
12
|
+
// "VERIFIED" HERE MEANS "operator-registered", NOT cryptographically
|
|
13
|
+
// authenticated. This is a local-first CLI tool with no server, no
|
|
14
|
+
// account system, and no multi-tenancy (the same hard line
|
|
15
|
+
// posture/fleet.js's own header states for the fleet feature) — there is
|
|
16
|
+
// no SSO or session to check a caller's identity against. What IS
|
|
17
|
+
// available, and what this module builds on, is the same pattern this
|
|
18
|
+
// session has used repeatedly for every other policy surface (egress
|
|
19
|
+
// providers, privacy sinks, compliance severity floors): an
|
|
20
|
+
// OPERATOR-AUTHORED, committed config file naming who is authorized to
|
|
21
|
+
// approve what. An approver not in that list is unauthorized by
|
|
22
|
+
// definition — the registry IS the authorization boundary, the same way
|
|
23
|
+
// egress/policy.js's allowedProviders list IS the provider boundary.
|
|
24
|
+
//
|
|
25
|
+
// NO REGISTRY FILE = NO-OP, matching every other policy gate this session
|
|
26
|
+
// has built (restricts nothing until an operator opts in). Once a
|
|
27
|
+
// registry exists, identity verification is enforced: an anonymous
|
|
28
|
+
// approval (empty/missing approvedBy) is refused, and an approvedBy not
|
|
29
|
+
// present in the registry is refused — this is the literal "anonymous...
|
|
30
|
+
// exceptions fail policy" half of the acceptance criterion. Per-category
|
|
31
|
+
// ROLE requirements (the "and roles" half) are themselves operator-
|
|
32
|
+
// configured in the same file, via `requiredRolesByCategory` — a category
|
|
33
|
+
// with no configured role requirement accepts any registered approver,
|
|
34
|
+
// so an operator opts into role-gating one category at a time rather
|
|
35
|
+
// than this module guessing which categories need which roles.
|
|
36
|
+
|
|
37
|
+
import * as fs from 'node:fs';
|
|
38
|
+
import { statePath } from '../posture/state-dir.js';
|
|
39
|
+
|
|
40
|
+
const REGISTRY_FILE = 'authorized-approvers.json';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Load the operator's approver registry. Never throws — a missing file
|
|
44
|
+
* (the common case: identity verification not opted into) or a malformed
|
|
45
|
+
* one both degrade to `null`, which `verifyApprover` treats as "no-op".
|
|
46
|
+
*
|
|
47
|
+
* @returns {{approvers: Array<{identity:string, roles?:string[]}>,
|
|
48
|
+
* requiredRolesByCategory?: Record<string,string[]>}|null}
|
|
49
|
+
*/
|
|
50
|
+
export function loadApproverRegistry(scanRoot) {
|
|
51
|
+
if (!scanRoot) return null;
|
|
52
|
+
let fp;
|
|
53
|
+
try { fp = statePath(scanRoot, REGISTRY_FILE); } catch { return null; }
|
|
54
|
+
// Read first, check second — an existsSync-then-readFileSync pair is a
|
|
55
|
+
// check-then-use race (this session's own D-0012/D-0022 discipline).
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = fs.readFileSync(fp, 'utf8');
|
|
59
|
+
} catch {
|
|
60
|
+
return null; // ENOENT (no registry configured) or any other read failure
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const doc = JSON.parse(raw);
|
|
64
|
+
if (!doc || typeof doc !== 'object' || !Array.isArray(doc.approvers)) return null;
|
|
65
|
+
return doc;
|
|
66
|
+
} catch {
|
|
67
|
+
return null; // malformed — degrade to no-op rather than blocking every apply
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check an approver identity against the registry.
|
|
73
|
+
*
|
|
74
|
+
* @param {{approvers: Array<{identity:string, roles?:string[]}>}|null} registry
|
|
75
|
+
* @param {string} identity - the caller-supplied `fixMeta.approval.approvedBy`
|
|
76
|
+
* @param {string[]} [requiredRoles] - roles that satisfy this specific
|
|
77
|
+
* approval (e.g. the union of `requiredRolesByCategory` entries for the
|
|
78
|
+
* categories this change touches); empty means "any registered approver".
|
|
79
|
+
* @returns {{verified: boolean, reason: string}}
|
|
80
|
+
*/
|
|
81
|
+
export function verifyApprover(registry, identity, requiredRoles = []) {
|
|
82
|
+
if (!registry) {
|
|
83
|
+
return { verified: true, reason: 'no authorized-approvers registry configured — identity verification is a no-op until an operator opts in' };
|
|
84
|
+
}
|
|
85
|
+
const trimmedIdentity = typeof identity === 'string' ? identity.trim() : '';
|
|
86
|
+
if (!trimmedIdentity) {
|
|
87
|
+
return { verified: false, reason: 'no approver identity supplied — a registry is configured, so an anonymous approval is refused' };
|
|
88
|
+
}
|
|
89
|
+
const entry = registry.approvers.find((a) => a && a.identity === trimmedIdentity);
|
|
90
|
+
if (!entry) {
|
|
91
|
+
return { verified: false, reason: `approver '${trimmedIdentity}' is not in the authorized-approvers registry` };
|
|
92
|
+
}
|
|
93
|
+
if (requiredRoles.length) {
|
|
94
|
+
const roles = Array.isArray(entry.roles) ? entry.roles : [];
|
|
95
|
+
const hasRole = requiredRoles.some((r) => roles.includes(r));
|
|
96
|
+
if (!hasRole) {
|
|
97
|
+
return {
|
|
98
|
+
verified: false,
|
|
99
|
+
reason: `approver '${trimmedIdentity}' lacks a required role for this change (needs one of: ${requiredRoles.join(', ')}; has: ${roles.join(', ') || 'none'})`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { verified: true, reason: `approver '${trimmedIdentity}' verified` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The union of `registry.requiredRolesByCategory[c]` for every category in
|
|
108
|
+
* `categories`, deduplicated. A category with no configured entry
|
|
109
|
+
* contributes nothing — it is not "any role", it is "no role requirement",
|
|
110
|
+
* matching `verifyApprover`'s own empty-array no-op semantics.
|
|
111
|
+
*/
|
|
112
|
+
export function requiredRolesFor(registry, categories) {
|
|
113
|
+
const map = registry?.requiredRolesByCategory;
|
|
114
|
+
if (!map || typeof map !== 'object') return [];
|
|
115
|
+
const roles = new Set();
|
|
116
|
+
for (const c of categories) {
|
|
117
|
+
const forCategory = Array.isArray(map[c]) ? map[c] : [];
|
|
118
|
+
for (const r of forCategory) roles.add(r);
|
|
119
|
+
}
|
|
120
|
+
return [...roles];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* FR-1003: "a configurable policy can prevent the patch author from
|
|
125
|
+
* self-approving a high-impact fix." Gated the same way every other
|
|
126
|
+
* dimension in this registry is — NO-OP unless an operator opts in, this
|
|
127
|
+
* time via `registry.separationOfDuties.enabled === true`. Without that
|
|
128
|
+
* flag, an author approving their own fix is unaffected, matching
|
|
129
|
+
* FR-1002's own approvedBy-presence-is-enough default. `author` is a
|
|
130
|
+
* second self-reported identity, `fixMeta.author` — this module cannot
|
|
131
|
+
* determine who actually wrote a patch (no VCS blame is consulted; a
|
|
132
|
+
* caller could lie about either field), so like `approvedBy` itself this
|
|
133
|
+
* is a policy check over CLAIMED identities, not a cryptographic one —
|
|
134
|
+
* consistent with the "operator-registered, not authenticated" scope this
|
|
135
|
+
* whole registry already documents above.
|
|
136
|
+
*
|
|
137
|
+
* @param {{separationOfDuties?: {enabled?: boolean}}|null} registry
|
|
138
|
+
* @param {string} author - the caller-supplied `fixMeta.author`
|
|
139
|
+
* @param {string} approvedBy - the caller-supplied `fixMeta.approval.approvedBy`
|
|
140
|
+
* @returns {{ok: boolean, reason: string}}
|
|
141
|
+
*/
|
|
142
|
+
export function checkSeparationOfDuties(registry, author, approvedBy) {
|
|
143
|
+
if (!registry?.separationOfDuties?.enabled) {
|
|
144
|
+
return { ok: true, reason: 'separation-of-duties is not enabled in the authorized-approvers registry — a no-op until an operator opts in' };
|
|
145
|
+
}
|
|
146
|
+
const trimmedAuthor = typeof author === 'string' ? author.trim().toLowerCase() : '';
|
|
147
|
+
const trimmedApprover = typeof approvedBy === 'string' ? approvedBy.trim().toLowerCase() : '';
|
|
148
|
+
if (!trimmedAuthor) {
|
|
149
|
+
return { ok: true, reason: 'no fixMeta.author supplied — nothing to compare the approver against' };
|
|
150
|
+
}
|
|
151
|
+
if (trimmedAuthor === trimmedApprover) {
|
|
152
|
+
return { ok: false, reason: `separation-of-duties: the patch author ('${author}') cannot also be its approver` };
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, reason: 'approver differs from the patch author' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const _internals = { REGISTRY_FILE };
|
package/src/history-scan.js
CHANGED
|
@@ -18,12 +18,16 @@
|
|
|
18
18
|
import { spawnSync } from 'node:child_process';
|
|
19
19
|
import * as fs from 'node:fs';
|
|
20
20
|
import * as path from 'node:path';
|
|
21
|
+
import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
|
|
21
22
|
import { runFullScan } from './engine.js';
|
|
22
23
|
|
|
23
24
|
const MAX_FILES_PER_SCAN = 5000;
|
|
24
25
|
|
|
26
|
+
// `root` is the scan target's repository, not this project's own trusted
|
|
27
|
+
// checkout — hardened per FR-PROV-024 / the second Finding Provenance PRD
|
|
28
|
+
// audit (same exposure class as provenance/git-evidence.js's `_run`).
|
|
25
29
|
function _git(root, args) {
|
|
26
|
-
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
30
|
+
const r = spawnSync('git', hardenGitArgs(args), { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: hardenGitEnv() });
|
|
27
31
|
return { ok: r.status === 0, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -83,7 +87,10 @@ function _listFilesAtRef(root, ref) {
|
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
function _readFileAtRef(root, ref, file) {
|
|
86
|
-
|
|
90
|
+
// `--no-textconv`: this blob-cat form of `show` was verified NOT
|
|
91
|
+
// reachable via a hostile textconv driver in current git — kept for
|
|
92
|
+
// defense-in-depth/uniformity, same as pr-delta.js's equivalent.
|
|
93
|
+
const r = _git(root, ['show', '--no-textconv', `${ref}:${file}`]);
|
|
87
94
|
if (!r.ok) return null;
|
|
88
95
|
return r.stdout;
|
|
89
96
|
}
|
|
@@ -95,7 +102,13 @@ async function _scanAtRef(root, ref) {
|
|
|
95
102
|
const c = _readFileAtRef(root, ref, f);
|
|
96
103
|
if (c != null) fileContents[f] = c;
|
|
97
104
|
}
|
|
98
|
-
|
|
105
|
+
// `provenance:false` — this is a HISTORICAL ref, not "the state of this repo
|
|
106
|
+
// right now". Two things must not happen here: resolving git provenance for
|
|
107
|
+
// findings that already are a point in history (pure waste), and letting
|
|
108
|
+
// updateLifecycle see this partial, historical finding set as the current
|
|
109
|
+
// one — it marks every open stableId NOT in the set as `remediated`, so one
|
|
110
|
+
// per-ref scan would mass-remediate the whole project's real open findings.
|
|
111
|
+
const scan = await runFullScan({ fileContents, scanRoot: root, provenance: false }, () => {});
|
|
99
112
|
return {
|
|
100
113
|
ref,
|
|
101
114
|
fileCount: Object.keys(fileContents).length,
|
|
@@ -177,8 +190,12 @@ export async function runWhatIf(root, { overlays = [], remove = [] } = {}) {
|
|
|
177
190
|
}
|
|
178
191
|
}
|
|
179
192
|
// Baseline (without overlays) for delta computation.
|
|
180
|
-
|
|
181
|
-
|
|
193
|
+
// Both legs are hypothetical snapshots being differenced against each other,
|
|
194
|
+
// never the repo's current state — `provenance:false` for the same reason as
|
|
195
|
+
// _scanAtRef above (no provenance to resolve, and updateLifecycle must not
|
|
196
|
+
// treat either snapshot as "what is open right now").
|
|
197
|
+
const baseScan = await runFullScan({ fileContents: _baselineFor(fileContents, overlays, remove, root), scanRoot: root, provenance: false }, () => {});
|
|
198
|
+
const whatIfScan = await runFullScan({ fileContents, scanRoot: root, provenance: false }, () => {});
|
|
182
199
|
const baseIds = new Set((baseScan.findings || []).map(f => f.stableId || f.id));
|
|
183
200
|
const wIds = new Set((whatIfScan.findings || []).map(f => f.stableId || f.id));
|
|
184
201
|
const introduced = (whatIfScan.findings || []).filter(f => !baseIds.has(f.stableId || f.id)).map(_compact);
|