@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -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 };