@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,786 @@
1
+ // Finding-provenance coordinator — the integration point where every other
2
+ // provenance module meets a real finding list.
3
+ //
4
+ // NAMING: `annotateGitProvenance`, not `annotateProvenance` or
5
+ // `annotateFindingProvenance` — both of those are already taken by unrelated
6
+ // features that `engine.js` imports today: `sca/sigstore-verify.js`'s
7
+ // `annotateProvenance` (build attestations) and `posture/provenance.js`'s
8
+ // `annotateFindingProvenance` (parser-corroboration signals). Either name here
9
+ // would be a duplicate binding — a SyntaxError — the moment engine.js imports
10
+ // this module, and the second is worse still because it takes a findings array
11
+ // as its first argument exactly like this one, so a wrong import would run
12
+ // rather than fail. The name states the mechanism that distinguishes this one:
13
+ // provenance derived from GIT HISTORY.
14
+ //
15
+ // Its single hard guarantee: after `annotateGitProvenance(findings, ctx)`
16
+ // returns, EVERY finding in the array carries a terminal `findingProvenance`
17
+ // object. There is no path — not a missing git binary, not a malformed
18
+ // finding, not a downstream module throwing — that leaves a finding with
19
+ // `findingProvenance === undefined`. A consumer that has to check for
20
+ // "annotated or not" before reading the status defeats the whole point of the
21
+ // status enum, so the enum carries the failure modes instead:
22
+ // `not_available` (nothing to look at), `uncommitted` (PRD Scenario G),
23
+ // `budget_exhausted` (we ran out of time), `error` (something below us threw).
24
+ //
25
+ // Two ordering decisions are load-bearing:
26
+ //
27
+ // 1. **Uncommitted is checked FIRST, via `blameLine`, before `resolveOrigin`.**
28
+ // A finding on a line that exists only in the working tree has no commit
29
+ // history to walk — running the candidate-replay walk for it would be pure
30
+ // waste, and worse, a cached result keyed on HEAD would be stale the moment
31
+ // the line was edited. One `git blame` call settles it (PRD Scenario G).
32
+ //
33
+ // 2. **`repoState` is the REAL `getRepoState()` output, resolved once and
34
+ // threaded into every `resolveOrigin` call.** This is not a convenience —
35
+ // `origin-resolver.js` disambiguates "true repository root" from "shallow
36
+ // clone boundary" purely on `repoState.shallow`. Handing it a stub or a
37
+ // default would let a shallow repo reach `status:'complete'`, which is
38
+ // exactly the false certainty the PRD forbids. Resolve it once (it is
39
+ // four `git` calls) and pass it down; never reconstruct it per finding.
40
+ //
41
+ // 3. **`ctx.findingType === 'sca'` selects a whole different strategy, not a
42
+ // flag on one.** A direct-dependency entry is a different SHAPE
43
+ // (`filePath`/`name`/`ecosystem`, no `ruleId`) answering a different
44
+ // QUESTION (which commit moved the declared version into the advisory's
45
+ // vulnerable range) than a SAST finding. Four things change with it, and
46
+ // each is commented at its site: the uncommitted blame short-circuit is
47
+ // skipped, `stableId` is backfilled from `scaStableId`, `resolveOrigin` is
48
+ // replaced by `resolveDirectSCAOrigin`, and `attributeEvidence` is replaced
49
+ // by a single `manifest` node. Everything else — the budget check, the
50
+ // cache, the terminal-status guarantee, the digest — is shared verbatim,
51
+ // because those are properties of the coordinator, not of the finding type.
52
+
53
+ import * as crypto from 'node:crypto';
54
+ import { getRepoState, isGitRepo, blameLine, getBlobAtCommit, getRemoteUrl, resolveRevision, getRootCommits } from './git-evidence.js';
55
+ import { readLifecycle } from './lifecycle.js';
56
+ import { resolveOrigin } from './origin-resolver.js';
57
+ import { resolveDirectSCAOrigin, scaStableId } from './sca-origin.js';
58
+ import { resolveTransitiveSCAOrigin } from './transitive-sca.js';
59
+ import { resolveMissingControl } from './missing-control-resolver.js';
60
+ import { resolveBranchEntry } from './branch-entry.js';
61
+ import { attributeEvidence } from './evidence-attribution.js';
62
+ import { assessConfidence } from './confidence.js';
63
+ import { cacheGet, cacheSet, makeCacheKey } from './cache.js';
64
+ import { loadRepoLineage } from './repo-lineage.js';
65
+ import { emptyProvenance, PROVENANCE_STATUS, PROVENANCE_METHOD, EVIDENCE_ROLE, CONFIDENCE_LEVEL } from './schema.js';
66
+ // FR-PROV-022: `resolveProviderConfig` is genuinely shared (both
67
+ // providers/github.js and providers/gitlab.js re-export the SAME function
68
+ // from config.js — importing it directly here avoids picking one module's
69
+ // re-export arbitrarily). `fetchPRMetadata`/`fetchCodeowners`, by contrast,
70
+ // are two DIFFERENT functions that happen to share a name across two
71
+ // DIFFERENT modules — aliased on import so both are reachable without a
72
+ // naming collision.
73
+ import { resolveProviderConfig } from './providers/config.js';
74
+ import { fetchPRMetadata as fetchPRMetadataGithub, fetchCodeowners as fetchCodeownersGithub } from './providers/github.js';
75
+ import { fetchPRMetadata as fetchPRMetadataGitlab, fetchCodeowners as fetchCodeownersGitlab } from './providers/gitlab.js';
76
+ // FR-PROV-017: the missing-control-candidate branch reuses rate-limit.js's
77
+ // OWN presence predicate rather than re-deriving one, so a historical blob is
78
+ // judged by the exact same regexes the live detector uses on HEAD. Imported
79
+ // statically (not via a dynamic import() inside the predicate) per this
80
+ // module's own convention for git-evidence.js's functions above — only
81
+ // `getBlobAtCommit` needed care about import placement, and it is already
82
+ // imported statically alongside the rest of git-evidence.js's exports.
83
+ import { hasRateLimit } from '../../sast/rate-limit.js';
84
+
85
+ // Detector label recorded in `analysisBasis.detector` for SCA entries. A
86
+ // dependency finding has no `parser` (no file was parsed by a SAST detector),
87
+ // so the honest answer to "what produced this" is the manifest-diff walk.
88
+ const SCA_DETECTOR = 'sca-manifest-diff';
89
+ const TRANSITIVE_SCA_DETECTOR = 'sca-lockfile-history-diff';
90
+
91
+ // Exported so `engine.js` can establish ONE deadline across both of the calls
92
+ // it makes (SAST findings, then direct SCA deps) using the same default this
93
+ // module would have used. Without a shared deadline the scan-level budget was
94
+ // silently 2× the operator's --provenance-timeout.
95
+ export const PROVENANCE_DEFAULT_TIMEOUT_MS = 60000;
96
+ const DEFAULT_TIMEOUT_MS = PROVENANCE_DEFAULT_TIMEOUT_MS;
97
+ const MAX_CONCURRENCY = 4;
98
+
99
+ // Per-finding floor from the spec's sub-budget formula
100
+ // (`max(2s, global/estimatedFindingCount)`). The floor matters more than the
101
+ // quotient: a 200-finding scan under a 60s global budget divides to 300ms,
102
+ // which is less than a single `git blame`'s own 2s timeout and would starve
103
+ // every finding equally instead of a few. 2s is one blame's worth of work.
104
+ const MIN_PER_FINDING_BUDGET_MS = 2000;
105
+
106
+ // FR-PROV-022: an 8s-timeout-capped network call per finding is not
107
+ // automatically bounded by the scan's own deadlineAt -- N findings x up to
108
+ // 8s each could dwarf any reasonable scan budget. Capping the COUNT, not the
109
+ // per-call timeout, keeps the existing AbortSignal.timeout(8000) in
110
+ // providers/github.js and providers/gitlab.js untouched while bounding the
111
+ // aggregate. Per this codebase's "no silent caps" convention, this cap is
112
+ // disclosed in findingProvenance.limitations for every finding that would
113
+ // have qualified for enrichment but didn't get it because the cap was
114
+ // already spent.
115
+ //
116
+ // This is a per-SCAN cap, not a per-CALL one -- engine.js invokes
117
+ // `annotateGitProvenance` FIVE times per scan (SAST findings, direct SCA,
118
+ // transitive SCA, secrets, blameable logicVulns), sharing one
119
+ // `provenanceCtx`. The intent was always a single scan-wide budget of 20
120
+ // (fix-round item 2): see the `providerEnrichments` counter below, threaded
121
+ // through `ctx` the same way `deadlineAt` already is, so all five calls draw
122
+ // from ONE real budget instead of each getting a fresh 20 (a silent 5x
123
+ // looser cap than this constant and its disclosure string claimed).
124
+ export const MAX_PROVIDER_ENRICHMENTS_PER_SCAN = 20;
125
+
126
+ // PRD Data Contract, "Evidence integrity": the digest must bind the stable
127
+ // finding ID, repository identity, analysis HEAD, origin commit,
128
+ // branch-introduction commit, evidence-node locations and blob IDs,
129
+ // detector/ruleset version, history boundary, method, confidence reasons,
130
+ // and limitations. This binds ten of those eleven.
131
+ //
132
+ // `repoIdentity` is deliberately just `scanRoot` (the absolute path) for now,
133
+ // not a remote-URL-derived identity — no helper computes one yet. A future
134
+ // `getRemoteUrl`-style signal is a stronger repository-identity value once it
135
+ // exists; `scanRoot` is today's honest best effort, and swapping it in later
136
+ // is itself a value-breaking digest change like this one.
137
+ //
138
+ // Evidence-node BLOB IDs (the PRD's eleventh input) are deliberately NOT
139
+ // bound here. No primitive anywhere upstream computes a `git hash-object`
140
+ // -style content hash per evidence node today — `getBlobAtCommit` returns raw
141
+ // text, never an OID — and adding one would mean a new git-evidence.js
142
+ // primitive plus touching every evidence-node construction site
143
+ // (origin-resolver.js, sca-origin.js, transitive-sca.js,
144
+ // evidence-attribution.js). That is real, separate follow-up work, not an
145
+ // oversight: path:line:commit locations are bound below and already make two
146
+ // evidence sets with different content at the same location produce
147
+ // different digests only insofar as `commit` differs.
148
+ function computeDigest(finding, provenance, repoIdentity) {
149
+ const material = JSON.stringify({
150
+ stableId: finding.stableId,
151
+ repoIdentity: repoIdentity || null,
152
+ analysisHead: provenance.analysisBasis?.head || null,
153
+ origin: provenance.findingOrigin?.commit || null,
154
+ branchEntry: provenance.branchIntroduction?.commit || null,
155
+ evidence: (provenance.evidenceAttribution || []).map((n) => `${n.role}:${n.path}:${n.line}:${n.commit}`),
156
+ detectorVersion: provenance.analysisBasis?.detector || null,
157
+ rulesetVersion: provenance.analysisBasis?.ruleset || null,
158
+ historyBoundary: provenance.historyCoverage?.boundaryCommit || null,
159
+ method: provenance.method,
160
+ reasons: provenance.confidence?.reasons || [],
161
+ limitations: provenance.limitations,
162
+ });
163
+ return crypto.createHash('sha256').update(material).digest('hex');
164
+ }
165
+
166
+ // The two `partial` producers answer two different questions, so they must not
167
+ // share one sentence. origin-resolver's partial means "we could not see far
168
+ // enough back to verify the parent boundary of a code change"; sca-origin's
169
+ // means "the manifest history never let us pin which commit made this
170
+ // dependency vulnerable" — most often because the advisory carries a `fixed`
171
+ // bound and no `introduced` bound, which makes a still-vulnerable patch bump
172
+ // literally indistinguishable from a bump INTO the vulnerable window. Reusing
173
+ // the SAST wording for the SCA case would describe a boundary nobody was
174
+ // looking for, and reusing `shallow_or_unverified_boundary` as the confidence
175
+ // reason would blame the clone depth for an ambiguity in the advisory data.
176
+ function describePartial(isScaLike, reason) {
177
+ if (!isScaLike) {
178
+ // M4 §4.2: a cross-repo lineage result is a materially different kind of
179
+ // "partial" from the shallow/unverified-boundary case above — the origin
180
+ // was found, just in a DIFFERENT repository the operator declared a link
181
+ // to, verified only by content presence rather than this repo's own
182
+ // predicate-replay machinery. Reusing the generic "could not confirm a
183
+ // verified parent boundary" wording here would bury that fact behind the
184
+ // machine-readable `crossRepoLineage` flag, leaving an operator reading
185
+ // only the limitations text with no way to know the answer crossed a
186
+ // repository boundary at all.
187
+ if (reason === 'cross-repo-lineage-best-effort') {
188
+ return {
189
+ limitation: 'origin resolved via a DIFFERENT, operator-linked repository (.agentic-security/repo-lineage.json) — a cross-repo content-presence match, not this repository\'s own verified history',
190
+ reasons: ['cross_repo_lineage_best_effort'],
191
+ };
192
+ }
193
+ // Second independent Finding Provenance PRD audit: a rename-shaped miss
194
+ // (origin-resolver.js's `renameShapedMiss` — a git-selected candidate's
195
+ // content lived at a path other than the finding's current one) is a
196
+ // materially different fact from the generic "we looked and it was never
197
+ // true" case the fallback wording below describes. This resolver still
198
+ // does NOT follow the rename to the true origin commit — that is the
199
+ // separately-scoped, honestly-disclosed engine gap
200
+ // (`bench/provenance-accuracy/fixtures/rename.mjs`'s header) — so this is
201
+ // still a `partial`/LOW-confidence result, just with an accurate reason
202
+ // for WHY it stayed partial instead of the misattributing generic string.
203
+ if (reason === 'rename-detected-not-followed') {
204
+ return {
205
+ limitation: 'a candidate commit was found for this line but its content could not be located at the finding\'s current file path — consistent with the file having been renamed after that commit; this resolver does not re-check candidates under a prior name, so the true origin could not be confirmed',
206
+ reasons: ['rename_detected_not_followed'],
207
+ };
208
+ }
209
+ return {
210
+ limitation: reason
211
+ ? `earliest observable — history could not confirm a verified parent boundary (${reason})`
212
+ : 'earliest observable — history could not confirm a verified parent boundary',
213
+ reasons: ['shallow_or_unverified_boundary'],
214
+ };
215
+ }
216
+ const base = 'manifest history could not confirm which commit introduced the vulnerable version';
217
+ return {
218
+ limitation: reason ? `${base} (${reason})` : base,
219
+ reasons: [reason === 'ambiguous-range-no-introduced-bound'
220
+ ? 'ambiguous_version_range'
221
+ : 'version_never_confirmed_in_manifest_history'],
222
+ };
223
+ }
224
+
225
+ // Second independent Finding Provenance PRD audit (Task 7, item 2):
226
+ // `historyCoverage.boundaryCommit` was hardcoded `null` at all four
227
+ // construction sites below — this is what makes it real. Resolved ONCE per
228
+ // scan (same precedent as `repoState`/`lineageKey`/`providerConfig` above:
229
+ // it's a couple of git calls, and every finding in one scan shares the same
230
+ // answer), not per finding.
231
+ //
232
+ // Two producers, and an operator-declared boundary always wins over an
233
+ // incidental one: `--provenance-since` is a deliberate statement ("don't
234
+ // walk earlier than this"), so it is checked first and, when it resolves,
235
+ // used regardless of clone depth. Only when no `since` was given (or it
236
+ // failed to resolve — a bad ref/tag) does a genuinely shallow clone's own
237
+ // graft boundary (`getRootCommits`) supply one. A full, unbounded clone with
238
+ // no `--provenance-since` has no boundary concept at all — `null` remains
239
+ // correct there, per this task's own brief: the fix is making it non-null
240
+ // where a real boundary exists, not eliminating `null` universally.
241
+ function resolveHistoryBoundaryCommit(scanRoot, since, shallow) {
242
+ if (since) {
243
+ const sha = resolveRevision(scanRoot, since);
244
+ if (sha) return sha;
245
+ }
246
+ if (shallow) {
247
+ const roots = getRootCommits(scanRoot);
248
+ if (roots.length) return roots[0];
249
+ }
250
+ return null;
251
+ }
252
+
253
+ // Second independent Finding Provenance PRD audit (Task 7, item 3):
254
+ // `firstObserved` was unconditionally `{ scanId: ctx.scanId, observedAt:
255
+ // ctx.observedAt }` — the CURRENT scan — on every cache miss, which only
256
+ // looked stable because the coordinator's own disk cache (cache.js) usually
257
+ // serves the same finding's PROVENANCE object back unchanged run-to-run. A
258
+ // cache miss (HEAD moves, the cache's 7-day TTL fires, an operator clears
259
+ // the cache) silently reset `firstObserved` to "now," even though this exact
260
+ // finding may have been open for months.
261
+ //
262
+ // The lifecycle ledger (lifecycle.js) already answers this correctly: its
263
+ // very first event for a stableId is ALWAYS type 'introduced' (`isOpenEvent`
264
+ // short-circuits every later scan while the finding stays open, so no
265
+ // second 'introduced' event is ever appended — see lifecycle.js's
266
+ // `applyScan`), so `events[0]` IS the true first-observed record. Read it
267
+ // back here instead of defaulting to the current scan; fall back to the
268
+ // current scan only when the ledger genuinely has no prior record for this
269
+ // stableId — a truly new finding, or a channel `updateLifecycle` is never
270
+ // fed (secrets/logicVulns/SCA all currently fall in this bucket, per
271
+ // engine.js's `updateLifecycle(scanRoot, finalFindings, ...)` call — SAST
272
+ // findings only. That is an honest degrade to "no ledger, use current scan,"
273
+ // not a bug this task introduces.
274
+ function resolveFirstObserved(scanRoot, stableId, scanId, observedAt) {
275
+ try {
276
+ const store = readLifecycle(scanRoot);
277
+ const events = stableId ? store[stableId] : null;
278
+ const first = Array.isArray(events) && events.length > 0 ? events[0] : null;
279
+ if (first && first.type === 'introduced') {
280
+ return { scanId: first.scanId ?? null, observedAt: first.observedAt ?? null };
281
+ }
282
+ } catch { /* degrade to the current-scan fallback below, same as every other posture annotator */ }
283
+ return { scanId, observedAt };
284
+ }
285
+
286
+ // FR-PROV-017: adapts resolveMissingControl's own status vocabulary
287
+ // (complete / unknown / budget_exhausted) onto the originResult shape the
288
+ // rest of resolveAndCache already branches on (status:'complete' / 'partial'
289
+ // / 'budget_exhausted' / anything else -> not_available), instead of adding a
290
+ // parallel branching structure below. `unknown` deliberately falls through to
291
+ // the shared `else` (not_available) branch — see missing-control-resolver.js's
292
+ // own header on why "control never present" and "genuine regression" must
293
+ // never be conflated, and Step 5's own mapping in this task's brief.
294
+ async function resolveMissingControlOrigin(scanRoot, finding, { since, deadlineAt } = {}) {
295
+ const result = await resolveMissingControl(scanRoot, {
296
+ file: finding.file,
297
+ predicate: async (root, sha, f) => {
298
+ const blob = getBlobAtCommit(root, sha, f);
299
+ return blob != null && hasRateLimit(blob);
300
+ },
301
+ since,
302
+ deadlineAt,
303
+ });
304
+
305
+ if (result.status === 'complete') {
306
+ const removedAt = result.removedAt;
307
+ return {
308
+ status: 'complete',
309
+ method: PROVENANCE_METHOD.MISSING_CONTROL_REGRESSION,
310
+ commitsConsidered: result.commitsConsidered,
311
+ // Standard findingOrigin shape, populated from removedAt's four fields
312
+ // (commit/authorName/authorDate/summary) per the brief — everything
313
+ // else the shape carries elsewhere (authorEmail, committerDate,
314
+ // revert/cherry-pick detection, AI-authorship) has no analogue in what
315
+ // resolveMissingControl itself observes, so it stays at its honest
316
+ // default rather than being fabricated.
317
+ findingOrigin: {
318
+ commit: removedAt.commit,
319
+ authorName: removedAt.authorName,
320
+ authorEmail: null,
321
+ authorDate: removedAt.authorDate,
322
+ committerDate: null,
323
+ summary: removedAt.summary,
324
+ presentInCommit: false,
325
+ absentInParents: [],
326
+ revertOf: null,
327
+ cherryPickOf: null,
328
+ aiAuthorship: { status: 'unknown', verifier: null },
329
+ },
330
+ };
331
+ }
332
+ if (result.status === 'budget_exhausted') {
333
+ return { status: 'budget_exhausted', commitsConsidered: result.commitsConsidered };
334
+ }
335
+ // 'unknown': the control was never observed present in reachable history.
336
+ // This is the ORDINARY case for a rate-limit finding on genuinely new
337
+ // code, not a regression — never attributed to the root commit as if a
338
+ // disappearance had been proven.
339
+ return {
340
+ status: 'unknown',
341
+ reason: 'no prior version of this control was observed in reachable history — this may be new code rather than a regression',
342
+ commitsConsidered: result.commitsConsidered,
343
+ };
344
+ }
345
+
346
+ async function resolveOne(finding, ctx) {
347
+ const { scanRoot, repoState, deadlineAt, historyBoundaryCommit } = ctx;
348
+ // Direct-dependency (SCA) entries are a different shape AND a different
349
+ // question from a SAST finding: they carry `filePath`/`name`/`ecosystem`
350
+ // rather than `file`/`line`/`ruleId`, and their origin is a version
351
+ // transition in a committed manifest blob rather than a line of code.
352
+ const isSca = ctx.findingType === 'sca';
353
+ const isTransitiveSca = ctx.findingType === 'sca-transitive';
354
+ // Both direct and transitive SCA entries share the same non-SAST shape —
355
+ // no file+line to blame, stableId backfilled the same way. Only WHICH
356
+ // resolver runs (Task 6 vs sca-origin.js) and what evidence/detector
357
+ // label gets attached differ between them.
358
+ const isScaLike = isSca || isTransitiveSca;
359
+ // FR-PROV-017: an explicit boolean marker set at finding-construction time
360
+ // (rate-limit.js), not a string-match on finding.id/finding.vuln here —
361
+ // string-matching would couple this module's branching to another
362
+ // module's id/vuln string format, which is exactly the fragility the
363
+ // isDirect/isTransitiveSca-style markers already avoid elsewhere in this
364
+ // pipeline.
365
+ const isMissingControlCandidate = !isScaLike && !!finding.missingControlCandidate;
366
+
367
+ // THE BUDGET CHECK COMES FIRST — before the blame call, not after it.
368
+ //
369
+ // `blameLine` is a synchronous execFileSync with a 2s timeout, and the
370
+ // scheduler runs these four at a time. Checking the deadline below the blame
371
+ // call meant every finding still queued when the budget expired paid for its
372
+ // blame before being told the budget was gone: N post-deadline findings could
373
+ // serialise into ~2N seconds PAST the configured timeout, which is precisely
374
+ // what this check exists to prevent. A deadline that is only consulted after
375
+ // the expensive call does not bound anything.
376
+ //
377
+ // A post-deadline finding therefore reports `budget_exhausted` rather than
378
+ // `uncommitted`. Both are terminal and honest; the budget is simply the
379
+ // question we can answer without spending anything.
380
+ if (deadlineAt && Date.now() > deadlineAt) {
381
+ return emptyProvenance(PROVENANCE_STATUS.BUDGET_EXHAUSTED, {
382
+ historyCoverage: { complete: false, shallow: repoState.shallow, boundaryCommit: historyBoundaryCommit || null, commitsConsidered: 0, crossRepoLineage: false },
383
+ limitations: ['analysis budget expired before this finding was reached'],
384
+ });
385
+ }
386
+
387
+ // Cheap, correct short-circuit (PRD Scenario G). One blame call, before any
388
+ // history walk: a working-tree-only line has no origin commit to find.
389
+ //
390
+ // Deliberately SAST-only. For a dependency the blame-able line is the
391
+ // manifest line that declares it, and "is that line uncommitted" is not the
392
+ // same question as "is this dependency uncommitted": reformatting or
393
+ // re-sorting package.json dirties every declaration line without changing a
394
+ // single declared version, which would turn every dependency in the project
395
+ // into a false `uncommitted`. resolveDirectSCAOrigin reads committed blobs
396
+ // and answers the real question directly, reporting `partial` when it cannot.
397
+ if (!isScaLike && finding.file && finding.line) {
398
+ const blame = blameLine(scanRoot, finding.file, finding.line);
399
+ if (blame && blame.uncommitted) {
400
+ return emptyProvenance(PROVENANCE_STATUS.UNCOMMITTED, {
401
+ limitations: ['finding exists only in working tree/index'],
402
+ });
403
+ }
404
+ }
405
+
406
+ // SCA entries reach this coordinator straight off the dependency parsers,
407
+ // which never run through `stable-id.js` (it keys on file+line+ruleId, none
408
+ // of which a dependency finding has). Backfill the SCA-shaped id here so the
409
+ // cache key — and every consumer downstream of it — has something stable to
410
+ // hold. An id the caller already set always wins.
411
+ if (isScaLike && !finding.stableId) {
412
+ finding.stableId = scaStableId(finding);
413
+ }
414
+
415
+ if (!finding.stableId) {
416
+ return emptyProvenance(PROVENANCE_STATUS.NOT_AVAILABLE, { limitations: ['finding has no stableId'] });
417
+ }
418
+
419
+ const cacheKey = makeCacheKey({
420
+ repoHead: repoState.head, stableId: finding.stableId,
421
+ detectorVersion: ctx.rulesetVersion, historyBoundary: ctx.since || '', mode: ctx.mode,
422
+ lineageKey: ctx.lineageKey,
423
+ });
424
+
425
+ // IN-SCAN MEMOIZATION (M2 §2.4 performance fix): the disk cache alone
426
+ // still pays a fresh cacheGet() read (and, on a miss, a fresh resolution
427
+ // walk) for every finding sharing this cacheKey WITHIN one scan. Two
428
+ // findings with the same stableId and history boundary are uncommon but
429
+ // real (duplicate array entries, the same finding reappearing across a
430
+ // dedupe boundary) — memoizing the PROMISE (not just the eventual value)
431
+ // means a second caller that arrives while the first is still resolving
432
+ // awaits the same in-flight work instead of starting its own.
433
+ if (ctx.memo && ctx.memo.has(cacheKey)) return ctx.memo.get(cacheKey);
434
+
435
+ const promise = resolveAndCache(finding, ctx, cacheKey, isSca, isTransitiveSca, isMissingControlCandidate);
436
+ if (ctx.memo) ctx.memo.set(cacheKey, promise);
437
+ return promise;
438
+ }
439
+
440
+ async function resolveAndCache(finding, ctx, cacheKey, isSca, isTransitiveSca, isMissingControlCandidate = false) {
441
+ const { scanRoot, repoState, deadlineAt, historyBoundaryCommit } = ctx;
442
+ const cached = cacheGet(scanRoot, cacheKey);
443
+ if (cached) return cached;
444
+ // resolveOne computes isScaLike in its own scope; resolveAndCache does not
445
+ // share it, so it is recomputed here from the two flags passed through.
446
+ const isScaLike = isSca || isTransitiveSca;
447
+
448
+ // PER-FINDING SUB-BUDGET (spec: `max(2s, global/estimatedFindingCount)`).
449
+ //
450
+ // The global deadline alone bounds the PASS but not the DISTRIBUTION inside
451
+ // it: one finding whose candidate-commit list is long — a hot file touched by
452
+ // a thousand commits — can walk it until the global deadline expires, and
453
+ // every finding queued behind it then reports `budget_exhausted` without a
454
+ // single git call spent on it. The sub-budget caps what any one finding may
455
+ // consume, so the walk is truncated for the expensive finding instead of for
456
+ // everyone after it. It never EXTENDS anything: the effective deadline is the
457
+ // earlier of the two, so the global deadline still hard-bounds the pass.
458
+ const perFindingDeadlineAt = ctx.perFindingBudgetMs
459
+ ? Math.min(deadlineAt || Infinity, Date.now() + ctx.perFindingBudgetMs)
460
+ : deadlineAt;
461
+
462
+ const originResult = isSca
463
+ ? await resolveDirectSCAOrigin(scanRoot, finding, { since: ctx.since, deadlineAt: perFindingDeadlineAt })
464
+ : isTransitiveSca
465
+ ? await resolveTransitiveSCAOrigin(scanRoot, finding, { since: ctx.since, deadlineAt: perFindingDeadlineAt })
466
+ // FR-PROV-017: "control absent" findings (rate-limit.js today) answer a
467
+ // fundamentally different question from every other SAST finding — "when
468
+ // did a previously-present safeguard disappear," not "when did this bad
469
+ // pattern first appear" — so they route to resolveMissingControl instead
470
+ // of resolveOrigin, same precedent as the isSca/isTransitiveSca branches
471
+ // above routing to their own question-specific resolvers.
472
+ : isMissingControlCandidate
473
+ ? await resolveMissingControlOrigin(scanRoot, finding, { since: ctx.since, deadlineAt: perFindingDeadlineAt })
474
+ // M3 §3.1: `ctx.mode` was already threaded into the CACHE KEY
475
+ // (makeCacheKey's `mode` field, present since M0+M1) but never actually
476
+ // reached resolveOrigin itself — `--provenance deep` was accepted and
477
+ // cached distinctly from `standard`, but both modes ran identical code.
478
+ : await resolveOrigin(scanRoot, finding, { since: ctx.since, deadlineAt: perFindingDeadlineAt, repoState, mode: ctx.mode });
479
+
480
+ const detector = isSca ? SCA_DETECTOR : isTransitiveSca ? TRANSITIVE_SCA_DETECTOR : (finding.parser || null);
481
+
482
+ let provenance;
483
+ let cacheable = true;
484
+ if (originResult.status === 'complete') {
485
+ const branchIntroduction = resolveBranchEntry(scanRoot, originResult.findingOrigin.commit, repoState.branch || 'HEAD');
486
+ // attributeEvidence walks `source`/`sink`/`pathSteps` — a taint-flow shape
487
+ // an SCA entry simply does not have, so calling it here would return an
488
+ // empty list at best and mis-attribute `finding.file`/`finding.line` at
489
+ // worst. A dependency's evidence is one node: the manifest that declares it,
490
+ // as of the commit that introduced the vulnerable version. `line` comes from
491
+ // the dependency parsers (Task 12 added it to package.json/requirements.txt
492
+ // components) and stays `null` rather than 0 when a parser did not supply
493
+ // one — 0 would read as a real line number.
494
+ const evidenceAttribution = isScaLike
495
+ ? [{
496
+ role: EVIDENCE_ROLE.MANIFEST,
497
+ path: finding.filePath || null,
498
+ line: Number.isInteger(finding.line) ? finding.line : null,
499
+ commit: originResult.findingOrigin.commit,
500
+ depChain: isTransitiveSca && Array.isArray(originResult.depChain) ? originResult.depChain : null,
501
+ }]
502
+ : attributeEvidence(scanRoot, finding, { removedGuard: isMissingControlCandidate, secret: ctx.findingType === 'secret' });
503
+ // Second independent Finding Provenance PRD audit: this call site used to
504
+ // pass `renameAmbiguous: false` as a hardcoded literal — never computed
505
+ // from any signal, so `confidence.js`'s `rename_ambiguous` reason was
506
+ // permanently dead code. Investigated rather than just deleted: this
507
+ // branch only runs when `originResult.status === 'complete'`, and under
508
+ // this resolver's current architecture a genuine rename-ambiguous case
509
+ // can never reach `complete` in the first place — `replayAt` looks up
510
+ // every candidate's blob at the finding's CURRENT path only, so a
511
+ // candidate whose content actually lived at a DIFFERENT (pre-rename)
512
+ // path always fails with `no-files-at-commit` and the walk falls through
513
+ // to `status:'partial'` (see origin-resolver.js's `renameShapedMiss` /
514
+ // reason `rename-detected-not-followed`), never `complete`. So there is
515
+ // no cheap real signal to wire here without doing the separately-scoped
516
+ // rename-follow work (`bench/provenance-accuracy/fixtures/rename.mjs`'s
517
+ // header) — the parameter is simply omitted rather than passing a
518
+ // literal that looked computed but never was; `assessConfidence`'s own
519
+ // default (`renameAmbiguous = false`) still applies, which is accurate
520
+ // here precisely because this path is unreachable with it true.
521
+ const confidence = assessConfidence({
522
+ parentBoundaryVerified: originResult.parentBoundaryVerified,
523
+ historyComplete: !repoState.shallow,
524
+ detectorCompatible: true,
525
+ shallow: repoState.shallow,
526
+ });
527
+ provenance = emptyProvenance(PROVENANCE_STATUS.COMPLETE, {
528
+ findingOrigin: originResult.findingOrigin,
529
+ branchIntroduction,
530
+ firstObserved: resolveFirstObserved(scanRoot, finding.stableId, ctx.scanId, ctx.observedAt),
531
+ evidenceAttribution,
532
+ method: originResult.method,
533
+ confidence,
534
+ historyCoverage: { complete: !repoState.shallow, shallow: repoState.shallow, boundaryCommit: historyBoundaryCommit || null, commitsConsidered: originResult.commitsConsidered, crossRepoLineage: false },
535
+ analysisBasis: { head: repoState.head, ruleset: ctx.rulesetVersion || null, detector, dirty: repoState.dirty },
536
+ // FR-PROV-017: a missing-control-regression `findingOrigin` names the
537
+ // commit that REMOVED the safeguard, not the commit that introduced the
538
+ // finding's line — every other `complete` result in this file means the
539
+ // opposite. Said explicitly so a reader of `findingOrigin` alone (which
540
+ // looks identical in shape to an ordinary origin) isn't misled by the
541
+ // field name.
542
+ limitations: isMissingControlCandidate
543
+ ? ['this is a control-removal event (the safeguard was present in an earlier commit and absent as of this one) — not an ordinary code-introduction event; findingOrigin names the commit that REMOVED the control']
544
+ : [],
545
+ });
546
+
547
+ // FR-PROV-022: provider (GitHub/GitLab) enrichment — strictly additive,
548
+ // never affects `status`/`method`/`confidence` above. Guarded on all
549
+ // three: a provider must actually be configured, the per-scan cap must
550
+ // not be spent, and the global deadline must not have already passed
551
+ // (the per-call 8s AbortSignal.timeout in providers/*.js is real but
552
+ // isn't itself deadline-aware, hence the cap — see its declaration).
553
+ if (ctx.providerConfig && ctx.providerEnrichments && ctx.providerEnrichments.remaining > 0 && !(deadlineAt && Date.now() > deadlineAt)) {
554
+ ctx.providerEnrichments.remaining--;
555
+ const fetchPRFn = ctx.providerName === 'github' ? fetchPRMetadataGithub : fetchPRMetadataGitlab;
556
+ const fetchCodeownersFn = ctx.providerName === 'github' ? fetchCodeownersGithub : fetchCodeownersGitlab;
557
+ const pr = await fetchPRFn(scanRoot, originResult.findingOrigin.commit, ctx.remoteUrl, ctx.providerConfig);
558
+ if (pr) {
559
+ const codeowners = await fetchCodeownersFn(scanRoot, ctx.remoteUrl, ctx.providerConfig);
560
+ provenance.providerEnrichment = {
561
+ provider: ctx.providerName,
562
+ prNumber: pr.prNumber,
563
+ reviewers: pr.reviewers,
564
+ approvals: pr.approvals,
565
+ mergedAt: pr.mergedAt,
566
+ codeowners: codeowners || [],
567
+ };
568
+ }
569
+ } else if (ctx.providerConfig && ctx.providerEnrichments && ctx.providerEnrichments.remaining === 0) {
570
+ provenance.limitations.push(`provider enrichment cap (${MAX_PROVIDER_ENRICHMENTS_PER_SCAN}/scan, shared across the whole scan's annotateGitProvenance calls) reached; not attempted for this finding`);
571
+ }
572
+ } else if (originResult.status === 'partial') {
573
+ // isScaLike, not isSca: transitive-sca.js reuses sca-origin.js's exact
574
+ // reason strings ('ambiguous-range-no-introduced-bound' /
575
+ // 'version-never-confirmed-in-candidates'), so a transitive partial must
576
+ // get the same "manifest history" wording a direct one does — the bare
577
+ // isSca check here would silently route it to the SAST branch instead
578
+ // ("verified parent boundary"), which describes a question the lockfile
579
+ // walk never asked.
580
+ const partial = describePartial(isScaLike, originResult.reason);
581
+ provenance = emptyProvenance(PROVENANCE_STATUS.PARTIAL, {
582
+ findingOrigin: originResult.findingOrigin || null,
583
+ firstObserved: resolveFirstObserved(scanRoot, finding.stableId, ctx.scanId, ctx.observedAt),
584
+ // `method` must be carried through. origin-resolver's shallow-boundary
585
+ // case returns status:'partial' WITH a populated findingOrigin AND
586
+ // method:'semantic-history-replay'. Letting emptyProvenance's 'none'
587
+ // default stand emitted a self-contradictory record — "here is the origin
588
+ // commit, found by no method" — which also fed computeDigest.
589
+ method: originResult.method || PROVENANCE_METHOD.NONE,
590
+ historyCoverage: {
591
+ complete: false, shallow: repoState.shallow, boundaryCommit: historyBoundaryCommit || null,
592
+ commitsConsidered: originResult.commitsConsidered || 0,
593
+ // M4 §4.2: origin-resolver's cross-repo lineage continuation is the
594
+ // only producer of this flag — every other 'partial' path (shallow
595
+ // boundary, predicate-never-confirmed, SCA's ambiguous-range /
596
+ // never-confirmed) leaves it at the schema default (false).
597
+ crossRepoLineage: !!originResult.crossRepoLineage,
598
+ },
599
+ analysisBasis: { head: repoState.head, ruleset: ctx.rulesetVersion || null, detector, dirty: repoState.dirty },
600
+ // The partial reasons mean materially different things — for SAST,
601
+ // 'shallow-boundary-reached' ("we could not see far enough") vs
602
+ // 'predicate-never-confirmed-in-candidates' ("we looked at the right
603
+ // path and it was never true there") vs 'rename-detected-not-followed'
604
+ // ("we found a candidate but its content lived at a different path");
605
+ // for SCA, 'ambiguous-range-no-introduced-bound' vs
606
+ // 'version-never-confirmed-in-candidates'. Collapsing any of them into one
607
+ // hardcoded string made them indistinguishable downstream, while the
608
+ // not_available branch below has always propagated its reason. See
609
+ // describePartial for why the SAST and SCA wordings are not shared.
610
+ limitations: [partial.limitation],
611
+ confidence: { level: CONFIDENCE_LEVEL.LOW, score: 0.2, reasons: partial.reasons },
612
+ });
613
+ } else if (originResult.status === 'budget_exhausted') {
614
+ // Which budget ran out is a materially different fact for the operator:
615
+ // the global one means "raise --provenance-timeout"; the per-finding one
616
+ // means "this ONE finding's history is unusually deep" and raising the
617
+ // global timeout will not help it unless the finding count drops too.
618
+ const globalExpired = !!deadlineAt && Date.now() > deadlineAt;
619
+ provenance = emptyProvenance(PROVENANCE_STATUS.BUDGET_EXHAUSTED, {
620
+ historyCoverage: { complete: false, shallow: repoState.shallow, boundaryCommit: historyBoundaryCommit || null, commitsConsidered: originResult.commitsConsidered || 0, crossRepoLineage: false },
621
+ limitations: [globalExpired
622
+ ? 'analysis budget expired before origin could be resolved'
623
+ : "this finding's per-finding share of the analysis budget expired before origin could be resolved"],
624
+ });
625
+ // NOT CACHED — see the cacheSet guard below.
626
+ cacheable = false;
627
+ } else {
628
+ provenance = emptyProvenance(PROVENANCE_STATUS.NOT_AVAILABLE, {
629
+ limitations: [originResult.reason || 'no candidate history available'],
630
+ });
631
+ }
632
+
633
+ provenance.evidenceDigest = computeDigest(finding, provenance, scanRoot);
634
+ // A budget_exhausted result is the ONE outcome that is not a property of the
635
+ // repository. complete/partial/not_available are all deterministic given
636
+ // (HEAD, stableId, ruleset, boundary, mode) — the cache key — so caching them
637
+ // is sound. "We ran out of time" is a property of the RUN: it depends on the
638
+ // machine, the load, and the operator's --timeout. The key has no time
639
+ // component and the cache has no TTL (both deliberate), so caching it would
640
+ // pin the timeout in place until HEAD moved — including across a re-run with
641
+ // a larger --timeout, silently defeating the operator's only remedy.
642
+ if (cacheable) cacheSet(scanRoot, cacheKey, provenance);
643
+ return provenance;
644
+ }
645
+
646
+ export async function annotateGitProvenance(findings, ctx) {
647
+ if (!Array.isArray(findings) || findings.length === 0) return;
648
+ const options = ctx || {};
649
+ const scanRoot = options.scanRoot;
650
+
651
+ const stampAll = (status, limitation) => {
652
+ for (const f of findings) {
653
+ if (!f || typeof f !== 'object') continue;
654
+ f.findingProvenance = emptyProvenance(status, { limitations: [limitation] });
655
+ }
656
+ };
657
+
658
+ if (options.disabled) {
659
+ stampAll(PROVENANCE_STATUS.NOT_AVAILABLE, 'provenance disabled via --no-provenance');
660
+ return;
661
+ }
662
+ if (!scanRoot || !isGitRepo(scanRoot)) {
663
+ stampAll(PROVENANCE_STATUS.NOT_AVAILABLE, 'not a Git repository');
664
+ return;
665
+ }
666
+
667
+ const repoState = getRepoState(scanRoot);
668
+ if (!repoState) {
669
+ // isGitRepo said yes and getRepoState said no — the repo moved out from
670
+ // under us mid-scan. Every downstream module reads repoState, so there is
671
+ // nothing to resolve against; say so rather than throwing on `.head`.
672
+ stampAll(PROVENANCE_STATUS.NOT_AVAILABLE, 'repository state unavailable');
673
+ return;
674
+ }
675
+
676
+ // A caller-supplied `deadlineAt` WINS over the locally-computed one. That is
677
+ // what makes a scan-level global budget possible at all: engine.js runs this
678
+ // annotator five times (SAST findings, direct SCA deps, transitive SCA deps
679
+ // per Task 7, then secrets and blameable logicVulns per Task 11) and each
680
+ // call computing its own fresh window made the effective budget a multiple
681
+ // of the configured timeout. Falling back to a computed one keeps every
682
+ // standalone caller (and every test) working unchanged.
683
+ const deadlineAt = options.deadlineAt || (Date.now() + (options.timeoutMs || DEFAULT_TIMEOUT_MS));
684
+ // Sub-budget, from the REMAINING global budget rather than the configured
685
+ // timeout: on a LATER one of engine.js's five calls, most of the window may
686
+ // already be spent, and dividing the original figure would hand each entry
687
+ // a share of time that no longer exists.
688
+ // A caller-supplied value wins, on the same principle as `deadlineAt` above:
689
+ // the caller is the only party that can see across multiple annotator passes.
690
+ const remainingMs = Math.max(0, deadlineAt - Date.now());
691
+ const perFindingBudgetMs = options.perFindingBudgetMs || Math.max(
692
+ MIN_PER_FINDING_BUDGET_MS,
693
+ Math.floor(remainingMs / Math.max(1, findings.length)),
694
+ );
695
+ // M2 §2.4: one memo per annotateGitProvenance call, not a module-level
696
+ // cache — scoped to THIS scan's findings so a memo entry never survives
697
+ // past the run that created it (the disk cache, keyed on repoHead already,
698
+ // is what persists ACROSS scans).
699
+ const memo = new Map();
700
+ // Resolved ONCE per scan, not per finding: `loadRepoLineage` is a fast
701
+ // local file read plus two git calls against the (small, local) lineage
702
+ // config — cheap to redo per finding (`origin-resolver.js`'s
703
+ // `tryCrossRepoLineage` already does, scoped to its own concern), but
704
+ // there is no reason to pay it again here when every finding in this scan
705
+ // shares the same answer. Feeds the cache key (see cache.js's
706
+ // `makeCacheKey` doc) so adding, removing, or repointing the declared link
707
+ // at the same HEAD invalidates stale cached results instead of serving
708
+ // them past the change.
709
+ const lineage = loadRepoLineage(scanRoot);
710
+ const lineageKey = lineage ? `${lineage.path}@${lineage.atCommit}` : 'none';
711
+
712
+ // Second independent Finding Provenance PRD audit (Task 7, item 2):
713
+ // resolved ONCE per scan, same precedent as `lineageKey` above — every
714
+ // finding in this scan shares the same `--provenance-since` value and the
715
+ // same repo shallow-ness, so there is exactly one boundary commit (or
716
+ // none) for the whole call. See `resolveHistoryBoundaryCommit`'s own
717
+ // comment for the since-wins-over-shallow precedence.
718
+ const historyBoundaryCommit = resolveHistoryBoundaryCommit(scanRoot, options.since, repoState.shallow);
719
+
720
+ // FR-PROV-022: resolved ONCE per scan, same precedent as `lineageKey`
721
+ // above. `resolveProviderConfig` is a config-file/env-var read, not a
722
+ // network call, so this stays cheap even when nothing is configured — but
723
+ // `getRemoteUrl` (a git subprocess) is only ever invoked when a provider
724
+ // actually is, which is what makes "zero network calls when unconfigured"
725
+ // structural rather than merely tested. At most one provider is active at
726
+ // a time; if both a GitHub and a GitLab config are somehow present (e.g. a
727
+ // fork mirrored across both), GitHub wins — an arbitrary but documented
728
+ // tie-break, since a finding's origin commit can only ever live on one of
729
+ // the two remotes' PR/MR history in practice.
730
+ const githubConfig = resolveProviderConfig(scanRoot, 'github');
731
+ const gitlabConfig = resolveProviderConfig(scanRoot, 'gitlab');
732
+ const providerConfig = githubConfig || gitlabConfig;
733
+ const providerName = githubConfig ? 'github' : gitlabConfig ? 'gitlab' : null;
734
+ const remoteUrl = providerConfig ? getRemoteUrl(scanRoot) : null;
735
+
736
+ // Fix-round item 2: a caller-supplied counter WINS, same precedent as
737
+ // `deadlineAt` above -- this is what makes the cap a real per-SCAN budget
738
+ // across engine.js's five `annotateGitProvenance` calls rather than five
739
+ // independent fresh-20 budgets. It MUST be a mutable object, not a bare
740
+ // number: engine.js reuses one `provenanceCtx` but spreads it into a NEW
741
+ // object literal for four of its five calls
742
+ // (`{ ...provenanceCtx, findingType: 'sca' }`) -- a bare number would be
743
+ // copied by value into each spread, so a decrement in one call would be
744
+ // invisible to the next. A nested object's IDENTITY survives a shallow
745
+ // spread, so every call decrements the SAME counter. A standalone caller
746
+ // that never supplies one (every direct test, every non-engine.js caller)
747
+ // gets a fresh `{ remaining: 20 }`, unchanged from before this fix.
748
+ const providerEnrichments = (options.providerEnrichments && typeof options.providerEnrichments.remaining === 'number')
749
+ ? options.providerEnrichments
750
+ : { remaining: MAX_PROVIDER_ENRICHMENTS_PER_SCAN };
751
+
752
+ const fullCtx = {
753
+ ...options, repoState, deadlineAt, perFindingBudgetMs, scanRoot, memo, lineageKey,
754
+ providerConfig, providerName, remoteUrl,
755
+ providerEnrichments, historyBoundaryCommit,
756
+ };
757
+
758
+ let active = 0;
759
+ let idx = 0;
760
+ await new Promise((resolve) => {
761
+ const next = () => {
762
+ if (idx >= findings.length && active === 0) { resolve(); return; }
763
+ while (active < MAX_CONCURRENCY && idx < findings.length) {
764
+ const f = findings[idx++];
765
+ if (!f || typeof f !== 'object') continue;
766
+ active++;
767
+ resolveOne(f, fullCtx)
768
+ .then((prov) => {
769
+ // A downstream module returning nothing must not leave a hole —
770
+ // the terminal-status guarantee has no exceptions.
771
+ f.findingProvenance = prov || emptyProvenance(PROVENANCE_STATUS.ERROR, {
772
+ limitations: ['provenance resolution returned no result'],
773
+ });
774
+ })
775
+ .catch((e) => {
776
+ f.findingProvenance = emptyProvenance(PROVENANCE_STATUS.ERROR, { limitations: [String((e && e.message) || e)] });
777
+ })
778
+ .finally(() => { active--; next(); });
779
+ }
780
+ // Every finding was dispatched (or skipped as a non-object) and nothing
781
+ // is in flight — the loop above cannot re-enter, so settle here.
782
+ if (idx >= findings.length && active === 0) resolve();
783
+ };
784
+ next();
785
+ });
786
+ }