Skip to main content

sbom_tools/model/
attestation.rs

1//! Normalized CycloneDX 1.6 Attestations (CDXA) evidence model.
2//!
3//! CycloneDX 1.6 introduced a root-level `declarations` object (CDXA) carrying
4//! machine-readable conformance evidence — assessors, attestations, claims,
5//! evidence, targets, and an affirmation — plus a root-level
6//! `definitions.standards` list of machine-readable standard encodings whose
7//! requirements the attestations map claims onto. This module is the
8//! format-agnostic home for that evidence after parsing; the compliance engine
9//! consumes it so rules that today rely on sidecar self-declarations or
10//! external-reference presence bits can consume signed, machine-readable
11//! evidence instead.
12//!
13//! Struct shapes were coded from the frozen CycloneDX 1.6 schema tag
14//! (<https://raw.githubusercontent.com/CycloneDX/specification/1.6/schema/bom-1.6.schema.json>).
15//!
16//! # Verification scope (phase 1): structural with signature PRESENCE only
17//!
18//! JSF signature objects (`declarations.signature`, and the `signature` slots
19//! on claims, evidence, attestations, standards, the affirmation, and its
20//! signatories) are parsed for PRESENCE — which algorithm they name, which
21//! key/signatories they identify, how many signers — but are **never
22//! cryptographically verified**. Every evidence level reported from this
23//! module is therefore capped at [`EvidenceLevel::SignaturePresent`];
24//! [`EvidenceLevel::SignatureVerified`] exists in the enum for output-schema
25//! stability but is unreachable until a later phase implements JSF
26//! verification.
27//!
28//! # Fail-closed reference resolution
29//!
30//! All CDXA refLinks (`claim.target`, `claim.evidence[]`, `attestation.map[]
31//! .requirement`, …) are resolved at parse time against the declarations-local
32//! bom-refs, the `definitions.standards` bom-refs, the `declarations.targets`
33//! entries, and the BOM inventory (component/service bom-refs, including the
34//! parser's purl-fallback keys). Unresolvable refs are kept and marked
35//! [`CdxaResolution::Dangling`] — the parser's tolerance convention, never a
36//! parse error — and fail closed at query time: a dangling ref can never help
37//! satisfy a requirement (see [`AttestationDeclarations::supported_requirements`]).
38//!
39//! # Phase 2 (external in-toto / DSSE bundles) — out of scope here
40//!
41//! Ingestion of external in-toto attestation bundles (`*.intoto.jsonl`, DSSE
42//! envelopes, SLSA provenance/VSA, test-result, vulns predicates) is a
43//! separate, later phase and deliberately has no surface in this module.
44//! Recorded for that phase (verified against
45//! <https://github.com/in-toto/attestation/blob/main/spec/predicates/vuln.md>):
46//! the in-toto vulns predicate marks `scanner.db.lastUpdate` as REQUIRED
47//! (while `scanner.db.uri`/`scanner.db.version` are optional), and nests the
48//! required result fields under an OPTIONAL `vulnerability` wrapper —
49//! `scanner.result[*].vulnerability.id` / `.severity.method` /
50//! `.severity.score` — with an empty result list being valid (no findings).
51//! A phase-2 well-formedness check must model both or valid attestations will
52//! be misclassified.
53
54use super::{CanonicalId, Contact, ExternalReference, Organization};
55use chrono::{DateTime, Utc};
56use serde::{Deserialize, Serialize};
57
58/// How strongly a piece of compliance evidence is attested.
59///
60/// Ordered: `SelfDeclared < Structural < SignaturePresent < SignatureVerified`.
61/// Phase 1 (this module) emits at most [`Self::SignaturePresent`] — signature
62/// objects are recorded, never cryptographically verified — so
63/// [`Self::SignatureVerified`] is currently unreachable and reserved for a
64/// later verification phase.
65#[derive(
66    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
67)]
68pub enum EvidenceLevel {
69    /// Sidecar self-declaration or external-reference presence bit.
70    #[default]
71    SelfDeclared,
72    /// Parsed machine-readable declarations without any signature.
73    Structural,
74    /// A JSF signature object exists and names a signatory; NOT verified.
75    SignaturePresent,
76    /// Cryptographically verified signature. Unreachable in phase 1/2.
77    SignatureVerified,
78}
79
80impl std::fmt::Display for EvidenceLevel {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            Self::SelfDeclared => write!(f, "self-declared"),
84            Self::Structural => write!(f, "structural"),
85            Self::SignaturePresent => write!(f, "signature-present"),
86            Self::SignatureVerified => write!(f, "signature-verified"),
87        }
88    }
89}
90
91/// Structural record that a JSF (JSON Signature Format) signature object was
92/// present, and what it names.
93///
94/// PRESENCE ONLY: the signature bytes are neither retained nor verified.
95/// Consumers must treat this as [`EvidenceLevel::SignaturePresent`] at most.
96#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
97pub struct SignaturePresence {
98    /// Signature algorithm named by the JSF object (e.g. "ES256"), when the
99    /// object (or its first signer) declares one.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub algorithm: Option<String>,
102    /// `keyId` named by the JSF object (or its first signer), when present.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub key_id: Option<String>,
105    /// Number of signers: 1 for the single-signature JSF form, the array
106    /// length for the `signers`/`chain` multi-signature forms.
107    pub signer_count: usize,
108}
109
110impl SignaturePresence {
111    /// Structurally inspect a raw JSF signature value.
112    ///
113    /// Handles the three JSF top-level forms (single signature, `signers`
114    /// array, `chain` array). Returns `None` for non-object values. No
115    /// cryptographic verification is performed.
116    #[must_use]
117    pub fn from_jsf(value: &serde_json::Value) -> Option<Self> {
118        let obj = value.as_object()?;
119        for multi_key in ["signers", "chain"] {
120            if let Some(entries) = obj.get(multi_key).and_then(|v| v.as_array()) {
121                let first = entries.first().and_then(|v| v.as_object());
122                return Some(Self {
123                    algorithm: first
124                        .and_then(|o| o.get("algorithm"))
125                        .and_then(|v| v.as_str())
126                        .map(String::from),
127                    key_id: first
128                        .and_then(|o| o.get("keyId"))
129                        .and_then(|v| v.as_str())
130                        .map(String::from),
131                    signer_count: entries.len(),
132                });
133            }
134        }
135        Some(Self {
136            algorithm: obj
137                .get("algorithm")
138                .and_then(|v| v.as_str())
139                .map(String::from),
140            key_id: obj.get("keyId").and_then(|v| v.as_str()).map(String::from),
141            signer_count: 1,
142        })
143    }
144}
145
146/// Where a CDXA refLink resolved at parse time.
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub enum CdxaResolution {
149    /// A `declarations.claims[]` entry.
150    Claim,
151    /// A `declarations.evidence[]` entry.
152    Evidence,
153    /// A `declarations.assessors[]` entry.
154    Assessor,
155    /// A `definitions.standards[]` entry or one of its `requirements[]`.
156    Requirement,
157    /// A `declarations.targets` entry (organization/component/service listed
158    /// as a claim target but not part of the BOM inventory).
159    Target,
160    /// A component or service in the BOM inventory, resolved by bom-ref or
161    /// by the parser's purl-fallback key.
162    Inventory(CanonicalId),
163    /// Unresolvable anywhere in the document. Fail closed: a dangling ref
164    /// never supports any requirement.
165    Dangling,
166}
167
168/// A CDXA refLink with its parse-time resolution.
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170pub struct CdxaRef {
171    /// The raw refLink string as it appeared in the document.
172    pub raw: String,
173    /// What the refLink resolved to.
174    pub resolution: CdxaResolution,
175}
176
177impl CdxaRef {
178    /// Whether the ref resolved to anything in the document.
179    #[must_use]
180    pub fn is_resolved(&self) -> bool {
181        !matches!(self.resolution, CdxaResolution::Dangling)
182    }
183}
184
185/// A `declarations.assessors[]` entry.
186#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
187pub struct DeclaredAssessor {
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub bom_ref: Option<String>,
190    /// `true` = assessor is outside the organization generating claims;
191    /// `false` = self assessor; `None` = not stated.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub third_party: Option<bool>,
194    /// The entity issuing the assessment.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub organization: Option<Organization>,
197}
198
199/// A `declarations.attestations[]` entry: an assessor's mapping of standard
200/// requirements to claims.
201#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
202pub struct AttestationAssertion {
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub summary: Option<String>,
205    /// refLink into `declarations.assessors[]`.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub assessor: Option<CdxaRef>,
208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209    pub map: Vec<AttestationMapEntry>,
210    /// JSF signature presence (structural only, never verified).
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub signature: Option<SignaturePresence>,
213}
214
215/// One `attestations[].map[]` entry: requirement → claims, with the
216/// attestor's declared conformance and confidence.
217#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
218pub struct AttestationMapEntry {
219    /// refLink to the requirement being attested to
220    /// (`definitions.standards[].requirements[]` bom-ref).
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub requirement: Option<CdxaRef>,
223    /// refLinks to the claims being attested to.
224    #[serde(default, skip_serializing_if = "Vec::is_empty")]
225    pub claims: Vec<CdxaRef>,
226    /// refLinks to counter claims. Any entry contests the attestation:
227    /// the map entry then satisfies nothing (surfaced, never silently passed).
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub counter_claims: Vec<CdxaRef>,
230    /// Conformance score in `0..=1`, where 1 is 100% conformance. Anything
231    /// below 1 is partial conformance and never auto-satisfies a rule.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub conformance_score: Option<f64>,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub conformance_rationale: Option<String>,
236    /// refLinks to evidence describing mitigation strategies for conformance
237    /// gaps.
238    #[serde(default, skip_serializing_if = "Vec::is_empty")]
239    pub conformance_mitigation_strategies: Vec<CdxaRef>,
240    /// Confidence score in `0..=1`, where 1 is 100% confidence.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub confidence_score: Option<f64>,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub confidence_rationale: Option<String>,
245}
246
247/// A `declarations.claims[]` entry: a statement about a target.
248#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
249pub struct DeclaredClaim {
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub bom_ref: Option<String>,
252    /// refLink to the target the claim applies to. The schema permits any
253    /// bom-ref'd element (team, process, business unit, …); this engine
254    /// resolves against `declarations.targets` entries and the BOM inventory
255    /// (a fail-closed design choice of this tool, not schema text).
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub target: Option<CdxaRef>,
258    /// The specific statement or assertion about the target.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub predicate: Option<String>,
261    /// refLinks to evidence describing how weaknesses in the claim's
262    /// evidence are mitigated.
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub mitigation_strategies: Vec<CdxaRef>,
265    /// Why the evidence substantiates the claim.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub reasoning: Option<String>,
268    /// refLinks into `declarations.evidence[]` supporting the claim.
269    #[serde(default, skip_serializing_if = "Vec::is_empty")]
270    pub evidence: Vec<CdxaRef>,
271    /// refLinks to counter evidence. Any entry contests the claim: the claim
272    /// then supports nothing (surfaced, never silently passed).
273    #[serde(default, skip_serializing_if = "Vec::is_empty")]
274    pub counter_evidence: Vec<CdxaRef>,
275    #[serde(default, skip_serializing_if = "Vec::is_empty")]
276    pub external_refs: Vec<ExternalReference>,
277    /// JSF signature presence (structural only, never verified).
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub signature: Option<SignaturePresence>,
280}
281
282/// One `evidence[].data[]` entry: output or analysis that supports claims.
283#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
284pub struct EvidenceDataItem {
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub name: Option<String>,
287    /// `contents.url` — where the data can be retrieved.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub url: Option<String>,
290    /// Whether `contents.attachment` embedded the data inline (the attachment
291    /// body itself is not retained in the normalized model).
292    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
293    pub has_attachment: bool,
294    /// Data classification tag (type/sensitivity/value).
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub classification: Option<String>,
297    /// Descriptions of any sensitive data included.
298    #[serde(default, skip_serializing_if = "Vec::is_empty")]
299    pub sensitive_data: Vec<String>,
300    /// Whether a `governance` block (custodians/stewards/owners) was present.
301    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
302    pub has_governance: bool,
303}
304
305/// A `declarations.evidence[]` entry.
306#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
307pub struct DeclaredEvidence {
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub bom_ref: Option<String>,
310    /// CycloneDX Property Taxonomy reference.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub property_name: Option<String>,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub description: Option<String>,
315    #[serde(default, skip_serializing_if = "Vec::is_empty")]
316    pub data: Vec<EvidenceDataItem>,
317    /// When the evidence was created.
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub created: Option<DateTime<Utc>>,
320    /// When the evidence stops being valid.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub expires: Option<DateTime<Utc>>,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub author: Option<Contact>,
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub reviewer: Option<Contact>,
327    /// JSF signature presence (structural only, never verified).
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub signature: Option<SignaturePresence>,
330}
331
332impl DeclaredEvidence {
333    /// Whether the evidence may count toward satisfying a requirement at the
334    /// evaluation instant `as_of` (the compliance engine's injectable clock,
335    /// never an inline wall-clock read).
336    ///
337    /// Fail closed: evidence with `expires <= as_of` (expired) or
338    /// `created > as_of` (anachronistic — created in the future relative to
339    /// the evaluation instant) never counts. Missing timestamps impose no
340    /// constraint.
341    #[must_use]
342    pub fn is_fresh(&self, as_of: DateTime<Utc>) -> bool {
343        if let Some(expires) = self.expires
344            && expires <= as_of
345        {
346            return false;
347        }
348        if let Some(created) = self.created
349            && created > as_of
350        {
351            return false;
352        }
353        true
354    }
355}
356
357/// One entry of `declarations.targets` (organization, component, or service
358/// listed as a claim target). These are NOT part of the BOM inventory; only
359/// their identity is retained so claim targets can resolve to them.
360#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
361pub struct DeclarationTarget {
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub bom_ref: Option<String>,
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub name: Option<String>,
366}
367
368/// The `declarations.targets` object.
369#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
370pub struct DeclarationTargets {
371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
372    pub organizations: Vec<DeclarationTarget>,
373    #[serde(default, skip_serializing_if = "Vec::is_empty")]
374    pub components: Vec<DeclarationTarget>,
375    #[serde(default, skip_serializing_if = "Vec::is_empty")]
376    pub services: Vec<DeclarationTarget>,
377}
378
379/// A `declarations.affirmation.signatories[]` entry.
380///
381/// The 1.6 schema constrains each signatory with a `oneOf`: it must carry
382/// either a JSF `signature` OR both `externalReference` and `organization`.
383/// [`Self::has_complete_identity`] reports that constraint; violating
384/// signatories have unusable identity and cannot raise the document's
385/// evidence level.
386#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
387pub struct AffirmationSignatory {
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub name: Option<String>,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub role: Option<String>,
392    /// JSF signature presence (structural only, never verified).
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub signature: Option<SignaturePresence>,
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub organization: Option<Organization>,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub external_reference: Option<ExternalReference>,
399}
400
401impl AffirmationSignatory {
402    /// Whether the signatory satisfies the schema's identity `oneOf`:
403    /// a signature, or both an external reference and an organization.
404    #[must_use]
405    pub fn has_complete_identity(&self) -> bool {
406        self.signature.is_some()
407            || (self.external_reference.is_some() && self.organization.is_some())
408    }
409}
410
411/// The `declarations.affirmation` object.
412#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
413pub struct DeclaredAffirmation {
414    /// The statement affirmed regarding all declarations.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub statement: Option<String>,
417    #[serde(default, skip_serializing_if = "Vec::is_empty")]
418    pub signatories: Vec<AffirmationSignatory>,
419    /// JSF signature presence (structural only, never verified).
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub signature: Option<SignaturePresence>,
422}
423
424/// One `definitions.standards[].requirements[]` entry.
425///
426/// Field set follows the frozen 1.6 schema: `text` is a plain string,
427/// `descriptions` is a PLURAL array of supplemental strings, `openCre` is an
428/// array of `CRE:x-y` identifiers, `parent` is a refLink to a parent
429/// requirement. (`properties` are not normalized in phase 1.)
430#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
431pub struct DefinedRequirement {
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub bom_ref: Option<String>,
434    /// The identifier used IN THE STANDARD (e.g. SSDF practice "PS.1") — not
435    /// the bom-ref. This is what maps to engine rule families.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub identifier: Option<String>,
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub title: Option<String>,
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub text: Option<String>,
442    #[serde(default, skip_serializing_if = "Vec::is_empty")]
443    pub descriptions: Vec<String>,
444    #[serde(default, skip_serializing_if = "Vec::is_empty")]
445    pub open_cre: Vec<String>,
446    /// refLink to the parent requirement (hierarchy), kept raw.
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub parent: Option<String>,
449    #[serde(default, skip_serializing_if = "Vec::is_empty")]
450    pub external_refs: Vec<ExternalReference>,
451}
452
453/// One `definitions.standards[]` entry: a machine-readable standard encoding.
454/// (`levels` are not normalized in phase 1.)
455#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
456pub struct DefinedStandard {
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub bom_ref: Option<String>,
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub name: Option<String>,
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub version: Option<String>,
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub description: Option<String>,
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub owner: Option<String>,
467    #[serde(default, skip_serializing_if = "Vec::is_empty")]
468    pub requirements: Vec<DefinedRequirement>,
469    /// JSF signature presence (structural only, never verified).
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub signature: Option<SignaturePresence>,
472}
473
474/// Normalized CDXA evidence: CycloneDX 1.6 `declarations` plus the
475/// `definitions.standards` encodings its attestations map into.
476///
477/// Attached at [`crate::model::FormatExtensions::declarations`] (reachable
478/// via [`crate::model::NormalizedSbom::declarations`]). Populated only by the
479/// CycloneDX JSON parser for `specVersion >= 1.6` documents that carry these
480/// sections; `None` for SPDX, older CycloneDX, and XML input — and skipped in
481/// serialization when absent, so documents without declarations serialize
482/// byte-identically to previous releases.
483#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
484pub struct AttestationDeclarations {
485    #[serde(default, skip_serializing_if = "Vec::is_empty")]
486    pub assessors: Vec<DeclaredAssessor>,
487    #[serde(default, skip_serializing_if = "Vec::is_empty")]
488    pub attestations: Vec<AttestationAssertion>,
489    #[serde(default, skip_serializing_if = "Vec::is_empty")]
490    pub claims: Vec<DeclaredClaim>,
491    #[serde(default, skip_serializing_if = "Vec::is_empty")]
492    pub evidence: Vec<DeclaredEvidence>,
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub targets: Option<DeclarationTargets>,
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub affirmation: Option<DeclaredAffirmation>,
497    /// Document-level JSF signature presence over the declarations
498    /// (structural only, never verified).
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub signature: Option<SignaturePresence>,
501    /// `definitions.standards[]` — the standard encodings attestation map
502    /// entries refLink their requirements into.
503    #[serde(default, skip_serializing_if = "Vec::is_empty")]
504    pub standards: Vec<DefinedStandard>,
505}
506
507/// Engine rule families that CDXA evidence can strengthen. Requirements whose
508/// (standard, identifier) pair classifies into none of these are recorded but
509/// satisfy nothing (unknown-content handling: fail-open for recognition,
510/// fail-closed for satisfaction).
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
512pub enum AttestationRuleFamily {
513    /// NIST SSDF practice rules (`SBOM-SSDF-*`).
514    Ssdf,
515    /// EO 14028 4(e) rules (`SBOM-EO14028-*`).
516    Eo14028,
517    /// EU CRA conformity rules (`SBOM-CRA-*`).
518    Cra,
519}
520
521impl AttestationRuleFamily {
522    /// Classify a (standard, requirement) pair into an engine rule family.
523    ///
524    /// Standard-level text wins over identifier shape so that, e.g., an
525    /// EO 14028 encoding that reuses SSDF practice identifiers classifies as
526    /// EO 14028; unlabeled standards fall back to the SSDF practice-identifier
527    /// pattern (`PO.n` / `PS.n` / `PW.n` / `RV.n`). Returns `None` for
528    /// unknown pairs — recorded, but never able to influence a verdict.
529    #[must_use]
530    pub fn classify(standard: &DefinedStandard, requirement: &DefinedRequirement) -> Option<Self> {
531        let hay = format!(
532            "{} {} {}",
533            standard.name.as_deref().unwrap_or(""),
534            standard.description.as_deref().unwrap_or(""),
535            standard.owner.as_deref().unwrap_or("")
536        )
537        .to_lowercase();
538        if hay.contains("ssdf") || hay.contains("secure software development framework") {
539            return Some(Self::Ssdf);
540        }
541        if hay.contains("14028") || hay.contains("executive order") {
542            return Some(Self::Eo14028);
543        }
544        if hay.contains("cyber resilience")
545            || hay
546                .split(|c: char| !c.is_ascii_alphanumeric())
547                .any(|token| token == "cra")
548        {
549            return Some(Self::Cra);
550        }
551        if Self::is_ssdf_practice_id(requirement.identifier.as_deref()) {
552            return Some(Self::Ssdf);
553        }
554        None
555    }
556
557    /// SSDF practice identifier shape: `PO.n` / `PS.n` / `PW.n` / `RV.n`.
558    fn is_ssdf_practice_id(identifier: Option<&str>) -> bool {
559        let Some(identifier) = identifier else {
560            return false;
561        };
562        let id = identifier.trim().to_ascii_uppercase();
563        ["PO.", "PS.", "PW.", "RV."].iter().any(|prefix| {
564            id.strip_prefix(prefix)
565                .is_some_and(|rest| rest.chars().next().is_some_and(|c| c.is_ascii_digit()))
566        })
567    }
568}
569
570/// A standard requirement that resolved CDXA evidence fully supports at the
571/// evaluation instant (see [`AttestationDeclarations::supported_requirements`]
572/// for the fail-closed criteria). Borrows from the declarations it was
573/// computed over; a query result, not a serialized artifact.
574#[derive(Debug, Clone)]
575pub struct SupportedRequirement<'a> {
576    /// The standard encoding the requirement belongs to.
577    pub standard: &'a DefinedStandard,
578    /// The requirement itself (its `identifier` is the standard's own ID,
579    /// e.g. SSDF "PS.1").
580    pub requirement: &'a DefinedRequirement,
581    /// The attestation asserting the mapping.
582    pub attestation: &'a AttestationAssertion,
583    /// The map entry connecting requirement to claims.
584    pub map_entry: &'a AttestationMapEntry,
585    /// The claims that survived fail-closed filtering (resolved target,
586    /// no counter evidence, at least one resolving fresh evidence item).
587    pub supporting_claims: Vec<&'a DeclaredClaim>,
588    /// Evidence strength: [`EvidenceLevel::SignaturePresent`] when the
589    /// declarations, the attestation, a supporting claim, or a supporting
590    /// evidence item carries a JSF signature object; otherwise
591    /// [`EvidenceLevel::Structural`]. Never `SignatureVerified` in phase 1.
592    pub evidence_level: EvidenceLevel,
593    /// Whether the asserting assessor resolved and declared
594    /// `thirdParty: true`.
595    pub third_party_assessed: bool,
596}
597
598impl AttestationDeclarations {
599    /// Look up a claim by its bom-ref.
600    #[must_use]
601    pub fn claim_by_ref(&self, bom_ref: &str) -> Option<&DeclaredClaim> {
602        self.claims
603            .iter()
604            .find(|c| c.bom_ref.as_deref() == Some(bom_ref))
605    }
606
607    /// Look up an evidence item by its bom-ref.
608    #[must_use]
609    pub fn evidence_by_ref(&self, bom_ref: &str) -> Option<&DeclaredEvidence> {
610        self.evidence
611            .iter()
612            .find(|e| e.bom_ref.as_deref() == Some(bom_ref))
613    }
614
615    /// Look up an assessor by its bom-ref.
616    #[must_use]
617    pub fn assessor_by_ref(&self, bom_ref: &str) -> Option<&DeclaredAssessor> {
618        self.assessors
619            .iter()
620            .find(|a| a.bom_ref.as_deref() == Some(bom_ref))
621    }
622
623    /// Resolve a requirement refLink to its (standard, requirement) pair.
624    #[must_use]
625    pub fn requirement_by_ref(
626        &self,
627        bom_ref: &str,
628    ) -> Option<(&DefinedStandard, &DefinedRequirement)> {
629        self.standards.iter().find_map(|standard| {
630            standard
631                .requirements
632                .iter()
633                .find(|req| req.bom_ref.as_deref() == Some(bom_ref))
634                .map(|req| (standard, req))
635        })
636    }
637
638    /// The document-wide evidence ceiling: [`EvidenceLevel::SignaturePresent`]
639    /// when the declarations themselves, the affirmation, or any signatory
640    /// carries a JSF signature object; [`EvidenceLevel::Structural`]
641    /// otherwise. Never `SignatureVerified` — phase 1 records signature
642    /// presence only.
643    #[must_use]
644    pub fn document_evidence_level(&self) -> EvidenceLevel {
645        let affirmation_signed = self.affirmation.as_ref().is_some_and(|a| {
646            a.signature.is_some() || a.signatories.iter().any(|s| s.signature.is_some())
647        });
648        if self.signature.is_some() || affirmation_signed {
649            EvidenceLevel::SignaturePresent
650        } else {
651            EvidenceLevel::Structural
652        }
653    }
654
655    /// The standard requirements this document's attestations fully support
656    /// at the evaluation instant `as_of` (pass the compliance engine's
657    /// injectable clock — `ComplianceChecker::now()` — never an inline
658    /// wall-clock read).
659    ///
660    /// Fail-closed criteria — an attestation map entry supports its
661    /// requirement only when ALL hold:
662    ///
663    /// 1. its `requirement` refLink resolves to a
664    ///    `definitions.standards[].requirements[]` entry;
665    /// 2. its declared conformance score is full (`>= 1.0`; the schema caps
666    ///    at 1). Partial conformance is surfaced elsewhere, never
667    ///    auto-satisfied;
668    /// 3. it carries no `counterClaims`;
669    /// 4. at least one `claims` refLink resolves to a claim whose target
670    ///    resolves, that carries no `counterEvidence`, and that cites at
671    ///    least one resolving evidence item fresh at `as_of`
672    ///    ([`DeclaredEvidence::is_fresh`]).
673    ///
674    /// Dangling refs anywhere in the chain simply drop that path — the
675    /// parser's tolerance convention keeps them in the model (marked
676    /// [`CdxaResolution::Dangling`]) for rules that surface them.
677    #[must_use]
678    pub fn supported_requirements(&self, as_of: DateTime<Utc>) -> Vec<SupportedRequirement<'_>> {
679        let mut supported = Vec::new();
680        for attestation in &self.attestations {
681            for entry in &attestation.map {
682                let Some(requirement_ref) = &entry.requirement else {
683                    continue;
684                };
685                if !matches!(requirement_ref.resolution, CdxaResolution::Requirement) {
686                    continue;
687                }
688                let Some((standard, requirement)) = self.requirement_by_ref(&requirement_ref.raw)
689                else {
690                    continue;
691                };
692                if !entry.conformance_score.is_some_and(|score| score >= 1.0) {
693                    continue;
694                }
695                if !entry.counter_claims.is_empty() {
696                    continue;
697                }
698
699                let mut supporting_claims = Vec::new();
700                let mut supporting_evidence_signed = false;
701                for claim_ref in &entry.claims {
702                    if !matches!(claim_ref.resolution, CdxaResolution::Claim) {
703                        continue;
704                    }
705                    let Some(claim) = self.claim_by_ref(&claim_ref.raw) else {
706                        continue;
707                    };
708                    if !claim.target.as_ref().is_some_and(CdxaRef::is_resolved) {
709                        continue;
710                    }
711                    if !claim.counter_evidence.is_empty() {
712                        continue;
713                    }
714                    let fresh_evidence: Vec<&DeclaredEvidence> = claim
715                        .evidence
716                        .iter()
717                        .filter(|e| matches!(e.resolution, CdxaResolution::Evidence))
718                        .filter_map(|e| self.evidence_by_ref(&e.raw))
719                        .filter(|evidence| evidence.is_fresh(as_of))
720                        .collect();
721                    if fresh_evidence.is_empty() {
722                        continue;
723                    }
724                    if claim.signature.is_some()
725                        || fresh_evidence.iter().any(|e| e.signature.is_some())
726                    {
727                        supporting_evidence_signed = true;
728                    }
729                    supporting_claims.push(claim);
730                }
731                if supporting_claims.is_empty() {
732                    continue;
733                }
734
735                let signature_present = self.signature.is_some()
736                    || attestation.signature.is_some()
737                    || supporting_evidence_signed;
738                let evidence_level = if signature_present {
739                    EvidenceLevel::SignaturePresent
740                } else {
741                    EvidenceLevel::Structural
742                };
743                let third_party_assessed = attestation
744                    .assessor
745                    .as_ref()
746                    .filter(|a| matches!(a.resolution, CdxaResolution::Assessor))
747                    .and_then(|a| self.assessor_by_ref(&a.raw))
748                    .and_then(|a| a.third_party)
749                    .unwrap_or(false);
750
751                supported.push(SupportedRequirement {
752                    standard,
753                    requirement,
754                    attestation,
755                    map_entry: entry,
756                    supporting_claims,
757                    evidence_level,
758                    third_party_assessed,
759                });
760            }
761        }
762        supported
763    }
764
765    /// [`Self::supported_requirements`] filtered to the requirements whose
766    /// (standard, identifier) pair classifies into `family`
767    /// ([`AttestationRuleFamily::classify`]). Unknown pairs never appear —
768    /// they are recorded in the model but cannot influence a verdict.
769    #[must_use]
770    pub fn evidence_for_family(
771        &self,
772        family: AttestationRuleFamily,
773        as_of: DateTime<Utc>,
774    ) -> Vec<SupportedRequirement<'_>> {
775        self.supported_requirements(as_of)
776            .into_iter()
777            .filter(|s| AttestationRuleFamily::classify(s.standard, s.requirement) == Some(family))
778            .collect()
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    fn ts(s: &str) -> DateTime<Utc> {
787        DateTime::parse_from_rfc3339(s)
788            .expect("test timestamp must parse")
789            .with_timezone(&Utc)
790    }
791
792    #[test]
793    fn evidence_level_ordering_reflects_strength() {
794        assert!(EvidenceLevel::SelfDeclared < EvidenceLevel::Structural);
795        assert!(EvidenceLevel::Structural < EvidenceLevel::SignaturePresent);
796        assert!(EvidenceLevel::SignaturePresent < EvidenceLevel::SignatureVerified);
797    }
798
799    #[test]
800    fn signature_presence_handles_all_jsf_forms() {
801        let single = serde_json::json!({"algorithm": "ES256", "keyId": "k1", "value": "sig"});
802        let presence = SignaturePresence::from_jsf(&single).expect("object form");
803        assert_eq!(presence.algorithm.as_deref(), Some("ES256"));
804        assert_eq!(presence.key_id.as_deref(), Some("k1"));
805        assert_eq!(presence.signer_count, 1);
806
807        let signers = serde_json::json!({"signers": [
808            {"algorithm": "Ed25519", "value": "a"},
809            {"algorithm": "ES256", "value": "b"}
810        ]});
811        let presence = SignaturePresence::from_jsf(&signers).expect("signers form");
812        assert_eq!(presence.algorithm.as_deref(), Some("Ed25519"));
813        assert_eq!(presence.signer_count, 2);
814
815        let chain = serde_json::json!({"chain": [{"algorithm": "RS256", "value": "a"}]});
816        let presence = SignaturePresence::from_jsf(&chain).expect("chain form");
817        assert_eq!(presence.signer_count, 1);
818        assert_eq!(presence.algorithm.as_deref(), Some("RS256"));
819
820        assert!(SignaturePresence::from_jsf(&serde_json::json!("not-an-object")).is_none());
821    }
822
823    #[test]
824    fn evidence_freshness_fails_closed_on_expiry_and_future_creation() {
825        let evidence = DeclaredEvidence {
826            created: Some(ts("2026-01-10T00:00:00Z")),
827            expires: Some(ts("2027-01-10T00:00:00Z")),
828            ..DeclaredEvidence::default()
829        };
830        assert!(evidence.is_fresh(ts("2026-06-01T00:00:00Z")));
831        // Expired: expires <= as_of.
832        assert!(!evidence.is_fresh(ts("2027-01-10T00:00:00Z")));
833        assert!(!evidence.is_fresh(ts("2027-06-01T00:00:00Z")));
834        // Anachronistic: created in the future relative to the evaluation instant.
835        assert!(!evidence.is_fresh(ts("2025-12-01T00:00:00Z")));
836        // No timestamps: no constraint.
837        assert!(DeclaredEvidence::default().is_fresh(ts("2026-06-01T00:00:00Z")));
838    }
839
840    #[test]
841    fn rule_family_classification() {
842        let ssdf = DefinedStandard {
843            name: Some("NIST Secure Software Development Framework".to_string()),
844            ..DefinedStandard::default()
845        };
846        let ps1 = DefinedRequirement {
847            identifier: Some("PS.1".to_string()),
848            ..DefinedRequirement::default()
849        };
850        assert_eq!(
851            AttestationRuleFamily::classify(&ssdf, &ps1),
852            Some(AttestationRuleFamily::Ssdf)
853        );
854
855        // Standard-level text wins over the SSDF identifier shape.
856        let eo = DefinedStandard {
857            name: Some("Executive Order 14028 4(e) attestation".to_string()),
858            ..DefinedStandard::default()
859        };
860        assert_eq!(
861            AttestationRuleFamily::classify(&eo, &ps1),
862            Some(AttestationRuleFamily::Eo14028)
863        );
864
865        // Unlabeled standard falls back to the practice-identifier pattern.
866        let unlabeled = DefinedStandard::default();
867        assert_eq!(
868            AttestationRuleFamily::classify(&unlabeled, &ps1),
869            Some(AttestationRuleFamily::Ssdf)
870        );
871
872        // "CRA" must match as a token, not a substring (e.g. not "Scrappy").
873        let scrappy = DefinedStandard {
874            name: Some("Scrappy Custom Framework".to_string()),
875            ..DefinedStandard::default()
876        };
877        let generic_req = DefinedRequirement {
878            identifier: Some("REQ-1".to_string()),
879            ..DefinedRequirement::default()
880        };
881        assert_eq!(
882            AttestationRuleFamily::classify(&scrappy, &generic_req),
883            None
884        );
885        let cra = DefinedStandard {
886            name: Some("EU CRA Annex I encoding".to_string()),
887            ..DefinedStandard::default()
888        };
889        assert_eq!(
890            AttestationRuleFamily::classify(&cra, &generic_req),
891            Some(AttestationRuleFamily::Cra)
892        );
893    }
894}