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