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