sbom-tools 0.2.0

Semantic SBOM diff and analysis tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
//! CISA 2026 Minimum Elements profile checks.
//!
//! Standard: "2026 Minimum Elements for a Software Bill of Materials (SBOM)",
//! Version 2.1, July 29, 2026 — jointly sealed by CISA, NSA, FBI, and 15
//! international partner agencies. Updates and replaces the NTIA "Minimum
//! Elements for an SBOM" (v1.0, July 12, 2021); the August 2025 CISA draft
//! was v2.0 of the same document. Non-binding joint guidance: it explicitly
//! does not create regulatory requirements.
//!
//! The rule catalogue for this profile lives in the registry
//! (`SBOM-CISA2026-*`, see `CISA2026_SARIF_RULE_IDS`), covering the 17 data
//! fields plus the document-checkable practices (Coverage / Explicitly
//! Identifying Unknown Information, Machine-Processable Data). The
//! organizational practices with no in-document evidence — Frequency,
//! Distribution and Delivery, Accommodation of Updates to SBOM Data — carry
//! no rules by design.
//!
//! Severity convention (CISA assigns none): required data fields = Error;
//! evidence-limited or heuristic checks = Warning. A check is
//! evidence-limited when at least one conforming way to satisfy the element
//! is invisible to a document checker (e.g., detached signatures, SPDX 2.x
//! fields that do not exist), so absence must not hard-fail the run.
//!
//! Escape hatches: several 2026 elements require the author to *explicitly*
//! state that information is unknown instead of omitting it (Component
//! Version, Component Producer, Component Hash Value, Component License).
//! For those rules an explicit sentinel (`NOASSERTION`/`NONE`/`UNKNOWN`)
//! satisfies the element — deliberately unlike the NTIA checks, where
//! sentinels count as missing — while silent absence fails.

use super::*;

/// Parse a dotted spec version ("1.6", "3.0.1") into numeric
/// `(major, minor, patch)`. Missing trailing segments count as zero; a
/// present-but-non-numeric segment (or an empty string) yields `None` so the
/// caller can skip the comparison instead of false-failing synthetic
/// documents that carry no version. (Same skip-when-unparseable posture as
/// the BSI TR-03183-2 §4 gate in `bsi.rs`.)
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))
}

/// Explicit-unknown sentinel: the marker an SBOM author uses to state that a
/// value is unknown rather than silently omitting it. The 2026 elements'
/// escape hatches ("the SBOM author should indicate the information is
/// unknown") make these markers *satisfy* the honesty requirement, so this
/// is deliberately the complement of `known_value` (which treats the same
/// sentinels as absent for the NTIA-style presence gates).
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")
}

/// Heuristic for the SBOM Tool Version element: whether a Tool creator's
/// name carries a version identifier (or an explicit unknown marker).
///
/// Heuristic by necessity: the parsers concatenate tool name and version
/// into one creator name — CycloneDX `metadata.tools` becomes
/// `"{name} {version}"` and the SPDX `Creator: Tool:` convention is
/// `name-version` — so there is no dedicated tool-version field to check.
/// A trailing whitespace- or hyphen-delimited segment containing an ASCII
/// digit counts as a version; a tool genuinely named with a trailing digit
/// (e.g. "syft2") is an accepted false pass, which is why the rule is a
/// Warning with heuristic wording rather than an Error.
fn tool_name_carries_version(name: &str) -> bool {
    let name = name.trim();
    // The element's escape hatch: an explicit unknown marker satisfies it.
    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())
}

/// Whether a hash value is ASCII-hexadecimal encoded (the Component Hash
/// Value element's required encoding).
fn is_ascii_hex(value: &str) -> bool {
    !value.is_empty() && value.chars().all(|c| c.is_ascii_hexdigit())
}

impl ComplianceChecker {
    // ════════════════════════════════════════════════════════════════════
    // CISA 2026 Minimum Elements (successor to NTIA 2021)
    // ════════════════════════════════════════════════════════════════════

    /// CISA 2026 Minimum Elements compliance checks.
    ///
    /// Document-level elements first (SBOM Author, Author Signature, Data
    /// Format, Generation Context, Timestamp, Tool Name/Version, SBOM
    /// Version), then the per-component data fields (Producer, Name,
    /// Version, Identifiers, Hash Value/Algorithm, License), then the
    /// graph-level Dependency Relationship element and the Coverage /
    /// Explicitly Identifying Unknown Information declaration check.
    #[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,
        };

        // SBOM Author (Error) — "the name of the entity that creates the
        // SBOM data": the entity operating the generation tool, not the tool
        // itself, so a tool-only creator list fails. Deliberately stricter
        // than SBOM-NTIA-AUTHOR, which passes on any non-empty creator list.
        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(),
            });
        }

        // SBOM Author Signature (Warning — evidence-limited). In-document
        // evidence exists only as a CycloneDX JSF signature or an SPDX 3
        // verifiedUsing signature entry; SPDX 2.x cannot express a signature
        // in-document and detached signatures are invisible to a document
        // checker, so absence must not hard-fail the run. Presence is
        // structural (algorithm + non-empty value) — nothing is
        // cryptographically verified.
        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(),
            });
        }

        // SBOM Data Format Name / Version + Machine-Processable Data
        // (Warning). The format name is vacuously satisfied post-parse; the
        // enforceable check is the deprecated-version gate. CISA names no
        // deprecated versions ("versions declared to be deprecated by the
        // organizations maintaining the data format should not be used"), so
        // the concrete floor — CycloneDX 1.4+ / SPDX 2.2+ — is tool policy
        // mirroring the repo's EO 14028 machine-readable gate, and the
        // message says so. Unparseable/absent spec versions skip the gate
        // rather than false-failing (parse_spec_version).
        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(),
            });
        }

        // SBOM Generation Context (Warning — evidence-limited): the relative
        // lifecycle phase at generation time ("before build" / "build" /
        // "after build", or a more specific identifier — free-form names are
        // accepted per the element). Only CycloneDX 1.5+ metadata.lifecycles
        // populates the field; SPDX 2.x has no standard field and its
        // parsers yield None, so an Error would condemn every SPDX 2.x SBOM
        // regardless of author diligence.
        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(),
            });
        }

        // SBOM Timestamp (Error) — "date and time of the most recent update
        // to the SBOM data". Parsers normalize timestamps to DateTime<Utc>
        // and substitute the UNIX_EPOCH sentinel for a missing/unparseable
        // source value, so the check degrades to has_known_timestamp(): the
        // element's RFC 9557 source-syntax requirement is unverifiable
        // post-normalization and is NOT claimed to be checked here.
        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(),
            });
        }

        // SBOM Tool Name (Error) and SBOM Tool Version (Warning —
        // heuristic). The version check only runs when a tool is identified
        // at all: with no Tool creator the TOOL rule already covers the gap,
        // and firing both would double-report one omission.
        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(),
            });
        }

        // SBOM Version (Warning — evidence-limited). The document should
        // declare its own version: CycloneDX bom.version surfaces as
        // DocumentMetadata.doc_version (the parser preserves absence as None
        // — no default-1 backfill), and a version-distinguishing serial
        // identifier (CycloneDX serialNumber / SPDX documentNamespace,
        // RFC 9562-style) is accepted as alternative evidence. A CycloneDX
        // document that omits bom.version AND carries no serialNumber has
        // declared nothing version-distinguishing and fails — this check
        // deliberately does not honour the CycloneDX default of 1, because
        // the element asks the author to declare the version, not the parser
        // to assume it. Warning because SPDX 2.x has no dedicated
        // document-version field at all.
        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(),
            });
        }

        // Per-component data fields.
        for comp in sbom.components.values() {
            // Component Name (Error) — applies to every enumerated entry.
            // Placeholder sentinels count as missing unless corroborated by
            // a matching identifier (known_component_name).
            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(),
                });
            }

            // File/snippet inventory entries are name+hash records, not
            // packages: the producer / version / identifier / hash / license
            // elements below do not apply to them (same carve-out as the
            // NTIA-path checks — without it a file-cataloguing SBOM emits
            // thousands of spurious Errors).
            if matches!(comp.component_type, ComponentType::File) {
                continue;
            }

            // Component Producer (Error) — the 2026 rename of the ambiguous
            // "Supplier Name". Author/originator (SPDX PackageOriginator) is
            // the closest evidence for "the entity that creates, defines,
            // and identifies components"; supplier is accepted; an explicit
            // unknown-provenance marker satisfies the element's escape hatch
            // ("the SBOM author should explicitly mark the component as of
            // unknown provenance"). Only silent absence fails.
            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(),
                });
            }

            // Component Version (Error). Explicit-unknown markers
            // (NOASSERTION/'unknown') satisfy the 2026 escape hatch and pass
            // — the honesty requirement is the point of the Major Update —
            // so only silent absence (no version string at all) fails.
            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(),
                });
            }

            // Component Identifiers (Error) — at least one common,
            // machine-processable identifier. Evidence: PURL/CPE/SWHID/SWID
            // (the model's identifier set). "Include all known identifiers"
            // is unenforceable (no oracle for what the author knew) and
            // stays out of the check.
            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(),
                });
            }

            // Component Hash Value (Error) + Component Hash Algorithm
            // (Warning). Only author-attested hashes count: enrichment adds
            // registry-fetched hashes to the same field post-parse, and
            // those are not part of the document under assessment (same
            // provenance posture as the BSI §5.2.2 hash check). The value
            // must be ASCII-hexadecimal per the element; an explicit unknown
            // marker satisfies the escape hatch for authors without access
            // to the executable artifact.
            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 {
                // Hash-value evidence is sound — assess the algorithms.
                // "The algorithm should be approved by a relevant authority,
                // such as NIST" is a 'should', hence Warning: MD5 was never
                // FIPS-approved; SHA-1 is deprecated with withdrawal slated
                // by 2030; an unrecognized algorithm cannot be mapped to an
                // IANA Hash Function Textual Name.
                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(),
                    });
                }
            }

            // Component License (Error). Any declared/concluded expression
            // counts: SPDX identifiers are preferred, but LicenseRef-*
            // expressions, license names, and URL pointers all satisfy
            // "indicate through other means where the full license details
            // are available", and NOASSERTION is the element's explicit
            // unknown. Only silent absence of any license information fails.
            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(),
                });
            }
        }

        // Component Dependency Relationship (Error) — "the relationship
        // between two components, where one component is necessary for the
        // operation of the other". A single-component SBOM has no
        // relationships to express; a multi-component SBOM must declare a
        // dependency graph, or link to separate SBOM documents per
        // dependency (the element's linking allowance — an external
        // reference of BOM type is accepted as alternative evidence).
        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(),
                });
            }
        }

        // Coverage / Explicitly Identifying Unknown Information (Warning) —
        // document-level honesty check for the two partially-checkable
        // practices: the SBOM should declare its completeness (CycloneDX
        // compositions aggregate). This rule verifies the *declaration*, not
        // actual completeness — the 2026 document itself points to external
        // repositories / binary analysis for that. A declared-incomplete
        // inventory still warns (the 2026 Coverage element expects all
        // components including transitive dependencies) but with a message
        // acknowledging the declaration is honest.
        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 => {}
        }
    }
}