@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,208 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as fsp from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { statePath, stateWritesEnabled, isSafeStateDir } from '../state-dir.js';
|
|
5
|
+
|
|
6
|
+
// Both paths go through the state-dir seam rather than joining the state
|
|
7
|
+
// directory name by hand — see test/no-stray-state.test.js.
|
|
8
|
+
//
|
|
9
|
+
// What that does and does NOT buy: statePath() resolves WHERE state belongs
|
|
10
|
+
// (resolveProjectRoot walks up for a project marker), but it performs no
|
|
11
|
+
// safety check of its own. The marker check is `isSafeStateDir`, and it lives
|
|
12
|
+
// inside safeWriteState()/ensureStateDir() — which updateLifecycle
|
|
13
|
+
// deliberately bypasses (see its comment). So this module calls isSafeStateDir
|
|
14
|
+
// explicitly before writing; without that it would happily create
|
|
15
|
+
// `.agentic-security/` in a directory that is not a recognised project root,
|
|
16
|
+
// which is the litter state-dir.js exists to prevent.
|
|
17
|
+
function storePath(scanRoot) { return statePath(scanRoot, 'provenance', 'lifecycle.json'); }
|
|
18
|
+
function lockPath(scanRoot) { return statePath(scanRoot, 'provenance', 'lifecycle.lock'); }
|
|
19
|
+
|
|
20
|
+
export function readLifecycle(scanRoot) {
|
|
21
|
+
// Read directly and let the catch handle "missing" — an explicit
|
|
22
|
+
// existsSync() check first is a check-then-use race for no benefit, since
|
|
23
|
+
// the catch already covers every failure mode a stale check would too.
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(storePath(scanRoot), 'utf8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Mirrors posture/fix-history.js's _withLogLock: an exclusive (wx) lockfile,
|
|
32
|
+
// released in finally{}. On contention (EEXIST), a stale lock — one whose
|
|
33
|
+
// holding PID is no longer alive, or one older than 30s — is reaped before
|
|
34
|
+
// falling through to the timeout-based retry loop, so a crashed/killed
|
|
35
|
+
// process (not just a throwing one) cannot wedge provenance updates forever.
|
|
36
|
+
async function withLock(scanRoot, fn) {
|
|
37
|
+
const lp = lockPath(scanRoot);
|
|
38
|
+
fs.mkdirSync(path.dirname(lp), { recursive: true });
|
|
39
|
+
const start = Date.now();
|
|
40
|
+
const TIMEOUT_MS = 5000;
|
|
41
|
+
while (true) {
|
|
42
|
+
try {
|
|
43
|
+
const handle = await fsp.open(lp, 'wx');
|
|
44
|
+
await handle.writeFile(String(process.pid));
|
|
45
|
+
try { await handle.close(); } catch {}
|
|
46
|
+
try {
|
|
47
|
+
return await fn();
|
|
48
|
+
} finally {
|
|
49
|
+
await fsp.unlink(lp).catch(() => {});
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
if (e && e.code === 'EEXIST') {
|
|
53
|
+
try {
|
|
54
|
+
const [st, pidStr] = await Promise.all([
|
|
55
|
+
fsp.stat(lp),
|
|
56
|
+
fsp.readFile(lp, 'utf8').catch(() => ''),
|
|
57
|
+
]);
|
|
58
|
+
const pid = parseInt(pidStr.trim(), 10);
|
|
59
|
+
const pidAlive = Number.isFinite(pid) && isProcessAlive(pid);
|
|
60
|
+
const old = Date.now() - st.mtimeMs > 30000;
|
|
61
|
+
if (!pidAlive || old) {
|
|
62
|
+
try {
|
|
63
|
+
// Only unlink if the lockfile still holds the PID we just
|
|
64
|
+
// read, so we don't race the unlink against a fresh lock
|
|
65
|
+
// taken by another process in the meantime.
|
|
66
|
+
const recheck = (await fsp.readFile(lp, 'utf8').catch(() => '')).trim();
|
|
67
|
+
if (recheck === pidStr.trim()) {
|
|
68
|
+
await fsp.unlink(lp);
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
} catch {}
|
|
74
|
+
if (Date.now() - start > TIMEOUT_MS) throw new Error('provenance/lifecycle: lock timed out');
|
|
75
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isProcessAlive(pid) {
|
|
84
|
+
// POSIX: process.kill(pid, 0) probes existence without sending a signal.
|
|
85
|
+
// EPERM also means the process exists; only ESRCH means dead.
|
|
86
|
+
try { process.kill(pid, 0); return true; }
|
|
87
|
+
catch (e) { return e && e.code === 'EPERM'; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isOpenEvent(events) {
|
|
91
|
+
const last = events[events.length - 1];
|
|
92
|
+
return !!last && ['introduced', 'reintroduced', 'reverted', 'cherry-picked'].includes(last.type);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fold this scan's findings into `store`, in memory. Pure with respect to the
|
|
97
|
+
* filesystem — extracted so the read-only path below can produce the SAME view
|
|
98
|
+
* the persisting path would, without writing anything.
|
|
99
|
+
*
|
|
100
|
+
* `completeScan` is the one structural guard in this module, and it exists
|
|
101
|
+
* because the two passes below make asymmetric claims:
|
|
102
|
+
*
|
|
103
|
+
* - The `introduced`/`reintroduced` pass reasons about findings that ARE
|
|
104
|
+
* present. Present is present regardless of how much of the tree was read,
|
|
105
|
+
* so it is sound on any scan and always runs.
|
|
106
|
+
* - The remediation pass reasons about findings that are ABSENT, turning
|
|
107
|
+
* absence into the positive claim "this was fixed." That is only sound if
|
|
108
|
+
* the scan actually looked everywhere it could have found them. On a subset
|
|
109
|
+
* scan (`--changed-since`, `--pr`, an MCP/LSP caller-supplied file list) the
|
|
110
|
+
* entire rest of the project is absent by construction, so running it marks
|
|
111
|
+
* every open finding outside the changed set remediated.
|
|
112
|
+
*
|
|
113
|
+
* Skipping the pass leaves those entries OPEN, which is the honest state: the
|
|
114
|
+
* scan has no evidence either way. A later complete scan closes whatever was
|
|
115
|
+
* genuinely fixed. Defaults true so an explicit `completeScan:false` is what
|
|
116
|
+
* suppresses it, never a caller forgetting to pass the flag.
|
|
117
|
+
*/
|
|
118
|
+
function applyScan(store, currentFindings, { scanId, observedAt, completeScan = true }) {
|
|
119
|
+
const currentIds = new Set(currentFindings.map((f) => f.stableId).filter(Boolean));
|
|
120
|
+
|
|
121
|
+
for (const f of currentFindings) {
|
|
122
|
+
if (!f.stableId) continue;
|
|
123
|
+
const events = store[f.stableId] || (store[f.stableId] = []);
|
|
124
|
+
if (isOpenEvent(events)) continue;
|
|
125
|
+
const fp = f.findingProvenance;
|
|
126
|
+
const commit = fp?.findingOrigin?.commit || null;
|
|
127
|
+
const authorDate = fp?.status === 'complete' ? (fp.findingOrigin?.authorDate || observedAt) : observedAt;
|
|
128
|
+
// M3 §3.1: a reintroduction whose resolved findingOrigin is a genuine
|
|
129
|
+
// revert-of-a-fix or a cherry-picked propagation of an earlier
|
|
130
|
+
// introduction is a DIFFERENT lifecycle story than an unrelated
|
|
131
|
+
// reintroduction — both fields are only ever populated by deep-mode
|
|
132
|
+
// resolution (Task 3), so this vocabulary is silent (both null) for
|
|
133
|
+
// every standard-mode scan, which is the honest state: standard mode
|
|
134
|
+
// has no opinion on the distinction.
|
|
135
|
+
let type = events.length === 0 ? 'introduced' : 'reintroduced';
|
|
136
|
+
// `relatedCommit` carries the revert-target / cherry-pick-source SHA that
|
|
137
|
+
// was already read to CLASSIFY `type` above — without this, that SHA was
|
|
138
|
+
// discarded once the classification was made, so a 'reverted'/
|
|
139
|
+
// 'cherry-picked' event recorded THAT something was reverted/cherry-picked
|
|
140
|
+
// but not WHAT commit it was reverted/cherry-picked from, which is the
|
|
141
|
+
// fact a consumer actually needs to follow the link back.
|
|
142
|
+
let relatedCommit = null;
|
|
143
|
+
if (fp?.findingOrigin?.revertOf) { type = 'reverted'; relatedCommit = fp.findingOrigin.revertOf; }
|
|
144
|
+
else if (fp?.findingOrigin?.cherryPickOf) { type = 'cherry-picked'; relatedCommit = fp.findingOrigin.cherryPickOf; }
|
|
145
|
+
events.push({ type, commit, authorDate, scanId, observedAt, relatedCommit });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (completeScan !== false) {
|
|
149
|
+
for (const [stableId, events] of Object.entries(store)) {
|
|
150
|
+
if (isOpenEvent(events) && !currentIds.has(stableId)) {
|
|
151
|
+
events.push({ type: 'remediated', commit: null, authorDate: null, scanId, observedAt });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return store;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function updateLifecycle(scanRoot, currentFindings, { scanId, observedAt, completeScan = true }) {
|
|
160
|
+
// Read-only scan (`--no-state` / AGENTIC_SECURITY_NO_STATE): return the view
|
|
161
|
+
// this scan WOULD have produced, computed in memory, and persist nothing.
|
|
162
|
+
//
|
|
163
|
+
// Returning the on-disk store unchanged would have been one line shorter and
|
|
164
|
+
// wrong in a quiet way — a caller asking "when was this finding introduced"
|
|
165
|
+
// would get "never" for every finding first seen in this scan, which is a
|
|
166
|
+
// false answer rather than a missing one. The lock is skipped too: a lockfile
|
|
167
|
+
// is itself a write into the scanned tree, and there is nothing to serialise
|
|
168
|
+
// when nothing is written.
|
|
169
|
+
if (!stateWritesEnabled()) {
|
|
170
|
+
return applyScan(readLifecycle(scanRoot), currentFindings, { scanId, observedAt, completeScan });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The project-marker check safeWriteState() would have applied, applied here
|
|
174
|
+
// because the write below deliberately does not go through it.
|
|
175
|
+
//
|
|
176
|
+
// Checked BEFORE withLock, not inside it: withLock's first act is
|
|
177
|
+
// `fs.mkdirSync(path.dirname(lockPath))`, so guarding only the store write
|
|
178
|
+
// would still have created `.agentic-security/provenance/` in an
|
|
179
|
+
// unrecognised directory before refusing — the directory IS the litter, so
|
|
180
|
+
// refusing after creating it refuses nothing. Returns the same in-memory view
|
|
181
|
+
// the read-only path returns, for the same reason: a missing answer, not a
|
|
182
|
+
// false one.
|
|
183
|
+
if (!isSafeStateDir(path.dirname(storePath(scanRoot)))) {
|
|
184
|
+
return applyScan(readLifecycle(scanRoot), currentFindings, { scanId, observedAt, completeScan });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return withLock(scanRoot, async () => {
|
|
188
|
+
const store = applyScan(readLifecycle(scanRoot), currentFindings, { scanId, observedAt, completeScan });
|
|
189
|
+
// Deliberately a direct write, not safeWriteState(): this write is inside a
|
|
190
|
+
// locked critical section and its failure MUST propagate so the lock is
|
|
191
|
+
// released and the caller learns the store was not persisted.
|
|
192
|
+
// safeWriteState swallows errors and returns false, which would turn a
|
|
193
|
+
// failed write into a silent no-op that still looks like success.
|
|
194
|
+
fs.mkdirSync(path.dirname(storePath(scanRoot)), { recursive: true });
|
|
195
|
+
fs.writeFileSync(storePath(scanRoot), JSON.stringify(store, null, 2));
|
|
196
|
+
return store;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function latestOpenIntroduction(store, stableId) {
|
|
201
|
+
const events = store[stableId];
|
|
202
|
+
if (!events || events.length === 0) return null;
|
|
203
|
+
const last = events[events.length - 1];
|
|
204
|
+
// Same open-type vocabulary as isOpenEvent: 'reverted'/'cherry-picked' are
|
|
205
|
+
// still open findings (M3 §3.1) — the classification is about HOW the
|
|
206
|
+
// finding became open, not whether it currently is.
|
|
207
|
+
return ['introduced', 'reintroduced', 'reverted', 'cherry-picked'].includes(last.type) ? last : null;
|
|
208
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Missing-control regression resolution (Finding Provenance PRD, M3 §3.3).
|
|
2
|
+
//
|
|
3
|
+
// Architecturally inverted from every other resolver in this directory:
|
|
4
|
+
// "when did a previously-observed safeguard DISAPPEAR," not "when did a bad
|
|
5
|
+
// pattern APPEAR." Walks backward from HEAD (newest-first — the opposite
|
|
6
|
+
// direction candidateCommitsForLine's oldest-first convention uses, because
|
|
7
|
+
// this resolver is searching for the MOST RECENT transition from present to
|
|
8
|
+
// absent, not the earliest transition from absent to present).
|
|
9
|
+
//
|
|
10
|
+
// The one invariant this module exists to enforce, verbatim from the spec:
|
|
11
|
+
// if the control is absent at EVERY reachable commit including the
|
|
12
|
+
// repository's own root, status is 'unknown' — NEVER attributed to the root
|
|
13
|
+
// commit. Every other resolver in this milestone treats "absent at the
|
|
14
|
+
// root, present now" as real evidence of introduction (the root IS the
|
|
15
|
+
// beginning of everything this repo can prove). This resolver's question is
|
|
16
|
+
// the mirror image — "when did it disappear" — and a control absent
|
|
17
|
+
// EVERYWHERE has no disappearance to date, which is a fundamentally
|
|
18
|
+
// different, weaker claim than "introduced at the beginning." Collapsing
|
|
19
|
+
// them would be exactly the false certainty the whole feature forbids.
|
|
20
|
+
|
|
21
|
+
import { getFirstParent, commitMeta, _relPath, _isSafeRevision } from './git-evidence.js';
|
|
22
|
+
import * as cp from 'node:child_process';
|
|
23
|
+
import { hardenGitArgs, hardenGitEnv } from '../../util/git-hardening.js';
|
|
24
|
+
|
|
25
|
+
const GIT_TIMEOUT_MS = 2000;
|
|
26
|
+
// Same hostile-repo hardening as git-evidence.js's `_run` (FR-PROV-024 /
|
|
27
|
+
// second audit). This module's own `_run` call is `log --format=%H
|
|
28
|
+
// --follow -- <path>` — no `-p`/`-L`, no diff/blob content rendered, so no
|
|
29
|
+
// `--no-textconv` surface to close here (see git-evidence.js for the calls
|
|
30
|
+
// where it is load-bearing).
|
|
31
|
+
function _run(scanRoot, args) {
|
|
32
|
+
try {
|
|
33
|
+
const stdout = cp.execFileSync('git', hardenGitArgs(args), {
|
|
34
|
+
cwd: scanRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS,
|
|
35
|
+
stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024,
|
|
36
|
+
env: hardenGitEnv(),
|
|
37
|
+
});
|
|
38
|
+
return { ok: true, stdout };
|
|
39
|
+
} catch (e) {
|
|
40
|
+
return { ok: false, stdout: '', error: e };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The commits touching `file` on the path from `since`/root to HEAD,
|
|
45
|
+
// NEWEST-FIRST (the reverse of git-evidence.js's candidateCommitsForFile,
|
|
46
|
+
// which is oldest-first — this resolver needs to walk backward from the
|
|
47
|
+
// present).
|
|
48
|
+
//
|
|
49
|
+
// This module reimplements its own git-invocation helper (`_run` above)
|
|
50
|
+
// rather than adding a new wrapper to git-evidence.js, but that must not
|
|
51
|
+
// mean skipping the argument-injection guards every OTHER resolver in this
|
|
52
|
+
// directory gets for free by routing through git-evidence.js. `since` feeds
|
|
53
|
+
// straight into a `<since>..HEAD` revision range and `file` becomes a bare
|
|
54
|
+
// argv token after `--` — both reuse git-evidence.js's own exported guards
|
|
55
|
+
// (`_isSafeRevision`, `_relPath`) rather than re-deriving the validation
|
|
56
|
+
// logic here, so a caller-supplied `since` shaped like a git flag (e.g.
|
|
57
|
+
// `--upload-pack=evil`) or a `file` that escapes scanRoot can never reach
|
|
58
|
+
// git's argv as an unvalidated token — same contract candidateCommitsForFile
|
|
59
|
+
// enforces for the forward-walking resolvers.
|
|
60
|
+
function candidateCommitsNewestFirst(scanRoot, file, since) {
|
|
61
|
+
const rel = _relPath(scanRoot, file);
|
|
62
|
+
if (!rel) return [];
|
|
63
|
+
if (since && !_isSafeRevision(since)) return [];
|
|
64
|
+
const args = ['log', '--format=%H', '--follow'];
|
|
65
|
+
if (since) args.push(`${since}..HEAD`);
|
|
66
|
+
args.push('--', rel);
|
|
67
|
+
const r = _run(scanRoot, args);
|
|
68
|
+
if (!r.ok) return [];
|
|
69
|
+
return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function resolveMissingControl(scanRoot, { file, predicate, since, deadlineAt } = {}) {
|
|
73
|
+
if (!file || typeof predicate !== 'function') {
|
|
74
|
+
return { status: 'unknown', commitsConsidered: 0 };
|
|
75
|
+
}
|
|
76
|
+
const candidates = candidateCommitsNewestFirst(scanRoot, file, since);
|
|
77
|
+
if (candidates.length === 0) return { status: 'unknown', commitsConsidered: 0 };
|
|
78
|
+
|
|
79
|
+
let commitsConsidered = 0;
|
|
80
|
+
let priorPresentCommit = null; // the most-recently-checked commit where the control WAS present (walking backward, so this is the newest such commit seen so far)
|
|
81
|
+
|
|
82
|
+
for (const sha of candidates) {
|
|
83
|
+
if (deadlineAt && Date.now() > deadlineAt) return { status: 'budget_exhausted', commitsConsidered };
|
|
84
|
+
commitsConsidered++;
|
|
85
|
+
let presentHere;
|
|
86
|
+
try { presentHere = await predicate(scanRoot, sha, file); } catch { presentHere = false; }
|
|
87
|
+
|
|
88
|
+
if (presentHere) {
|
|
89
|
+
// Found a commit where it WAS present. If we've already seen a LATER
|
|
90
|
+
// (newer) commit where it was NOT present, that later commit's own
|
|
91
|
+
// transition (or the commit just after this one, going forward) is
|
|
92
|
+
// the removal — but walking newest-first, the removal transition is
|
|
93
|
+
// between THIS commit (present) and the previous iteration's commit
|
|
94
|
+
// (absent, checked earlier in this loop = newer in time). So: if
|
|
95
|
+
// priorPresentCommit is unset AND we've already walked past at least
|
|
96
|
+
// one absent commit, that means HEAD-side history is absent and THIS
|
|
97
|
+
// commit is present — the transition is between this commit and the
|
|
98
|
+
// one checked immediately before it in the loop.
|
|
99
|
+
if (commitsConsidered > 1 && priorPresentCommit === null) {
|
|
100
|
+
// The immediately-prior (newer) candidate was absent, and this one
|
|
101
|
+
// is present — that prior candidate (or the gap right after this
|
|
102
|
+
// commit) is where it disappeared. Report THIS commit (the last
|
|
103
|
+
// proven-present one) as the evidence anchor, and the prior
|
|
104
|
+
// candidate as the removal point.
|
|
105
|
+
const removedMeta = commitMeta(scanRoot, candidates[commitsConsidered - 2]);
|
|
106
|
+
const presentMeta = commitMeta(scanRoot, sha);
|
|
107
|
+
if (removedMeta && presentMeta) {
|
|
108
|
+
return {
|
|
109
|
+
status: 'complete',
|
|
110
|
+
removedAt: { commit: removedMeta.commit, authorName: removedMeta.authorName, authorDate: removedMeta.authorDate, summary: removedMeta.summary },
|
|
111
|
+
presentAt: { commit: presentMeta.commit, authorDate: presentMeta.authorDate },
|
|
112
|
+
commitsConsidered,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
priorPresentCommit = sha;
|
|
117
|
+
// Present at the oldest commit we've checked so far and no removal
|
|
118
|
+
// found yet — keep walking older history in case there's an EARLIER
|
|
119
|
+
// removal-then-readd cycle; but for M3's scope (the Scenario I
|
|
120
|
+
// acceptance case is "never present, resolves unknown" and "present
|
|
121
|
+
// then removed, resolves complete"), stop here: control is present
|
|
122
|
+
// at this point in history and we have not yet found where it was
|
|
123
|
+
// removed relative to HEAD. Continue the loop.
|
|
124
|
+
}
|
|
125
|
+
// presentHere === false: keep walking older history (newest-first),
|
|
126
|
+
// looking for the presence that precedes this absence.
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Walked every candidate and never found a present→absent transition —
|
|
130
|
+
// either the control was NEVER present in any reachable commit (the
|
|
131
|
+
// Scenario I case — must resolve 'unknown', never attributed to the
|
|
132
|
+
// oldest candidate as if that were meaningful), or it has been present
|
|
133
|
+
// at every commit checked (no removal to report, which for THIS
|
|
134
|
+
// resolver's question — "when did it disappear" — is also 'unknown':
|
|
135
|
+
// there is no disappearance to date).
|
|
136
|
+
return { status: 'unknown', commitsConsidered };
|
|
137
|
+
}
|