use super::*;
fn parse_spec_version(v: &str) -> Option<(u64, u64, u64)> {
let mut parts = v.trim().split('.');
let major: u64 = parts.next()?.parse().ok()?;
let minor: u64 = match parts.next() {
Some(s) => s.parse().ok()?,
None => 0,
};
let patch: u64 = match parts.next() {
Some(s) => s.parse().ok()?,
None => 0,
};
Some((major, minor, patch))
}
fn is_unknown_marker(value: &str) -> bool {
value.eq_ignore_ascii_case("NOASSERTION")
|| value.eq_ignore_ascii_case("NONE")
|| value.eq_ignore_ascii_case("UNKNOWN")
}
fn tool_name_carries_version(name: &str) -> bool {
let name = name.trim();
if name.split_whitespace().any(is_unknown_marker) {
return true;
}
let last_ws = name.rsplit(char::is_whitespace).next().unwrap_or(name);
let last_seg = last_ws.rsplit('-').next().unwrap_or(last_ws);
last_seg.chars().any(|c| c.is_ascii_digit())
}
fn is_ascii_hex(value: &str) -> bool {
!value.is_empty() && value.chars().all(|c| c.is_ascii_hexdigit())
}
impl ComplianceChecker {
#[allow(clippy::too_many_lines)]
pub(crate) fn check_cisa2026(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
use crate::model::{
CompletenessDeclaration, ComponentType, CreatorType, ExternalRefType, HashAlgorithm,
HashProvenance,
};
let has_person_or_org = sbom
.document
.creators
.iter()
.any(|c| c.creator_type != CreatorType::Tool);
if !has_person_or_org {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message: "[CISA 2026] SBOM Author missing: no Person or Organization creator \
names the entity that created the SBOM data (tool-only creator lists \
do not satisfy the element — the author is the entity operating the \
tool, not the tool itself)"
.to_string(),
element: None,
requirement: "CISA 2026 Minimum Elements: SBOM Author".to_string(),
rule_id: "SBOM-CISA2026-AUTHOR",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
let has_signature = sbom
.document
.signature
.as_ref()
.is_some_and(|s| s.has_value);
if !has_signature {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "[CISA 2026] No SBOM Author Signature found in the document (CycloneDX \
JSF signature or SPDX 3 verifiedUsing signature); SPDX 2.x cannot \
express one in-document and detached signatures are invisible to this \
check"
.to_string(),
element: None,
requirement: "CISA 2026 Minimum Elements: SBOM Author Signature".to_string(),
rule_id: "SBOM-CISA2026-SIGNATURE",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
let minimum = match sbom.document.format {
SbomFormat::CycloneDx => (1, 4, 0),
SbomFormat::Spdx => (2, 2, 0),
};
if let Some(actual) = parse_spec_version(&sbom.document.spec_version)
&& actual < minimum
{
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::FormatSpecific,
message: format!(
"[CISA 2026] {} {} is an outdated release of the data format; this tool's \
policy floor is CycloneDX 1.4+ / SPDX 2.2+ (CISA itself names no deprecated \
versions — the floor mirrors the EO 14028 machine-readable gate)",
sbom.document.format, sbom.document.spec_version
),
element: None,
requirement: "CISA 2026 Minimum Elements: SBOM Data Format Name/Version \
(Machine-Processable Data)"
.to_string(),
rule_id: "SBOM-CISA2026-FORMAT",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
if known_value(sbom.document.lifecycle_phase.as_deref()).is_none() {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "[CISA 2026] SBOM Generation Context missing: no software lifecycle \
phase is declared (CycloneDX 1.5+ metadata.lifecycles, e.g. \
'pre-build', 'build', 'post-build'; SPDX 2.x has no standard field)"
.to_string(),
element: None,
requirement: "CISA 2026 Minimum Elements: SBOM Generation Context".to_string(),
rule_id: "SBOM-CISA2026-GENERATION-CONTEXT",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
if !sbom.document.has_known_timestamp() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message: "[CISA 2026] SBOM Timestamp missing or invalid: no date/time of the \
most recent update to the SBOM data (the element targets RFC 9557 \
syntax; source syntax is not verified post-normalization)"
.to_string(),
element: None,
requirement: "CISA 2026 Minimum Elements: SBOM Timestamp".to_string(),
rule_id: "SBOM-CISA2026-TIMESTAMP",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
let tool_creators: Vec<_> = sbom
.document
.creators
.iter()
.filter(|c| c.creator_type == CreatorType::Tool)
.collect();
if tool_creators.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message: "[CISA 2026] SBOM Tool Name missing: no generation tool is identified \
(CycloneDX metadata.tools; SPDX 'Creator: Tool:')"
.to_string(),
element: None,
requirement: "CISA 2026 Minimum Elements: SBOM Tool Name".to_string(),
rule_id: "SBOM-CISA2026-TOOL",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
} else if !tool_creators
.iter()
.any(|t| tool_name_carries_version(&t.name))
{
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "[CISA 2026] SBOM Tool Version not discernible: no identified tool \
carries a version identifier or an explicit unknown marker (heuristic \
check — parsers concatenate tool name and version into one creator \
name, so a dedicated tool-version field cannot be inspected)"
.to_string(),
element: None,
requirement: "CISA 2026 Minimum Elements: SBOM Tool Version".to_string(),
rule_id: "SBOM-CISA2026-TOOL-VERSION",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
let declares_version = sbom.document.doc_version.is_some()
|| known_value(sbom.document.serial_number.as_deref()).is_some();
if !declares_version {
let message = match sbom.document.format {
SbomFormat::CycloneDx => {
"[CISA 2026] SBOM Version missing: the document omits bom.version (absence \
is not backfilled with the CycloneDX default of 1) and carries no \
serialNumber to distinguish document versions"
}
SbomFormat::Spdx => {
"[CISA 2026] SBOM Version missing: SPDX 2.x has no document-version field \
and no version-distinguishing documentNamespace is present"
}
};
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: message.to_string(),
element: None,
requirement: "CISA 2026 Minimum Elements: SBOM Version".to_string(),
rule_id: "SBOM-CISA2026-SBOM-VERSION",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
for comp in sbom.components.values() {
if !known_component_name(comp) {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: "[CISA 2026] Component must have the name assigned by its producer"
.to_string(),
element: Some(comp.identifiers.format_id.clone()),
requirement: "CISA 2026 Minimum Elements: Component Name".to_string(),
rule_id: "SBOM-CISA2026-NAME",
component_id: Some(comp.canonical_id.value().to_string()),
counts: None,
standard_refs: Vec::new(),
});
}
if matches!(comp.component_type, ComponentType::File) {
continue;
}
let producer_named = has_known_supplier(&comp.supplier, &comp.author);
let producer_marked_unknown = comp
.supplier
.as_ref()
.is_some_and(|s| is_unknown_marker(s.name.trim()))
|| comp
.author
.as_deref()
.is_some_and(|a| is_unknown_marker(a.trim()));
if !producer_named && !producer_marked_unknown {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::SupplierInfo,
message: format!(
"[CISA 2026] Component '{}' names no producer (author/originator or \
supplier) and is not explicitly marked as of unknown provenance",
comp.name
),
element: Some(comp.name.clone()),
requirement: "CISA 2026 Minimum Elements: Component Producer".to_string(),
rule_id: "SBOM-CISA2026-PRODUCER",
component_id: Some(comp.canonical_id.value().to_string()),
counts: None,
standard_refs: Vec::new(),
});
}
let version_declared = comp
.version
.as_deref()
.is_some_and(|v| !v.trim().is_empty());
if !version_declared {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: format!(
"[CISA 2026] Component '{}' is missing a version and does not explicitly \
indicate the version is unknown",
comp.name
),
element: Some(comp.name.clone()),
requirement: "CISA 2026 Minimum Elements: Component Version".to_string(),
rule_id: "SBOM-CISA2026-VERSION",
component_id: Some(comp.canonical_id.value().to_string()),
counts: None,
standard_refs: Vec::new(),
});
}
if !comp.identifiers.has_cra_identifier() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: format!(
"[CISA 2026] Component '{}' has no machine-processable identifier \
(CPE or PURL are named by the element; SWHID/SWID also qualify)",
comp.name
),
element: Some(comp.name.clone()),
requirement: "CISA 2026 Minimum Elements: Component Identifiers".to_string(),
rule_id: "SBOM-CISA2026-IDENTIFIER",
component_id: Some(comp.canonical_id.value().to_string()),
counts: None,
standard_refs: Vec::new(),
});
}
let authored: Vec<_> = comp
.hashes
.iter()
.filter(|h| h.provenance == HashProvenance::Authored)
.collect();
let has_hex_value = authored.iter().any(|h| is_ascii_hex(h.value.trim()));
let hash_marked_unknown = authored.iter().any(|h| is_unknown_marker(h.value.trim()));
if authored.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::IntegrityInfo,
message: format!(
"[CISA 2026] Component '{}' carries no cryptographic hash of the \
executable component artifact (explicitly indicate the value is \
unknown when the artifact is not available to the SBOM author)",
comp.name
),
element: Some(comp.name.clone()),
requirement: "CISA 2026 Minimum Elements: Component Hash Value".to_string(),
rule_id: "SBOM-CISA2026-HASH",
component_id: Some(comp.canonical_id.value().to_string()),
counts: None,
standard_refs: Vec::new(),
});
} else if !has_hex_value && !hash_marked_unknown {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::IntegrityInfo,
message: format!(
"[CISA 2026] Component '{}' declares hash value(s) that are not \
ASCII-hexadecimal encoded as the element requires",
comp.name
),
element: Some(comp.name.clone()),
requirement: "CISA 2026 Minimum Elements: Component Hash Value".to_string(),
rule_id: "SBOM-CISA2026-HASH",
component_id: Some(comp.canonical_id.value().to_string()),
counts: None,
standard_refs: Vec::new(),
});
} else {
let offending: Vec<String> = authored
.iter()
.filter_map(|h| match &h.algorithm {
HashAlgorithm::Md5 => Some("MD5 (not NIST-approved)".to_string()),
HashAlgorithm::Sha1 => {
Some("SHA-1 (deprecated; NIST withdrawal by 2030)".to_string())
}
HashAlgorithm::Other(name) => {
Some(format!("{name} (not a recognized hash function name)"))
}
_ => None,
})
.collect();
if !offending.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::IntegrityInfo,
message: format!(
"[CISA 2026] Component '{}' declares hash algorithm(s) that are not \
approved by a relevant authority or not identifiable by an IANA \
Hash Function Textual Name: {}",
comp.name,
truncate_list(&offending, 5)
),
element: Some(comp.name.clone()),
requirement: "CISA 2026 Minimum Elements: Component Hash Algorithm"
.to_string(),
rule_id: "SBOM-CISA2026-HASH-ALGO",
component_id: Some(comp.canonical_id.value().to_string()),
counts: None,
standard_refs: Vec::new(),
});
}
}
let has_license_info = comp
.licenses
.all_licenses()
.iter()
.any(|l| !l.expression.trim().is_empty());
if !has_license_info {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::LicenseInfo,
message: format!(
"[CISA 2026] Component '{}' has no license information and no explicit \
unknown indication (prefer SPDX identifiers; use NOASSERTION when the \
license is unknown to the author)",
comp.name
),
element: Some(comp.name.clone()),
requirement: "CISA 2026 Minimum Elements: Component License".to_string(),
rule_id: "SBOM-CISA2026-LICENSE",
component_id: Some(comp.canonical_id.value().to_string()),
counts: None,
standard_refs: Vec::new(),
});
}
}
if sbom.components.len() > 1 && sbom.edges.is_empty() {
let has_external_sbom_link = sbom.components.values().any(|c| {
c.external_refs
.iter()
.any(|r| matches!(r.ref_type, ExternalRefType::Bom))
});
if !has_external_sbom_link {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DependencyInfo,
message: "[CISA 2026] SBOM enumerates multiple components but declares no \
dependency relationships and links no external SBOM documents"
.to_string(),
element: None,
requirement: "CISA 2026 Minimum Elements: Component Dependency Relationship"
.to_string(),
rule_id: "SBOM-CISA2026-DEPENDENCY",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
}
match sbom.document.completeness_declaration {
CompletenessDeclaration::Unknown | CompletenessDeclaration::NotSpecified => {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DependencyInfo,
message: "[CISA 2026] Coverage: the SBOM does not declare its completeness \
(CycloneDX compositions aggregate); information gaps must be \
explicitly stated as unknown or deliberately withheld"
.to_string(),
element: None,
requirement: "CISA 2026 Minimum Elements: Coverage / Explicitly Identifying \
Unknown Information"
.to_string(),
rule_id: "SBOM-CISA2026-COVERAGE",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
CompletenessDeclaration::Incomplete
| CompletenessDeclaration::IncompleteFirstPartyOnly
| CompletenessDeclaration::IncompleteThirdPartyOnly => {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DependencyInfo,
message: format!(
"[CISA 2026] Coverage: the SBOM declares an incomplete inventory \
({}); the declaration is honest, but the 2026 Coverage element expects \
all components including transitive dependencies — close the gap or \
link the missing SBOM documents",
sbom.document.completeness_declaration
),
element: None,
requirement: "CISA 2026 Minimum Elements: Coverage / Explicitly Identifying \
Unknown Information"
.to_string(),
rule_id: "SBOM-CISA2026-COVERAGE",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
CompletenessDeclaration::Complete => {}
}
}
}