@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +251 -0
  2. package/bin/agentic-security.js +294 -3
  3. package/dist/113.index.js +11 -3
  4. package/dist/178.index.js +24 -6
  5. package/dist/271.index.js +165 -0
  6. package/dist/384.index.js +1 -1
  7. package/dist/435.index.js +22 -0
  8. package/dist/444.index.js +11 -2
  9. package/dist/449.index.js +76 -12
  10. package/dist/526.index.js +11 -3
  11. package/dist/637.index.js +27 -5
  12. package/dist/970.index.js +65 -1
  13. package/dist/agentic-security.mjs +9 -9
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/package.json +14 -8
  16. package/src/compare.js +6 -1
  17. package/src/dataflow/CLAUDE.md +1 -1
  18. package/src/engine.js +488 -29
  19. package/src/fix/apply-fix-service.js +1 -0
  20. package/src/history-scan.js +22 -5
  21. package/src/ir/CLAUDE.md +1 -1
  22. package/src/lsp/server.js +49 -2
  23. package/src/mcp/tools.js +20 -0
  24. package/src/pipeline/assurance-mode.js +64 -1
  25. package/src/pipeline/finding-schema.js +8 -1
  26. package/src/posture/CLAUDE.md +121 -0
  27. package/src/posture/accuracy-scorecard.js +60 -0
  28. package/src/posture/artifact-registry.js +24 -0
  29. package/src/posture/auditor-walkthrough.js +116 -13
  30. package/src/posture/compliance-policy.js +12 -2
  31. package/src/posture/cross-repo-memory.js +7 -2
  32. package/src/posture/fix-history.js +25 -2
  33. package/src/posture/fix-verify.js +9 -1
  34. package/src/posture/fleet.js +0 -0
  35. package/src/posture/git-history.js +13 -5
  36. package/src/posture/material-change.js +21 -2
  37. package/src/posture/mttr.js +75 -12
  38. package/src/posture/pre-incident-archaeology.js +39 -7
  39. package/src/posture/privacy-framework.js +14 -0
  40. package/src/posture/provenance/ai-authorship.js +68 -0
  41. package/src/posture/provenance/branch-entry.js +80 -0
  42. package/src/posture/provenance/cache.js +143 -0
  43. package/src/posture/provenance/confidence.js +36 -0
  44. package/src/posture/provenance/coordinator.js +786 -0
  45. package/src/posture/provenance/dag-walk.js +249 -0
  46. package/src/posture/provenance/evidence-attribution.js +59 -0
  47. package/src/posture/provenance/git-evidence.js +310 -0
  48. package/src/posture/provenance/lifecycle.js +208 -0
  49. package/src/posture/provenance/missing-control-resolver.js +137 -0
  50. package/src/posture/provenance/origin-resolver.js +342 -0
  51. package/src/posture/provenance/predicate-replay.js +133 -0
  52. package/src/posture/provenance/providers/config.js +39 -0
  53. package/src/posture/provenance/providers/github.js +62 -0
  54. package/src/posture/provenance/providers/gitlab.js +58 -0
  55. package/src/posture/provenance/repo-lineage.js +74 -0
  56. package/src/posture/provenance/sca-origin.js +139 -0
  57. package/src/posture/provenance/schema.js +255 -0
  58. package/src/posture/provenance/transitive-sca.js +147 -0
  59. package/src/posture/provenance/validate.js +30 -0
  60. package/src/posture/provenance-evidence-bundle.js +144 -0
  61. package/src/posture/sbom-diff.js +15 -2
  62. package/src/posture/secret-history.js +10 -2
  63. package/src/posture/state-dir.js +38 -14
  64. package/src/posture/vuln-archaeology.js +8 -2
  65. package/src/pr-delta.js +25 -4
  66. package/src/report/index.js +197 -3
  67. package/src/runScan.js +34 -5
  68. package/src/sast/rate-limit.js +33 -3
  69. 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
+ }