@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,205 @@
1
+ // FR-705 (assurance-hardening PRD): "Encrypt state classes marked
2
+ // confidential when an encryption provider is configured or required |
3
+ // Required encryption absence fails before sensitive state is written."
4
+ //
5
+ // SCOPE (Phase 1 of a deliberately staged rollout — see this module's own
6
+ // header for what is and is not covered, and D-0039-style honesty about
7
+ // what remains):
8
+ //
9
+ // - Confidentiality is a NEW `confidential: true` field on specific
10
+ // artifact-registry.js entries (see that module for the exact list and
11
+ // the reasoning for each). `last-scan.json`/`findings.json` are
12
+ // DELIBERATELY EXCLUDED from Phase 1: dozens of commands read them
13
+ // directly as plain JSON, so encrypting them is a much larger,
14
+ // separate migration (every reader needs a decrypt-on-read hook) —
15
+ // doing that alongside a handful of narrow, low-traffic audit/evidence
16
+ // artifacts in one pass would repeat exactly the "large, structurally-
17
+ // coupled epic attempted in one unplanned cycle" mistake this session
18
+ // has deliberately avoided elsewhere (see D-0028, D-0039).
19
+ // - ONE provider is implemented: 'local-key' — AES-256-GCM with a
20
+ // per-install key generated the first time encryption is actually
21
+ // used, stored at the same $XDG_CONFIG_HOME/agentic-security/ directory
22
+ // integrity.js already uses for its own signing key (a SEPARATE key
23
+ // file — never reuse one key across different cryptographic purposes).
24
+ // A KMS/envelope-encryption provider is a real future extension point
25
+ // (the provider interface is already shaped to allow one) but is out
26
+ // of scope here: this codebase makes no runtime cloud calls, and a KMS
27
+ // call is unavoidably a network call.
28
+ // - "Configured or required" is a per-project opt-in policy file
29
+ // (.agentic-security/encryption-policy.yml: `provider: local-key`,
30
+ // `required: true|false`). No file means "not configured, not
31
+ // required" — every confidential artifact keeps writing exactly as it
32
+ // always has, so this feature is inert until an operator opts in,
33
+ // matching every other policy surface in this codebase
34
+ // (retention-policy.yml, risk-config.yml, sca-policy.yml, ...).
35
+ // - The FAIL-CLOSED half of the acceptance criterion is
36
+ // `maybeEncryptForWrite`'s own contract: when `required: true` is set
37
+ // for a confidential artifact but no working provider is available,
38
+ // it returns `{ok:false}` — the caller must not write ANYTHING in that
39
+ // case, plaintext or otherwise. This is checked BEFORE any bytes touch
40
+ // disk, per the literal "fails before sensitive state is written"
41
+ // wording.
42
+
43
+ import * as crypto from 'node:crypto';
44
+ import * as fs from 'node:fs';
45
+ import * as os from 'node:os';
46
+ import * as path from 'node:path';
47
+ import * as yaml from '../util/yaml.js';
48
+ import { statePath } from './state-dir.js';
49
+ import { confidentialOf } from './artifact-registry.js';
50
+
51
+ export const ENCRYPTION_POLICY_FILE = 'encryption-policy.yml';
52
+ export const ENCRYPTION_MARKER = '__agentic_security_encrypted_v1__';
53
+ const KEY_FILE_NAME = 'encryption-key';
54
+ const ALGORITHM = 'aes-256-gcm';
55
+ const IV_BYTES = 12;
56
+
57
+ function _keyDir() {
58
+ const xdg = process.env.XDG_CONFIG_HOME;
59
+ const base = xdg && xdg.length ? xdg : path.join(os.homedir(), '.config');
60
+ return path.join(base, 'agentic-security');
61
+ }
62
+ function _keyPath() { return path.join(_keyDir(), KEY_FILE_NAME); }
63
+
64
+ // Same atomic-publish shape as integrity.js's _publishKeyAtomically — a
65
+ // hard-link into place is atomic and fails EEXIST rather than clobbering,
66
+ // closing the same first-writer-wins race a plain exclusive-create alone
67
+ // leaves open on filesystems without hard-link support (documented in
68
+ // integrity.js's own header; reproduced here rather than imported, since
69
+ // this is a genuinely separate key with its own purpose and no reason to
70
+ // couple the two modules' internals together).
71
+ function _publishKeyAtomically(fp, contents) {
72
+ const dir = _keyDir();
73
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
74
+ const tmp = path.join(dir, `${KEY_FILE_NAME}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
75
+ try {
76
+ fs.writeFileSync(tmp, contents, { mode: 0o600 });
77
+ try {
78
+ fs.linkSync(tmp, fp);
79
+ return 'created';
80
+ } catch (e) {
81
+ if (e.code === 'EEXIST') return 'exists';
82
+ try {
83
+ fs.writeFileSync(fp, contents, { mode: 0o600, flag: 'wx' });
84
+ return 'created';
85
+ } catch (e2) {
86
+ if (e2.code === 'EEXIST') return 'exists';
87
+ throw e2;
88
+ }
89
+ }
90
+ } finally {
91
+ try { fs.unlinkSync(tmp); } catch { /* best effort */ }
92
+ }
93
+ }
94
+
95
+ function _readOrGenerateKey() {
96
+ const fromEnv = process.env.AGENTIC_SECURITY_ENCRYPTION_KEY;
97
+ if (fromEnv && /^[0-9a-fA-F]{64}$/.test(fromEnv.trim())) return Buffer.from(fromEnv.trim(), 'hex');
98
+ const fp = _keyPath();
99
+ try {
100
+ if (fs.existsSync(fp)) {
101
+ const hex = fs.readFileSync(fp, 'utf8').trim();
102
+ if (/^[0-9a-fA-F]{64}$/.test(hex)) return Buffer.from(hex, 'hex');
103
+ }
104
+ } catch { /* fall through to generate */ }
105
+ const buf = crypto.randomBytes(32);
106
+ try {
107
+ const outcome = _publishKeyAtomically(fp, buf.toString('hex') + '\n');
108
+ if (outcome === 'created') return buf;
109
+ const hex = fs.readFileSync(fp, 'utf8').trim();
110
+ return Buffer.from(hex, 'hex');
111
+ } catch {
112
+ return buf; // ephemeral — a write can proceed this run, but nothing encrypted with it will ever decrypt again. Degraded, not blocked.
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Read `.agentic-security/encryption-policy.yml`. Never throws; a missing
118
+ * or malformed file is "not configured, not required" (the safe,
119
+ * inert-by-default state every other policy file in this codebase uses).
120
+ */
121
+ export function loadEncryptionPolicy(scanRoot) {
122
+ let fp;
123
+ try { fp = statePath(scanRoot, ENCRYPTION_POLICY_FILE); } catch { return null; }
124
+ let raw;
125
+ try { raw = fs.readFileSync(fp, 'utf8'); } catch { return null; }
126
+ try {
127
+ const doc = yaml.load(raw);
128
+ if (!doc || typeof doc !== 'object') return null;
129
+ const provider = doc.provider === 'local-key' ? 'local-key' : null;
130
+ return { provider, required: doc.required === true };
131
+ } catch { return null; }
132
+ }
133
+
134
+ function _encryptBuffer(plaintext, key) {
135
+ const iv = crypto.randomBytes(IV_BYTES);
136
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
137
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
138
+ return { iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ciphertext: ciphertext.toString('base64') };
139
+ }
140
+
141
+ function _decryptBuffer(envelope, key) {
142
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(envelope.iv, 'base64'));
143
+ decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
144
+ return Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, 'base64')), decipher.final()]);
145
+ }
146
+
147
+ /**
148
+ * Is `parsed` (an already-JSON.parsed value) an encryption envelope this
149
+ * module produced?
150
+ */
151
+ export function isEncryptedEnvelope(parsed) {
152
+ return !!(parsed && typeof parsed === 'object' && parsed[ENCRYPTION_MARKER] === true);
153
+ }
154
+
155
+ /**
156
+ * The fail-closed gate. `artifactName` is matched against
157
+ * artifact-registry.js's `confidential` flag — an artifact not marked
158
+ * confidential is always {ok:true, content} unchanged, regardless of
159
+ * policy, so this function is a safe no-op to call for every write site
160
+ * without needing to duplicate the confidentiality check at each caller.
161
+ *
162
+ * @returns {{ok:true, content:string, encrypted:boolean} | {ok:false, reason:string}}
163
+ */
164
+ export function maybeEncryptForWrite(scanRoot, artifactName, content) {
165
+ if (!confidentialOf(artifactName)) return { ok: true, content, encrypted: false };
166
+
167
+ const policy = loadEncryptionPolicy(scanRoot);
168
+ if (!policy || !policy.provider) {
169
+ if (policy && policy.required) {
170
+ return { ok: false, reason: `encryption is required for confidential artifact "${artifactName}" but no provider is configured (.agentic-security/${ENCRYPTION_POLICY_FILE})` };
171
+ }
172
+ return { ok: true, content, encrypted: false };
173
+ }
174
+
175
+ try {
176
+ const key = _readOrGenerateKey();
177
+ const envelope = _encryptBuffer(Buffer.from(content, 'utf8'), key);
178
+ return { ok: true, content: JSON.stringify({ [ENCRYPTION_MARKER]: true, provider: 'local-key', ...envelope }), encrypted: true };
179
+ } catch (e) {
180
+ if (policy.required) return { ok: false, reason: `encryption required but failed for "${artifactName}": ${e.message}` };
181
+ return { ok: true, content, encrypted: false };
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Transparent decrypt-on-read: if `rawContent` parses as an envelope this
187
+ * module produced, decrypt and return the original plaintext string;
188
+ * otherwise return `rawContent` unchanged (a plaintext file, or a file
189
+ * from before encryption was ever configured). Never throws — a corrupt
190
+ * or undecryptable envelope degrades to returning the raw envelope JSON
191
+ * back (a caller expecting markdown/plaintext will visibly get neither,
192
+ * which is the correct, honest failure mode for a decrypt this module
193
+ * cannot perform, rather than silently returning empty content).
194
+ */
195
+ export function maybeDecryptForRead(rawContent) {
196
+ let parsed;
197
+ try { parsed = JSON.parse(rawContent); } catch { return rawContent; }
198
+ if (!isEncryptedEnvelope(parsed)) return rawContent;
199
+ try {
200
+ const key = _readOrGenerateKey();
201
+ return _decryptBuffer(parsed, key).toString('utf8');
202
+ } catch { return rawContent; }
203
+ }
204
+
205
+ export const _internals = { _readOrGenerateKey, _encryptBuffer, _decryptBuffer, _keyPath };
@@ -0,0 +1,71 @@
1
+ // Canonical evidence-grade wording (assurance-hardening PRD FR-507).
2
+ //
3
+ // Every compliance-adjacent artifact this engine emits needs to disclaim
4
+ // the same thing, and four of them had already drifted into hand-rolled,
5
+ // SLIGHTLY WRONG phrasings before this module existed: compliance-policy.js
6
+ // and auditor-walkthrough.js both said "A licensed assessor... is
7
+ // responsible for the final attestation" — which conflates two DIFFERENT
8
+ // things the PRD (and the standards this tooling maps to) treat as
9
+ // distinct:
10
+ //
11
+ // - An ATTESTATION is the ORGANIZATION'S OWN management formally
12
+ // asserting that stated controls are in place and operating (SOC 2's
13
+ // "management assertion" is the canonical example). This tool has no
14
+ // visibility into organizational process, personnel, or intent, so it
15
+ // cannot produce one.
16
+ // - A CERTIFICATION is a formal opinion or certificate issued by an
17
+ // ACCREDITED, INDEPENDENT third party (an ISO 27001 certification
18
+ // body, a SOC 2 auditor, a PCI DSS QSA) after their own examination.
19
+ // A "licensed assessor" produces a certification/independent
20
+ // assessment, not an attestation — the exact word swap the old
21
+ // wording got backwards.
22
+ //
23
+ // This tool produces neither. It produces the third, distinct tier:
24
+ //
25
+ // - AUTOMATED TECHNICAL ASSESSMENT — deterministic, code-derived
26
+ // evidence for a bounded, disclosed set of controls. Real evidence,
27
+ // scoped honestly, but not a judgment about the organization.
28
+ //
29
+ // Naming all three, every time, is what FR-507 asks for ("UI and docs
30
+ // distinguish automated technical assessment, management attestation, and
31
+ // independent certification") — not just a generic "this isn't
32
+ // certification" caveat, which is necessary but not sufficient: a reader
33
+ // who has never heard the word "attestation" used correctly has no way to
34
+ // know a real management attestation is a DIFFERENT, valid artifact they
35
+ // might separately need.
36
+ //
37
+ // Every artifact quoting from here keeps the exact substring "does not
38
+ // certify compliance" — several existing tests (world-class-batch2.test.js's
39
+ // CMP-5 cases) already pin that phrase, and there's no reason to break a
40
+ // correct, if incomplete, assertion while fixing the incompleteness.
41
+
42
+ export const ASSURANCE_TIERS = Object.freeze({
43
+ automatedTechnicalAssessment:
44
+ 'Automated technical assessment — what THIS TOOL produces: deterministic, ' +
45
+ 'code-derived evidence for a bounded, disclosed set of controls.',
46
+ managementAttestation:
47
+ 'Management attestation — the ORGANIZATION\'S OWN leadership formally ' +
48
+ 'asserting that stated controls are in place and operating. This tool ' +
49
+ 'cannot produce one; only the organization\'s management can.',
50
+ independentCertification:
51
+ 'Independent certification — a formal opinion or certificate issued by an ' +
52
+ 'ACCREDITED, INDEPENDENT third party (e.g. an ISO 27001 certification body, ' +
53
+ 'a SOC 2 auditor, a PCI DSS QSA) after their own examination. This tool ' +
54
+ 'cannot produce one; only an accredited independent party can.',
55
+ });
56
+
57
+ /** One-paragraph disclaimer for machine-consumed / GRC-ingested artifacts. */
58
+ export const EVIDENCE_GRADE_DISCLAIMER =
59
+ 'This artifact organizes automated technical assessment evidence produced by this scanner. ' +
60
+ 'It does not certify compliance, and it is not a management attestation: only the ' +
61
+ 'organization\'s own leadership can formally assert that stated controls are in place ' +
62
+ 'and operating. It is also not an independent certification: only an accredited, ' +
63
+ 'independent third party (an auditor, assessor, or certification body) examining the ' +
64
+ 'organization directly can issue one.';
65
+
66
+ /** Shorter inline variant for a markdown blockquote or narrow UI surface. */
67
+ export const EVIDENCE_GRADE_DISCLAIMER_SHORT =
68
+ 'This is automated technical assessment evidence — it does not certify compliance. ' +
69
+ 'It is neither a management attestation (the organization\'s own sign-off) nor an ' +
70
+ 'independent certification (an accredited third party\'s opinion); a licensed assessor ' +
71
+ 'or the organization\'s management, not this tool, is responsible for either.';
@@ -13,6 +13,7 @@ import * as fsp from 'node:fs/promises';
13
13
  import * as path from 'node:path';
14
14
  import * as crypto from 'node:crypto';
15
15
  import { isSafeStateDir, statePath, stateWritesEnabled } from './state-dir.js';
16
+ import { AGE_BASIS } from './provenance/schema.js';
16
17
 
17
18
  function historyDir(scanRoot) {
18
19
  return statePath(scanRoot, 'fix-history');
@@ -58,10 +59,6 @@ export function fixAcceptanceRate(scanRoot) {
58
59
  return acceptanceFromEntries(readLog(scanRoot));
59
60
  }
60
61
 
61
- function writeLog(scanRoot, log) {
62
- ensure(scanRoot);
63
- fs.writeFileSync(logPath(scanRoot), JSON.stringify(log, null, 2));
64
- }
65
62
  function sha(s) { return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16); }
66
63
 
67
64
  // Premortem 3R-12: cross-check helpers for last-scan.json. We look up
@@ -242,7 +239,36 @@ function _countPriorAttempts(log, stableId, findingId) {
242
239
  return n;
243
240
  }
244
241
 
245
- export async function applyFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId }) {
242
+ // FR-PROV §7.4 / M2 §2.2: how old was this finding, by which basis, at the
243
+ // moment it was fixed. Computed ONCE, at fix time, and never re-derived
244
+ // later — a finding's origin doesn't change, but re-computing "age at fix"
245
+ // from a LATER read of findingProvenance would silently answer "how old is
246
+ // it now", not "how old was it when fixed". Mirrors mttr.js's ageBasis
247
+ // tiering (Task 6) so the two surfaces agree on vocabulary.
248
+ function _snapshotProvenanceAtFix(findingProvenance, appliedAt) {
249
+ if (!findingProvenance) return null;
250
+ const status = findingProvenance.status;
251
+ const origin = findingProvenance.findingOrigin;
252
+ const observedAt = findingProvenance.firstObserved?.observedAt || null;
253
+ let ageBasis, basisDate;
254
+ if (status === 'complete' && origin?.authorDate) { ageBasis = AGE_BASIS.FINDING_ORIGIN; basisDate = origin.authorDate; }
255
+ else if (status === 'partial' && origin?.authorDate) { ageBasis = AGE_BASIS.EARLIEST_OBSERVABLE; basisDate = origin.authorDate; }
256
+ else if (status === 'uncommitted') { ageBasis = AGE_BASIS.UNCOMMITTED; basisDate = observedAt; }
257
+ else { ageBasis = AGE_BASIS.FIRST_OBSERVED; basisDate = observedAt; }
258
+ const ageDays = basisDate ? Math.max(0, Math.floor((Date.parse(appliedAt) - Date.parse(basisDate)) / 86400000)) : null;
259
+ return { commit: origin?.commit || null, authorDate: basisDate, ageBasis, ageDays };
260
+ }
261
+
262
+ // @param {boolean} [fileExisted] - did `file` exist on disk before this call?
263
+ // Determines what "restore" means on rollback: write `originalContent`
264
+ // back for a file that existed (default, for backward compatibility with
265
+ // callers that don't pass it — safer to assume "existed" than to risk
266
+ // deleting a real file), or delete the file entirely for one that did not
267
+ // (a fix that created a NEW file, which is `originalContent: ''`'s only
268
+ // real-world meaning in this codebase's callers — writing '' back would
269
+ // leave a phantom empty file where none existed before, not a true
270
+ // rollback).
271
+ export async function applyFix({ scanRoot, file, originalContent, newContent, findingId, ruleId, vuln, stableId, fileExisted = true, findingProvenance = null }) {
246
272
  return _withLogLock(scanRoot, async () => {
247
273
  ensure(scanRoot);
248
274
  const absFile = path.resolve(scanRoot, file);
@@ -260,8 +286,11 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
260
286
  MAX_ATTEMPTS_PER_KEY,
261
287
  );
262
288
  }
263
- // Phase 1: backup + fsync.
264
- await _writeAndSync(bakPath, originalContent);
289
+ // Phase 1: backup + fsync. Atomic for the same reason the target write
290
+ // is below — a corrupted backup is worse than no backup, because it
291
+ // silently defeats rollback.
292
+ await _writeAtomicAndSync(bakPath, originalContent);
293
+ const appliedAt = new Date().toISOString();
265
294
  const entry = {
266
295
  id,
267
296
  findingId,
@@ -269,22 +298,52 @@ export async function applyFix({ scanRoot, file, originalContent, newContent, fi
269
298
  ruleId: ruleId || null,
270
299
  vuln: vuln || null,
271
300
  file,
301
+ fileExisted,
272
302
  backupPath: path.relative(scanRoot, bakPath),
273
303
  originalSha: sha(originalContent),
274
304
  newSha: sha(newContent),
275
- appliedAt: new Date().toISOString(),
305
+ appliedAt,
276
306
  status: 'pending',
277
307
  reverted: false,
278
308
  attemptOrdinal: priorAttempts + 1,
309
+ provenanceAtFix: _snapshotProvenanceAtFix(findingProvenance, appliedAt),
279
310
  };
280
311
  // Phase 2: log entry marked pending + fsync.
281
312
  const log = priorLog;
282
313
  log.push(entry);
283
314
  await _writeLogAndSync(scanRoot, log);
284
- // Phase 3: write the new content to the target file + fsync.
315
+ // Phase 3: atomic write of the new content, then FR-306's post-write
316
+ // hash verification — read the file back and confirm it genuinely
317
+ // contains what was just written, not merely that the write call
318
+ // returned without throwing (a write can "succeed" against a
319
+ // corrupting filesystem, a truncated disk-full write that still exits
320
+ // 0, or a concurrent external modification landing between our write
321
+ // and the read-back proving it).
285
322
  try {
286
- await _writeAndSync(absFile, newContent);
323
+ await _writeAtomicAndSync(absFile, newContent);
324
+ const writtenBack = await fsp.readFile(absFile, 'utf8');
325
+ if (sha(writtenBack) !== entry.newSha) {
326
+ throw new Error(`post-write hash verification failed for ${file}: on-disk content does not match what was written`);
327
+ }
287
328
  } catch (e) {
329
+ // FR-306: "injected write failure restores all files" — a failure at
330
+ // this point (the write itself, or the verification catching a
331
+ // corrupted write) must not leave the target in a partial or wrong
332
+ // state. Restore it now, synchronously with the failure, not as a
333
+ // later manual `recover()` step.
334
+ try {
335
+ if (fileExisted) {
336
+ await _writeAtomicAndSync(absFile, originalContent);
337
+ } else {
338
+ await fsp.unlink(absFile).catch(() => {}); // the failed write may not have landed at all
339
+ }
340
+ entry.rolledBack = true;
341
+ } catch (restoreErr) {
342
+ // Genuinely worse case (e.g. the filesystem itself is unwritable) —
343
+ // recorded honestly rather than silently claimed as rolled back.
344
+ entry.rolledBack = false;
345
+ entry.restoreError = restoreErr.message;
346
+ }
288
347
  entry.status = 'failed';
289
348
  entry.error = e.message;
290
349
  await _writeLogAndSync(scanRoot, log);
@@ -308,6 +367,33 @@ async function _writeAndSync(fp, content) {
308
367
  }
309
368
  }
310
369
 
370
+ // FR-306: "atomic replacement". `_writeAndSync` above opens the TARGET path
371
+ // directly in truncate mode — a crash or thrown error between the truncate
372
+ // and the write completing leaves the file partially written, not atomically
373
+ // replaced. This writes to a sibling temp file first, fsyncs it, then
374
+ // renames it over the target — `rename()` is atomic on the same filesystem,
375
+ // so the target is either the old content or the new content in full, never
376
+ // a partial mix. The temp file is cleaned up on any failure before the
377
+ // rename so a crash never leaves an orphaned `.tmp-*` file behind.
378
+ async function _writeAtomicAndSync(fp, content) {
379
+ const dir = path.dirname(fp);
380
+ await fsp.mkdir(dir, { recursive: true });
381
+ const tmp = path.join(dir, `.${path.basename(fp)}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
382
+ try {
383
+ const handle = await fsp.open(tmp, 'w');
384
+ try {
385
+ await handle.writeFile(content);
386
+ if (typeof handle.sync === 'function') await handle.sync();
387
+ } finally {
388
+ await handle.close();
389
+ }
390
+ await fsp.rename(tmp, fp);
391
+ } catch (e) {
392
+ try { await fsp.unlink(tmp); } catch { /* never existed, or already gone — fine either way */ }
393
+ throw e;
394
+ }
395
+ }
396
+
311
397
  async function _writeLogAndSync(scanRoot, log) {
312
398
  ensure(scanRoot);
313
399
  const fp = logPath(scanRoot);
@@ -369,22 +455,37 @@ async function _recoverInner(scanRoot) {
369
455
  return recovered;
370
456
  }
371
457
 
458
+ // Shared revert primitive. `fileExisted === false` means the fix created a
459
+ // file that did not exist beforehand — reverting deletes it rather than
460
+ // writing the empty-string sentinel back, which would leave a phantom empty
461
+ // file where none existed (see applyFix's `fileExisted` doc comment for why
462
+ // that distinction matters). Entries logged before FR-306 have no
463
+ // `fileExisted` field at all; `undefined !== false` so they fall through to
464
+ // the pre-existing "write original content back" behavior, unchanged.
465
+ async function _revertEntryInner(scanRoot, entry) {
466
+ const bak = path.resolve(scanRoot, entry.backupPath);
467
+ const absFile = path.resolve(scanRoot, entry.file);
468
+ if (!fs.existsSync(bak)) return { error: `backup missing: ${bak}` };
469
+ const original = await fsp.readFile(bak, 'utf8');
470
+ if (entry.fileExisted === false) {
471
+ await fsp.unlink(absFile).catch(() => {}); // may already be gone; deletion is the goal either way
472
+ } else {
473
+ await _writeAtomicAndSync(absFile, original);
474
+ }
475
+ entry.reverted = true;
476
+ entry.revertedAt = new Date().toISOString();
477
+ return entry;
478
+ }
479
+
372
480
  // Revert the most recent un-reverted fix. Returns the entry or null.
373
481
  export async function undoLast(scanRoot) {
374
482
  return _withLogLock(scanRoot, async () => {
375
483
  const log = readLog(scanRoot);
376
484
  for (let i = log.length - 1; i >= 0; i--) {
377
485
  if (!log[i].reverted) {
378
- const entry = log[i];
379
- const bak = path.resolve(scanRoot, entry.backupPath);
380
- const absFile = path.resolve(scanRoot, entry.file);
381
- if (!fs.existsSync(bak)) return { error: `backup missing: ${bak}` };
382
- const original = await fsp.readFile(bak, 'utf8');
383
- await fsp.writeFile(absFile, original);
384
- entry.reverted = true;
385
- entry.revertedAt = new Date().toISOString();
386
- writeLog(scanRoot, log);
387
- return entry;
486
+ const result = await _revertEntryInner(scanRoot, log[i]);
487
+ if (!result.error) await _writeLogAndSync(scanRoot, log);
488
+ return result;
388
489
  }
389
490
  }
390
491
  return null;
@@ -399,6 +500,22 @@ export async function undoAll(scanRoot) {
399
500
  return reverted;
400
501
  }
401
502
 
503
+ // FR-306: revert ONE specific entry by id, regardless of its position in the
504
+ // log. The building block for same-batch rollback — a multi-file apply that
505
+ // fails partway through must undo only the files THIS batch itself wrote,
506
+ // not every unrelated pending entry `undoAll` would touch.
507
+ export async function revertEntryById(scanRoot, entryId) {
508
+ return _withLogLock(scanRoot, async () => {
509
+ const log = readLog(scanRoot);
510
+ const entry = log.find(e => e.id === entryId);
511
+ if (!entry) return { error: `no such history entry: ${entryId}` };
512
+ if (entry.reverted) return entry;
513
+ const result = await _revertEntryInner(scanRoot, entry);
514
+ if (!result.error) await _writeLogAndSync(scanRoot, log);
515
+ return result;
516
+ });
517
+ }
518
+
402
519
  export function listHistory(scanRoot) { return readLog(scanRoot); }
403
520
 
404
521
  // Premortem 3R-17: fix-history/log.json grows monotonically. A long-running
@@ -141,23 +141,64 @@ export function computeFixTier(signals) {
141
141
  }
142
142
 
143
143
  /**
144
- * Compose the three gates for a single fix's output.
144
+ * FR-308: cross-check a fix-completeness TIER against MECHANICAL evidence,
145
+ * when any is available. `signals` (computeFixTier's input) is agent-
146
+ * self-reported — this module cannot compute sinkSignatureChanged /
147
+ * allCallersRouted / testDiscriminates itself (see the header above: "the
148
+ * gate can only run against claims the AGENT self-reports... nothing here
149
+ * is server-computable"). `pocLeg` is different: fix-verify.js's PoC leg is
150
+ * a REAL execution result (posture/CLAUDE.md's execution-proof tiers), not
151
+ * a claim. When it is available and shows the proof-of-concept STILL
152
+ * demonstrates the vulnerability against the patch, a self-reported FULL
153
+ * tier is not merely internally inconsistent — it is REFUTED by fact. This
154
+ * is the literal "a mitigation or workaround cannot be represented as a
155
+ * full fix" acceptance criterion, now backed by mechanical evidence where
156
+ * it exists rather than by self-report consistency alone.
145
157
  *
146
- * ok = residual-honesty ok AND evidence-citation ok, further constrained by the
147
- * tier/residual consistency invariant:
158
+ * A `pocLeg` of `not-requested` or `inconclusive` carries no mechanical
159
+ * signal either way and is a no-op here — this check can only ever ADD a
160
+ * violation on real contrary evidence, never manufacture one from absence.
161
+ *
162
+ * @param {string} tier
163
+ * @param {{status: string, reason?: string}|null} pocLeg
164
+ * @returns {{ ok: boolean, violations: string[] }}
165
+ */
166
+ export function checkMechanicalTierEvidence(tier, pocLeg) {
167
+ if (!pocLeg || typeof pocLeg !== 'object') return { ok: true, violations: [] };
168
+ if (tier === 'FULL' && pocLeg.status === 'still-exploitable') {
169
+ return {
170
+ ok: false,
171
+ violations: [`tier 'FULL' is refuted by mechanical evidence: the proof-of-concept still demonstrates the vulnerability against the patch${pocLeg.reason ? ` (${pocLeg.reason})` : ''}`],
172
+ };
173
+ }
174
+ return { ok: true, violations: [] };
175
+ }
176
+
177
+ /**
178
+ * Compose the four gates for a single fix's output.
179
+ *
180
+ * ok = residual-honesty ok AND evidence-citation ok AND mechanical-tier-
181
+ * evidence ok, further constrained by the tier/residual consistency
182
+ * invariant:
148
183
  * - a FULL tier must NOT carry a residual (a full fix has nothing left);
149
184
  * - a non-FULL tier MUST document a residual (say what's still open).
150
185
  *
151
186
  * @param {{ residual?: string, verdict?: string, evidence?: any, signals?: object }} input
187
+ * @param {{ pocLeg?: object|null }} [mechanical] - FR-308: optional real
188
+ * execution evidence (fix-verify.js's pocLeg) to cross-check the
189
+ * self-reported tier against. Omitted entirely by any caller that has no
190
+ * PoC leg to offer — this parameter never REQUIRES mechanical evidence,
191
+ * it only USES it when present.
152
192
  * @returns {{ ok: boolean, tier: string, violations: string[] }}
153
193
  */
154
- export function gateFixOutput({ residual, verdict, evidence, signals } = {}) {
194
+ export function gateFixOutput({ residual, verdict, evidence, signals } = {}, { pocLeg = null } = {}) {
155
195
  const tier = computeFixTier(signals);
156
196
  const residualCheck = checkResidualHonesty(residual);
157
197
  const evidenceCheck = requireCitedEvidence(verdict, evidence);
198
+ const mechanicalCheck = checkMechanicalTierEvidence(tier, pocLeg);
158
199
 
159
- const violations = [...residualCheck.violations, ...evidenceCheck.violations];
160
- let ok = residualCheck.ok && evidenceCheck.ok;
200
+ const violations = [...residualCheck.violations, ...evidenceCheck.violations, ...mechanicalCheck.violations];
201
+ let ok = residualCheck.ok && evidenceCheck.ok && mechanicalCheck.ok;
161
202
 
162
203
  const residualEmpty = typeof residual !== 'string' || residual.trim() === '';
163
204
  if (tier === 'FULL' && !residualEmpty) {