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