@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.
- package/CHANGELOG.md +390 -0
- package/bin/agentic-security.js +3813 -83
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +13 -4
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/2271.index.js +165 -0
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +14 -6
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +14 -6
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +67 -3
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +26 -8
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +580 -187
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/src/util/git-hardening.js +128 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- 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
|
+
}
|