@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
@@ -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 };
@@ -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
- const r = _git(root, ['show', `${ref}:${file}`]);
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
- const scan = await runFullScan({ fileContents, scanRoot: root }, () => {});
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
- const baseScan = await runFullScan({ fileContents: _baselineFor(fileContents, overlays, remove, root), scanRoot: root }, () => {});
181
- const whatIfScan = await runFullScan({ fileContents, scanRoot: root }, () => {});
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);