@clear-capabilities/agentic-security-scanner 0.144.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 +251 -0
- package/bin/agentic-security.js +294 -3
- package/dist/113.index.js +11 -3
- package/dist/178.index.js +24 -6
- package/dist/271.index.js +165 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +22 -0
- package/dist/444.index.js +11 -2
- package/dist/449.index.js +76 -12
- package/dist/526.index.js +11 -3
- package/dist/637.index.js +27 -5
- package/dist/970.index.js +65 -1
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +14 -8
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/engine.js +488 -29
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +1 -1
- package/src/lsp/server.js +49 -2
- package/src/mcp/tools.js +20 -0
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/posture/CLAUDE.md +121 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +24 -0
- package/src/posture/auditor-walkthrough.js +116 -13
- 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/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/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/util/git-hardening.js +128 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Non-linear DAG analysis for --provenance deep (Finding Provenance PRD,
|
|
2
|
+
// M3 §3.1). Three independent capabilities, each documented at its export:
|
|
3
|
+
//
|
|
4
|
+
// 1. checkAbsentInAllParents — generalizes origin-resolver.js's existing
|
|
5
|
+
// first-parent-only absence check to EVERY parent of a candidate commit.
|
|
6
|
+
// A vulnerability introduced via a merged feature branch (not mainline)
|
|
7
|
+
// needs this: checking only the first parent would see the predicate
|
|
8
|
+
// already present there (inherited from mainline before the merge) and
|
|
9
|
+
// wrongly conclude the merge commit isn't the origin, when the real
|
|
10
|
+
// introduction happened on the feature branch's own history — which the
|
|
11
|
+
// first-parent-only walk never visits at all.
|
|
12
|
+
//
|
|
13
|
+
// 2. detectRevert / detectCherryPick — lifecycle event classification, not
|
|
14
|
+
// origin resolution. A reintroduction that is actually a revert-of-a-fix
|
|
15
|
+
// or propagation of an earlier introduction via cherry-pick is a
|
|
16
|
+
// DIFFERENT lifecycle story than an unrelated re-introduction, and
|
|
17
|
+
// lifecycle.js's event vocabulary (Task 4) needs to say so.
|
|
18
|
+
import { getAllParents, commitDiff } from './git-evidence.js';
|
|
19
|
+
import * as cp from 'node:child_process';
|
|
20
|
+
import { hardenGitArgs, hardenGitEnv } from '../../util/git-hardening.js';
|
|
21
|
+
|
|
22
|
+
const GIT_TIMEOUT_MS = 2000;
|
|
23
|
+
// Same hostile-repo hardening as git-evidence.js's `_run` (FR-PROV-024 /
|
|
24
|
+
// second audit). This module's own `_run` calls are both `show -s
|
|
25
|
+
// --format=...` (detectRevert's message check, detectCherryPick) — no diff
|
|
26
|
+
// content rendered, so `--no-textconv` is defense-in-depth/uniformity here
|
|
27
|
+
// rather than closing a verified path (the diff-content case,
|
|
28
|
+
// `commitDiff`, is imported from git-evidence.js and already hardened
|
|
29
|
+
// there).
|
|
30
|
+
function _run(scanRoot, args) {
|
|
31
|
+
try {
|
|
32
|
+
const stdout = cp.execFileSync('git', hardenGitArgs(args), {
|
|
33
|
+
cwd: scanRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS,
|
|
34
|
+
stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024,
|
|
35
|
+
env: hardenGitEnv(),
|
|
36
|
+
});
|
|
37
|
+
return { ok: true, stdout };
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return { ok: false, stdout: '', error: e };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const SHA_RE = /^[0-9a-f]{4,40}$/i;
|
|
43
|
+
function _isSha(sha) { return typeof sha === 'string' && SHA_RE.test(sha); }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check whether a predicate is absent in EVERY parent of `sha`, not just the
|
|
47
|
+
* first. `replay` is the caller's (sha) => Promise<{present:boolean}> closure
|
|
48
|
+
* (origin-resolver.js already has one via its own memoized `replay`).
|
|
49
|
+
*/
|
|
50
|
+
export async function checkAbsentInAllParents(scanRoot, sha, replay) {
|
|
51
|
+
const parents = getAllParents(scanRoot, sha);
|
|
52
|
+
if (parents.length === 0) return { absentInAll: true, parents: [], rootCommit: true };
|
|
53
|
+
const results = await Promise.all(parents.map((p) => replay(p)));
|
|
54
|
+
const absentInAll = results.every((r) => !r.present);
|
|
55
|
+
return { absentInAll, parents, rootCommit: false };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check whether a predicate is absent in AT LEAST ONE parent of `sha` (not
|
|
60
|
+
* necessarily all of them). This is the mathematically correct
|
|
61
|
+
* generalization of origin-resolver.js's first-parent-only check: "absent in
|
|
62
|
+
* the first parent" is the special case where that one parent happens to be
|
|
63
|
+
* absent, so "absent in ANY parent" is a strict SUPERSET of what the
|
|
64
|
+
* first-parent check alone can find — it additionally catches a merge
|
|
65
|
+
* candidate whose FIRST parent has the predicate (inherited from that
|
|
66
|
+
* line's own earlier history) but a DIFFERENT parent's line did not, which
|
|
67
|
+
* means that other line's merge into this one is genuinely where the
|
|
68
|
+
* predicate became reachable via THIS path. `checkAbsentInAllParents`
|
|
69
|
+
* (above) answers a different question — "did every contributing line lack
|
|
70
|
+
* it" — and is a strict SUBSET of the first-parent check instead, which is
|
|
71
|
+
* why it can never resolve anything the first-parent check couldn't already
|
|
72
|
+
* resolve on its own; it exists as a safety/non-regression check, not a
|
|
73
|
+
* resolving-power check. Do not conflate the two.
|
|
74
|
+
*/
|
|
75
|
+
export async function checkAbsentInSomeParent(scanRoot, sha, replay) {
|
|
76
|
+
const parents = getAllParents(scanRoot, sha);
|
|
77
|
+
if (parents.length === 0) return { absentInSome: true, absentParents: [], parents: [], rootCommit: true };
|
|
78
|
+
const results = await Promise.all(parents.map((p) => replay(p)));
|
|
79
|
+
const absentParents = parents.filter((_, i) => !results[i].present);
|
|
80
|
+
return { absentInSome: absentParents.length > 0, absentParents, parents, rootCommit: false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// git's own convention when using `git revert` (no -n/--no-edit override):
|
|
84
|
+
// "Revert \"<original subject>\"". Message alone is spoofable, so this is
|
|
85
|
+
// only the FIRST half of detection — see detectRevert.
|
|
86
|
+
const REVERT_MESSAGE_RE = /^Revert "/;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A commit is a real revert only when its message matches git's own Revert
|
|
90
|
+
* convention AND its diff is a genuine structural inverse of an EARLIER
|
|
91
|
+
* candidate commit's diff (message-only detection is spoofable). Checked
|
|
92
|
+
* against each of `candidateShas` (typically the same candidate list the
|
|
93
|
+
* caller's walk already has, oldest-first) — the first one whose diff is the
|
|
94
|
+
* exact reverse wins.
|
|
95
|
+
*/
|
|
96
|
+
export function detectRevert(scanRoot, sha, candidateShas) {
|
|
97
|
+
if (!_isSha(sha)) return { isRevert: false, revertsCommit: null };
|
|
98
|
+
const meta = _run(scanRoot, ['show', '-s', '--no-textconv', '--format=%s', sha]);
|
|
99
|
+
if (!meta.ok || !REVERT_MESSAGE_RE.test(meta.stdout.trim())) {
|
|
100
|
+
return { isRevert: false, revertsCommit: null };
|
|
101
|
+
}
|
|
102
|
+
const thisDiff = commitDiff(scanRoot, sha);
|
|
103
|
+
if (!thisDiff) return { isRevert: false, revertsCommit: null };
|
|
104
|
+
const invertedThisDiff = _invertUnifiedDiff(thisDiff);
|
|
105
|
+
for (const candidate of candidateShas || []) {
|
|
106
|
+
if (candidate === sha) continue;
|
|
107
|
+
const candidateDiff = commitDiff(scanRoot, candidate);
|
|
108
|
+
if (candidateDiff && candidateDiff.trim() === invertedThisDiff.trim()) {
|
|
109
|
+
return { isRevert: true, revertsCommit: candidate };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { isRevert: false, revertsCommit: null };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Produce what the INVERSE of a `-U0` unified diff would look like, so it
|
|
116
|
+
// can be string-compared against a candidate's real diff.
|
|
117
|
+
//
|
|
118
|
+
// DEVIATION FROM THE M3 BRIEF: the brief specified a per-LINE +/- swap
|
|
119
|
+
// ("this is intentionally a simple line-level swap ... -U0 makes this
|
|
120
|
+
// sufficient"). That is not sufficient — verified empirically against real
|
|
121
|
+
// `git revert` output before shipping this version. A unified diff hunk
|
|
122
|
+
// lists its removed lines as a BLOCK followed by its added lines as a
|
|
123
|
+
// BLOCK (git's own convention, not something -U0 changes); a real reverse
|
|
124
|
+
// diff swaps those two BLOCKS (additions become the new removal block,
|
|
125
|
+
// removals become the new addition block, in their respective original
|
|
126
|
+
// orders) — it does not swap each line's marker in place. For a hunk with
|
|
127
|
+
// one removal and one addition (the common single-line-change case) a
|
|
128
|
+
// per-line swap silently reverses the two lines' relative order versus
|
|
129
|
+
// what git itself produces, so the string comparison in detectRevert never
|
|
130
|
+
// matched a real revert: `-safe\n+eval(x)\n` inverted to `+safe\n-eval(x)\n`,
|
|
131
|
+
// but git's actual reverse diff is `-eval(x)\n+safe\n`. The `index a..b`
|
|
132
|
+
// header line's two blob hashes and a hunk header's `-x,y +p,q` ranges also
|
|
133
|
+
// swap under a real reversal and were previously left untouched, which
|
|
134
|
+
// mismatches too whenever the two commits touch different blobs (always,
|
|
135
|
+
// in practice). This version fixes both: it groups each contiguous run of
|
|
136
|
+
// -/+ content lines into a block and swaps the two blocks (not each line),
|
|
137
|
+
// and it swaps the index-line hash pair and the hunk-header range pair.
|
|
138
|
+
// Confirmed against both a single-line change and a two-hunk multi-line
|
|
139
|
+
// change, string-compared to `git diff <new> <old>`'s real output.
|
|
140
|
+
//
|
|
141
|
+
// POST-REVIEW FIX (M3 §3.1 review, finding #1): a whole-file add or delete
|
|
142
|
+
// was still mishandled. `git show -U0` on a commit that ADDS a file emits
|
|
143
|
+
// `new file mode <mode>` and a `---`/`+++` pair of `--- /dev/null` /
|
|
144
|
+
// `+++ b/<path>`; a commit that DELETES a file emits `deleted file mode
|
|
145
|
+
// <mode>` and the mirror pair, `--- a/<path>` / `+++ /dev/null`. Neither
|
|
146
|
+
// was touched before, so inverting an add-diff still read as an add (still
|
|
147
|
+
// `new file mode`, still `--- /dev/null`), which never matches a real
|
|
148
|
+
// delete diff and made `detectRevert` miss a revert-of-an-add or
|
|
149
|
+
// revert-of-a-delete entirely (fails safe — under-detection, not
|
|
150
|
+
// misattribution, but still a real gap `detectRevert` should not have).
|
|
151
|
+
// Fixed by: swapping `new file mode` <-> `deleted file mode` line-for-line,
|
|
152
|
+
// and — since the two file-mode header lines are independent per-line but
|
|
153
|
+
// the `---`/`+++` pair must be read TOGETHER to know which side is
|
|
154
|
+
// /dev/null — handling `---`/`+++` as a pair: an add's `--- /dev/null` +
|
|
155
|
+
// `+++ b/<path>` becomes a delete's `--- a/<path>` + `+++ /dev/null`, and
|
|
156
|
+
// vice versa; an ordinary modify's `--- a/<path>` + `+++ b/<path>` (neither
|
|
157
|
+
// side /dev/null) is left as-is, matching the existing a/ b/ convention
|
|
158
|
+
// that already made the plain-modify case direction-invariant. Confirmed
|
|
159
|
+
// against real git output for both a file addition reverted (delete) and a
|
|
160
|
+
// file deletion reverted (re-add), plus a regression check that the
|
|
161
|
+
// existing plain-modify case is unaffected.
|
|
162
|
+
function _invertUnifiedDiff(diffText) {
|
|
163
|
+
const lines = diffText.split('\n');
|
|
164
|
+
const out = [];
|
|
165
|
+
let i = 0;
|
|
166
|
+
while (i < lines.length) {
|
|
167
|
+
const line = lines[i];
|
|
168
|
+
if (line.startsWith('new file mode ')) {
|
|
169
|
+
out.push('deleted file mode ' + line.slice('new file mode '.length));
|
|
170
|
+
i++;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (line.startsWith('deleted file mode ')) {
|
|
174
|
+
out.push('new file mode ' + line.slice('deleted file mode '.length));
|
|
175
|
+
i++;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (line.startsWith('index ')) {
|
|
179
|
+
const m = line.match(/^index ([0-9a-f]+)\.\.([0-9a-f]+)(.*)$/);
|
|
180
|
+
out.push(m ? `index ${m[2]}..${m[1]}${m[3]}` : line);
|
|
181
|
+
i++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (line.startsWith('@@')) {
|
|
185
|
+
const m = line.match(/^@@ -(\S+) \+(\S+) @@(.*)$/);
|
|
186
|
+
out.push(m ? `@@ -${m[2]} +${m[1]} @@${m[3]}` : line);
|
|
187
|
+
i++;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (line.startsWith('--- ')) {
|
|
191
|
+
const next = lines[i + 1];
|
|
192
|
+
if (typeof next === 'string' && next.startsWith('+++ ')) {
|
|
193
|
+
const oldSide = line.slice(4);
|
|
194
|
+
const newSide = next.slice(4);
|
|
195
|
+
if (oldSide === '/dev/null' && newSide.startsWith('b/')) {
|
|
196
|
+
// Add -> inverted to a delete.
|
|
197
|
+
out.push('--- a/' + newSide.slice(2));
|
|
198
|
+
out.push('+++ /dev/null');
|
|
199
|
+
} else if (newSide === '/dev/null' && oldSide.startsWith('a/')) {
|
|
200
|
+
// Delete -> inverted to an add.
|
|
201
|
+
out.push('--- /dev/null');
|
|
202
|
+
out.push('+++ b/' + oldSide.slice(2));
|
|
203
|
+
} else {
|
|
204
|
+
// Ordinary modify: a/ and b/ labels are direction-invariant.
|
|
205
|
+
out.push(line);
|
|
206
|
+
out.push(next);
|
|
207
|
+
}
|
|
208
|
+
i += 2;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
out.push(line);
|
|
212
|
+
i++;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (line.startsWith('+++') || line.startsWith('diff ')) {
|
|
216
|
+
out.push(line);
|
|
217
|
+
i++;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (line.startsWith('-') || line.startsWith('+')) {
|
|
221
|
+
const removals = [];
|
|
222
|
+
const additions = [];
|
|
223
|
+
while (i < lines.length && (lines[i].startsWith('-') || lines[i].startsWith('+'))) {
|
|
224
|
+
if (lines[i].startsWith('-')) removals.push(lines[i].slice(1));
|
|
225
|
+
else additions.push(lines[i].slice(1));
|
|
226
|
+
i++;
|
|
227
|
+
}
|
|
228
|
+
for (const a of additions) out.push('-' + a);
|
|
229
|
+
for (const r of removals) out.push('+' + r);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
out.push(line);
|
|
233
|
+
i++;
|
|
234
|
+
}
|
|
235
|
+
return out.join('\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// `git cherry-pick -x` leaves this exact trailer format in the new commit's
|
|
239
|
+
// message body: "(cherry picked from commit <full-or-abbrev-sha>)".
|
|
240
|
+
const CHERRY_PICK_TRAILER_RE = /\(cherry picked from commit ([0-9a-f]{4,40})\)/;
|
|
241
|
+
|
|
242
|
+
export function detectCherryPick(scanRoot, sha) {
|
|
243
|
+
if (!_isSha(sha)) return { isCherryPick: false, originalCommit: null };
|
|
244
|
+
const r = _run(scanRoot, ['show', '-s', '--no-textconv', '--format=%B', sha]);
|
|
245
|
+
if (!r.ok) return { isCherryPick: false, originalCommit: null };
|
|
246
|
+
const m = r.stdout.match(CHERRY_PICK_TRAILER_RE);
|
|
247
|
+
if (!m) return { isCherryPick: false, originalCommit: null };
|
|
248
|
+
return { isCherryPick: true, originalCommit: m[1] };
|
|
249
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { blameLine } from './git-evidence.js';
|
|
2
|
+
import { EVIDENCE_ROLE } from './schema.js';
|
|
3
|
+
|
|
4
|
+
// Second independent Finding Provenance PRD audit: `opts.removedGuard` and
|
|
5
|
+
// `opts.secret` let coordinator.js tell this function WHICH question a
|
|
6
|
+
// no-source/no-sink finding is actually answering, instead of every such
|
|
7
|
+
// finding collapsing onto the generic `sink` role. Both are cheap,
|
|
8
|
+
// coordinator-level hints — no new detector work:
|
|
9
|
+
// - `removedGuard` — set for a `missingControlCandidate` finding
|
|
10
|
+
// (today, rate-limit.js's routes) once `resolveMissingControl` has
|
|
11
|
+
// confirmed a present->absent transition. The finding's own file:line
|
|
12
|
+
// IS the site where the (now-removed) control was expected, so that
|
|
13
|
+
// single evidence node is `removed_guard`, not `sink`.
|
|
14
|
+
// - `secret` — set for every finding routed through the coordinator with
|
|
15
|
+
// `findingType: 'secret'`. A hardcoded-credential finding's evidence
|
|
16
|
+
// node names where the SECRET sits, not a taint sink.
|
|
17
|
+
// `guard` and `config` are NOT wired: no current detector's evidence
|
|
18
|
+
// naturally maps to either without new detector-side work (a "control is
|
|
19
|
+
// PRESENT" observation, or a misconfigured-setting location) — see
|
|
20
|
+
// schema.js's EVIDENCE_ROLE comment for the honest disclosure.
|
|
21
|
+
export function attributeEvidence(scanRoot, finding, opts = {}) {
|
|
22
|
+
const nodes = [];
|
|
23
|
+
const push = (role, file, line) => {
|
|
24
|
+
if (!file || !line) return;
|
|
25
|
+
const blame = blameLine(scanRoot, file, line);
|
|
26
|
+
nodes.push({ role, path: file, line, commit: blame && !blame.uncommitted ? blame.commit : null });
|
|
27
|
+
};
|
|
28
|
+
const fallbackRole = opts.removedGuard
|
|
29
|
+
? EVIDENCE_ROLE.REMOVED_GUARD
|
|
30
|
+
: opts.secret
|
|
31
|
+
? EVIDENCE_ROLE.SECRET
|
|
32
|
+
: EVIDENCE_ROLE.SINK;
|
|
33
|
+
|
|
34
|
+
if (finding.source || finding.sink) {
|
|
35
|
+
if (finding.source) push(EVIDENCE_ROLE.SOURCE, finding.source.file || finding.file, finding.source.line);
|
|
36
|
+
if (finding.sink) push(EVIDENCE_ROLE.SINK, finding.sink.file || finding.file, finding.sink.line);
|
|
37
|
+
} else {
|
|
38
|
+
push(fallbackRole, finding.file, finding.line);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// `step.removedGuard` — a per-STEP (not whole-finding) removed-guard
|
|
42
|
+
// marker for a taint-flow backward slice, e.g. "a sanitizer that used to
|
|
43
|
+
// sit on this path is gone." Honest disclosure (second independent audit):
|
|
44
|
+
// no producer sets this today — `dataflow/backward.js`'s
|
|
45
|
+
// `annotateBackwardSlices` is the only `pathSteps` writer and only ever
|
|
46
|
+
// sets `{type, label, line}`, never `removedGuard`. Left in place as the
|
|
47
|
+
// intended hook for a future taint-based guard-removal detector; the
|
|
48
|
+
// finding-level `opts.removedGuard` hint above is the one currently-live
|
|
49
|
+
// path to a `removed_guard` node (the rate-limit / missing-control case,
|
|
50
|
+
// which has no `pathSteps` at all).
|
|
51
|
+
if (Array.isArray(finding.pathSteps)) {
|
|
52
|
+
for (const step of finding.pathSteps) {
|
|
53
|
+
const role = step.removedGuard ? EVIDENCE_ROLE.REMOVED_GUARD : EVIDENCE_ROLE.TRANSFORMATION;
|
|
54
|
+
push(role, step.file || finding.file, step.line);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return nodes;
|
|
59
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import * as cp from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { hardenGitArgs, hardenGitEnv } from '../../util/git-hardening.js';
|
|
5
|
+
|
|
6
|
+
// 2s keeps a single call bounded for the common case; `-L`/`--follow` walks on a
|
|
7
|
+
// very large history can be slower, at which point they just return an empty/
|
|
8
|
+
// null result rather than blocking the caller — degrading gracefully was judged
|
|
9
|
+
// preferable to a longer timeout that lets one pathological repo stall a scan.
|
|
10
|
+
const GIT_TIMEOUT_MS = 2000;
|
|
11
|
+
|
|
12
|
+
// Second independent Finding Provenance PRD audit (FR-PROV-024 / Section 8
|
|
13
|
+
// control 3): this scanRoot is a SCANNED repository, not this project's own
|
|
14
|
+
// trusted checkout — its .git/config is attacker-influenceable. Every git
|
|
15
|
+
// invocation in this module MUST route through this one function so the
|
|
16
|
+
// hardening applies uniformly; see util/git-hardening.js for what each flag
|
|
17
|
+
// closes and why it was verified necessary.
|
|
18
|
+
function _run(scanRoot, args) {
|
|
19
|
+
try {
|
|
20
|
+
const stdout = cp.execFileSync('git', hardenGitArgs(args), {
|
|
21
|
+
cwd: scanRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS,
|
|
22
|
+
stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024,
|
|
23
|
+
env: hardenGitEnv(),
|
|
24
|
+
});
|
|
25
|
+
return { ok: true, stdout };
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return { ok: false, stdout: '', error: e };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Exported so other resolvers in this directory that build their own git
|
|
32
|
+
// invocations (rather than adding a new wrapper function here) still route
|
|
33
|
+
// path/revision arguments through the same validation, instead of
|
|
34
|
+
// reimplementing it and risking drift — see missing-control-resolver.js.
|
|
35
|
+
export function _relPath(scanRoot, file) {
|
|
36
|
+
const abs = path.resolve(scanRoot, file);
|
|
37
|
+
const rel = path.relative(scanRoot, abs);
|
|
38
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
39
|
+
// Lexical containment alone isn't enough: `file` could be a symlink whose
|
|
40
|
+
// TARGET resolves outside scanRoot even though its own path lexically
|
|
41
|
+
// sits inside it. realpathSync follows every symlink in the chain; if the
|
|
42
|
+
// real (post-symlink) path escapes scanRoot, treat it the same as any
|
|
43
|
+
// other traversal attempt.
|
|
44
|
+
//
|
|
45
|
+
// realpathSync can throw for reasons other than "doesn't exist": ELOOP
|
|
46
|
+
// (a symlink cycle), EACCES (permission denied partway through
|
|
47
|
+
// resolution), ENOTDIR (a path component that should be a directory
|
|
48
|
+
// isn't). None of those have the "historical commit, file plausibly
|
|
49
|
+
// doesn't exist right now" excuse -- they mean something genuinely
|
|
50
|
+
// couldn't be verified, so they fail CLOSED (return null), matching
|
|
51
|
+
// every other fail-closed check in this module. ENOENT is the one
|
|
52
|
+
// exception and fails OPEN: most callers are asking about a file's state
|
|
53
|
+
// at some HISTORICAL commit, which git-evidence.js reads from git
|
|
54
|
+
// objects, not the working tree, so the working tree may not have this
|
|
55
|
+
// file at all, or may have it under a different name after a rename --
|
|
56
|
+
// that is routine and must not be treated as a proven escape.
|
|
57
|
+
try {
|
|
58
|
+
const real = fs.realpathSync(abs);
|
|
59
|
+
const realRoot = fs.realpathSync(scanRoot);
|
|
60
|
+
const realRel = path.relative(realRoot, real);
|
|
61
|
+
if (realRel === '' || realRel.startsWith('..') || path.isAbsolute(realRel)) return null;
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (e.code !== 'ENOENT') return null;
|
|
64
|
+
}
|
|
65
|
+
return rel.split(path.sep).join('/');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Git SHAs (full or abbreviated) are always lowercase/uppercase hex, 4-40 chars.
|
|
69
|
+
// Rejecting anything else closes off flag injection (e.g. a "sha" of
|
|
70
|
+
// "--output=/tmp/pwned" reaching `git show`/`git log` as a bare argv token).
|
|
71
|
+
const SHA_RE = /^[0-9a-f]{4,40}$/i;
|
|
72
|
+
function _isSha(sha) {
|
|
73
|
+
return typeof sha === 'string' && SHA_RE.test(sha);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// `since` is only ever used as the left side of a `<since>..HEAD` revision
|
|
77
|
+
// range, so it must look like a ref/tag/sha — never start with `-` (which
|
|
78
|
+
// git would parse as an option) and contain only characters refs can hold.
|
|
79
|
+
const SINCE_RE = /^[A-Za-z0-9._/-]+$/;
|
|
80
|
+
export function _isSafeRevision(since) {
|
|
81
|
+
return typeof since === 'string' && since.length > 0 && !since.startsWith('-') && SINCE_RE.test(since);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isGitRepo(scanRoot) {
|
|
85
|
+
return _run(scanRoot, ['rev-parse', '--git-dir']).ok;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// FR-PROV-022: repository-identity signal for provider enrichment
|
|
89
|
+
// (providers/github.js / providers/gitlab.js parse owner/repo out of this
|
|
90
|
+
// URL). No `origin` remote (a local-only repo, or a fixture that never added
|
|
91
|
+
// one) degrades to null rather than throwing — same convention as every
|
|
92
|
+
// other `_run`-backed helper here.
|
|
93
|
+
export function getRemoteUrl(scanRoot) {
|
|
94
|
+
const r = _run(scanRoot, ['remote', 'get-url', 'origin']);
|
|
95
|
+
return r.ok ? r.stdout.trim() : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getRepoState(scanRoot) {
|
|
99
|
+
if (!isGitRepo(scanRoot)) return null;
|
|
100
|
+
const head = _run(scanRoot, ['rev-parse', 'HEAD']);
|
|
101
|
+
const branch = _run(scanRoot, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
102
|
+
const dirty = _run(scanRoot, ['status', '--porcelain']);
|
|
103
|
+
const shallow = _run(scanRoot, ['rev-parse', '--is-shallow-repository']);
|
|
104
|
+
return {
|
|
105
|
+
head: head.ok ? head.stdout.trim() : null,
|
|
106
|
+
branch: branch.ok ? branch.stdout.trim() : null,
|
|
107
|
+
dirty: dirty.ok ? dirty.stdout.trim().length > 0 : false,
|
|
108
|
+
shallow: shallow.ok ? shallow.stdout.trim() === 'true' : false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function commitMeta(scanRoot, sha) {
|
|
113
|
+
if (!_isSha(sha)) return null;
|
|
114
|
+
// `--no-textconv`: verified this specific invocation (`-s`, no diff/blob
|
|
115
|
+
// content rendered) is NOT reachable via a hostile `.gitattributes`
|
|
116
|
+
// textconv driver in current git — kept for defense-in-depth /
|
|
117
|
+
// uniformity with every other `show` call in this module, not because a
|
|
118
|
+
// live exploit path was found here.
|
|
119
|
+
//
|
|
120
|
+
// `%P` (Task 6, provenance-second-audit-remediation): the parent-hash list,
|
|
121
|
+
// added to this SAME `show` call rather than requiring a second
|
|
122
|
+
// `getFirstParent` subprocess spawn right after this one — profiling the
|
|
123
|
+
// bench (bench/provenance/runner.mjs) found git subprocess spawn count,
|
|
124
|
+
// not the detector pipeline, is the dominant cost of provenance overhead
|
|
125
|
+
// (~6-8ms per `execFileSync('git', ...)` on the dev machine; the standard
|
|
126
|
+
// origin-resolver.js walk calls `getFirstParent` and `commitMeta` back to
|
|
127
|
+
// back for the identical sha on every candidate). Purely additive to the
|
|
128
|
+
// returned shape and to the existing `%x1f`-delimited format — no
|
|
129
|
+
// hardening flag changed, no existing field removed.
|
|
130
|
+
//
|
|
131
|
+
// `%P` SITS AT INDEX 1, IMMEDIATELY AFTER `%H`, AND MUST STAY THERE.
|
|
132
|
+
// It was first added at index 5 (after `%an`/`%ae`), which was a real,
|
|
133
|
+
// demonstrated vulnerability caught in review before release: git strips
|
|
134
|
+
// only `\n`/`<`/`>` from an ident, so a literal 0x1f inside an AUTHOR NAME
|
|
135
|
+
// survives `git commit` and shifts every later field left. An attacker who
|
|
136
|
+
// controls their own author name (an outside PR contributor is enough — no
|
|
137
|
+
// hostile clone, no crafted objects) could therefore choose the string this
|
|
138
|
+
// code reads as the first parent. That is not cosmetic: origin-resolver.js
|
|
139
|
+
// feeds `parents[0]` to `replay(parent)`, and a parent whose blobs cannot
|
|
140
|
+
// be fetched is INDISTINGUISHABLE from a parent that genuinely lacks the
|
|
141
|
+
// finding — so a spoofed or garbage value yields absentInParent=true,
|
|
142
|
+
// status:'complete', parentBoundaryVerified:true, and CONFIDENCE HIGH
|
|
143
|
+
// (0.95, `parent_absence_verified`) for a finding whose boundary was never
|
|
144
|
+
// actually verified. That is the exact "never false certainty" invariant in
|
|
145
|
+
// posture/CLAUDE.md, and these values reach signed evidence bundles.
|
|
146
|
+
//
|
|
147
|
+
// Only `%H` may precede it: 40 chars of git-generated hex, not
|
|
148
|
+
// attacker-influenceable. The residual shift risk on `authorName`/`summary`
|
|
149
|
+
// is pre-existing and cosmetic — those fields are display-only and feed no
|
|
150
|
+
// trust decision. Validating parents as hex (below) is defense in depth,
|
|
151
|
+
// NOT a substitute for the ordering: an attacker can supply valid hex.
|
|
152
|
+
const r = _run(scanRoot, ['show', '-s', '--no-textconv', '--format=%H%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cI%x1f%s', sha]);
|
|
153
|
+
if (!r.ok) return null;
|
|
154
|
+
const [full, parentsRaw, authorName, authorEmail, authorDate, committerDate, summary] = r.stdout.trim().split('\x1f');
|
|
155
|
+
if (!full) return null;
|
|
156
|
+
// Every parent must look like a real object name. A non-hex entry here can
|
|
157
|
+
// only mean the record was malformed or tampered with, and silently
|
|
158
|
+
// accepting it is what turns a parse bug into a false HIGH-confidence
|
|
159
|
+
// verdict — drop the whole list rather than resolve against a value we
|
|
160
|
+
// cannot vouch for. `[]` is safe: it reads as "root commit", which
|
|
161
|
+
// origin-resolver.js treats as unverifiable, not as verified-absent.
|
|
162
|
+
const parents = parentsRaw ? parentsRaw.split(' ').filter(Boolean) : [];
|
|
163
|
+
const parentsValid = parents.every((p) => /^[0-9a-f]{4,40}$/i.test(p));
|
|
164
|
+
return {
|
|
165
|
+
commit: full, authorName, authorEmail, authorDate, committerDate, summary,
|
|
166
|
+
parents: parentsValid ? parents : [],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function getFirstParent(scanRoot, sha) {
|
|
171
|
+
if (!_isSha(sha)) return null;
|
|
172
|
+
const r = _run(scanRoot, ['rev-parse', `${sha}^1`]);
|
|
173
|
+
return r.ok ? r.stdout.trim() : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// M3 §3.1: the full parent list, not just the first. `git rev-parse
|
|
177
|
+
// <sha>^@` expands to every parent SHA, one per line — the same mechanism
|
|
178
|
+
// `<sha>^1` (getFirstParent) uses for a single parent, generalized. A
|
|
179
|
+
// commit with no parents (the repo root) returns empty stdout, which
|
|
180
|
+
// `.filter(Boolean)` turns into `[]` rather than `['']`.
|
|
181
|
+
export function getAllParents(scanRoot, sha) {
|
|
182
|
+
if (!_isSha(sha)) return [];
|
|
183
|
+
const r = _run(scanRoot, ['rev-parse', `${sha}^@`]);
|
|
184
|
+
if (!r.ok) return [];
|
|
185
|
+
return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// M3 §3.1: the diff a commit introduces, as a normalized patch string — used
|
|
189
|
+
// by dag-walk.js to confirm a claimed revert is a REAL structural inverse of
|
|
190
|
+
// an earlier commit's diff, not just a commit whose MESSAGE says "Revert" (a
|
|
191
|
+
// spoofable, unreliable signal on its own per the spec). `--no-color` and a
|
|
192
|
+
// fixed context of 0 lines keep the two diffs comparable independent of
|
|
193
|
+
// terminal/config state; `-U0` removes context lines so unrelated nearby
|
|
194
|
+
// edits between the two commits don't defeat the comparison.
|
|
195
|
+
export function commitDiff(scanRoot, sha) {
|
|
196
|
+
if (!_isSha(sha)) return null;
|
|
197
|
+
// `--no-textconv`: VERIFIED exploitable without it — a hostile
|
|
198
|
+
// `.gitattributes` `diff=<name>` attribute + a matching
|
|
199
|
+
// `diff.<name>.textconv` config value runs an attacker script on this
|
|
200
|
+
// exact invocation shape (`show -U0`, real diff content rendered). This
|
|
201
|
+
// is the call the second audit named explicitly.
|
|
202
|
+
const r = _run(scanRoot, ['show', '--no-color', '--no-textconv', '-U0', '--format=', sha]);
|
|
203
|
+
return r.ok ? r.stdout : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// M4 §4.2: does `ancestor` reach `descendant` by following parent links —
|
|
207
|
+
// true for `ancestor === descendant` too, matching `git merge-base
|
|
208
|
+
// --is-ancestor`'s own semantics. Used by cross-repo lineage continuation
|
|
209
|
+
// (origin-resolver.js's `tryCrossRepoLineage`) to keep a candidate drawn from
|
|
210
|
+
// the LINKED repo's own (possibly still-developing) history from resolving
|
|
211
|
+
// to something that postdates the declared `atCommit` boundary — the
|
|
212
|
+
// DECLARED lineage link only vouches for history up to and including
|
|
213
|
+
// atCommit, never past it.
|
|
214
|
+
export function isAncestor(scanRoot, ancestor, descendant) {
|
|
215
|
+
if (!_isSha(ancestor) || !_isSha(descendant)) return false;
|
|
216
|
+
return _run(scanRoot, ['merge-base', '--is-ancestor', ancestor, descendant]).ok;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function getBlobAtCommit(scanRoot, sha, file) {
|
|
220
|
+
if (!_isSha(sha)) return null;
|
|
221
|
+
const rel = _relPath(scanRoot, file);
|
|
222
|
+
if (!rel) return null;
|
|
223
|
+
// `./` makes git resolve `rel` relative to cwd (scanRoot) rather than the
|
|
224
|
+
// repo root — load-bearing when scanRoot is a SUBDIRECTORY of the actual
|
|
225
|
+
// git repository (e.g. one package of a monorepo). Without it, `git show
|
|
226
|
+
// <sha>:<bare-relative-path>` resolves the bare path against the repo
|
|
227
|
+
// root and silently fails for every caller whose scanRoot != repo root.
|
|
228
|
+
//
|
|
229
|
+
// `--no-textconv`: verified this blob-cat form of `show` (`<sha>:<path>`,
|
|
230
|
+
// not a diff) is NOT reachable via a hostile textconv driver in current
|
|
231
|
+
// git — kept for defense-in-depth / uniformity, same as commitMeta above.
|
|
232
|
+
const r = _run(scanRoot, ['show', '--no-textconv', `${sha}:./${rel}`]);
|
|
233
|
+
return r.ok ? r.stdout : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function candidateCommitsForLine(scanRoot, file, line, { since } = {}) {
|
|
237
|
+
const rel = _relPath(scanRoot, file);
|
|
238
|
+
if (!rel) return [];
|
|
239
|
+
if (since && !_isSafeRevision(since)) return [];
|
|
240
|
+
const args = ['log', '--format=commit %H', '--reverse'];
|
|
241
|
+
if (since) args.push(`${since}..HEAD`);
|
|
242
|
+
// `-L` renders per-commit line-history diff content, which VERIFIED goes
|
|
243
|
+
// through a hostile textconv driver the same way `commitDiff`'s `show
|
|
244
|
+
// -U0` does — `--no-textconv` is required here for the same real reason.
|
|
245
|
+
args.push('-L', `${line},${line}:${rel}`, '--no-textconv');
|
|
246
|
+
const r = _run(scanRoot, args);
|
|
247
|
+
if (!r.ok) return [];
|
|
248
|
+
const shas = [];
|
|
249
|
+
for (const ln of r.stdout.split('\n')) {
|
|
250
|
+
const m = ln.match(/^commit ([0-9a-f]{40})/);
|
|
251
|
+
if (m) shas.push(m[1]);
|
|
252
|
+
}
|
|
253
|
+
return [...new Set(shas)];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function candidateCommitsForFile(scanRoot, file, { since } = {}) {
|
|
257
|
+
const rel = _relPath(scanRoot, file);
|
|
258
|
+
if (!rel) return [];
|
|
259
|
+
if (since && !_isSafeRevision(since)) return [];
|
|
260
|
+
const args = ['log', '--format=%H', '--follow', '--reverse'];
|
|
261
|
+
if (since) args.push(`${since}..HEAD`);
|
|
262
|
+
args.push('--', rel);
|
|
263
|
+
const r = _run(scanRoot, args);
|
|
264
|
+
if (!r.ok) return [];
|
|
265
|
+
return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function resolveRevision(scanRoot, revision) {
|
|
269
|
+
if (!_isSafeRevision(revision)) return null;
|
|
270
|
+
const r = _run(scanRoot, ['rev-parse', revision]);
|
|
271
|
+
return r.ok ? r.stdout.trim() : null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The commit(s) HEAD's history walk treats as having no parent — the true
|
|
275
|
+
// repository root in a full clone, or the shallow-clone graft boundary in a
|
|
276
|
+
// `--depth=N` clone (git's history walk cannot tell the two apart without
|
|
277
|
+
// separately consulting `.git/shallow`, and this project doesn't need to:
|
|
278
|
+
// either way, it is precisely "as far back as this checkout can see").
|
|
279
|
+
// `--max-parents=0` returns them oldest-reachable-first is NOT guaranteed by
|
|
280
|
+
// git, so a caller wanting a single deterministic boundary should treat the
|
|
281
|
+
// first line as "a" boundary commit, not "the" canonical one when there is
|
|
282
|
+
// more than one (a repo with multiple disconnected roots, or more than one
|
|
283
|
+
// shallow-grafted commit).
|
|
284
|
+
export function getRootCommits(scanRoot) {
|
|
285
|
+
const r = _run(scanRoot, ['rev-list', '--max-parents=0', 'HEAD']);
|
|
286
|
+
if (!r.ok) return [];
|
|
287
|
+
return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function blameLine(scanRoot, file, line) {
|
|
291
|
+
const rel = _relPath(scanRoot, file);
|
|
292
|
+
if (!rel || !line || line < 1) return null;
|
|
293
|
+
// `--no-textconv`: VERIFIED exploitable without it — `git blame` applies
|
|
294
|
+
// a hostile `.gitattributes` textconv driver by default in current git.
|
|
295
|
+
const r = _run(scanRoot, ['blame', '-L', `${line},${line}`, '--porcelain', '--no-textconv', '--', rel]);
|
|
296
|
+
if (!r.ok || !r.stdout) return null;
|
|
297
|
+
const lines = r.stdout.split('\n');
|
|
298
|
+
const head = lines[0].split(' ');
|
|
299
|
+
const sha = head[0];
|
|
300
|
+
if (!sha) return null;
|
|
301
|
+
if (/^0+$/.test(sha)) return { commit: null, uncommitted: true };
|
|
302
|
+
const meta = { commit: sha };
|
|
303
|
+
for (const ln of lines) {
|
|
304
|
+
if (ln.startsWith('author ')) meta.authorName = ln.slice(7);
|
|
305
|
+
else if (ln.startsWith('author-mail ')) meta.authorEmail = ln.slice(12).replace(/[<>]/g, '');
|
|
306
|
+
else if (ln.startsWith('author-time ')) meta.authorDate = new Date(parseInt(ln.slice(12), 10) * 1000).toISOString();
|
|
307
|
+
else if (ln.startsWith('summary ')) meta.summary = ln.slice(8);
|
|
308
|
+
}
|
|
309
|
+
return meta;
|
|
310
|
+
}
|