1use crate::model::{CreatorType, ExternalRefType, HashAlgorithm, NormalizedSbom, Severity};
6use crate::pipeline::{OutputTarget, parse_sbom_with_context, write_output};
7use crate::quality::{
8 ComplianceChecker, ComplianceLevel, ComplianceResult, Violation, ViolationCategory,
9 ViolationSeverity,
10};
11use crate::reports::{ReportFormat, generate_compliance_sarif};
12use anyhow::{Result, bail};
13use std::collections::HashSet;
14use std::path::PathBuf;
15
16#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
18pub fn run_validate(
19 sbom_path: PathBuf,
20 standard: String,
21 output: ReportFormat,
22 output_file: Option<PathBuf>,
23 fail_on_warning: bool,
24 summary: bool,
25 cra_sidecar_path: Option<PathBuf>,
26 cra_product_class: Option<String>,
27) -> Result<()> {
28 let parsed = parse_sbom_with_context(&sbom_path, false)?;
29
30 let cra_sidecar = match cra_sidecar_path {
32 Some(p) => Some(
33 crate::model::CraSidecarMetadata::from_file(&p).map_err(|e| {
34 anyhow::anyhow!("Failed to load CRA sidecar from {}: {e}", p.display())
35 })?,
36 ),
37 None => crate::model::CraSidecarMetadata::find_for_sbom(&sbom_path),
38 };
39
40 let cli_class = cra_product_class
44 .as_deref()
45 .and_then(crate::model::CraProductClass::parse_cli);
46 let sidecar_class = cra_sidecar.as_ref().and_then(|s| s.product_class);
47 if let (Some(cli), Some(side)) = (cli_class, sidecar_class)
48 && cli != side
49 {
50 tracing::warn!(
51 "CRA product class mismatch: --cra-product-class={} but sidecar says {}; using sidecar.",
52 cli.label(),
53 side.label()
54 );
55 }
56 let effective_class = sidecar_class.or(cli_class);
57
58 let standards: Vec<&str> = standard.split(',').map(str::trim).collect();
59 let mut results = Vec::new();
60
61 for std_name in &standards {
62 let result = match std_name.to_lowercase().as_str() {
63 "ntia" => check_ntia_compliance(parsed.sbom()),
64 "fda" => check_fda_compliance(parsed.sbom()),
65 "cra" => {
66 let mut checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
67 if let Some(sc) = cra_sidecar.clone() {
68 checker = checker.with_sidecar(sc);
69 }
70 if let Some(c) = effective_class {
71 checker = checker.with_product_class(c);
72 }
73 checker.check(parsed.sbom())
74 }
75 "ssdf" | "nist-ssdf" | "nist_ssdf" => {
76 ComplianceChecker::new(ComplianceLevel::NistSsdf).check(parsed.sbom())
77 }
78 "eo14028" | "eo-14028" | "eo_14028" => {
79 ComplianceChecker::new(ComplianceLevel::Eo14028).check(parsed.sbom())
80 }
81 "cnsa2" | "cnsa-2" | "cnsa_2" | "cnsa2.0" => {
82 ComplianceChecker::new(ComplianceLevel::Cnsa2).check(parsed.sbom())
83 }
84 "pqc" | "nist-pqc" | "nist_pqc" => {
85 ComplianceChecker::new(ComplianceLevel::NistPqc).check(parsed.sbom())
86 }
87 "bsi" | "tr-03183" | "tr03183" | "bsi-tr-03183-2" => {
88 ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(parsed.sbom())
89 }
90 "oss-steward" | "cra-oss-steward" | "cra-oss" | "cra-art24" | "art24" => {
91 let mut checker = ComplianceChecker::new(ComplianceLevel::CraOssSteward);
92 if let Some(sc) = cra_sidecar.clone() {
93 checker = checker.with_sidecar(sc);
94 }
95 checker.check(parsed.sbom())
96 }
97 "eucc" | "eucc-substantial" | "common-criteria" => {
98 let mut checker = ComplianceChecker::new(ComplianceLevel::EuccSubstantial);
99 if let Some(sc) = cra_sidecar.clone() {
100 checker = checker.with_sidecar(sc);
101 }
102 checker.check(parsed.sbom())
103 }
104 _ => {
105 bail!(
106 "Unknown validation standard: {std_name}. \
107 Valid options: ntia, fda, cra, ssdf, eo14028, cnsa2, pqc, bsi, oss-steward, eucc"
108 );
109 }
110 };
111 results.push(result);
112 }
113
114 if results.len() == 1 {
115 let result = &results[0];
116 if summary {
117 write_compliance_summary(result, output_file)?;
118 } else {
119 write_compliance_output(result, output, output_file)?;
120 }
121
122 if result.error_count > 0 {
123 std::process::exit(1);
124 }
125 if fail_on_warning && result.warning_count > 0 {
126 std::process::exit(2);
127 }
128 } else {
129 if summary {
131 write_multi_compliance_summary(&results, output_file)?;
132 } else {
133 write_multi_compliance_output(&results, output, output_file)?;
134 }
135
136 let has_errors = results.iter().any(|r| r.error_count > 0);
137 let has_warnings = results.iter().any(|r| r.warning_count > 0);
138 if has_errors {
139 std::process::exit(1);
140 }
141 if fail_on_warning && has_warnings {
142 std::process::exit(2);
143 }
144 }
145
146 Ok(())
147}
148
149fn write_compliance_output(
150 result: &ComplianceResult,
151 output: ReportFormat,
152 output_file: Option<PathBuf>,
153) -> Result<()> {
154 let target = OutputTarget::from_option(output_file);
155
156 let content = match output {
157 ReportFormat::Json => serde_json::to_string_pretty(result)
158 .map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
159 ReportFormat::Sarif => generate_compliance_sarif(result)?,
160 _ => format_compliance_text(result),
161 };
162
163 write_output(&content, &target, false)?;
164 Ok(())
165}
166
167#[derive(serde::Serialize)]
169struct ComplianceSummary {
170 standard: String,
171 compliant: bool,
172 score: u8,
173 errors: usize,
174 warnings: usize,
175 info: usize,
176}
177
178fn write_compliance_summary(result: &ComplianceResult, output_file: Option<PathBuf>) -> Result<()> {
179 let target = OutputTarget::from_option(output_file);
180 let total = result.violations.len() + 1;
181 let issues = result.error_count + result.warning_count;
182 let score = if issues >= total {
183 0
184 } else {
185 ((total - issues) * 100) / total
186 }
187 .min(100) as u8;
188
189 let summary = ComplianceSummary {
190 standard: result.level.name().to_string(),
191 compliant: result.is_compliant,
192 score,
193 errors: result.error_count,
194 warnings: result.warning_count,
195 info: result.info_count,
196 };
197 let content = serde_json::to_string(&summary)
198 .map_err(|e| anyhow::anyhow!("Failed to serialize summary: {e}"))?;
199 write_output(&content, &target, false)?;
200 Ok(())
201}
202
203fn write_multi_compliance_output(
204 results: &[ComplianceResult],
205 output: ReportFormat,
206 output_file: Option<PathBuf>,
207) -> Result<()> {
208 let target = OutputTarget::from_option(output_file);
209
210 let content = match output {
211 ReportFormat::Json => serde_json::to_string_pretty(results)
212 .map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
213 ReportFormat::Sarif => crate::reports::generate_multi_compliance_sarif(results)?,
214 _ => {
215 let mut parts = Vec::new();
216 for result in results {
217 parts.push(format_compliance_text(result));
218 }
219 parts.join("\n---\n\n")
220 }
221 };
222
223 write_output(&content, &target, false)?;
224 Ok(())
225}
226
227fn write_multi_compliance_summary(
228 results: &[ComplianceResult],
229 output_file: Option<PathBuf>,
230) -> Result<()> {
231 let target = OutputTarget::from_option(output_file);
232 let summaries: Vec<ComplianceSummary> = results
233 .iter()
234 .map(|result| {
235 let total = result.violations.len() + 1;
236 let issues = result.error_count + result.warning_count;
237 let score = if issues >= total {
238 0
239 } else {
240 ((total - issues) * 100) / total
241 }
242 .min(100) as u8;
243
244 ComplianceSummary {
245 standard: result.level.name().to_string(),
246 compliant: result.is_compliant,
247 score,
248 errors: result.error_count,
249 warnings: result.warning_count,
250 info: result.info_count,
251 }
252 })
253 .collect();
254
255 let content = serde_json::to_string(&summaries)
256 .map_err(|e| anyhow::anyhow!("Failed to serialize multi-standard summary: {e}"))?;
257 write_output(&content, &target, false)?;
258 Ok(())
259}
260
261fn format_compliance_text(result: &ComplianceResult) -> String {
262 let mut lines = Vec::new();
263 lines.push(format!("Compliance ({})", result.level.name()));
264 lines.push(format!(
265 "Status: {} ({} errors, {} warnings, {} info)",
266 if result.is_compliant {
267 "COMPLIANT"
268 } else {
269 "NON-COMPLIANT"
270 },
271 result.error_count,
272 result.warning_count,
273 result.info_count
274 ));
275 lines.push(String::new());
276
277 if result.violations.is_empty() {
278 lines.push("No violations found.".to_string());
279 return lines.join("\n");
280 }
281
282 for v in &result.violations {
283 let severity = match v.severity {
284 ViolationSeverity::Error => "ERROR",
285 ViolationSeverity::Warning => "WARN",
286 ViolationSeverity::Info => "INFO",
287 };
288 let element = v.element.as_deref().unwrap_or("-");
289 lines.push(format!(
290 "[{}] {} | {} | {}",
291 severity,
292 v.category.name(),
293 v.requirement,
294 element
295 ));
296 lines.push(format!(" {}", v.message));
297 }
298
299 lines.join("\n")
300}
301
302fn check_ntia_compliance(sbom: &NormalizedSbom) -> ComplianceResult {
304 let mut violations = Vec::new();
305
306 if sbom.document.creators.is_empty() {
307 violations.push(Violation {
308 severity: ViolationSeverity::Error,
309 category: ViolationCategory::DocumentMetadata,
310 message: "Missing author/creator information".to_string(),
311 element: None,
312 requirement: "NTIA Minimum Elements: Author".to_string(),
313 standard_refs: Vec::new(),
314 });
315 }
316
317 for (_id, comp) in &sbom.components {
318 if comp.name.is_empty() {
319 violations.push(Violation {
320 severity: ViolationSeverity::Error,
321 category: ViolationCategory::ComponentIdentification,
322 message: "Component missing name".to_string(),
323 element: None,
324 requirement: "NTIA Minimum Elements: Component Name".to_string(),
325 standard_refs: Vec::new(),
326 });
327 }
328 if comp.version.is_none() {
329 violations.push(Violation {
330 severity: ViolationSeverity::Warning,
331 category: ViolationCategory::ComponentIdentification,
332 message: format!("Component '{}' missing version", comp.name),
333 element: Some(comp.name.clone()),
334 requirement: "NTIA Minimum Elements: Version".to_string(),
335 standard_refs: Vec::new(),
336 });
337 }
338 if comp.supplier.is_none() {
339 violations.push(Violation {
340 severity: ViolationSeverity::Warning,
341 category: ViolationCategory::SupplierInfo,
342 message: format!("Component '{}' missing supplier", comp.name),
343 element: Some(comp.name.clone()),
344 requirement: "NTIA Minimum Elements: Supplier Name".to_string(),
345 standard_refs: Vec::new(),
346 });
347 }
348 if comp.identifiers.purl.is_none()
349 && comp.identifiers.cpe.is_empty()
350 && comp.identifiers.swid.is_none()
351 {
352 violations.push(Violation {
353 severity: ViolationSeverity::Warning,
354 category: ViolationCategory::ComponentIdentification,
355 message: format!(
356 "Component '{}' missing unique identifier (PURL/CPE/SWID)",
357 comp.name
358 ),
359 element: Some(comp.name.clone()),
360 requirement: "NTIA Minimum Elements: Unique Identifier".to_string(),
361 standard_refs: Vec::new(),
362 });
363 }
364 }
365
366 if sbom.edges.is_empty() && sbom.component_count() > 1 {
367 violations.push(Violation {
368 severity: ViolationSeverity::Error,
369 category: ViolationCategory::DependencyInfo,
370 message: "Missing dependency relationships".to_string(),
371 element: None,
372 requirement: "NTIA Minimum Elements: Dependency Relationship".to_string(),
373 standard_refs: Vec::new(),
374 });
375 }
376
377 ComplianceResult::new(ComplianceLevel::NtiaMinimum, violations)
378}
379
380fn check_fda_compliance(sbom: &NormalizedSbom) -> ComplianceResult {
382 let mut fda_issues: Vec<FdaIssue> = Vec::new();
383
384 validate_fda_document(sbom, &mut fda_issues);
385 validate_fda_components(sbom, &mut fda_issues);
386 validate_fda_relationships(sbom, &mut fda_issues);
387 validate_fda_vulnerabilities(sbom, &mut fda_issues);
388
389 let violations = fda_issues
390 .into_iter()
391 .map(|issue| Violation {
392 severity: match issue.severity {
393 FdaSeverity::Error => ViolationSeverity::Error,
394 FdaSeverity::Warning => ViolationSeverity::Warning,
395 FdaSeverity::Info => ViolationSeverity::Info,
396 },
397 category: match issue.category {
398 "Document" => ViolationCategory::DocumentMetadata,
399 "Component" => ViolationCategory::ComponentIdentification,
400 "Dependency" => ViolationCategory::DependencyInfo,
401 "Security" => ViolationCategory::SecurityInfo,
402 _ => ViolationCategory::DocumentMetadata,
403 },
404 requirement: format!("FDA Medical Device: {}", issue.category),
405 message: issue.message,
406 element: None,
407 standard_refs: Vec::new(),
408 })
409 .collect();
410
411 ComplianceResult::new(ComplianceLevel::FdaMedicalDevice, violations)
412}
413
414#[allow(clippy::unnecessary_wraps)]
416pub fn validate_ntia_elements(sbom: &NormalizedSbom) -> Result<()> {
417 let mut issues = Vec::new();
418
419 if sbom.document.creators.is_empty() {
421 issues.push("Missing author/creator information");
422 }
423
424 for (_id, comp) in &sbom.components {
426 if comp.name.is_empty() {
427 issues.push("Component missing name");
428 }
429 if comp.version.is_none() {
430 tracing::warn!("Component '{}' missing version", comp.name);
431 }
432 if comp.supplier.is_none() {
433 tracing::warn!("Component '{}' missing supplier", comp.name);
434 }
435 if comp.identifiers.purl.is_none()
436 && comp.identifiers.cpe.is_empty()
437 && comp.identifiers.swid.is_none()
438 {
439 tracing::warn!(
440 "Component '{}' missing unique identifier (PURL/CPE/SWID)",
441 comp.name
442 );
443 }
444 }
445
446 if sbom.edges.is_empty() && sbom.component_count() > 1 {
447 issues.push("Missing dependency relationships");
448 }
449
450 if issues.is_empty() {
451 tracing::info!("SBOM passes NTIA minimum elements validation");
452 println!("NTIA Validation: PASSED");
453 } else {
454 tracing::warn!("SBOM has {} NTIA validation issues", issues.len());
455 println!("NTIA Validation: FAILED");
456 for issue in &issues {
457 println!(" - {issue}");
458 }
459 }
460
461 Ok(())
462}
463
464#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
466enum FdaSeverity {
467 Error, Warning, Info, }
471
472impl std::fmt::Display for FdaSeverity {
473 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
474 match self {
475 Self::Error => write!(f, "ERROR"),
476 Self::Warning => write!(f, "WARNING"),
477 Self::Info => write!(f, "INFO"),
478 }
479 }
480}
481
482struct FdaIssue {
484 severity: FdaSeverity,
485 category: &'static str,
486 message: String,
487}
488
489struct ComponentStats {
491 total: usize,
492 without_version: usize,
493 without_supplier: usize,
494 without_hash: usize,
495 without_strong_hash: usize,
496 without_identifier: usize,
497 without_support_info: usize,
498}
499
500fn validate_fda_document(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
501 if sbom.document.creators.is_empty() {
503 issues.push(FdaIssue {
504 severity: FdaSeverity::Error,
505 category: "Document",
506 message: "Missing SBOM author/manufacturer information".to_string(),
507 });
508 } else {
509 let has_org = sbom
510 .document
511 .creators
512 .iter()
513 .any(|c| c.creator_type == CreatorType::Organization);
514 if !has_org {
515 issues.push(FdaIssue {
516 severity: FdaSeverity::Warning,
517 category: "Document",
518 message: "No organization/manufacturer listed as SBOM creator".to_string(),
519 });
520 }
521
522 let has_contact = sbom.document.creators.iter().any(|c| c.email.is_some());
523 if !has_contact {
524 issues.push(FdaIssue {
525 severity: FdaSeverity::Warning,
526 category: "Document",
527 message: "No contact email provided for SBOM creators".to_string(),
528 });
529 }
530 }
531
532 if sbom.document.name.is_none() {
534 issues.push(FdaIssue {
535 severity: FdaSeverity::Warning,
536 category: "Document",
537 message: "Missing SBOM document name/title".to_string(),
538 });
539 }
540
541 if sbom.document.serial_number.is_none() {
543 issues.push(FdaIssue {
544 severity: FdaSeverity::Warning,
545 category: "Document",
546 message: "Missing SBOM serial number or document namespace".to_string(),
547 });
548 }
549}
550
551fn validate_fda_components(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) -> ComponentStats {
552 let mut stats = ComponentStats {
553 total: sbom.component_count(),
554 without_version: 0,
555 without_supplier: 0,
556 without_hash: 0,
557 without_strong_hash: 0,
558 without_identifier: 0,
559 without_support_info: 0,
560 };
561
562 for (_id, comp) in &sbom.components {
563 if comp.name.is_empty() {
564 issues.push(FdaIssue {
565 severity: FdaSeverity::Error,
566 category: "Component",
567 message: "Component has empty name".to_string(),
568 });
569 }
570
571 if comp.version.is_none() {
572 stats.without_version += 1;
573 }
574
575 if comp.supplier.is_none() {
576 stats.without_supplier += 1;
577 }
578
579 if comp.hashes.is_empty() {
580 stats.without_hash += 1;
581 } else {
582 let has_strong_hash = comp.hashes.iter().any(|h| {
583 matches!(
584 h.algorithm,
585 HashAlgorithm::Sha256
586 | HashAlgorithm::Sha384
587 | HashAlgorithm::Sha512
588 | HashAlgorithm::Sha3_256
589 | HashAlgorithm::Sha3_384
590 | HashAlgorithm::Sha3_512
591 | HashAlgorithm::Blake2b256
592 | HashAlgorithm::Blake2b384
593 | HashAlgorithm::Blake2b512
594 | HashAlgorithm::Blake3
595 )
596 });
597 if !has_strong_hash {
598 stats.without_strong_hash += 1;
599 }
600 }
601
602 if comp.identifiers.purl.is_none()
603 && comp.identifiers.cpe.is_empty()
604 && comp.identifiers.swid.is_none()
605 {
606 stats.without_identifier += 1;
607 }
608
609 let has_support_info = comp.external_refs.iter().any(|r| {
610 matches!(
611 r.ref_type,
612 ExternalRefType::Support
613 | ExternalRefType::Website
614 | ExternalRefType::SecurityContact
615 | ExternalRefType::Advisories
616 )
617 });
618 if !has_support_info {
619 stats.without_support_info += 1;
620 }
621 }
622
623 if stats.without_version > 0 {
625 issues.push(FdaIssue {
626 severity: FdaSeverity::Error,
627 category: "Component",
628 message: format!(
629 "{}/{} components missing version information",
630 stats.without_version, stats.total
631 ),
632 });
633 }
634
635 if stats.without_supplier > 0 {
636 issues.push(FdaIssue {
637 severity: FdaSeverity::Error,
638 category: "Component",
639 message: format!(
640 "{}/{} components missing supplier/manufacturer information",
641 stats.without_supplier, stats.total
642 ),
643 });
644 }
645
646 if stats.without_hash > 0 {
647 issues.push(FdaIssue {
648 severity: FdaSeverity::Error,
649 category: "Component",
650 message: format!(
651 "{}/{} components missing cryptographic hash",
652 stats.without_hash, stats.total
653 ),
654 });
655 }
656
657 if stats.without_strong_hash > 0 {
658 issues.push(FdaIssue {
659 severity: FdaSeverity::Warning,
660 category: "Component",
661 message: format!(
662 "{}/{} components have only weak hash algorithms (MD5/SHA-1). FDA recommends SHA-256 or stronger",
663 stats.without_strong_hash, stats.total
664 ),
665 });
666 }
667
668 if stats.without_identifier > 0 {
669 issues.push(FdaIssue {
670 severity: FdaSeverity::Error,
671 category: "Component",
672 message: format!(
673 "{}/{} components missing unique identifier (PURL/CPE/SWID)",
674 stats.without_identifier, stats.total
675 ),
676 });
677 }
678
679 if stats.without_support_info > 0 && stats.total > 0 {
680 let percentage = (stats.without_support_info as f64 / stats.total as f64) * 100.0;
681 if percentage > 50.0 {
682 issues.push(FdaIssue {
683 severity: FdaSeverity::Info,
684 category: "Component",
685 message: format!(
686 "{}/{} components ({:.0}%) lack support/contact information",
687 stats.without_support_info, stats.total, percentage
688 ),
689 });
690 }
691 }
692
693 stats
694}
695
696fn validate_fda_relationships(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
697 let total = sbom.component_count();
698
699 if sbom.edges.is_empty() && total > 1 {
700 issues.push(FdaIssue {
701 severity: FdaSeverity::Error,
702 category: "Dependency",
703 message: format!("No dependency relationships defined for {total} components"),
704 });
705 }
706
707 if !sbom.edges.is_empty() {
709 let mut connected: HashSet<String> = HashSet::new();
710 for edge in &sbom.edges {
711 connected.insert(edge.from.value().to_string());
712 connected.insert(edge.to.value().to_string());
713 }
714 let orphan_count = sbom
715 .components
716 .keys()
717 .filter(|id| !connected.contains(id.value()))
718 .count();
719
720 if orphan_count > 0 && orphan_count < total {
721 issues.push(FdaIssue {
722 severity: FdaSeverity::Warning,
723 category: "Dependency",
724 message: format!(
725 "{orphan_count}/{total} components have no dependency relationships (orphaned)"
726 ),
727 });
728 }
729 }
730}
731
732fn validate_fda_vulnerabilities(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
733 let vuln_info = sbom.all_vulnerabilities();
734 if !vuln_info.is_empty() {
735 let critical_vulns = vuln_info
736 .iter()
737 .filter(|(_, v)| matches!(v.severity, Some(Severity::Critical)))
738 .count();
739 let high_vulns = vuln_info
740 .iter()
741 .filter(|(_, v)| matches!(v.severity, Some(Severity::High)))
742 .count();
743
744 if critical_vulns > 0 || high_vulns > 0 {
745 issues.push(FdaIssue {
746 severity: FdaSeverity::Warning,
747 category: "Security",
748 message: format!(
749 "SBOM contains {critical_vulns} critical and {high_vulns} high severity vulnerabilities"
750 ),
751 });
752 }
753 }
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759
760 #[test]
761 fn test_fda_severity_order() {
762 assert!(FdaSeverity::Error < FdaSeverity::Warning);
763 assert!(FdaSeverity::Warning < FdaSeverity::Info);
764 }
765
766 #[test]
767 fn test_fda_severity_display() {
768 assert_eq!(format!("{}", FdaSeverity::Error), "ERROR");
769 assert_eq!(format!("{}", FdaSeverity::Warning), "WARNING");
770 assert_eq!(format!("{}", FdaSeverity::Info), "INFO");
771 }
772
773 #[test]
774 fn test_validate_empty_sbom() {
775 let sbom = NormalizedSbom::default();
776 let _ = validate_ntia_elements(&sbom);
778 }
779
780 #[test]
781 fn test_fda_document_validation() {
782 let sbom = NormalizedSbom::default();
783 let mut issues = Vec::new();
784 validate_fda_document(&sbom, &mut issues);
785 assert!(!issues.is_empty());
787 }
788}