@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,342 @@
|
|
|
1
|
+
// Candidate-seeded linear-replay origin resolution (Finding Provenance PRD,
|
|
2
|
+
// Scenarios A/B/F).
|
|
3
|
+
//
|
|
4
|
+
// Given a finding, walk the commits git identifies as having touched its
|
|
5
|
+
// line (oldest-first, via `candidateCommitsForLine`) and, for each one,
|
|
6
|
+
// semantically replay the detector at that commit's blob content
|
|
7
|
+
// (`predicate-replay.js`) to answer "did this finding's condition hold
|
|
8
|
+
// here". The origin commit is the OLDEST candidate where the predicate is
|
|
9
|
+
// present AND absent in that commit's first parent — i.e. the commit that
|
|
10
|
+
// introduced it, not merely a commit that happens to contain it (a later
|
|
11
|
+
// candidate might just be an unrelated edit to the same line that leaves
|
|
12
|
+
// the vulnerable shape intact).
|
|
13
|
+
//
|
|
14
|
+
// The one subtlety this module exists to get right: a commit with no first
|
|
15
|
+
// parent is ambiguous. It might genuinely be the repository's root commit
|
|
16
|
+
// (real evidence: nothing precedes it, so "absent in parent" is vacuously
|
|
17
|
+
// true) — or it might be the boundary of a SHALLOW clone, where a parent
|
|
18
|
+
// exists in real history but was never fetched. Those two cases must never
|
|
19
|
+
// be reported the same way: reporting a shallow boundary as `complete`
|
|
20
|
+
// would be exactly the false certainty the PRD forbids (claiming to have
|
|
21
|
+
// proven the finding wasn't present a commit earlier, when the truth is we
|
|
22
|
+
// simply couldn't look). `repoState.shallow` is the caller-supplied signal
|
|
23
|
+
// that disambiguates them; see the branch below for exactly how each is
|
|
24
|
+
// handled.
|
|
25
|
+
|
|
26
|
+
import { candidateCommitsForLine, commitMeta, getBlobAtCommit, isAncestor } from './git-evidence.js';
|
|
27
|
+
import { replayAt } from './predicate-replay.js';
|
|
28
|
+
import { PROVENANCE_METHOD } from './schema.js';
|
|
29
|
+
import { checkAbsentInSomeParent, detectRevert, detectCherryPick } from './dag-walk.js';
|
|
30
|
+
import { loadRepoLineage } from './repo-lineage.js';
|
|
31
|
+
import { resolveAIAuthorship } from './ai-authorship.js';
|
|
32
|
+
|
|
33
|
+
function relevantFiles(finding) {
|
|
34
|
+
const files = new Set();
|
|
35
|
+
if (finding.file) files.add(finding.file);
|
|
36
|
+
if (finding.source?.file) files.add(finding.source.file);
|
|
37
|
+
if (finding.sink?.file) files.add(finding.sink.file);
|
|
38
|
+
if (Array.isArray(finding.pathSteps)) {
|
|
39
|
+
for (const step of finding.pathSteps) if (step.file) files.add(step.file);
|
|
40
|
+
}
|
|
41
|
+
return [...files];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function originFrom(meta, { absentInParents }) {
|
|
45
|
+
return {
|
|
46
|
+
commit: meta.commit, authorName: meta.authorName, authorEmail: meta.authorEmail,
|
|
47
|
+
authorDate: meta.authorDate, committerDate: meta.committerDate, summary: meta.summary,
|
|
48
|
+
presentInCommit: true, absentInParents, revertOf: null, cherryPickOf: null,
|
|
49
|
+
aiAuthorship: resolveAIAuthorship(meta),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Extracts line `lineNo` (1-based) from a blob, trimmed, or null if the blob
|
|
54
|
+
// doesn't have that many lines or the line is blank. Shared by the initial
|
|
55
|
+
// gate and the per-candidate walk in `tryCrossRepoLineage` below, so both
|
|
56
|
+
// apply the exact same normalization.
|
|
57
|
+
function trimmedLineAt(blob, lineNo) {
|
|
58
|
+
if (blob == null) return null;
|
|
59
|
+
const lines = blob.split('\n');
|
|
60
|
+
if (lineNo > lines.length) return null;
|
|
61
|
+
const text = lines[lineNo - 1];
|
|
62
|
+
if (!text || !text.trim()) return null;
|
|
63
|
+
return text.trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Best-effort cross-repo continuation when the standard walk reaches this
|
|
68
|
+
* repo's TRUE root commit (no parent, non-shallow) without resolving. Only
|
|
69
|
+
* fires when a repo-lineage link is declared and verified (`loadRepoLineage`
|
|
70
|
+
* — Task 4).
|
|
71
|
+
*
|
|
72
|
+
* A REAL content-presence check, not merely a non-blank-line check: the
|
|
73
|
+
* finding's OWN line text is read fresh from THIS repo at `rootMeta.commit`
|
|
74
|
+
* (the commit the standard walk actually resolved to) and compared, trimmed,
|
|
75
|
+
* against the linked repo's line at `atCommit`. Only an exact match is any
|
|
76
|
+
* evidence at all that "this same line of code" exists in the linked repo —
|
|
77
|
+
* an earlier version of this check merely asked "is *something* non-blank at
|
|
78
|
+
* this line index", which could fabricate an attribution for code that never
|
|
79
|
+
* existed in the linked repo at all (caught by review; see the M4 §4.2 fix
|
|
80
|
+
* commit). This is still a textual match, NOT a predicate replay — the
|
|
81
|
+
* detector pipeline that proved the finding's predicate true in THIS repo
|
|
82
|
+
* cannot be assumed identical in the linked one.
|
|
83
|
+
*
|
|
84
|
+
* The same comparison is applied PER CANDIDATE when walking
|
|
85
|
+
* `candidateCommitsForLine` in the linked repo (restricted to candidates
|
|
86
|
+
* reachable from atCommit, via `isAncestor`): `-L` tracks a LINE NUMBER's
|
|
87
|
+
* history, which can include commits where a completely unrelated statement
|
|
88
|
+
* occupied that same line number before/after the finding's actual code, so
|
|
89
|
+
* "oldest eligible candidate" alone is not evidence either — it must also be
|
|
90
|
+
* the oldest eligible candidate whose content at that line actually matches.
|
|
91
|
+
*
|
|
92
|
+
* Returns null on ANY failure to extend (no lineage, file/line absent or
|
|
93
|
+
* non-matching there, nothing further resolves) — the caller falls through
|
|
94
|
+
* to its existing not-linked-or-unresolved behavior unchanged.
|
|
95
|
+
*
|
|
96
|
+
* `deadlineAt` is the SAME budget `resolveOrigin` checks in its own loops
|
|
97
|
+
* ("one budget for the whole scan" — see posture/CLAUDE.md). This function
|
|
98
|
+
* spawns two git subprocesses per candidate against a SEPARATE repository
|
|
99
|
+
* whose history size this scan does not control, so it must honor the same
|
|
100
|
+
* deadline rather than running unbounded. A `null` return here just means
|
|
101
|
+
* "cross-repo lineage did not extend the answer" — the caller already falls
|
|
102
|
+
* through to the honest same-repo result, so an early bail-out degrades
|
|
103
|
+
* safely by construction.
|
|
104
|
+
*/
|
|
105
|
+
export function tryCrossRepoLineage(scanRoot, finding, rootMeta, deadlineAt) {
|
|
106
|
+
const lineage = loadRepoLineage(scanRoot);
|
|
107
|
+
if (!lineage) return null;
|
|
108
|
+
|
|
109
|
+
const lineNo = finding.line || finding.sink?.line;
|
|
110
|
+
if (!lineNo) return null;
|
|
111
|
+
|
|
112
|
+
// The real basis for comparison: THIS repo's own committed line text at
|
|
113
|
+
// the commit the standard walk resolved to — not a detector-supplied
|
|
114
|
+
// snippet, which could be stale, normalized, or absent.
|
|
115
|
+
const ownTrimmed = trimmedLineAt(getBlobAtCommit(scanRoot, rootMeta.commit, finding.file), lineNo);
|
|
116
|
+
if (!ownTrimmed) return null;
|
|
117
|
+
|
|
118
|
+
const linkedTrimmedAtBoundary = trimmedLineAt(getBlobAtCommit(lineage.path, lineage.atCommit, finding.file), lineNo);
|
|
119
|
+
if (linkedTrimmedAtBoundary !== ownTrimmed) return null;
|
|
120
|
+
|
|
121
|
+
if (deadlineAt && Date.now() > deadlineAt) return null;
|
|
122
|
+
|
|
123
|
+
const linkedCandidates = candidateCommitsForLine(lineage.path, finding.file, lineNo, {});
|
|
124
|
+
// Only candidates reachable from (at or before) atCommit are eligible —
|
|
125
|
+
// the lineage link says history was imported AT that commit, so anything
|
|
126
|
+
// the linked repo's own timeline added after it is not part of what
|
|
127
|
+
// became this repo. `isAncestor` (git-evidence.js, backed by `git
|
|
128
|
+
// merge-base --is-ancestor`) is true for atCommit itself as well as any
|
|
129
|
+
// real ancestor of it, so this both bounds the walk and keeps atCommit
|
|
130
|
+
// itself eligible.
|
|
131
|
+
//
|
|
132
|
+
// A plain `.filter()` can't early-exit, so a large linked-repo candidate
|
|
133
|
+
// list means one `git merge-base` per candidate with no aggregate cap —
|
|
134
|
+
// the same "one budget for the whole scan" gap the entry check above
|
|
135
|
+
// guards against, just one loop later. An explicit loop with the same
|
|
136
|
+
// deadline check closes it.
|
|
137
|
+
const eligible = [];
|
|
138
|
+
for (const sha of linkedCandidates) {
|
|
139
|
+
if (deadlineAt && Date.now() > deadlineAt) return null;
|
|
140
|
+
if (isAncestor(lineage.path, sha, lineage.atCommit)) eligible.push(sha);
|
|
141
|
+
}
|
|
142
|
+
if (eligible.length === 0) return null;
|
|
143
|
+
|
|
144
|
+
// Oldest-first: the first candidate whose OWN content at this line also
|
|
145
|
+
// matches is the real answer. A candidate that is merely eligible (reaches
|
|
146
|
+
// atCommit) but whose content at this line differs is not evidence of
|
|
147
|
+
// anything and must be skipped, not accepted for being oldest.
|
|
148
|
+
let meta = null;
|
|
149
|
+
for (const sha of eligible) {
|
|
150
|
+
if (deadlineAt && Date.now() > deadlineAt) return null;
|
|
151
|
+
const candidateTrimmed = trimmedLineAt(getBlobAtCommit(lineage.path, sha, finding.file), lineNo);
|
|
152
|
+
if (candidateTrimmed !== ownTrimmed) continue;
|
|
153
|
+
meta = commitMeta(lineage.path, sha);
|
|
154
|
+
if (meta) break;
|
|
155
|
+
}
|
|
156
|
+
if (!meta) return null;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
status: 'partial',
|
|
160
|
+
reason: 'cross-repo-lineage-best-effort',
|
|
161
|
+
commitsConsidered: eligible.length,
|
|
162
|
+
findingOrigin: originFrom(meta, { absentInParents: [] }),
|
|
163
|
+
method: PROVENANCE_METHOD.SEMANTIC_REPLAY,
|
|
164
|
+
crossRepoLineage: true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function resolveOrigin(scanRoot, finding, { since, deadlineAt, repoState, mode } = {}) {
|
|
169
|
+
const file = finding?.file;
|
|
170
|
+
const line = finding?.line || finding?.sink?.line;
|
|
171
|
+
const stableId = finding?.stableId;
|
|
172
|
+
if (!file || !line || !stableId) {
|
|
173
|
+
return { status: 'not_available', reason: 'missing-file-line-or-stableId', commitsConsidered: 0 };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const candidates = candidateCommitsForLine(scanRoot, file, line, { since });
|
|
177
|
+
if (candidates.length === 0) {
|
|
178
|
+
return { status: 'not_available', reason: 'no-candidate-commits', commitsConsidered: 0 };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const files = relevantFiles(finding);
|
|
182
|
+
let commitsConsidered = 0;
|
|
183
|
+
// Second independent Finding Provenance PRD audit: the generic fallback
|
|
184
|
+
// reason at the bottom of this function ('predicate-never-confirmed-in-
|
|
185
|
+
// candidates') was reported for every kind of replay miss alike, including
|
|
186
|
+
// the specific rename-shaped one `bench/provenance-accuracy/fixtures/
|
|
187
|
+
// rename.mjs`'s header and `provenance-origin-resolver.test.js`'s
|
|
188
|
+
// "Rename-boundary honesty" test already traced precisely: `-L` has its
|
|
189
|
+
// own built-in rename tracing independent of `--follow` (verified in both
|
|
190
|
+
// places above), so a candidate this search returns for the finding's
|
|
191
|
+
// CURRENT path can legitimately be a commit where the content lived under
|
|
192
|
+
// an OLDER path — `replayAt`/`getBlobAtCommit` only ever look up the
|
|
193
|
+
// CURRENT path, so such a candidate reproducibly fails with
|
|
194
|
+
// `reason:'no-files-at-commit'` (`predicate-replay.js`), never
|
|
195
|
+
// `stableId-not-reproduced` (the "we looked at the right file and the
|
|
196
|
+
// predicate just wasn't true here" case). That distinction is already
|
|
197
|
+
// sitting in `presentHere.reason` below — no extra git call needed to
|
|
198
|
+
// surface it. This flag does NOT make the resolver follow the rename (that
|
|
199
|
+
// is the separately-scoped, honestly-disclosed engine gap); it only makes
|
|
200
|
+
// the fallback reason say which honest-miss shape actually occurred.
|
|
201
|
+
let renameShapedMiss = false;
|
|
202
|
+
|
|
203
|
+
// M2 §2.4 performance fix: within ONE resolveOrigin call, replayAt(sha) is
|
|
204
|
+
// pure given (scanRoot, sha, files, stableId) — all fixed for this call.
|
|
205
|
+
// The SAME sha is asked about twice whenever one candidate's first parent
|
|
206
|
+
// equals the previous candidate: candidate i's "presentHere" check IS
|
|
207
|
+
// candidate i+1's "presentInParent" check when parent(candidate i+1) ===
|
|
208
|
+
// candidate i, which is the common case for a file with no gaps in its
|
|
209
|
+
// edit history. Memoized here (not in predicate-replay.js itself) so the
|
|
210
|
+
// cache stays scoped to one finding's walk — a cross-finding cache is
|
|
211
|
+
// coordinator.js's job (Task 7), not this module's.
|
|
212
|
+
const replayCache = new Map();
|
|
213
|
+
const replay = (sha) => {
|
|
214
|
+
if (replayCache.has(sha)) return replayCache.get(sha);
|
|
215
|
+
const p = replayAt(scanRoot, sha, files, stableId);
|
|
216
|
+
replayCache.set(sha, p);
|
|
217
|
+
return p;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
for (const sha of candidates) {
|
|
221
|
+
if (deadlineAt && Date.now() > deadlineAt) {
|
|
222
|
+
return { status: 'budget_exhausted', commitsConsidered };
|
|
223
|
+
}
|
|
224
|
+
commitsConsidered++;
|
|
225
|
+
const presentHere = await replay(sha);
|
|
226
|
+
if (!presentHere.present) {
|
|
227
|
+
if (presentHere.reason === 'no-files-at-commit') renameShapedMiss = true;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Task 6 (provenance-second-audit-remediation): one `git show` call, not
|
|
232
|
+
// two — `commitMeta` now carries `parents` (see git-evidence.js), so the
|
|
233
|
+
// separate `getFirstParent(scanRoot, sha)` subprocess spawn this loop
|
|
234
|
+
// used to make for the SAME sha right before this line is gone. `<sha>^1`
|
|
235
|
+
// (what getFirstParent asked for) and `parents[0]` (the first token of
|
|
236
|
+
// `%P`) are the same first-parent semantics for both a normal and a
|
|
237
|
+
// merge commit.
|
|
238
|
+
const meta = commitMeta(scanRoot, sha);
|
|
239
|
+
if (!meta) continue;
|
|
240
|
+
const parent = meta.parents.length ? meta.parents[0] : null;
|
|
241
|
+
|
|
242
|
+
if (!parent) {
|
|
243
|
+
if (repoState && repoState.shallow) {
|
|
244
|
+
// Shallow boundary — cannot prove absence in a parent we cannot see.
|
|
245
|
+
// This is exactly the false-certainty case the PRD forbids.
|
|
246
|
+
return {
|
|
247
|
+
status: 'partial', reason: 'shallow-boundary-reached', commitsConsidered,
|
|
248
|
+
findingOrigin: originFrom(meta, { absentInParents: [] }),
|
|
249
|
+
method: PROVENANCE_METHOD.SEMANTIC_REPLAY,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
// True repository root, non-shallow. Before settling for "no parent
|
|
253
|
+
// exists to verify absence in" (M2/M3's existing weaker-evidence
|
|
254
|
+
// path), try extending the walk into a declared cross-repo lineage
|
|
255
|
+
// link (M4 §4.2) — this repo's root may not be where the code was
|
|
256
|
+
// actually first written, just where THIS repo's history starts.
|
|
257
|
+
const crossRepo = tryCrossRepoLineage(scanRoot, finding, meta, deadlineAt);
|
|
258
|
+
if (crossRepo) return crossRepo;
|
|
259
|
+
// True repository root, non-shallow, no lineage link (or the link
|
|
260
|
+
// didn't extend the answer) — valid but weaker evidence: no parent
|
|
261
|
+
// exists to verify absence in, so parentBoundaryVerified stays false
|
|
262
|
+
// and confidence.js will cap this at MEDIUM.
|
|
263
|
+
return {
|
|
264
|
+
status: 'complete', method: PROVENANCE_METHOD.SEMANTIC_REPLAY, commitsConsidered,
|
|
265
|
+
findingOrigin: originFrom(meta, { absentInParents: [] }),
|
|
266
|
+
parentBoundaryVerified: false,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const presentInParent = await replay(parent);
|
|
271
|
+
const absentInParent = !presentInParent.present;
|
|
272
|
+
if (!absentInParent) continue; // predicate already true in parent — keep walking older candidates
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
status: 'complete', method: PROVENANCE_METHOD.SEMANTIC_REPLAY, commitsConsidered,
|
|
276
|
+
findingOrigin: originFrom(meta, { absentInParents: [parent] }),
|
|
277
|
+
parentBoundaryVerified: true,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// M3 §3.1: `--provenance deep`. The standard walk above only ever checks a
|
|
282
|
+
// candidate's FIRST parent for absence — correct for linear history, but a
|
|
283
|
+
// vulnerability introduced via a merged feature branch can be absent from
|
|
284
|
+
// a NON-first parent while the first parent (inherited from mainline
|
|
285
|
+
// before the merge) already carries it — the standard check never looks
|
|
286
|
+
// past parents[0], so it never sees that other, absent parent. Deep mode
|
|
287
|
+
// re-checks the SAME candidates the standard walk already found, this
|
|
288
|
+
// time via `checkAbsentInSomeParent` — absence in AT LEAST ONE parent, not
|
|
289
|
+
// necessarily the first. See that function's own doc comment in
|
|
290
|
+
// dag-walk.js for why "any parent absent" (a strict superset of the
|
|
291
|
+
// first-parent-only check) is the correct generalization here, and why
|
|
292
|
+
// `checkAbsentInAllParents` (a strict SUBSET, used only for lifecycle
|
|
293
|
+
// safety checks elsewhere) can never resolve anything this retry couldn't
|
|
294
|
+
// already resolve via the primary loop above.
|
|
295
|
+
if (mode === 'deep') {
|
|
296
|
+
for (const sha of candidates) {
|
|
297
|
+
if (deadlineAt && Date.now() > deadlineAt) {
|
|
298
|
+
return { status: 'budget_exhausted', commitsConsidered };
|
|
299
|
+
}
|
|
300
|
+
const presentHere = await replay(sha);
|
|
301
|
+
if (!presentHere.present) continue;
|
|
302
|
+
commitsConsidered++;
|
|
303
|
+
const { absentInSome, absentParents, rootCommit } = await checkAbsentInSomeParent(scanRoot, sha, replay);
|
|
304
|
+
if (!absentInSome) continue;
|
|
305
|
+
const meta = commitMeta(scanRoot, sha);
|
|
306
|
+
if (!meta) continue;
|
|
307
|
+
if (rootCommit && repoState && repoState.shallow) {
|
|
308
|
+
return {
|
|
309
|
+
status: 'partial', reason: 'shallow-boundary-reached', commitsConsidered,
|
|
310
|
+
findingOrigin: originFrom(meta, { absentInParents: [] }),
|
|
311
|
+
method: PROVENANCE_METHOD.SEMANTIC_REPLAY,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const { isRevert, revertsCommit } = detectRevert(scanRoot, sha, candidates);
|
|
315
|
+
const { isCherryPick, originalCommit } = detectCherryPick(scanRoot, sha);
|
|
316
|
+
const origin = originFrom(meta, { absentInParents: absentParents });
|
|
317
|
+
origin.revertOf = isRevert ? revertsCommit : null;
|
|
318
|
+
origin.cherryPickOf = isCherryPick ? originalCommit : null;
|
|
319
|
+
return {
|
|
320
|
+
status: 'complete', method: PROVENANCE_METHOD.SEMANTIC_REPLAY, commitsConsidered,
|
|
321
|
+
findingOrigin: origin,
|
|
322
|
+
parentBoundaryVerified: !rootCommit,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 'rename-detected-not-followed': at least one candidate's blob lookup
|
|
328
|
+
// failed with 'no-files-at-commit' — the specific, cheaply-observed shape
|
|
329
|
+
// that means git found this content living at a path other than the
|
|
330
|
+
// finding's current one, and this resolver never re-tries the lookup
|
|
331
|
+
// under an older name (see the comment on `renameShapedMiss` above). This
|
|
332
|
+
// does NOT mean every such miss IS a rename — the same shape could
|
|
333
|
+
// theoretically come from an unrelated path mismatch — only that it is
|
|
334
|
+
// never the generic "we looked at the right path and the predicate simply
|
|
335
|
+
// wasn't true" miss, so reporting the generic reason here would be
|
|
336
|
+
// actively misleading about WHAT the search looked at, not merely vague.
|
|
337
|
+
return {
|
|
338
|
+
status: 'partial',
|
|
339
|
+
reason: renameShapedMiss ? 'rename-detected-not-followed' : 'predicate-never-confirmed-in-candidates',
|
|
340
|
+
commitsConsidered,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Historical-blob predicate replay (Finding Provenance PRD).
|
|
2
|
+
//
|
|
3
|
+
// Given a finding's stableId and the commit it is being investigated against,
|
|
4
|
+
// answer "does this finding's condition hold at that point in history" by
|
|
5
|
+
// re-running the FULL detector suite (`runFullScan`) scoped to just the
|
|
6
|
+
// finding's file(s) at that commit's blob content, and checking whether the
|
|
7
|
+
// same stableId reappears. This deliberately reuses the real pipeline instead
|
|
8
|
+
// of hand-mapping a finding to one of 60+ detector modules — it costs a real
|
|
9
|
+
// (if narrowly-scoped) scan, not a cheap pattern match, which is the tradeoff
|
|
10
|
+
// `origin-resolver.js` (Task 6) accepts for correctness.
|
|
11
|
+
|
|
12
|
+
import { runFullScan, _snapshotSuppressionLog, _restoreSuppressionLog } from '../../engine.js';
|
|
13
|
+
import { computeStableId } from '../stable-id.js';
|
|
14
|
+
import { getBlobAtCommit } from './git-evidence.js';
|
|
15
|
+
|
|
16
|
+
// Task 11 concurrency fix: `coordinator.js` resolves several findings' origins
|
|
17
|
+
// CONCURRENTLY (its own comment: "the scheduler runs these four at a time"),
|
|
18
|
+
// and each finding's `resolveOrigin` walk can call `replayAt` more than once
|
|
19
|
+
// sequentially -- so two DIFFERENT findings' replay calls can legitimately be
|
|
20
|
+
// in flight at the same time, interleaved at `runFullScan`'s own internal
|
|
21
|
+
// await points. `runFullScan` clears+writes a module-level suppression log
|
|
22
|
+
// (engine.js's `_suppressionLog`) unconditionally on every call; a plain
|
|
23
|
+
// snapshot/restore around one call is not safe under that interleaving.
|
|
24
|
+
//
|
|
25
|
+
// The actual failure shape (traced through, not guessed): call A snapshots
|
|
26
|
+
// the outer log, then awaits its nested `runFullScan`, which resets the log
|
|
27
|
+
// to empty and starts writing its OWN (nested-scan) entries. Before A's
|
|
28
|
+
// nested call finishes, call B starts: B's snapshot now captures A's
|
|
29
|
+
// in-progress nested state, not the outer scan's real log. When A finishes
|
|
30
|
+
// and restores ITS (correct) snapshot, that's fine -- but B's `finally` then
|
|
31
|
+
// restores what B snapshotted (A's nested state), overwriting A's correct
|
|
32
|
+
// restore with garbage that belongs to neither scan. It is not simply "the
|
|
33
|
+
// last restore wins" as a symmetric race between two correct values; the
|
|
34
|
+
// corrupting snapshot (B's) was already wrong the moment it was taken,
|
|
35
|
+
// because it read the log mid-mutation by a DIFFERENT nested scan.
|
|
36
|
+
//
|
|
37
|
+
// This queue serializes every `replayAt` call process-wide so the
|
|
38
|
+
// snapshot -> runFullScan -> restore critical section below is never entered
|
|
39
|
+
// by two calls at once -- call B cannot snapshot until call A has fully
|
|
40
|
+
// restored, so B always sees a clean outer-scan log. Correctness over
|
|
41
|
+
// throughput: `replayAt` is already the expensive path (a full nested scan
|
|
42
|
+
// per call, ~39ms fixed overhead) and this queue only serializes that nested
|
|
43
|
+
// scan itself, not the rest of each finding's concurrent resolution walk
|
|
44
|
+
// (blame calls, cache reads, etc. all still run unserialized).
|
|
45
|
+
//
|
|
46
|
+
// Exported (test-only) so a dedicated regression test can drive this queue
|
|
47
|
+
// directly and prove it serializes -- see
|
|
48
|
+
// test/posture/provenance-secrets-logic.test.js's "_runExclusive serializes
|
|
49
|
+
// concurrent critical sections" test, which fails if this queue is ever
|
|
50
|
+
// removed or replaced with a no-op passthrough.
|
|
51
|
+
let _replayQueue = Promise.resolve();
|
|
52
|
+
export function _runExclusive(fn) {
|
|
53
|
+
const run = _replayQueue.then(fn, fn);
|
|
54
|
+
// Never let one rejected replay poison the queue for subsequent callers --
|
|
55
|
+
// `run`'s own rejection still propagates to ITS caller below.
|
|
56
|
+
_replayQueue = run.then(() => {}, () => {});
|
|
57
|
+
return run;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function replayAt(scanRoot, sha, files, targetStableId) {
|
|
61
|
+
const fileContents = {};
|
|
62
|
+
for (const f of files) {
|
|
63
|
+
const content = getBlobAtCommit(scanRoot, sha, f);
|
|
64
|
+
if (content != null) fileContents[f] = content;
|
|
65
|
+
}
|
|
66
|
+
if (Object.keys(fileContents).length === 0) {
|
|
67
|
+
return { present: false, reason: 'no-files-at-commit' };
|
|
68
|
+
}
|
|
69
|
+
let scan;
|
|
70
|
+
try {
|
|
71
|
+
scan = await _runExclusive(async () => {
|
|
72
|
+
// Task 11 reentrancy fix: `runFullScan` clears its module-level
|
|
73
|
+
// suppression log unconditionally at the top of every call. This
|
|
74
|
+
// function calls `runFullScan` recursively FROM WITHIN an outer, still-
|
|
75
|
+
// running scan's provenance resolution -- without a snapshot/restore
|
|
76
|
+
// around it, the nested call below silently wipes the OUTER scan's
|
|
77
|
+
// suppression log before its own return value reads it (found via
|
|
78
|
+
// `test/fixtures/entropy-fp`'s suppression count going to 0 once
|
|
79
|
+
// scan.secrets was wired into real provenance resolution and started
|
|
80
|
+
// reaching this recursive call for the first time). The nested scan's
|
|
81
|
+
// own suppression output is never read below, so nothing is lost by
|
|
82
|
+
// discarding it here. Safe from the concurrency hazard described above
|
|
83
|
+
// ONLY because this whole callback runs inside `_runExclusive`.
|
|
84
|
+
const _suppSnapshot = _snapshotSuppressionLog();
|
|
85
|
+
try {
|
|
86
|
+
// `provenance:false` is mandatory, not an optimisation: runFullScan
|
|
87
|
+
// now runs the provenance pass, which lands back here — an unbounded
|
|
88
|
+
// scan→provenance→replay→scan recursion that never returns. See the
|
|
89
|
+
// comment on runFullScan's signature.
|
|
90
|
+
//
|
|
91
|
+
// `skipAnnotators:true` (FR-PROV-029): this function only ever reads
|
|
92
|
+
// `scan.findings`/`scan.secrets` to recompute `computeStableId()`
|
|
93
|
+
// below — it never reads anything any of runFullScan's ~54 post-
|
|
94
|
+
// detection annotators set, nor the other finalization steps the
|
|
95
|
+
// same option also skips (secret dedup, orphan classification,
|
|
96
|
+
// freeze, checkpoint-close — see the guard comment in engine.js).
|
|
97
|
+
// Skipping all of it avoids paying its cost (measured ~39ms fixed
|
|
98
|
+
// overhead per call) on every one of the ~2 replay calls per finding
|
|
99
|
+
// the resolution walk already makes. Verified empirically (byte-
|
|
100
|
+
// identical computeStableId output with and without annotators, over
|
|
101
|
+
// a real scan) before this was wired in — see the FR-PROV-029 commit
|
|
102
|
+
// message for methodology.
|
|
103
|
+
return await runFullScan({ fileContents, scanRoot, provenance: false, skipAnnotators: true }, () => {});
|
|
104
|
+
} finally {
|
|
105
|
+
_restoreSuppressionLog(_suppSnapshot);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return { present: false, reason: 'replay-error' };
|
|
110
|
+
}
|
|
111
|
+
// scan.logicVulns is included here too (Task 11, PRD P0 secrets/logicVulns
|
|
112
|
+
// scope). This is safe even though scan.logicVulns includes the 3
|
|
113
|
+
// synthetic-line producers (license-policy:/deploy-platform:/stack-playbook:
|
|
114
|
+
// — fixed placeholder `line`, read scanRoot-level files directly rather than
|
|
115
|
+
// from the `fileContents` this replay scan is scoped to) because those
|
|
116
|
+
// producers are NEVER routed through resolveOrigin in the first place (see
|
|
117
|
+
// engine.js's isSyntheticLogicFinding classification) — replayAt is never
|
|
118
|
+
// asked to match against them. Their presence in this array when SOME OTHER
|
|
119
|
+
// finding's replay runs is harmless: they just won't match that other
|
|
120
|
+
// finding's stableId. Do NOT be tempted to wire ALL of scan.logicVulns
|
|
121
|
+
// through provenance without reading the plan this task came from — see
|
|
122
|
+
// docs/superpowers/plans/2026-08-28-finding-provenance-prd-completion.md's
|
|
123
|
+
// "Global Research Findings" section.
|
|
124
|
+
const candidates = [...(scan.findings || []), ...(scan.secrets || []), ...(scan.logicVulns || [])];
|
|
125
|
+
for (const f of candidates) {
|
|
126
|
+
let sid;
|
|
127
|
+
try { sid = computeStableId(f); } catch { continue; }
|
|
128
|
+
if (sid === targetStableId) {
|
|
129
|
+
return { present: true, replayedFinding: f };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { present: false, reason: 'stableId-not-reproduced' };
|
|
133
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Shared opt-in config resolution for provider enrichment (Finding
|
|
2
|
+
// Provenance PRD, M3 §3.4). Strictly opt-in: with neither an env var nor a
|
|
3
|
+
// config file present, resolveProviderConfig returns null and NEITHER
|
|
4
|
+
// providers/github.js nor providers/gitlab.js makes any network call —
|
|
5
|
+
// this is the property provenance-providers.test.js's hermeticity test
|
|
6
|
+
// proves. Modeled on llm-validator/index.js's existing
|
|
7
|
+
// AGENTIC_SECURITY_LLM_ENDPOINT precedent (opt-in via env var, degrades to
|
|
8
|
+
// a no-op when unset) rather than inventing a new convention.
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import { statePath } from '../../state-dir.js';
|
|
11
|
+
import { load as loadYaml } from '../../../util/yaml.js';
|
|
12
|
+
|
|
13
|
+
const ENV_VAR_BY_PROVIDER = { github: 'AGENTIC_SECURITY_GITHUB_TOKEN', gitlab: 'AGENTIC_SECURITY_GITLAB_TOKEN' };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `provider` is 'github' | 'gitlab'. Returns {token, baseUrl} or null.
|
|
17
|
+
* Env var wins if both are present — the same "explicit beats inferred"
|
|
18
|
+
* precedent config-resolution follows elsewhere in this codebase (e.g.
|
|
19
|
+
* state-dir.js's caller-supplied-scanRoot-wins-over-cwd-walk).
|
|
20
|
+
*/
|
|
21
|
+
export function resolveProviderConfig(scanRoot, provider) {
|
|
22
|
+
const envVar = ENV_VAR_BY_PROVIDER[provider];
|
|
23
|
+
const envToken = envVar ? process.env[envVar] : undefined;
|
|
24
|
+
if (envToken) return { token: envToken, baseUrl: null };
|
|
25
|
+
|
|
26
|
+
// .agentic-security/provenance-providers.yml — NOT gated behind
|
|
27
|
+
// stateWritesEnabled()/isSafeStateDir() the way STATE WRITES are; this is
|
|
28
|
+
// a READ of an operator-authored config file, the same class of read
|
|
29
|
+
// rules.yml already performs unconditionally.
|
|
30
|
+
const configPath = statePath(scanRoot, 'provenance-providers.yml');
|
|
31
|
+
let text;
|
|
32
|
+
try { text = fs.readFileSync(configPath, 'utf8'); } catch { return null; }
|
|
33
|
+
let doc;
|
|
34
|
+
try { doc = loadYaml(text); } catch { return null; }
|
|
35
|
+
if (!doc || typeof doc !== 'object') return null;
|
|
36
|
+
const entry = doc[provider];
|
|
37
|
+
if (!entry || !entry.token) return null;
|
|
38
|
+
return { token: entry.token, baseUrl: entry.baseUrl || null };
|
|
39
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// GitHub provider enrichment (Finding Provenance PRD, M3 §3.4). Strictly
|
|
2
|
+
// opt-in — see config.js's resolveProviderConfig. Every export returns null
|
|
3
|
+
// immediately, with zero network calls, when unconfigured.
|
|
4
|
+
import { resolveProviderConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_API_BASE = 'https://api.github.com';
|
|
7
|
+
|
|
8
|
+
function ownerRepoFromRemote(remoteUrl) {
|
|
9
|
+
// Handles both "git@github.com:owner/repo.git" and
|
|
10
|
+
// "https://github.com/owner/repo.git" — the two forms `git remote -v`
|
|
11
|
+
// actually produces.
|
|
12
|
+
const m = String(remoteUrl || '').match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
13
|
+
return m ? { owner: m[1], repo: m[2] } : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function fetchPRMetadata(scanRoot, commitSha, remoteUrl, config) {
|
|
17
|
+
if (!config || !config.token) return null;
|
|
18
|
+
const or = ownerRepoFromRemote(remoteUrl);
|
|
19
|
+
if (!or) return null;
|
|
20
|
+
const base = config.baseUrl || DEFAULT_API_BASE;
|
|
21
|
+
try {
|
|
22
|
+
const r = await fetch(`${base}/repos/${or.owner}/${or.repo}/commits/${commitSha}/pulls`, {
|
|
23
|
+
headers: { Authorization: `Bearer ${config.token}`, Accept: 'application/vnd.github+json' },
|
|
24
|
+
signal: AbortSignal.timeout(8000),
|
|
25
|
+
});
|
|
26
|
+
if (!r.ok) return null;
|
|
27
|
+
const prs = await r.json();
|
|
28
|
+
if (!Array.isArray(prs) || prs.length === 0) return null;
|
|
29
|
+
const pr = prs[0];
|
|
30
|
+
return {
|
|
31
|
+
prNumber: pr.number,
|
|
32
|
+
reviewers: (pr.requested_reviewers || []).map((u) => u.login),
|
|
33
|
+
approvals: null, // GitHub's PR-list-by-commit endpoint doesn't include review state; a real approvals count needs a second call, deliberately not made here to keep this a single-request enrichment.
|
|
34
|
+
mergedAt: pr.merged_at || null,
|
|
35
|
+
};
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function fetchCodeowners(scanRoot, remoteUrl, config) {
|
|
42
|
+
if (!config || !config.token) return null;
|
|
43
|
+
const or = ownerRepoFromRemote(remoteUrl);
|
|
44
|
+
if (!or) return null;
|
|
45
|
+
const base = config.baseUrl || DEFAULT_API_BASE;
|
|
46
|
+
for (const path of ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS']) {
|
|
47
|
+
try {
|
|
48
|
+
const r = await fetch(`${base}/repos/${or.owner}/${or.repo}/contents/${path}`, {
|
|
49
|
+
headers: { Authorization: `Bearer ${config.token}`, Accept: 'application/vnd.github+json' },
|
|
50
|
+
signal: AbortSignal.timeout(8000),
|
|
51
|
+
});
|
|
52
|
+
if (!r.ok) continue;
|
|
53
|
+
const body = await r.json();
|
|
54
|
+
if (!body.content) continue;
|
|
55
|
+
const text = Buffer.from(body.content, 'base64').toString('utf8');
|
|
56
|
+
return text.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
57
|
+
} catch { continue; }
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { resolveProviderConfig };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// GitLab provider enrichment (Finding Provenance PRD, M3 §3.4). Same
|
|
2
|
+
// contract as providers/github.js — strictly opt-in, zero network calls
|
|
3
|
+
// when unconfigured.
|
|
4
|
+
import { resolveProviderConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_API_BASE = 'https://gitlab.com/api/v4';
|
|
7
|
+
|
|
8
|
+
function projectPathFromRemote(remoteUrl) {
|
|
9
|
+
const m = String(remoteUrl || '').match(/gitlab\.com[:/](.+?)(?:\.git)?$/);
|
|
10
|
+
return m ? m[1] : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function fetchPRMetadata(scanRoot, commitSha, remoteUrl, config) {
|
|
14
|
+
if (!config || !config.token) return null;
|
|
15
|
+
const projectPath = projectPathFromRemote(remoteUrl);
|
|
16
|
+
if (!projectPath) return null;
|
|
17
|
+
const base = config.baseUrl || DEFAULT_API_BASE;
|
|
18
|
+
const encodedProject = encodeURIComponent(projectPath);
|
|
19
|
+
try {
|
|
20
|
+
const r = await fetch(`${base}/projects/${encodedProject}/repository/commits/${commitSha}/merge_requests`, {
|
|
21
|
+
headers: { 'PRIVATE-TOKEN': config.token },
|
|
22
|
+
signal: AbortSignal.timeout(8000),
|
|
23
|
+
});
|
|
24
|
+
if (!r.ok) return null;
|
|
25
|
+
const mrs = await r.json();
|
|
26
|
+
if (!Array.isArray(mrs) || mrs.length === 0) return null;
|
|
27
|
+
const mr = mrs[0];
|
|
28
|
+
return {
|
|
29
|
+
prNumber: mr.iid,
|
|
30
|
+
reviewers: (mr.reviewers || []).map((u) => u.username),
|
|
31
|
+
approvals: typeof mr.upvotes === 'number' ? mr.upvotes : null,
|
|
32
|
+
mergedAt: mr.merged_at || null,
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function fetchCodeowners(scanRoot, remoteUrl, config) {
|
|
40
|
+
if (!config || !config.token) return null;
|
|
41
|
+
const projectPath = projectPathFromRemote(remoteUrl);
|
|
42
|
+
if (!projectPath) return null;
|
|
43
|
+
const base = config.baseUrl || DEFAULT_API_BASE;
|
|
44
|
+
const encodedProject = encodeURIComponent(projectPath);
|
|
45
|
+
try {
|
|
46
|
+
const r = await fetch(`${base}/projects/${encodedProject}/repository/files/CODEOWNERS/raw?ref=HEAD`, {
|
|
47
|
+
headers: { 'PRIVATE-TOKEN': config.token },
|
|
48
|
+
signal: AbortSignal.timeout(8000),
|
|
49
|
+
});
|
|
50
|
+
if (!r.ok) return null;
|
|
51
|
+
const text = await r.text();
|
|
52
|
+
return text.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { resolveProviderConfig };
|