use super::*;
use crate::model::{
CanonicalId, CompletenessDeclaration, Component, ComponentType, CreatorType, DependencyType,
ExternalRefType, HashAlgorithm, HashProvenance, SbomFormat,
};
use std::collections::HashSet;
impl ComplianceChecker {
pub(crate) fn check_fsct(&self, sbom: &NormalizedSbom, violations: &mut Vec<Violation>) {
let evidence = FsctEvidence::collect(sbom);
fsct_minimum_expected(sbom, &evidence, violations);
fsct_recommended_practice(sbom, &evidence, violations);
fsct_aspirational_goals(sbom, &evidence, violations);
}
}
const fn completeness_undeclared(declaration: &CompletenessDeclaration) -> bool {
matches!(
declaration,
CompletenessDeclaration::Unknown | CompletenessDeclaration::NotSpecified
)
}
fn is_ambiguous_placeholder(value: &str) -> bool {
const AMBIGUOUS: &[&str] = &[
"tbd",
"to be determined",
"todo",
"n/a",
"n.a.",
"placeholder",
"unspecified",
"not specified",
"not available",
"unavailable",
"null",
"nil",
"undefined",
"xxx",
"-",
"--",
"?",
];
let lower = value.trim().to_ascii_lowercase();
AMBIGUOUS.contains(&lower.as_str())
}
fn has_known_license(comp: &Component) -> bool {
comp.licenses
.all_licenses()
.into_iter()
.any(|l| known_value(Some(l.expression.as_str())).is_some())
}
fn declared_no_dependencies(comp: &Component) -> bool {
comp.extensions
.properties
.iter()
.any(|p| p.name == crate::parsers::DECLARED_NO_DEPENDENCIES_PROPERTY)
}
fn identifier_kind_count(comp: &Component) -> usize {
usize::from(comp.identifiers.purl.is_some())
+ usize::from(!comp.identifiers.cpe.is_empty())
+ usize::from(!comp.identifiers.swhid.is_empty())
+ usize::from(comp.identifiers.swid.is_some())
}
struct FsctEvidence {
total: usize,
pkg_total: usize,
nameless: usize,
without_version: Vec<String>,
without_supplier: Vec<String>,
without_identifier: Vec<String>,
fewer_than_two_identifier_kinds: Vec<String>,
without_hash: Vec<String>,
weak_hash_only: Vec<String>,
without_license: Vec<String>,
without_copyright: Vec<String>,
ambiguous_placeholders: Vec<String>,
has_concluded_license: bool,
primary_resolvable: bool,
direct_edge_count: usize,
unresolved_direct: Vec<String>,
primary_declared_no_deps: bool,
has_depth2_edges: bool,
all_direct_declared_leaf: bool,
primary_in_edge_set: bool,
orphans: Vec<String>,
has_dynamic_edge: bool,
direct_without_upstream: Vec<String>,
}
impl FsctEvidence {
#[allow(clippy::too_many_lines)]
fn collect(sbom: &NormalizedSbom) -> Self {
let total = sbom.components.len();
let mut pkg_total = 0usize;
let mut nameless = 0usize;
let mut without_version = Vec::new();
let mut without_supplier = Vec::new();
let mut without_identifier = Vec::new();
let mut fewer_than_two_identifier_kinds = Vec::new();
let mut without_hash = Vec::new();
let mut weak_hash_only = Vec::new();
let mut without_license = Vec::new();
let mut without_copyright = Vec::new();
let mut ambiguous_placeholders = Vec::new();
let mut has_concluded_license = false;
for comp in sbom.components.values() {
if !known_component_name(comp) {
nameless += 1;
}
if matches!(comp.component_type, ComponentType::File) {
continue;
}
pkg_total += 1;
let mut has_authored_hash = false;
let mut has_sha2_256_plus = false;
for h in &comp.hashes {
if h.provenance == HashProvenance::Authored {
has_authored_hash = true;
if matches!(
h.algorithm,
HashAlgorithm::Sha256 | HashAlgorithm::Sha384 | HashAlgorithm::Sha512
) {
has_sha2_256_plus = true;
}
}
}
if !has_known_value(&comp.version) && !has_authored_hash {
without_version.push(comp.name.clone());
}
let supplier_declared = comp
.supplier
.as_ref()
.is_some_and(|s| !s.name.trim().is_empty());
if !supplier_declared {
without_supplier.push(comp.name.clone());
}
if !comp.identifiers.has_cra_identifier() && !has_authored_hash {
without_identifier.push(comp.name.clone());
}
if identifier_kind_count(comp) < 2 {
fewer_than_two_identifier_kinds.push(comp.name.clone());
}
if has_authored_hash {
if !has_sha2_256_plus {
weak_hash_only.push(comp.name.clone());
}
} else {
without_hash.push(comp.name.clone());
}
if !has_known_license(comp) {
without_license.push(comp.name.clone());
}
if comp
.licenses
.concluded
.as_ref()
.is_some_and(|l| known_value(Some(l.expression.as_str())).is_some())
{
has_concluded_license = true;
}
if !has_known_value(&comp.copyright) {
without_copyright.push(comp.name.clone());
}
let mut fields = Vec::new();
if comp
.version
.as_deref()
.is_some_and(is_ambiguous_placeholder)
{
fields.push("version");
}
if comp
.supplier
.as_ref()
.is_some_and(|s| is_ambiguous_placeholder(&s.name))
{
fields.push("supplier");
}
if comp
.licenses
.all_licenses()
.iter()
.any(|l| is_ambiguous_placeholder(&l.expression))
{
fields.push("license");
}
if comp
.copyright
.as_deref()
.is_some_and(is_ambiguous_placeholder)
{
fields.push("copyright");
}
if !fields.is_empty() {
ambiguous_placeholders.push(format!("{} ({})", comp.name, fields.join(", ")));
}
}
let primary_id: Option<&CanonicalId> = sbom
.primary_component_id
.as_ref()
.filter(|id| sbom.components.contains_key(*id));
let primary_resolvable = primary_id.is_some();
let primary_declared_no_deps = sbom
.primary_component()
.is_some_and(declared_no_dependencies);
let mut direct_edge_count = 0usize;
let mut unresolved_direct = Vec::new();
let mut direct_ids: HashSet<&CanonicalId> = HashSet::new();
let mut primary_in_edge_set = false;
if let Some(primary) = primary_id {
for edge in &sbom.edges {
if &edge.from == primary {
direct_edge_count += 1;
if sbom.components.contains_key(&edge.to) {
direct_ids.insert(&edge.to);
} else {
unresolved_direct.push(edge.to.value().to_string());
}
}
if &edge.from == primary || &edge.to == primary {
primary_in_edge_set = true;
}
}
}
let has_depth2_edges = sbom.edges.iter().any(|e| direct_ids.contains(&e.from));
let all_direct_declared_leaf = !direct_ids.is_empty()
&& direct_ids.iter().all(|id| {
sbom.components
.get(*id)
.is_some_and(declared_no_dependencies)
});
let mut touched: HashSet<&CanonicalId> = HashSet::new();
for edge in &sbom.edges {
touched.insert(&edge.from);
touched.insert(&edge.to);
}
let orphans: Vec<String> = sbom
.components
.iter()
.filter(|(id, _)| {
!touched.contains(id) && sbom.primary_component_id.as_ref() != Some(id)
})
.map(|(_, c)| c.name.clone())
.collect();
let has_dynamic_edge = sbom.edges.iter().any(|e| {
matches!(
e.relationship,
DependencyType::DynamicLink
| DependencyType::RuntimeDependsOn
| DependencyType::ProvidedDependsOn
)
});
let mut direct_without_upstream = Vec::new();
for id in &direct_ids {
let Some(comp) = sbom.components.get(*id) else {
continue;
};
if matches!(comp.component_type, ComponentType::File) {
continue;
}
let has_children = sbom.edges.iter().any(|e| &&e.from == id);
let has_bom_ref = comp
.external_refs
.iter()
.any(|r| r.ref_type == ExternalRefType::Bom);
if !has_children && !declared_no_dependencies(comp) && !has_bom_ref {
direct_without_upstream.push(comp.name.clone());
}
}
Self {
total,
pkg_total,
nameless,
without_version,
without_supplier,
without_identifier,
fewer_than_two_identifier_kinds,
without_hash,
weak_hash_only,
without_license,
without_copyright,
ambiguous_placeholders,
has_concluded_license,
primary_resolvable,
direct_edge_count,
unresolved_direct,
primary_declared_no_deps,
has_depth2_edges,
all_direct_declared_leaf,
primary_in_edge_set,
orphans,
has_dynamic_edge,
direct_without_upstream,
}
}
}
#[allow(clippy::too_many_lines)]
fn fsct_minimum_expected(
sbom: &NormalizedSbom,
evidence: &FsctEvidence,
violations: &mut Vec<Violation>,
) {
let total = evidence.total;
let pkg_total = evidence.pkg_total;
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 FSCT 3e §2.2.1.1] SBOM names no person/organization author — a \
tool-only creator list does not satisfy the Author Name attribute \
(CycloneDX: metadata.authors/manufacturer; SPDX: Creator: \
Person/Organization)"
.to_string(),
element: None,
requirement: "CISA FSCT 3e §2.2.1.1: Author Name (Minimum Expected)".to_string(),
rule_id: "SBOM-FSCT-AUTHOR",
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 FSCT 3e §2.2.1.2] SBOM creation timestamp missing or unparseable \
(ISO 8601-style international format expected)"
.to_string(),
element: None,
requirement: "CISA FSCT 3e §2.2.1.2: Timestamp (Minimum Expected)".to_string(),
rule_id: "SBOM-FSCT-TIMESTAMP",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
let primary = sbom.primary_component();
if primary.is_none() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DocumentMetadata,
message: "[CISA FSCT 3e §2.2.1.4] Primary Component (root of dependencies) is not \
identified (CycloneDX: metadata.component; SPDX 2.x: documentDescribes / \
DESCRIBES; SPDX 3.0: Software.Sbom.rootElement)"
.to_string(),
element: None,
requirement: "CISA FSCT 3e §2.2.1.4: Primary Component (Minimum Expected)".to_string(),
rule_id: "SBOM-FSCT-PRIMARY",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
if evidence.primary_resolvable
&& evidence.direct_edge_count == 0
&& !evidence.primary_declared_no_deps
&& completeness_undeclared(&sbom.document.completeness_declaration)
{
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DependencyInfo,
message: "[CISA FSCT 3e §2.2.2/§2.3.3] No direct dependencies of the Primary \
Component are identified, and no completeness declaration covers their \
absence — identify all static direct dependencies or declare the \
enumeration's completeness"
.to_string(),
element: None,
requirement: "CISA FSCT 3e §2.2.2/§2.3.3: Direct dependencies (Minimum Expected)"
.to_string(),
rule_id: "SBOM-FSCT-DIRECT-DEPS",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
if !evidence.unresolved_direct.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DependencyInfo,
message: format!(
"[CISA FSCT 3e §2.2.2/§2.3.3] {} direct dependency reference(s) of the Primary \
Component resolve to no component in the inventory: {}",
evidence.unresolved_direct.len(),
truncate_list(&evidence.unresolved_direct, 5)
),
element: None,
requirement: "CISA FSCT 3e §2.2.2/§2.3.3: Direct dependencies (Minimum Expected)"
.to_string(),
rule_id: "SBOM-FSCT-DIRECT-DEPS",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
if evidence.nameless > 0 {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: format!(
"[CISA FSCT 3e §2.2.2.1] {}/{total} component(s) missing the commonly used \
public name (placeholder values do not satisfy the attribute)",
evidence.nameless
),
element: None,
requirement: "CISA FSCT 3e §2.2.2.1: Component Name (Minimum Expected)".to_string(),
rule_id: "SBOM-FSCT-COMPONENT-NAME",
component_id: None,
counts: Some(ViolationCounts {
affected: evidence.nameless,
total,
}),
standard_refs: Vec::new(),
});
}
if !evidence.without_version.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: format!(
"[CISA FSCT 3e §2.2.2.2] {}/{pkg_total} component(s) declare neither a \
supplier-provided version nor the documented fallback of an author-provided \
cryptographic hash: {}",
evidence.without_version.len(),
truncate_list(&evidence.without_version, 5)
),
element: None,
requirement: "CISA FSCT 3e §2.2.2.2: Version (Minimum Expected)".to_string(),
rule_id: "SBOM-FSCT-VERSION",
component_id: None,
counts: Some(ViolationCounts {
affected: evidence.without_version.len(),
total: pkg_total,
}),
standard_refs: Vec::new(),
});
}
if !evidence.without_supplier.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::SupplierInfo,
message: format!(
"[CISA FSCT 3e §2.2.2.3] {}/{pkg_total} component(s) declare no Supplier Name \
(an explicit 'unknown' is a permitted last resort; silent absence is not): {}",
evidence.without_supplier.len(),
truncate_list(&evidence.without_supplier, 5)
),
element: None,
requirement: "CISA FSCT 3e §2.2.2.3: Supplier Name (Minimum Expected)".to_string(),
rule_id: "SBOM-FSCT-SUPPLIER",
component_id: None,
counts: Some(ViolationCounts {
affected: evidence.without_supplier.len(),
total: pkg_total,
}),
standard_refs: Vec::new(),
});
}
if !evidence.without_identifier.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: format!(
"[CISA FSCT 3e §2.2.2.4] {}/{pkg_total} component(s) declare no globally unique \
identifier (PURL/CPE/SWHID/SWID) and no cryptographic hash usable as an \
intrinsic identifier: {}",
evidence.without_identifier.len(),
truncate_list(&evidence.without_identifier, 5)
),
element: None,
requirement: "CISA FSCT 3e §2.2.2.4: Unique Identifier (Minimum Expected)".to_string(),
rule_id: "SBOM-FSCT-IDENTIFIER",
component_id: None,
counts: Some(ViolationCounts {
affected: evidence.without_identifier.len(),
total: pkg_total,
}),
standard_refs: Vec::new(),
});
}
if !evidence.without_hash.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::IntegrityInfo,
message: format!(
"[CISA FSCT 3e §2.2.2.5] {}/{pkg_total} component(s) carry no author-provided \
cryptographic hash (enrichment-fetched hashes are not author evidence): {}",
evidence.without_hash.len(),
truncate_list(&evidence.without_hash, 5)
),
element: None,
requirement: "CISA FSCT 3e §2.2.2.5: Cryptographic Hash (Minimum Expected)".to_string(),
rule_id: "SBOM-FSCT-HASH",
component_id: None,
counts: Some(ViolationCounts {
affected: evidence.without_hash.len(),
total: pkg_total,
}),
standard_refs: Vec::new(),
});
}
if evidence.primary_resolvable
&& evidence.total > 1
&& !evidence.primary_in_edge_set
&& !evidence.primary_declared_no_deps
{
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::DependencyInfo,
message: format!(
"[CISA FSCT 3e §2.2.2.6] SBOM lists {total} components but the Primary \
Component appears in no dependency relationship — declare the primary/\
included-in relationships connecting it to its direct dependencies"
),
element: None,
requirement: "CISA FSCT 3e §2.2.2.6: Relationship (Minimum Expected)".to_string(),
rule_id: "SBOM-FSCT-RELATIONSHIP",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
if let Some(primary) = primary {
if !has_known_license(primary) {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::LicenseInfo,
message: format!(
"[CISA FSCT 3e §2.2.2.7] Primary Component '{}' has no license information \
(SPDX license identifiers in standard form preferred; NOASSERTION does not \
satisfy)",
primary.name
),
element: Some(primary.name.clone()),
requirement: "CISA FSCT 3e §2.2.2.7: License (Minimum Expected)".to_string(),
rule_id: "SBOM-FSCT-LICENSE-PRIMARY",
component_id: Some(primary.canonical_id.value().to_string()),
counts: None,
standard_refs: Vec::new(),
});
}
if !has_known_value(&primary.copyright) {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::LicenseInfo,
message: format!(
"[CISA FSCT 3e §2.2.2.8] Primary Component '{}' has no copyright notice \
(identifies the legal rights holder; SPDX: PackageCopyrightText; \
CycloneDX: component copyright)",
primary.name
),
element: Some(primary.name.clone()),
requirement: "CISA FSCT 3e §2.2.2.8: Copyright Notice (Minimum Expected)"
.to_string(),
rule_id: "SBOM-FSCT-COPYRIGHT-PRIMARY",
component_id: Some(primary.canonical_id.value().to_string()),
counts: None,
standard_refs: Vec::new(),
});
}
}
if !evidence.ambiguous_placeholders.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: format!(
"[CISA FSCT 3e §2.3.1] {}/{pkg_total} component(s) carry ambiguous placeholder \
attribute values that neither populate the attribute nor explicitly declare \
no-assertion/no-value (use NOASSERTION/NONE to differentiate 'data missing' \
from 'not applicable'): {}",
evidence.ambiguous_placeholders.len(),
truncate_list(&evidence.ambiguous_placeholders, 5)
),
element: None,
requirement: "CISA FSCT 3e §2.3.1: Unknown component attributes (Minimum Expected)"
.to_string(),
rule_id: "SBOM-FSCT-NOASSERTION",
component_id: None,
counts: Some(ViolationCounts {
affected: evidence.ambiguous_placeholders.len(),
total: pkg_total,
}),
standard_refs: Vec::new(),
});
}
}
#[allow(clippy::too_many_lines)]
fn fsct_recommended_practice(
sbom: &NormalizedSbom,
evidence: &FsctEvidence,
violations: &mut Vec<Violation>,
) {
let pkg_total = evidence.pkg_total;
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::Warning,
category: ViolationCategory::DocumentMetadata,
message: "[CISA FSCT 3e §2.2.1.1] SBOM does not identify the tool(s) that assisted \
its creation (CycloneDX: metadata.tools; SPDX: Creator: Tool)"
.to_string(),
element: None,
requirement: "CISA FSCT 3e §2.2.1.1: Author Name — creation tool (Recommended \
Practice)"
.to_string(),
rule_id: "SBOM-FSCT-AUTHOR-TOOL",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
} else if !tool_creators
.iter()
.any(|c| c.name.chars().any(|ch| ch.is_ascii_digit()))
{
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DocumentMetadata,
message: "[CISA FSCT 3e §2.2.1.1] SBOM creation tool(s) are identified without a \
discernible version (heuristic: no digit in any tool creator name)"
.to_string(),
element: None,
requirement: "CISA FSCT 3e §2.2.1.1: Author Name — creation tool version \
(Recommended Practice)"
.to_string(),
rule_id: "SBOM-FSCT-AUTHOR-TOOL",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
if evidence.primary_resolvable
&& evidence.direct_edge_count > 0
&& !evidence.has_depth2_edges
&& !evidence.all_direct_declared_leaf
&& completeness_undeclared(&sbom.document.completeness_declaration)
{
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DependencyInfo,
message: "[CISA FSCT 3e §2.2.2] Only direct dependencies of the Primary Component \
are identified — no subcomponent levels (depth ≥ 2) and no completeness \
declaration explaining their absence (heuristic threshold; profile policy)"
.to_string(),
element: None,
requirement: "CISA FSCT 3e §2.2.2: Transitive dependencies (Recommended Practice)"
.to_string(),
rule_id: "SBOM-FSCT-TRANSITIVE-DEPS",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
if !evidence.fewer_than_two_identifier_kinds.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::ComponentIdentification,
message: format!(
"[CISA FSCT 3e §2.2.2.4] {}/{pkg_total} component(s) declare fewer than two \
globally unique identifier kinds (PURL/CPE/SWHID/SWID) — list as many as are \
available (heuristic threshold; profile policy): {}",
evidence.fewer_than_two_identifier_kinds.len(),
truncate_list(&evidence.fewer_than_two_identifier_kinds, 5)
),
element: None,
requirement: "CISA FSCT 3e §2.2.2.4: Unique Identifier multiplicity (Recommended \
Practice)"
.to_string(),
rule_id: "SBOM-FSCT-IDENTIFIER-MULTI",
component_id: None,
counts: Some(ViolationCounts {
affected: evidence.fewer_than_two_identifier_kinds.len(),
total: pkg_total,
}),
standard_refs: Vec::new(),
});
}
if let Some(primary) = sbom.primary_component() {
let primary_has_authored_hash = primary
.hashes
.iter()
.any(|h| h.provenance == HashProvenance::Authored);
if !primary_has_authored_hash {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::IntegrityInfo,
message: format!(
"[CISA FSCT 3e §2.2.2.5] Primary Component '{}' has no author-provided \
cryptographic hash",
primary.name
),
element: Some(primary.name.clone()),
requirement: "CISA FSCT 3e §2.2.2.5: Primary Component hash (Recommended \
Practice)"
.to_string(),
rule_id: "SBOM-FSCT-HASH-PRIMARY-SHA2",
component_id: Some(primary.canonical_id.value().to_string()),
counts: None,
standard_refs: Vec::new(),
});
}
}
if !evidence.weak_hash_only.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::IntegrityInfo,
message: format!(
"[CISA FSCT 3e §2.2.2.5] {}/{pkg_total} component(s) carry author-provided \
hashes but none from the SHA-2 family at SHA-256 or stronger — add a \
cryptographically secure hash alongside weaker ones: {}",
evidence.weak_hash_only.len(),
truncate_list(&evidence.weak_hash_only, 5)
),
element: None,
requirement: "CISA FSCT 3e §2.2.2.5: SHA-2 family hash (Recommended Practice)"
.to_string(),
rule_id: "SBOM-FSCT-HASH-PRIMARY-SHA2",
component_id: None,
counts: Some(ViolationCounts {
affected: evidence.weak_hash_only.len(),
total: pkg_total,
}),
standard_refs: Vec::new(),
});
}
if !sbom.edges.is_empty() && !evidence.orphans.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DependencyInfo,
message: format!(
"[CISA FSCT 3e §2.2.2.6] {}/{} component(s) appear in no dependency \
relationship (orphans in the inventory): {}",
evidence.orphans.len(),
evidence.total,
truncate_list(&evidence.orphans, 5)
),
element: None,
requirement: "CISA FSCT 3e §2.2.2.6: Relationships for all components (Recommended \
Practice)"
.to_string(),
rule_id: "SBOM-FSCT-RELATIONSHIP-ALL",
component_id: None,
counts: Some(ViolationCounts {
affected: evidence.orphans.len(),
total: evidence.total,
}),
standard_refs: Vec::new(),
});
}
if completeness_undeclared(&sbom.document.completeness_declaration) {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DependencyInfo,
message: "[CISA FSCT 3e §2.2.2.6.4] No relationship-completeness assertion \
(Unknown/None/Partial/Known) is recorded (CycloneDX: \
compositions.aggregate)"
.to_string(),
element: None,
requirement: "CISA FSCT 3e §2.2.2.6.4: Relationship completeness (supplemental, \
optional)"
.to_string(),
rule_id: "SBOM-FSCT-COMPLETENESS",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
let licensed = pkg_total - evidence.without_license.len();
if pkg_total > 0 && licensed * 2 < pkg_total {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::LicenseInfo,
message: format!(
"[CISA FSCT 3e §2.2.2.7] Only {licensed}/{pkg_total} component(s) carry license \
information — provide it for as many components as possible (≥50% coverage \
floor is profile policy)"
),
element: None,
requirement: "CISA FSCT 3e §2.2.2.7: License coverage (Recommended Practice)"
.to_string(),
rule_id: "SBOM-FSCT-LICENSE-COVERAGE",
component_id: None,
counts: Some(ViolationCounts {
affected: licensed,
total: pkg_total,
}),
standard_refs: Vec::new(),
});
}
let copyrighted = pkg_total - evidence.without_copyright.len();
if pkg_total > 0 && copyrighted * 2 < pkg_total {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::LicenseInfo,
message: format!(
"[CISA FSCT 3e §2.2.2.8] Only {copyrighted}/{pkg_total} component(s) carry a \
copyright notice — provide one for as many components as possible (≥50% \
coverage floor is profile policy)"
),
element: None,
requirement: "CISA FSCT 3e §2.2.2.8: Copyright coverage (Recommended Practice)"
.to_string(),
rule_id: "SBOM-FSCT-COPYRIGHT-COVERAGE",
component_id: None,
counts: Some(ViolationCounts {
affected: copyrighted,
total: pkg_total,
}),
standard_refs: Vec::new(),
});
}
if !evidence.direct_without_upstream.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::DependencyInfo,
message: format!(
"[CISA FSCT 3e §2.3.3] {} direct dependenc(ies) of the Primary Component carry \
neither nested transitive data nor a linked upstream SBOM (BOM-type external \
reference): {}",
evidence.direct_without_upstream.len(),
truncate_list(&evidence.direct_without_upstream, 5)
),
element: None,
requirement: "CISA FSCT 3e §2.3.3: Upstream SBOM data (Recommended Practice)"
.to_string(),
rule_id: "SBOM-FSCT-UPSTREAM-SBOM",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
}
#[allow(clippy::too_many_lines)]
fn fsct_aspirational_goals(
sbom: &NormalizedSbom,
evidence: &FsctEvidence,
violations: &mut Vec<Violation>,
) {
let pkg_total = evidence.pkg_total;
if sbom.document.format == SbomFormat::CycloneDx
&& known_value(sbom.document.lifecycle_phase.as_deref()).is_none()
{
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::DocumentMetadata,
message: "[CISA FSCT 3e §2.2.1.3] SBOM Type (design/source/build/analyzed/deployed/\
runtime) is not declared (CycloneDX 1.5+: metadata.lifecycles) — the \
attribute is optional and an aspirational goal"
.to_string(),
element: None,
requirement: "CISA FSCT 3e §2.2.1.3: SBOM Type (Aspirational Goal)".to_string(),
rule_id: "SBOM-FSCT-SBOM-TYPE",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
if sbom.document.format == SbomFormat::Spdx && !evidence.has_dynamic_edge {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::DependencyInfo,
message: "[CISA FSCT 3e §2.2.2/§2.2.2.6] No dynamic, runtime, or provided \
dependency relationships are identified (DYNAMIC_LINK / \
RUNTIME_DEPENDENCY_OF / PROVIDED_DEPENDENCY_OF) — readiness note for the \
aspirational goal of identifying dynamic and remote dependencies"
.to_string(),
element: None,
requirement: "CISA FSCT 3e §2.2.2/§2.2.2.6: Dynamic and remote dependencies \
(Aspirational Goal)"
.to_string(),
rule_id: "SBOM-FSCT-DYNAMIC-DEPS",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
if !evidence.without_license.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::LicenseInfo,
message: format!(
"[CISA FSCT 3e §2.2.2.7] {}/{pkg_total} component(s) have no license \
information — the aspirational goal covers ALL listed components: {}",
evidence.without_license.len(),
truncate_list(&evidence.without_license, 5)
),
element: None,
requirement: "CISA FSCT 3e §2.2.2.7: License for all components (Aspirational Goal)"
.to_string(),
rule_id: "SBOM-FSCT-LICENSE-ALL",
component_id: None,
counts: Some(ViolationCounts {
affected: evidence.without_license.len(),
total: pkg_total,
}),
standard_refs: Vec::new(),
});
}
if sbom.document.format == SbomFormat::Spdx && pkg_total > 0 && !evidence.has_concluded_license
{
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::LicenseInfo,
message: "[CISA FSCT 3e §2.2.2.7] No component carries a concluded-license \
attestation (SPDX PackageLicenseConcluded) — aspirational alongside \
declared license information"
.to_string(),
element: None,
requirement: "CISA FSCT 3e §2.2.2.7: Concluded license attestation (Aspirational \
Goal)"
.to_string(),
rule_id: "SBOM-FSCT-LICENSE-ALL",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
if !evidence.without_copyright.is_empty() {
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::LicenseInfo,
message: format!(
"[CISA FSCT 3e §2.2.2.8] {}/{pkg_total} component(s) have no copyright notice — \
the aspirational goal covers ALL listed components: {}",
evidence.without_copyright.len(),
truncate_list(&evidence.without_copyright, 5)
),
element: None,
requirement: "CISA FSCT 3e §2.2.2.8: Copyright for all components (Aspirational \
Goal)"
.to_string(),
rule_id: "SBOM-FSCT-COPYRIGHT-ALL",
component_id: None,
counts: Some(ViolationCounts {
affected: evidence.without_copyright.len(),
total: pkg_total,
}),
standard_refs: Vec::new(),
});
}
if sbom.document.format == SbomFormat::CycloneDx
&& !sbom
.document
.signature
.as_ref()
.is_some_and(|s| s.has_value)
{
violations.push(Violation {
severity: ViolationSeverity::Info,
category: ViolationCategory::IntegrityInfo,
message: "[CISA FSCT 3e §2.4] SBOM carries no verifiable digital signature \
(CycloneDX JSF signature) — supplemental authenticity/integrity \
capability, not a Baseline Attribute"
.to_string(),
element: None,
requirement: "CISA FSCT 3e §2.4: Authenticity and integrity (supplemental)".to_string(),
rule_id: "SBOM-FSCT-SIGNATURE",
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
}