@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,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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
147
|
-
*
|
|
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) {
|