Skip to main content

libverify_core/
evidence.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5/// Represents the completeness of a collected evidence value.
6///
7/// Controls use this to distinguish between a verified absence and an
8/// evidence-collection failure, which maps to different control statuses.
9#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
10#[serde(tag = "state", rename_all = "snake_case")]
11pub enum EvidenceState<T> {
12    /// All expected data was collected successfully.
13    Complete { value: T },
14    /// Data was collected but some aspects are missing or degraded.
15    Partial { value: T, gaps: Vec<EvidenceGap> },
16    /// No usable data could be collected; only gap descriptions remain.
17    Missing { gaps: Vec<EvidenceGap> },
18    /// The evidence category does not apply to this context.
19    #[default]
20    NotApplicable,
21}
22
23impl<T> EvidenceState<T> {
24    pub fn complete(value: T) -> Self {
25        Self::Complete { value }
26    }
27
28    pub fn partial(value: T, gaps: Vec<EvidenceGap>) -> Self {
29        Self::Partial { value, gaps }
30    }
31
32    pub fn missing(gaps: Vec<EvidenceGap>) -> Self {
33        Self::Missing { gaps }
34    }
35
36    pub fn not_applicable() -> Self {
37        Self::NotApplicable
38    }
39
40    pub fn value(&self) -> Option<&T> {
41        match self {
42            Self::Complete { value } | Self::Partial { value, .. } => Some(value),
43            Self::Missing { .. } | Self::NotApplicable => None,
44        }
45    }
46
47    pub fn gaps(&self) -> &[EvidenceGap] {
48        match self {
49            Self::Partial { gaps, .. } | Self::Missing { gaps } => gaps,
50            Self::Complete { .. } | Self::NotApplicable => &[],
51        }
52    }
53
54    pub fn has_gaps(&self) -> bool {
55        !self.gaps().is_empty()
56    }
57}
58
59/// Describes why a piece of evidence is incomplete or absent.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(tag = "kind", rename_all = "snake_case")]
62pub enum EvidenceGap {
63    CollectionFailed {
64        source: String,
65        subject: String,
66        detail: String,
67    },
68    Truncated {
69        source: String,
70        subject: String,
71    },
72    MissingField {
73        source: String,
74        subject: String,
75        field: String,
76    },
77    DiffUnavailable {
78        subject: String,
79    },
80    Unsupported {
81        source: String,
82        capability: String,
83    },
84}
85
86impl fmt::Display for EvidenceGap {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            Self::CollectionFailed {
90                source,
91                subject,
92                detail,
93            } => write!(f, "collection failed: {source}/{subject}: {detail}"),
94            Self::Truncated { source, subject } => write!(f, "truncated: {source}/{subject}"),
95            Self::MissingField {
96                source,
97                subject,
98                field,
99            } => write!(f, "missing field: {source}/{subject}.{field}"),
100            Self::DiffUnavailable { subject } => write!(f, "diff unavailable: {subject}"),
101            Self::Unsupported { source, capability } => {
102                write!(f, "unsupported: {source}/{capability}")
103            }
104        }
105    }
106}
107
108/// Platform-independent identifier for a change request (e.g. a pull request).
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct ChangeRequestId {
111    pub system: String,
112    pub value: String,
113}
114
115impl ChangeRequestId {
116    pub fn new(system: impl Into<String>, value: impl Into<String>) -> Self {
117        Self {
118            system: system.into(),
119            value: value.into(),
120        }
121    }
122}
123
124impl fmt::Display for ChangeRequestId {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        write!(f, "{}:{}", self.system, self.value)
127    }
128}
129
130/// Reference to an external work item (issue, Jira ticket, etc.).
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct WorkItemRef {
133    pub system: String,
134    pub value: String,
135}
136
137/// A file or artifact that was modified in a change request.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct ChangedAsset {
140    pub path: String,
141    pub diff_available: bool,
142    #[serde(default)]
143    pub additions: u32,
144    #[serde(default)]
145    pub deletions: u32,
146    #[serde(default)]
147    pub status: String,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub diff: Option<String>,
150}
151
152/// Normalized outcome of a review action, independent of platform terminology.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum ApprovalDisposition {
156    Approved,
157    Rejected,
158    Commented,
159    Dismissed,
160    Unknown,
161}
162
163/// A single review decision recorded against a change request.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct ApprovalDecision {
166    pub actor: String,
167    pub disposition: ApprovalDisposition,
168    pub submitted_at: Option<String>,
169}
170
171/// Cryptographic verification state for a source revision.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct AuthenticityEvidence {
174    pub verified: bool,
175    pub mechanism: Option<String>,
176}
177
178impl AuthenticityEvidence {
179    pub fn new(verified: bool, mechanism: Option<String>) -> Self {
180        Self {
181            verified,
182            mechanism,
183        }
184    }
185}
186
187/// A single commit or source revision associated with a change request.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct SourceRevision {
190    pub id: String,
191    pub authored_by: Option<String>,
192    pub committed_at: Option<String>,
193    pub merge: bool,
194    pub authenticity: EvidenceState<AuthenticityEvidence>,
195}
196
197/// Normalized representation of a governed change request (e.g. a pull request).
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct GovernedChange {
200    pub id: ChangeRequestId,
201    pub title: String,
202    pub summary: Option<String>,
203    pub submitted_by: Option<String>,
204    pub changed_assets: EvidenceState<Vec<ChangedAsset>>,
205    pub approval_decisions: EvidenceState<Vec<ApprovalDecision>>,
206    pub source_revisions: EvidenceState<Vec<SourceRevision>>,
207    pub work_item_refs: EvidenceState<Vec<WorkItemRef>>,
208}
209
210impl GovernedChange {
211    /// Returns true if this change was submitted by a known merge/rollup bot.
212    /// Bot-submitted PRs aggregate already-reviewed changes and should not
213    /// be individually evaluated for review controls.
214    pub fn is_bot_submitted(&self) -> bool {
215        let Some(author) = self.submitted_by.as_deref() else {
216            return false;
217        };
218        let lower = author.to_ascii_lowercase();
219        const BOT_SUBMITTERS: &[&str] = &[
220            "bors",
221            "bors[bot]",
222            "mergify[bot]",
223            "mergify",
224            "dependabot[bot]",
225            "dependabot",
226            "renovate[bot]",
227            "renovate",
228            "k8s-ci-robot",
229            "github-actions[bot]",
230            "copybara-service[bot]",
231        ];
232        BOT_SUBMITTERS.contains(&lower.as_str()) || lower.ends_with("[bot]")
233    }
234}
235
236/// A release or deployment batch that promotes one or more source revisions.
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct PromotionBatch {
239    pub id: String,
240    pub source_revisions: EvidenceState<Vec<SourceRevision>>,
241    pub linked_change_requests: EvidenceState<Vec<ChangeRequestId>>,
242}
243
244/// Structured outcome of attestation verification.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(tag = "outcome", rename_all = "snake_case")]
247pub enum VerificationOutcome {
248    /// Cryptographic signature verified (Sigstore, PGP, cosign, etc.).
249    Verified,
250    /// Checksum/integrity hash matched but no cryptographic signature was verified.
251    /// This confirms download integrity but NOT authenticity.
252    ChecksumMatch,
253    SignatureInvalid {
254        detail: String,
255    },
256    SignerMismatch {
257        detail: String,
258    },
259    TransparencyLogMissing {
260        detail: String,
261    },
262    AttestationAbsent {
263        detail: String,
264    },
265    DigestMismatch {
266        detail: String,
267    },
268    Failed {
269        detail: String,
270    },
271}
272
273impl VerificationOutcome {
274    /// Returns true for both `Verified` (signature) and `ChecksumMatch` (integrity).
275    pub fn is_verified(&self) -> bool {
276        matches!(self, Self::Verified | Self::ChecksumMatch)
277    }
278
279    /// Returns true only for cryptographic signature verification.
280    pub fn is_cryptographically_signed(&self) -> bool {
281        matches!(self, Self::Verified)
282    }
283
284    pub fn failure_detail(&self) -> Option<&str> {
285        match self {
286            Self::Verified | Self::ChecksumMatch => None,
287            Self::SignatureInvalid { detail }
288            | Self::SignerMismatch { detail }
289            | Self::TransparencyLogMissing { detail }
290            | Self::AttestationAbsent { detail }
291            | Self::DigestMismatch { detail }
292            | Self::Failed { detail } => Some(detail),
293        }
294    }
295
296    pub fn failure_kind(&self) -> Option<&'static str> {
297        match self {
298            Self::Verified | Self::ChecksumMatch => None,
299            Self::SignatureInvalid { .. } => Some("signature_invalid"),
300            Self::SignerMismatch { .. } => Some("signer_mismatch"),
301            Self::TransparencyLogMissing { .. } => Some("transparency_log_missing"),
302            Self::AttestationAbsent { .. } => Some("attestation_absent"),
303            Self::DigestMismatch { .. } => Some("digest_mismatch"),
304            Self::Failed { .. } => Some("failed"),
305        }
306    }
307}
308
309/// Result of verifying an artifact's build provenance attestation.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311pub struct ArtifactAttestation {
312    pub subject: String,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub subject_digest: Option<String>,
315    pub predicate_type: String,
316    pub signer_workflow: Option<String>,
317    pub source_repo: Option<String>,
318    pub verification: VerificationOutcome,
319}
320
321/// Conclusion of a CI check run, normalized across platforms.
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "snake_case")]
324pub enum CheckConclusion {
325    Success,
326    Failure,
327    Neutral,
328    Cancelled,
329    Skipped,
330    TimedOut,
331    ActionRequired,
332    Pending,
333    Unknown,
334}
335
336/// Evidence for a single CI check run executed against a commit.
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338pub struct CheckRunEvidence {
339    pub name: String,
340    pub conclusion: CheckConclusion,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub app_slug: Option<String>,
343}
344
345/// Provenance and signature verification evidence for a single dependency.
346///
347/// Supports multiple verification mechanisms including:
348/// - **npm provenance**: Sigstore-signed SLSA provenance via `npm audit signatures`
349/// - **Sigstore/cosign**: General Sigstore verification with Rekor transparency log
350/// - **PGP signatures**: Traditional GPG/PGP package signatures
351/// - **Checksum pinning**: Lock-file checksum verification (e.g. Cargo.lock, package-lock.json)
352///
353/// The `verification` field uses `VerificationOutcome` for structured failure reasons,
354/// matching the pattern used by `ArtifactAttestation`.
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct DependencySignatureEvidence {
357    /// Package name (e.g. "serde", "lodash").
358    pub name: String,
359    /// Package version (e.g. "1.0.204", "4.17.21").
360    pub version: String,
361    /// Registry origin (e.g. "crates.io", "registry.npmjs.org").
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub registry: Option<String>,
364    /// Structured verification outcome, reusing `VerificationOutcome` for consistency
365    /// with `ArtifactAttestation`. `Verified` = signature valid, otherwise structured failure.
366    pub verification: VerificationOutcome,
367    /// Signing mechanism (e.g. "sigstore", "pgp", "checksum").
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub signature_mechanism: Option<String>,
370    /// Signer identity: OIDC issuer URI, public key fingerprint, or email.
371    /// For npm provenance this is the GitHub Actions OIDC token subject.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub signer_identity: Option<String>,
374    /// Source repository that built the package (from SLSA provenance predicate).
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub source_repo: Option<String>,
377    /// Source commit SHA at which the package was built.
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub source_commit: Option<String>,
380    /// Expected artifact digest from lock file (e.g. "sha512:..." from Cargo.lock/package-lock.json).
381    /// Populated by lock-file parsers. Compare with `actual_digest` to detect artifact replacement.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub pinned_digest: Option<String>,
384    /// Actual artifact digest computed from downloaded artifact at install/build time.
385    /// Populated by build-time adapters (not lock-file parsers). When both `pinned_digest`
386    /// and `actual_digest` are present, `has_digest_mismatch()` in the control detects
387    /// registry-side artifact replacement attacks.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub actual_digest: Option<String>,
390    /// Transparency log entry URL (e.g. Rekor log index for Sigstore).
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub transparency_log_uri: Option<String>,
393    /// Whether this is a direct dependency (true) or transitive (false).
394    /// Transitive dependencies are more susceptible to typosquatting attacks.
395    #[serde(default = "default_true")]
396    pub is_direct: bool,
397}
398
399fn default_true() -> bool {
400    true
401}
402
403/// Provenance capability levels supported by a package registry.
404///
405/// Registries evolve at different speeds. This enum captures the highest
406/// SLSA Dependencies level a registry's infrastructure can currently support,
407/// allowing controls to skip dependencies from registries that lack the
408/// required infrastructure rather than producing false positives.
409///
410/// Current ecosystem status (as of March 2026):
411/// - **npm** (`registry.npmjs.org`): L3 — Sigstore keyless signing + Rekor.
412///   GA since Oct 2023, 134+ high-impact projects adopted.
413/// - **PyPI** (`pypi.org`): L3 — Trusted Publishers + Sigstore attestations
414///   (Fulcio + Rekor, same stack as npm). 17% of uploads include attestations.
415///   Packages with attestations provide full L3: signer identity
416///   (publisher.repository + Fulcio cert SAN) and Rekor transparency log.
417/// - **Maven Central**: L3 capability — Sigstore `.sigstore.json` validation
418///   added Jan 2025 (opt-in). PGP `.asc` still mandatory. Very low Sigstore
419///   adoption. No dedicated query API (URL convention only).
420/// - **crates.io**: L1 only — SHA-256 checksums in Cargo.lock.
421///   Trusted Publishing (RFC #3691) covers auth only; Sigstore RFC #3403
422///   proposed but not merged.
423/// - **Go** (`proxy.golang.org`): L1 only — `sum.golang.org` provides
424///   tamper-evident checksum log but no provenance/signing.
425/// - **NuGet** (`nuget.org`): L1 — X.509 signing exists but no
426///   Sigstore/attestation API at registry level.
427#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
428pub enum RegistryProvenanceCapability {
429    /// L1: integrity only (checksum). No cryptographic signing infrastructure.
430    ChecksumOnly,
431    /// L2: cryptographic signature + source provenance available.
432    CryptographicProvenance,
433    /// L3: signature + signer identity + transparency log available.
434    FullTrustChain,
435}
436
437impl DependencySignatureEvidence {
438    /// Returns the provenance capability level of this dependency's registry.
439    ///
440    /// This determines whether higher-level controls (L2 provenance, L3 signer
441    /// verification) are meaningful for this dependency. Dependencies from
442    /// registries that lack the required infrastructure are excluded from
443    /// evaluation rather than producing false positives.
444    pub fn registry_provenance_capability(&self) -> RegistryProvenanceCapability {
445        match self.registry.as_deref() {
446            Some(r) if r.contains("npmjs.org") => RegistryProvenanceCapability::FullTrustChain,
447            Some("pypi.org") => RegistryProvenanceCapability::FullTrustChain,
448            _ => RegistryProvenanceCapability::ChecksumOnly,
449        }
450    }
451}
452
453/// A single CODEOWNERS entry mapping a file pattern to its designated owners.
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455pub struct CodeownersEntry {
456    /// File pattern (e.g. "*.rs", "/src/auth/", "*").
457    pub pattern: String,
458    /// Designated owners (e.g. "@org/security-team", "alice@example.com").
459    pub owners: Vec<String>,
460}
461
462/// Repository-level security posture evidence for ASPM controls.
463///
464/// Captures configuration-level signals that are independent of any single
465/// change request: code ownership, scanning settings, and security policy.
466/// Designed to be populated from GitHub REST API, GitLab API, or other platform adapters.
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468pub struct RepositoryPosture {
469    /// Parsed CODEOWNERS entries. Empty vec means no CODEOWNERS file found.
470    pub codeowners_entries: Vec<CodeownersEntry>,
471
472    // --- Secret scanning (CC6.1 / CC6.6) ---
473    /// Whether secret scanning is enabled (detection).
474    pub secret_scanning_enabled: bool,
475    /// Whether push protection is enabled (prevention). Requires GHAS on private repos.
476    #[serde(default)]
477    pub secret_push_protection_enabled: bool,
478
479    // --- Vulnerability scanning (CC7.1) ---
480    /// Whether dependency vulnerability scanning (Dependabot, Snyk, etc.) is enabled.
481    pub vulnerability_scanning_enabled: bool,
482    /// Whether code scanning / SAST (CodeQL, Semgrep, etc.) is enabled.
483    #[serde(default)]
484    pub code_scanning_enabled: bool,
485
486    // --- Security policy (CC7.3 / CC7.4) ---
487    /// Whether a SECURITY.md or equivalent security policy file exists.
488    pub security_policy_present: bool,
489    /// Whether the security policy describes a responsible disclosure process.
490    pub security_policy_has_disclosure: bool,
491
492    // --- Branch protection (CC6.1 / CC8.1) ---
493    /// Whether the default branch has protection rules configured.
494    #[serde(default)]
495    pub default_branch_protected: bool,
496}
497
498/// Build platform evidence for Build Track L2+.
499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
500pub struct BuildPlatformEvidence {
501    pub platform: String,
502    pub hosted: bool,
503    pub ephemeral: bool,
504    pub isolated: bool,
505    pub runner_labels: Vec<String>,
506    pub signing_key_isolated: bool,
507}
508
509/// Top-level container for all evidence collected from adapters.
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
511pub struct EvidenceBundle {
512    pub change_requests: Vec<GovernedChange>,
513    pub promotion_batches: Vec<PromotionBatch>,
514    pub artifact_attestations: EvidenceState<Vec<ArtifactAttestation>>,
515    pub check_runs: EvidenceState<Vec<CheckRunEvidence>>,
516    pub build_platform: EvidenceState<Vec<BuildPlatformEvidence>>,
517    pub dependency_signatures: EvidenceState<Vec<DependencySignatureEvidence>>,
518    #[serde(default)]
519    pub repository_posture: EvidenceState<RepositoryPosture>,
520}