@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 +390 -0
- package/bin/agentic-security.js +3813 -83
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +13 -4
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/2271.index.js +165 -0
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +14 -6
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +14 -6
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +67 -3
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +26 -8
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +580 -187
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -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/remediation-ledger.js +337 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/src/util/git-hardening.js +128 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Signed provenance evidence bundles (Finding Provenance PRD, M4 §4.1).
|
|
2
|
+
//
|
|
3
|
+
// Sibling to posture/evidence-bundle.js, not a generalization of it — that
|
|
4
|
+
// module's bundle shape (proofTier, taintPath, exploitability...) answers
|
|
5
|
+
// "is this finding real"; this one answers "who/when introduced it, how
|
|
6
|
+
// sure are we." Forcing one shape to cover both would leave half of every
|
|
7
|
+
// bundle null. Same four-function pattern (build/sign/verify + a schema
|
|
8
|
+
// string + a top-level-key allowlist), same Ed25519 key material — reused,
|
|
9
|
+
// not reimplemented.
|
|
10
|
+
//
|
|
11
|
+
// The allowlisted fields mirror provenance/coordinator.js's own
|
|
12
|
+
// computeDigest() material EXACTLY (stableId, findingOrigin.commit,
|
|
13
|
+
// branchIntroduction.commit, evidenceAttribution role:path:line:commit
|
|
14
|
+
// strings, method, confidence.reasons, limitations) plus repo/HEAD identity
|
|
15
|
+
// (not in computeDigest's material, since that digest never leaves the repo
|
|
16
|
+
// it was computed in, but a bundle does). Everything here is copied from
|
|
17
|
+
// what findingProvenance already computed. Nothing is inferred.
|
|
18
|
+
//
|
|
19
|
+
// canonicalJson vs canonicalBytes — resolved before writing this file
|
|
20
|
+
// -------------------------------------------------------------------
|
|
21
|
+
// evidence-bundle.js exports TWO canonicalisation helpers, not one, and they
|
|
22
|
+
// are not interchangeable. `canonicalJson(value)` is a pure, shape-agnostic
|
|
23
|
+
// deterministic serialiser (sorted keys at every level, order-preserving
|
|
24
|
+
// arrays) — safe to reuse for any bundle shape. `canonicalBytes(bundle)`
|
|
25
|
+
// is NOT generic: it hardcodes evidence-bundle.js's own six top-level keys
|
|
26
|
+
// (`schema, finding, evidence, engine, proves, doesNotProve`) when building
|
|
27
|
+
// the object it signs. Reusing `canonicalBytes` here would silently sign a
|
|
28
|
+
// filtered object missing this module's `repo` and `provenance` fields
|
|
29
|
+
// (and would read `bundle.evidence`, which a provenance bundle never has) —
|
|
30
|
+
// exactly the EA-03 failure mode the allowlist check below exists to catch,
|
|
31
|
+
// just introduced from the signing side instead of the verifying side. So
|
|
32
|
+
// this module imports the generic `canonicalJson` (as the brief's Step 1
|
|
33
|
+
// sketch already did) and signs `canonicalJson(bundle)` directly, since
|
|
34
|
+
// `buildProvenanceEvidenceBundle` never puts anything on the unsigned
|
|
35
|
+
// object beyond this module's own allowlisted fields.
|
|
36
|
+
|
|
37
|
+
import * as crypto from 'node:crypto';
|
|
38
|
+
import { ensureKeyPair, keyPaths, canonicalJson } from './evidence-bundle.js';
|
|
39
|
+
|
|
40
|
+
export const PROVENANCE_BUNDLE_SCHEMA = 'agentic-security/provenance-evidence@1';
|
|
41
|
+
|
|
42
|
+
const PROVES = 'This bundle\'s contents are exactly what was signed at attestation time.';
|
|
43
|
+
const DOES_NOT_PROVE = 'This bundle does NOT prove the origin commit is correctly identified — read confidence.level and limitations for that. It proves the RECORD is unmodified, not that the record is right.';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build an unsigned provenance bundle from one finding's findingProvenance.
|
|
47
|
+
* Returns null for a finding with no findingProvenance at all (nothing to
|
|
48
|
+
* attest) — this is a caller error (attest a scan before its provenance
|
|
49
|
+
* pass ran), not a case to paper over with an empty bundle.
|
|
50
|
+
*/
|
|
51
|
+
export function buildProvenanceEvidenceBundle(finding, { engineVersion, repoIdentity, head } = {}) {
|
|
52
|
+
if (!finding || typeof finding !== 'object') return null;
|
|
53
|
+
const fp = finding.findingProvenance;
|
|
54
|
+
if (!fp || typeof fp !== 'object') return null;
|
|
55
|
+
return {
|
|
56
|
+
schema: PROVENANCE_BUNDLE_SCHEMA,
|
|
57
|
+
finding: {
|
|
58
|
+
id: finding.id ?? null,
|
|
59
|
+
stableId: finding.stableId ?? null,
|
|
60
|
+
},
|
|
61
|
+
repo: {
|
|
62
|
+
identity: repoIdentity ?? null,
|
|
63
|
+
head: head ?? fp.analysisBasis?.head ?? null,
|
|
64
|
+
},
|
|
65
|
+
provenance: {
|
|
66
|
+
status: fp.status ?? null,
|
|
67
|
+
findingOrigin: fp.findingOrigin
|
|
68
|
+
? {
|
|
69
|
+
commit: fp.findingOrigin.commit ?? null,
|
|
70
|
+
authorName: fp.findingOrigin.authorName ?? null,
|
|
71
|
+
authorDate: fp.findingOrigin.authorDate ?? null,
|
|
72
|
+
summary: fp.findingOrigin.summary ?? null,
|
|
73
|
+
}
|
|
74
|
+
: null,
|
|
75
|
+
branchIntroduction: fp.branchIntroduction
|
|
76
|
+
? { commit: fp.branchIntroduction.commit ?? null, branch: fp.branchIntroduction.branch ?? null }
|
|
77
|
+
: null,
|
|
78
|
+
evidenceAttribution: (fp.evidenceAttribution || []).map((n) => ({
|
|
79
|
+
role: n.role ?? null, path: n.path ?? null, line: n.line ?? null, commit: n.commit ?? null,
|
|
80
|
+
})),
|
|
81
|
+
method: fp.method ?? null,
|
|
82
|
+
confidence: fp.confidence
|
|
83
|
+
? { level: fp.confidence.level ?? null, score: fp.confidence.score ?? null, reasons: fp.confidence.reasons || [] }
|
|
84
|
+
: null,
|
|
85
|
+
limitations: fp.limitations || [],
|
|
86
|
+
// M4 §4.2 final-review fix: this module was written (Task 1) before
|
|
87
|
+
// the cross-repo lineage feature existed (Task 5), so a
|
|
88
|
+
// cross-repo-resolved origin's boundary-crossing marker was dropped
|
|
89
|
+
// from the signed bundle — a foreign repository's commit SHA and a
|
|
90
|
+
// real author name, with no MACHINE-READABLE signal that the origin
|
|
91
|
+
// crossed a repository boundary (only the prose in `limitations`
|
|
92
|
+
// said so, and a programmatic verifier does not read prose). Nested
|
|
93
|
+
// under `provenance` alongside the fields above, so it is part of
|
|
94
|
+
// what gets signed — never a new top-level key.
|
|
95
|
+
historyCoverage: { crossRepoLineage: fp.historyCoverage?.crossRepoLineage ?? false },
|
|
96
|
+
},
|
|
97
|
+
engine: { engineVersion: engineVersion ?? null },
|
|
98
|
+
proves: PROVES,
|
|
99
|
+
doesNotProve: DOES_NOT_PROVE,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function signProvenanceEvidenceBundle(bundle, privateKeyPem) {
|
|
104
|
+
const sig = crypto.sign(null, Buffer.from(canonicalJson(bundle), 'utf8'), privateKeyPem);
|
|
105
|
+
return {
|
|
106
|
+
...bundle,
|
|
107
|
+
signature: { algorithm: 'ed25519', canonicalisation: PROVENANCE_BUNDLE_SCHEMA, value: sig.toString('base64') },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const PROVENANCE_BUNDLE_TOP_LEVEL_KEYS = new Set([
|
|
112
|
+
'schema', 'finding', 'repo', 'provenance', 'engine', 'proves', 'doesNotProve', 'signature',
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Verify with a PUBLIC key only. Rejects any top-level key outside the
|
|
117
|
+
* allowlist BEFORE checking the signature — same EA-03 fix evidence-bundle.js
|
|
118
|
+
* carries: a signature only covers the bytes it was computed over, so an
|
|
119
|
+
* unknown key stapled on after signing would otherwise verify as authentic.
|
|
120
|
+
*/
|
|
121
|
+
export function verifyProvenanceEvidenceBundle(bundle, publicKeyPem) {
|
|
122
|
+
if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
|
|
123
|
+
if (bundle.schema !== PROVENANCE_BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
|
|
124
|
+
const unknownKeys = Object.keys(bundle).filter((k) => !PROVENANCE_BUNDLE_TOP_LEVEL_KEYS.has(k));
|
|
125
|
+
if (unknownKeys.length) {
|
|
126
|
+
return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
|
|
127
|
+
}
|
|
128
|
+
const sig = bundle.signature;
|
|
129
|
+
if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
|
|
130
|
+
if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
|
|
131
|
+
if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
|
|
132
|
+
const { signature, ...unsigned } = bundle;
|
|
133
|
+
let ok = false;
|
|
134
|
+
try {
|
|
135
|
+
ok = crypto.verify(null, Buffer.from(canonicalJson(unsigned), 'utf8'), publicKeyPem, Buffer.from(sig.value, 'base64'));
|
|
136
|
+
} catch (e) {
|
|
137
|
+
return { ok: false, reason: `verification error: ${e.message}` };
|
|
138
|
+
}
|
|
139
|
+
return ok
|
|
140
|
+
? { ok: true, reason: null }
|
|
141
|
+
: { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export { ensureKeyPair, keyPaths };
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// remediation-ledger.js — M5 deliverable #6 (Blast-Radius: Remediation
|
|
2
|
+
// Command Center, FR-507 + AC-31), Task 2 of the sub-project: the IMPURE
|
|
3
|
+
// half. `../lineage/remediation.js` (Task 1) ships the pure RemediationItem
|
|
4
|
+
// contract — zero imports, no fs, never throws. This module owns every side
|
|
5
|
+
// effect that contract needs to become a real, durable, tamper-evident
|
|
6
|
+
// record: file locking, JSONL append, tolerant read, and the hash chain.
|
|
7
|
+
//
|
|
8
|
+
// This is the SECOND `posture/` → `lineage/` import in the codebase
|
|
9
|
+
// (`auditor-walkthrough.js`'s `graph:` branch was the first, per
|
|
10
|
+
// `posture/CLAUDE.md`'s "First `posture/` → `lineage/` import" section) —
|
|
11
|
+
// noted here so the boundary stays a deliberate, documented exception
|
|
12
|
+
// rather than an accident.
|
|
13
|
+
//
|
|
14
|
+
// ── The append-only contract (PRD line 984) ──────────────────────────────
|
|
15
|
+
//
|
|
16
|
+
// A remediation item's history is never rewritten, only appended to. Every
|
|
17
|
+
// decision — owner assignment, a state change, a manual attestation, an
|
|
18
|
+
// accepted-risk exception — is a permanent event, never an edit of a prior
|
|
19
|
+
// one. `foldRemediationItem`/`foldRemediationLedger` (Task 1) derive an
|
|
20
|
+
// item's CURRENT shape by replaying its events forward; nothing here ever
|
|
21
|
+
// mutates a written line.
|
|
22
|
+
//
|
|
23
|
+
// ── Why the physical shape is a JSONL append, not a whole-file rewrite ────
|
|
24
|
+
//
|
|
25
|
+
// `fix-metrics.js:69`'s `recordFixAttempt` is the precedent this module
|
|
26
|
+
// follows: "One writeSync of one newline-terminated line: a concurrent
|
|
27
|
+
// reader sees whole records or nothing, and a torn tail is dropped on
|
|
28
|
+
// read." `provenance/lifecycle.js:195`'s `updateLifecycle`, by contrast,
|
|
29
|
+
// reads the WHOLE store into memory, folds one scan's worth of changes in,
|
|
30
|
+
// and rewrites the WHOLE file — safe there because a lifecycle update is a
|
|
31
|
+
// single scan touching potentially every finding at once. A remediation
|
|
32
|
+
// ledger is the opposite shape: a human, one item at a time, arbitrarily
|
|
33
|
+
// interleaved with edits to every OTHER item. A partial rewrite of a shared
|
|
34
|
+
// document risks losing every OTHER item's history to a crash or a bug
|
|
35
|
+
// mid-rewrite; an append can only ever add a new, independently-readable
|
|
36
|
+
// line.
|
|
37
|
+
//
|
|
38
|
+
// ── Why a lock is still required, despite the append itself being atomic ──
|
|
39
|
+
//
|
|
40
|
+
// `fs.appendFileSync` of one line is atomic at the OS level, but writing an
|
|
41
|
+
// event is not just "append a line" — it is a READ-MODIFY-WRITE: the
|
|
42
|
+
// proposed event must be validated (`validateTransition`) against the
|
|
43
|
+
// item's CURRENT folded state, which requires reading and folding every
|
|
44
|
+
// prior event first. Two concurrent callers each reading the same "before"
|
|
45
|
+
// state and then both appending could both validate against a state that
|
|
46
|
+
// is stale by the time either write lands — e.g. two racing attempts to
|
|
47
|
+
// open the same item, both seeing "no item yet" and both succeeding, when
|
|
48
|
+
// exactly one must win. The lock serializes the whole
|
|
49
|
+
// read-fold-validate-append sequence into one critical section per event.
|
|
50
|
+
//
|
|
51
|
+
// `withLock` below is a faithful local PORT of
|
|
52
|
+
// `provenance/lifecycle.js:36`'s own `withLock` — NOT an import, because
|
|
53
|
+
// that function is not exported (verified directly against the file: it is
|
|
54
|
+
// a bare, module-private `async function withLock`). `isProcessAlive` is
|
|
55
|
+
// ported alongside it for the same reason.
|
|
56
|
+
//
|
|
57
|
+
// ── The hash chain ─────────────────────────────────────────────────────
|
|
58
|
+
//
|
|
59
|
+
// Byte-identical in behavior to `mcp/audit.js`'s own chain (`_sha`,
|
|
60
|
+
// `GENESIS`, `_readLastEntryHash`, `verifyAuditLog`, verified directly
|
|
61
|
+
// against that file before writing this one): each event carries `prev`,
|
|
62
|
+
// the SHA-256 hex digest of the PREVIOUS line's exact serialized JSON text
|
|
63
|
+
// (including that line's own `prev` field). The first event's `prev` is
|
|
64
|
+
// the literal string `'GENESIS'`. `readLedgerEvents`/`latestEventHash`
|
|
65
|
+
// walk forward from GENESIS; a line that fails to `JSON.parse` (a torn
|
|
66
|
+
// tail) or whose `prev` does not match the expected running hash (tamper)
|
|
67
|
+
// STOPS the walk. Both functions therefore return the longest verifying
|
|
68
|
+
// PREFIX of the file — never the full stream when any of it is
|
|
69
|
+
// unverifiable, and never a thrown error. See `_walkLedger` below, which
|
|
70
|
+
// both exported readers share so they cannot silently disagree on what
|
|
71
|
+
// "the last valid line" means.
|
|
72
|
+
//
|
|
73
|
+
// `appendLedgerEvent` is async and is the SINGLE place `validateTransition`
|
|
74
|
+
// is called in this codebase. No CLI command (Task 3) computes validity
|
|
75
|
+
// for itself — every proposed event is validated at this one write
|
|
76
|
+
// boundary, inside the lock, against the real current folded state. As of
|
|
77
|
+
// final-review fix round 1, this is also the single place THREE more
|
|
78
|
+
// things are enforced, all inside the same lock so none of them can race
|
|
79
|
+
// the write they guard: an `opened` event is additionally checked against
|
|
80
|
+
// `validateOpenPayload` (I4/M11 — previously only the CLI validated an
|
|
81
|
+
// `opened` payload's own shape, so a non-CLI caller could append a
|
|
82
|
+
// malformed one); the ledger's on-disk tail is checked for tearing before
|
|
83
|
+
// anything is appended onto it (I4 — appending onto a torn line would
|
|
84
|
+
// merge them into one unparseable line, silently losing the new event,
|
|
85
|
+
// and everything after it, forever); and an optional
|
|
86
|
+
// `opts.expectedBaseHash` optimistic-concurrency check runs against the
|
|
87
|
+
// real `lastHash` computed inside the lock (I5 — previously the CLI's own
|
|
88
|
+
// `--base-event` guard ran OUTSIDE the lock, a real TOCTOU: another
|
|
89
|
+
// process could append in the window between that check and this
|
|
90
|
+
// function's own lock acquisition).
|
|
91
|
+
|
|
92
|
+
import * as fs from 'node:fs';
|
|
93
|
+
import * as fsp from 'node:fs/promises';
|
|
94
|
+
import * as path from 'node:path';
|
|
95
|
+
import * as crypto from 'node:crypto';
|
|
96
|
+
import { statePath, isSafeStateDir, stateWritesEnabled } from './state-dir.js';
|
|
97
|
+
import { validateTransition, validateOpenPayload, foldRemediationLedger } from '../lineage/remediation.js';
|
|
98
|
+
|
|
99
|
+
const GENESIS = 'GENESIS';
|
|
100
|
+
|
|
101
|
+
function _sha(s) {
|
|
102
|
+
return crypto.createHash('sha256').update(s).digest('hex');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function ledgerPaths(scanRoot) {
|
|
106
|
+
return {
|
|
107
|
+
ledgerPath: statePath(scanRoot, 'remediation', 'items.jsonl'),
|
|
108
|
+
lockPath: statePath(scanRoot, 'remediation', 'items.lock'),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Shared walk: reads the ledger file (missing → empty), splits on '\n',
|
|
113
|
+
// drops empty lines, and walks forward maintaining `expectedPrev` (starting
|
|
114
|
+
// at GENESIS) exactly as `mcp/audit.js`'s `verifyAuditLog` does. A parse
|
|
115
|
+
// failure or a `prev` mismatch stops the walk without throwing. Returns
|
|
116
|
+
// `{events, lastHash}` so `readLedgerEvents`/`latestEventHash` cannot drift
|
|
117
|
+
// apart on what "the last valid line" means.
|
|
118
|
+
function _walkLedger(scanRoot) {
|
|
119
|
+
const { ledgerPath } = ledgerPaths(scanRoot);
|
|
120
|
+
let raw;
|
|
121
|
+
try {
|
|
122
|
+
raw = fs.readFileSync(ledgerPath, 'utf8');
|
|
123
|
+
} catch {
|
|
124
|
+
return { events: [], lastHash: GENESIS };
|
|
125
|
+
}
|
|
126
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
127
|
+
const events = [];
|
|
128
|
+
let expectedPrev = GENESIS;
|
|
129
|
+
let lastHash = GENESIS;
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
let event;
|
|
132
|
+
try {
|
|
133
|
+
event = JSON.parse(line);
|
|
134
|
+
} catch {
|
|
135
|
+
break; // torn tail — stop, do not throw
|
|
136
|
+
}
|
|
137
|
+
if (!event || typeof event !== 'object' || Array.isArray(event) || event.prev !== expectedPrev) {
|
|
138
|
+
break; // tamper detected — stop, do not throw
|
|
139
|
+
}
|
|
140
|
+
events.push(event);
|
|
141
|
+
lastHash = _sha(line);
|
|
142
|
+
expectedPrev = lastHash;
|
|
143
|
+
}
|
|
144
|
+
return { events, lastHash };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Returns the longest verifying PREFIX of the ledger — never the full
|
|
148
|
+
// stream when a tail is torn or a middle line is tampered. Never throws.
|
|
149
|
+
export function readLedgerEvents(scanRoot) {
|
|
150
|
+
return _walkLedger(scanRoot).events;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// GENESIS when the ledger is empty/missing, or when nothing in it verifies.
|
|
154
|
+
export function latestEventHash(scanRoot) {
|
|
155
|
+
return _walkLedger(scanRoot).lastHash;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Reports whether the ledger's real content on disk has more raw lines than
|
|
159
|
+
// the longest verifying prefix — i.e. a torn tail OR a tampered middle line
|
|
160
|
+
// broke the hash chain partway through. Never throws. This is a read-only
|
|
161
|
+
// diagnostic; it does not change what readLedgerEvents/latestEventHash
|
|
162
|
+
// return (both still return the longest verifying prefix, unconditionally
|
|
163
|
+
// safe by construction) — it exists so a caller (the CLI's `list` command)
|
|
164
|
+
// can surface a loud warning instead of silently presenting a shorter or
|
|
165
|
+
// stale history as if it were the whole truth. (I7, final-review fix
|
|
166
|
+
// round 1.)
|
|
167
|
+
export function ledgerIntegrity(scanRoot) {
|
|
168
|
+
const { ledgerPath } = ledgerPaths(scanRoot);
|
|
169
|
+
let raw;
|
|
170
|
+
try {
|
|
171
|
+
raw = fs.readFileSync(ledgerPath, 'utf8');
|
|
172
|
+
} catch {
|
|
173
|
+
return { ok: true, totalLines: 0, verifiedLines: 0 };
|
|
174
|
+
}
|
|
175
|
+
const totalLines = raw.split('\n').filter(Boolean).length;
|
|
176
|
+
const { events } = _walkLedger(scanRoot);
|
|
177
|
+
return { ok: events.length === totalLines, totalLines, verifiedLines: events.length };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isProcessAlive(pid) {
|
|
181
|
+
// POSIX: process.kill(pid, 0) probes existence without sending a signal.
|
|
182
|
+
// EPERM also means the process exists; only ESRCH means dead.
|
|
183
|
+
try { process.kill(pid, 0); return true; }
|
|
184
|
+
catch (e) { return e && e.code === 'EPERM'; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Faithful local port of `provenance/lifecycle.js:36-88`'s `withLock` — not
|
|
188
|
+
// an import, since that function is module-private there. Exclusive `wx`
|
|
189
|
+
// open of the lockfile, write the PID, run `fn` in a `try`, `unlink` in
|
|
190
|
+
// `finally`. On EEXIST, a stale lock (holding PID not alive, or lockfile
|
|
191
|
+
// older than 30s) is reaped, re-reading the lockfile before unlinking so a
|
|
192
|
+
// fresh holder taken by another process in the meantime is never raced.
|
|
193
|
+
// 25ms retry; 5s timeout throwing a named error.
|
|
194
|
+
async function withLock(lockPath, fn) {
|
|
195
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
196
|
+
const start = Date.now();
|
|
197
|
+
const TIMEOUT_MS = 5000;
|
|
198
|
+
while (true) {
|
|
199
|
+
try {
|
|
200
|
+
const handle = await fsp.open(lockPath, 'wx');
|
|
201
|
+
await handle.writeFile(String(process.pid));
|
|
202
|
+
try { await handle.close(); } catch {}
|
|
203
|
+
try {
|
|
204
|
+
return await fn();
|
|
205
|
+
} finally {
|
|
206
|
+
await fsp.unlink(lockPath).catch(() => {});
|
|
207
|
+
}
|
|
208
|
+
} catch (e) {
|
|
209
|
+
if (e && e.code === 'EEXIST') {
|
|
210
|
+
try {
|
|
211
|
+
const [st, pidStr] = await Promise.all([
|
|
212
|
+
fsp.stat(lockPath),
|
|
213
|
+
fsp.readFile(lockPath, 'utf8').catch(() => ''),
|
|
214
|
+
]);
|
|
215
|
+
const pid = parseInt(pidStr.trim(), 10);
|
|
216
|
+
const pidAlive = Number.isFinite(pid) && isProcessAlive(pid);
|
|
217
|
+
const old = Date.now() - st.mtimeMs > 30000;
|
|
218
|
+
if (!pidAlive || old) {
|
|
219
|
+
try {
|
|
220
|
+
// Only unlink if the lockfile still holds the PID we just
|
|
221
|
+
// read, so we don't race the unlink against a fresh lock
|
|
222
|
+
// taken by another process in the meantime.
|
|
223
|
+
const recheck = (await fsp.readFile(lockPath, 'utf8').catch(() => '')).trim();
|
|
224
|
+
if (recheck === pidStr.trim()) {
|
|
225
|
+
await fsp.unlink(lockPath);
|
|
226
|
+
}
|
|
227
|
+
} catch {}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
} catch {}
|
|
231
|
+
if (Date.now() - start > TIMEOUT_MS) throw new Error('remediation-ledger: lock timed out');
|
|
232
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
throw e;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Resolves the itemId a proposed event refers to, mirroring
|
|
241
|
+
// `foldRemediationLedger`'s own grouping fallback exactly (Task 1's
|
|
242
|
+
// `../lineage/remediation.js`: `ev.itemId ?? (ev.type === 'opened' ?
|
|
243
|
+
// ev.id : undefined)`) — an `opened` event carries `id` (per
|
|
244
|
+
// `foldRemediationItem`'s own use of `openEvent.id`), every other event
|
|
245
|
+
// type carries `itemId`. Reusing the identical fallback here, rather than
|
|
246
|
+
// reading `eventPayload.itemId` alone, is what makes item resolution agree
|
|
247
|
+
// with how the ledger will actually be folded on read — an opened event
|
|
248
|
+
// for an id that already exists must resolve to that existing item, not
|
|
249
|
+
// silently miss it because the payload only carries `id`.
|
|
250
|
+
function _resolveItemId(eventPayload) {
|
|
251
|
+
if (!eventPayload || typeof eventPayload !== 'object') return undefined;
|
|
252
|
+
return eventPayload.itemId ?? (eventPayload.type === 'opened' ? eventPayload.id : undefined);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// This function is the single place `validateTransition` is called. No CLI
|
|
256
|
+
// command computes validity for itself. `opts.expectedBaseHash` (I5) is an
|
|
257
|
+
// optional optimistic-concurrency check, compared against the real
|
|
258
|
+
// `lastHash` computed INSIDE the lock — the authoritative half of the
|
|
259
|
+
// `--base-event` guard; `undefined` (the flag was never passed) performs no
|
|
260
|
+
// check.
|
|
261
|
+
export async function appendLedgerEvent(scanRoot, eventPayload, opts = {}) {
|
|
262
|
+
const { ledgerPath, lockPath } = ledgerPaths(scanRoot);
|
|
263
|
+
const dir = path.dirname(ledgerPath);
|
|
264
|
+
|
|
265
|
+
// Refused BEFORE the lock is taken, so an unsafe/disabled target never
|
|
266
|
+
// even gets a lockfile written into it.
|
|
267
|
+
if (!isSafeStateDir(dir)) {
|
|
268
|
+
return {
|
|
269
|
+
valid: false,
|
|
270
|
+
errors: [{ field: '(scanRoot)', message: 'refusing to write — not a recognized project state directory' }],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (!stateWritesEnabled()) {
|
|
274
|
+
return {
|
|
275
|
+
valid: false,
|
|
276
|
+
errors: [{ field: '(state)', message: 'state writes are disabled (AGENTIC_SECURITY_NO_STATE or setStateWritesEnabled(false))' }],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return withLock(lockPath, async () => {
|
|
281
|
+
// I4 (final-review fix round 1): refuse to append onto a torn tail —
|
|
282
|
+
// concatenating a new event onto an unterminated final line would
|
|
283
|
+
// merge them into one unparseable line, silently losing this event
|
|
284
|
+
// (and everything after it) forever while still reporting success. A
|
|
285
|
+
// crash/ENOSPC mid-write leaves exactly this shape. Checked first,
|
|
286
|
+
// inside the lock, before anything else touches the file.
|
|
287
|
+
try {
|
|
288
|
+
const raw = fs.readFileSync(ledgerPath, 'utf8');
|
|
289
|
+
if (raw.length > 0 && !raw.endsWith('\n')) {
|
|
290
|
+
return {
|
|
291
|
+
valid: false,
|
|
292
|
+
errors: [{ field: '(ledger)', message: 'the ledger file has a torn/unterminated final line — refusing to append onto it. Recover the file (restore from backup, or manually truncate to its last complete, newline-terminated line) before retrying.' }],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
} catch { /* missing file — nothing to check */ }
|
|
296
|
+
|
|
297
|
+
const { events, lastHash } = _walkLedger(scanRoot);
|
|
298
|
+
|
|
299
|
+
// I5 (final-review fix round 1): the authoritative optimistic-
|
|
300
|
+
// concurrency check, run against the real `lastHash` computed inside
|
|
301
|
+
// this same critical section — the CLI's own pre-lock check is still
|
|
302
|
+
// useful as a cheap, early fail, but this is the one that cannot be
|
|
303
|
+
// raced by a concurrent writer.
|
|
304
|
+
if (opts.expectedBaseHash !== undefined && opts.expectedBaseHash !== lastHash) {
|
|
305
|
+
return {
|
|
306
|
+
valid: false,
|
|
307
|
+
errors: [{ field: '(base-event)', message: 'the ledger changed since --base-event was computed (a concurrent write) — refusing to append.' }],
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const items = foldRemediationLedger(events);
|
|
312
|
+
const itemId = _resolveItemId(eventPayload);
|
|
313
|
+
const item = itemId != null ? (items[itemId] ?? null) : null;
|
|
314
|
+
|
|
315
|
+
// M11: an `opened` event's own shape is validated here too, not just
|
|
316
|
+
// by the CLI — mirrors "the single place validity is enforced" for
|
|
317
|
+
// the one event type `validateTransition` deliberately does not
|
|
318
|
+
// shape-check (it only checks that no item with this id exists yet).
|
|
319
|
+
if (eventPayload && eventPayload.type === 'opened') {
|
|
320
|
+
const openCheck = validateOpenPayload(eventPayload);
|
|
321
|
+
if (!openCheck.valid) {
|
|
322
|
+
return { valid: false, errors: openCheck.errors };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const { valid, errors } = validateTransition(item, eventPayload);
|
|
327
|
+
if (!valid) {
|
|
328
|
+
return { valid: false, errors };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const event = { ...eventPayload, prev: lastHash };
|
|
332
|
+
const line = JSON.stringify(event);
|
|
333
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
334
|
+
fs.appendFileSync(ledgerPath, line + '\n', 'utf8');
|
|
335
|
+
return { valid: true, errors: [], event, hash: _sha(line) };
|
|
336
|
+
});
|
|
337
|
+
}
|
package/src/posture/sbom-diff.js
CHANGED
|
@@ -24,7 +24,8 @@ import * as fs from 'node:fs';
|
|
|
24
24
|
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
25
25
|
import * as path from 'node:path';
|
|
26
26
|
import * as crypto from 'node:crypto';
|
|
27
|
-
import {
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
28
29
|
|
|
29
30
|
const HISTORY_DIR = 'sbom-history';
|
|
30
31
|
|
|
@@ -32,9 +33,21 @@ function _historyDir(scanRoot) {
|
|
|
32
33
|
return statePath(scanRoot, HISTORY_DIR);
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
// `scanRoot` is the scanned project's repository, not this project's own
|
|
37
|
+
// trusted checkout — hardened per FR-PROV-024 / the second Finding
|
|
38
|
+
// Provenance PRD audit sweep (found missing here by a follow-up review that
|
|
39
|
+
// grepped for `child_process` usage beyond just `execFileSync('git'` call
|
|
40
|
+
// sites). `rev-parse HEAD` was VERIFIED not to itself trigger
|
|
41
|
+
// `core.fsmonitor`/a hook (it touches neither the working tree nor the
|
|
42
|
+
// index), so this is not a second live RCE — but leaving
|
|
43
|
+
// `GIT_CONFIG_NOSYSTEM`/`GIT_TERMINAL_PROMPT`/`core.hooksPath` unset here
|
|
44
|
+
// still violated this module's own "every git call routes through
|
|
45
|
+
// hardenGitArgs/hardenGitEnv" contract, and `execSync` (a shell string) is
|
|
46
|
+
// gratuitous risk this call never needed — `rev-parse HEAD` has no
|
|
47
|
+
// caller-controlled input to interpolate at all.
|
|
35
48
|
function _gitHead(scanRoot) {
|
|
36
49
|
try {
|
|
37
|
-
return
|
|
50
|
+
return execFileSync('git', hardenGitArgs(['rev-parse', 'HEAD']), { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() }).trim();
|
|
38
51
|
} catch { return null; }
|
|
39
52
|
}
|
|
40
53
|
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// needs network and is deferred — see the rollup.
|
|
13
13
|
|
|
14
14
|
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
15
16
|
|
|
16
17
|
// Pull the post-image (added) lines out of a unified diff: lines starting with
|
|
17
18
|
// a single '+' (not the '+++' file header). Returns reconstructed text.
|
|
@@ -62,8 +63,15 @@ export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs
|
|
|
62
63
|
if (!scanRoot || typeof detectFn !== 'function') return [];
|
|
63
64
|
let out;
|
|
64
65
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
// Second independent Finding Provenance PRD audit (FR-PROV-024): this
|
|
67
|
+
// scanRoot is a scanned repository, not this project's own trusted
|
|
68
|
+
// checkout. `--no-textconv` alone (the pre-existing hardening here) closes
|
|
69
|
+
// the .gitattributes textconv surface but NOT `core.fsmonitor` /
|
|
70
|
+
// `core.hooksPath` — this `log -p` call renders every historical commit's
|
|
71
|
+
// diff content, the same shape verified exploitable in
|
|
72
|
+
// provenance/git-evidence.js, so it gets the full hardening too.
|
|
73
|
+
out = execFileSync('git', hardenGitArgs(['-C', scanRoot, 'log', '-p', '-n', String(maxCommits), '--no-color', '--no-merges', '--no-textconv']),
|
|
74
|
+
{ encoding: 'utf8', maxBuffer: 96 * 1024 * 1024, timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() });
|
|
67
75
|
} catch { return []; }
|
|
68
76
|
const parts = out.split(/^commit ([0-9a-f]{7,40})/m); // [pre, sha, body, sha, body, ...]
|
|
69
77
|
const findings = [];
|
package/src/posture/state-dir.js
CHANGED
|
@@ -130,6 +130,14 @@ export function isSafeStateDir(dir) {
|
|
|
130
130
|
// per-caller discipline already failed once: its header records a user who
|
|
131
131
|
// uninstalled the plugin after stray state directories broke their build.
|
|
132
132
|
let _stateWritesEnabled = true;
|
|
133
|
+
// Category-scoped override (M2 §2.4 performance fix): when the blanket
|
|
134
|
+
// switch above is OFF, a category listed here still writes. Exists for
|
|
135
|
+
// lsp/server.js, which needs the provenance disk cache live on every
|
|
136
|
+
// keystroke-save while every OTHER state write (dpia.md, lifecycle.json,
|
|
137
|
+
// ...) stays suppressed — see the withStateWritesDisabled call site in
|
|
138
|
+
// lsp/server.js for why the blanket switch alone made every LSP scan pay
|
|
139
|
+
// the FULL uncached provenance-resolution cost on every single save.
|
|
140
|
+
let _enabledCategories = new Set();
|
|
133
141
|
|
|
134
142
|
/** Turn all state writing off (or back on) for this process. */
|
|
135
143
|
export function setStateWritesEnabled(enabled) {
|
|
@@ -148,6 +156,15 @@ export function stateWritesEnabled() {
|
|
|
148
156
|
return _stateWritesEnabled;
|
|
149
157
|
}
|
|
150
158
|
|
|
159
|
+
// Category-aware check used by ensureStateDir/safeWriteState below. A
|
|
160
|
+
// caller that never passes `category` behaves EXACTLY as before: it only
|
|
161
|
+
// ever consults the blanket switch.
|
|
162
|
+
function _categoryEnabled(category) {
|
|
163
|
+
if (process.env.AGENTIC_SECURITY_NO_STATE === '1') return false;
|
|
164
|
+
if (_stateWritesEnabled) return true;
|
|
165
|
+
return !!category && _enabledCategories.has(category);
|
|
166
|
+
}
|
|
167
|
+
|
|
151
168
|
/**
|
|
152
169
|
* Run `fn` with state writes forced off, restoring the PRIOR flag value
|
|
153
170
|
* afterward — for a caller (assurance-hardening PRD FR-704) that must
|
|
@@ -157,31 +174,38 @@ export function stateWritesEnabled() {
|
|
|
157
174
|
* the same process — exactly `apply_fix`'s failure mode this wrapper exists
|
|
158
175
|
* to prevent, via `finally` rather than caller discipline.
|
|
159
176
|
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
177
|
+
* `exceptCategories` lets a caller keep ONE narrow category of write alive
|
|
178
|
+
* while everything else stays suppressed — see lsp/server.js. Restored via
|
|
179
|
+
* the same `finally` as the blanket flag, for the same reason.
|
|
180
|
+
*
|
|
181
|
+
* KNOWN LIMITATION: `_stateWritesEnabled` and `_enabledCategories` are both
|
|
182
|
+
* process-global, not per-call. Two overlapping calls to this function (or
|
|
183
|
+
* one overlapping a direct setStateWritesEnabled() call) can race and leave
|
|
184
|
+
* the flags in the wrong state for one of them once both finish. mcp/CLAUDE.md
|
|
185
|
+
* already documents an accepted concurrency limitation of the same shape for
|
|
186
|
+
* fix-history.js ("concurrent apply_fix calls can race... today benign... a
|
|
187
|
+
* future stateful tool needs serialization") — this is the same class of
|
|
188
|
+
* risk, not a new one, and this wrapper is still a strict improvement over
|
|
189
|
+
* the alternative it replaces (a caller that writes state UNCONDITIONALLY on
|
|
169
190
|
* every call, with no opt-out at all).
|
|
170
191
|
*/
|
|
171
|
-
export async function withStateWritesDisabled(fn) {
|
|
192
|
+
export async function withStateWritesDisabled(fn, { exceptCategories = [] } = {}) {
|
|
172
193
|
const prior = _stateWritesEnabled;
|
|
194
|
+
const priorCategories = new Set(_enabledCategories);
|
|
173
195
|
_stateWritesEnabled = false;
|
|
196
|
+
for (const c of exceptCategories) _enabledCategories.add(c);
|
|
174
197
|
try {
|
|
175
198
|
return await fn();
|
|
176
199
|
} finally {
|
|
177
200
|
_stateWritesEnabled = prior;
|
|
201
|
+
_enabledCategories = priorCategories;
|
|
178
202
|
}
|
|
179
203
|
}
|
|
180
204
|
|
|
181
205
|
// Safe mkdir: only creates .agentic-security/ if the parent has a project marker.
|
|
182
206
|
// Returns the dir on success, null if refused. Logs a warning when refused.
|
|
183
|
-
export function ensureStateDir(scanRoot) {
|
|
184
|
-
if (!
|
|
207
|
+
export function ensureStateDir(scanRoot, { category } = {}) {
|
|
208
|
+
if (!_categoryEnabled(category)) return null;
|
|
185
209
|
const dir = stateDir(scanRoot);
|
|
186
210
|
if (!isSafeStateDir(dir)) {
|
|
187
211
|
if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
|
|
@@ -199,8 +223,8 @@ export function ensureStateDir(scanRoot) {
|
|
|
199
223
|
|
|
200
224
|
// Safe write: only writes if isSafeStateDir(parent) returns true.
|
|
201
225
|
// Returns true on success, false if refused or errored.
|
|
202
|
-
export function safeWriteState(filePath, content) {
|
|
203
|
-
if (!
|
|
226
|
+
export function safeWriteState(filePath, content, { category } = {}) {
|
|
227
|
+
if (!_categoryEnabled(category)) return false;
|
|
204
228
|
const dir = path.dirname(filePath);
|
|
205
229
|
if (!isSafeStateDir(dir)) {
|
|
206
230
|
if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
|