1use crate::model::{CreatorType, ExternalRefType, HashAlgorithm, NormalizedSbom, Severity};
6use crate::pipeline::{parse_sbom_with_context, write_output, OutputTarget};
7use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult, ViolationSeverity};
8use crate::reports::{generate_compliance_sarif, ReportFormat};
9use anyhow::{bail, Result};
10use std::collections::HashSet;
11use std::path::PathBuf;
12
13#[allow(clippy::needless_pass_by_value)]
15pub fn run_validate(
16 sbom_path: PathBuf,
17 standard: String,
18 output: ReportFormat,
19 output_file: Option<PathBuf>,
20) -> Result<()> {
21 let parsed = parse_sbom_with_context(&sbom_path, false)?;
22
23 match standard.to_lowercase().as_str() {
24 "ntia" => validate_ntia_elements(parsed.sbom())?,
25 "fda" => validate_fda_elements(parsed.sbom())?,
26 "cra" => {
27 let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
28 let result = checker.check(parsed.sbom());
29 write_compliance_output(&result, output, output_file)?;
30 }
31 _ => {
32 bail!("Unknown validation standard: {standard}");
33 }
34 }
35
36 Ok(())
37}
38
39fn write_compliance_output(
40 result: &ComplianceResult,
41 output: ReportFormat,
42 output_file: Option<PathBuf>,
43) -> Result<()> {
44 let target = OutputTarget::from_option(output_file);
45
46 let content = match output {
47 ReportFormat::Json => serde_json::to_string_pretty(result)
48 .map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
49 ReportFormat::Sarif => generate_compliance_sarif(result)?,
50 _ => format_compliance_text(result),
51 };
52
53 write_output(&content, &target, false)?;
54 Ok(())
55}
56
57fn format_compliance_text(result: &ComplianceResult) -> String {
58 let mut lines = Vec::new();
59 lines.push(format!(
60 "Compliance ({})",
61 result.level.name()
62 ));
63 lines.push(format!(
64 "Status: {} ({} errors, {} warnings, {} info)",
65 if result.is_compliant {
66 "COMPLIANT"
67 } else {
68 "NON-COMPLIANT"
69 },
70 result.error_count,
71 result.warning_count,
72 result.info_count
73 ));
74 lines.push(String::new());
75
76 if result.violations.is_empty() {
77 lines.push("No violations found.".to_string());
78 return lines.join("\n");
79 }
80
81 for v in &result.violations {
82 let severity = match v.severity {
83 ViolationSeverity::Error => "ERROR",
84 ViolationSeverity::Warning => "WARN",
85 ViolationSeverity::Info => "INFO",
86 };
87 let element = v.element.as_deref().unwrap_or("-");
88 lines.push(format!(
89 "[{}] {} | {} | {}",
90 severity,
91 v.category.name(),
92 v.requirement,
93 element
94 ));
95 lines.push(format!(" {}", v.message));
96 }
97
98 lines.join("\n")
99}
100
101#[allow(clippy::unnecessary_wraps)]
103pub fn validate_ntia_elements(sbom: &NormalizedSbom) -> Result<()> {
104 let mut issues = Vec::new();
105
106 if sbom.document.creators.is_empty() {
108 issues.push("Missing author/creator information");
109 }
110
111 for (_id, comp) in &sbom.components {
113 if comp.name.is_empty() {
114 issues.push("Component missing name");
115 }
116 if comp.version.is_none() {
117 tracing::warn!("Component '{}' missing version", comp.name);
118 }
119 if comp.supplier.is_none() {
120 tracing::warn!("Component '{}' missing supplier", comp.name);
121 }
122 if comp.identifiers.purl.is_none()
123 && comp.identifiers.cpe.is_empty()
124 && comp.identifiers.swid.is_none()
125 {
126 tracing::warn!(
127 "Component '{}' missing unique identifier (PURL/CPE/SWID)",
128 comp.name
129 );
130 }
131 }
132
133 if sbom.edges.is_empty() && sbom.component_count() > 1 {
134 issues.push("Missing dependency relationships");
135 }
136
137 if issues.is_empty() {
138 tracing::info!("SBOM passes NTIA minimum elements validation");
139 println!("NTIA Validation: PASSED");
140 } else {
141 tracing::warn!("SBOM has {} NTIA validation issues", issues.len());
142 println!("NTIA Validation: FAILED");
143 for issue in &issues {
144 println!(" - {issue}");
145 }
146 }
147
148 Ok(())
149}
150
151#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
153enum FdaSeverity {
154 Error, Warning, Info, }
158
159impl std::fmt::Display for FdaSeverity {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 match self {
162 Self::Error => write!(f, "ERROR"),
163 Self::Warning => write!(f, "WARNING"),
164 Self::Info => write!(f, "INFO"),
165 }
166 }
167}
168
169struct FdaIssue {
171 severity: FdaSeverity,
172 category: &'static str,
173 message: String,
174}
175
176#[allow(clippy::unnecessary_wraps)]
178pub fn validate_fda_elements(sbom: &NormalizedSbom) -> Result<()> {
179 let mut issues: Vec<FdaIssue> = Vec::new();
180
181 validate_fda_document(sbom, &mut issues);
183
184 let component_stats = validate_fda_components(sbom, &mut issues);
186
187 validate_fda_relationships(sbom, &mut issues);
189
190 validate_fda_vulnerabilities(sbom, &mut issues);
192
193 output_fda_results(sbom, &mut issues, &component_stats);
195
196 Ok(())
197}
198
199struct ComponentStats {
201 total: usize,
202 without_version: usize,
203 without_supplier: usize,
204 without_hash: usize,
205 without_strong_hash: usize,
206 without_identifier: usize,
207 without_support_info: usize,
208}
209
210fn validate_fda_document(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
211 if sbom.document.creators.is_empty() {
213 issues.push(FdaIssue {
214 severity: FdaSeverity::Error,
215 category: "Document",
216 message: "Missing SBOM author/manufacturer information".to_string(),
217 });
218 } else {
219 let has_org = sbom
220 .document
221 .creators
222 .iter()
223 .any(|c| c.creator_type == CreatorType::Organization);
224 if !has_org {
225 issues.push(FdaIssue {
226 severity: FdaSeverity::Warning,
227 category: "Document",
228 message: "No organization/manufacturer listed as SBOM creator".to_string(),
229 });
230 }
231
232 let has_contact = sbom.document.creators.iter().any(|c| c.email.is_some());
233 if !has_contact {
234 issues.push(FdaIssue {
235 severity: FdaSeverity::Warning,
236 category: "Document",
237 message: "No contact email provided for SBOM creators".to_string(),
238 });
239 }
240 }
241
242 if sbom.document.name.is_none() {
244 issues.push(FdaIssue {
245 severity: FdaSeverity::Warning,
246 category: "Document",
247 message: "Missing SBOM document name/title".to_string(),
248 });
249 }
250
251 if sbom.document.serial_number.is_none() {
253 issues.push(FdaIssue {
254 severity: FdaSeverity::Warning,
255 category: "Document",
256 message: "Missing SBOM serial number or document namespace".to_string(),
257 });
258 }
259}
260
261fn validate_fda_components(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) -> ComponentStats {
262 let mut stats = ComponentStats {
263 total: sbom.component_count(),
264 without_version: 0,
265 without_supplier: 0,
266 without_hash: 0,
267 without_strong_hash: 0,
268 without_identifier: 0,
269 without_support_info: 0,
270 };
271
272 for (_id, comp) in &sbom.components {
273 if comp.name.is_empty() {
274 issues.push(FdaIssue {
275 severity: FdaSeverity::Error,
276 category: "Component",
277 message: "Component has empty name".to_string(),
278 });
279 }
280
281 if comp.version.is_none() {
282 stats.without_version += 1;
283 }
284
285 if comp.supplier.is_none() {
286 stats.without_supplier += 1;
287 }
288
289 if comp.hashes.is_empty() {
290 stats.without_hash += 1;
291 } else {
292 let has_strong_hash = comp.hashes.iter().any(|h| {
293 matches!(
294 h.algorithm,
295 HashAlgorithm::Sha256
296 | HashAlgorithm::Sha384
297 | HashAlgorithm::Sha512
298 | HashAlgorithm::Sha3_256
299 | HashAlgorithm::Sha3_384
300 | HashAlgorithm::Sha3_512
301 | HashAlgorithm::Blake2b256
302 | HashAlgorithm::Blake2b384
303 | HashAlgorithm::Blake2b512
304 | HashAlgorithm::Blake3
305 )
306 });
307 if !has_strong_hash {
308 stats.without_strong_hash += 1;
309 }
310 }
311
312 if comp.identifiers.purl.is_none()
313 && comp.identifiers.cpe.is_empty()
314 && comp.identifiers.swid.is_none()
315 {
316 stats.without_identifier += 1;
317 }
318
319 let has_support_info = comp.external_refs.iter().any(|r| {
320 matches!(
321 r.ref_type,
322 ExternalRefType::Support
323 | ExternalRefType::Website
324 | ExternalRefType::SecurityContact
325 | ExternalRefType::Advisories
326 )
327 });
328 if !has_support_info {
329 stats.without_support_info += 1;
330 }
331 }
332
333 if stats.without_version > 0 {
335 issues.push(FdaIssue {
336 severity: FdaSeverity::Error,
337 category: "Component",
338 message: format!(
339 "{}/{} components missing version information",
340 stats.without_version, stats.total
341 ),
342 });
343 }
344
345 if stats.without_supplier > 0 {
346 issues.push(FdaIssue {
347 severity: FdaSeverity::Error,
348 category: "Component",
349 message: format!(
350 "{}/{} components missing supplier/manufacturer information",
351 stats.without_supplier, stats.total
352 ),
353 });
354 }
355
356 if stats.without_hash > 0 {
357 issues.push(FdaIssue {
358 severity: FdaSeverity::Error,
359 category: "Component",
360 message: format!(
361 "{}/{} components missing cryptographic hash",
362 stats.without_hash, stats.total
363 ),
364 });
365 }
366
367 if stats.without_strong_hash > 0 {
368 issues.push(FdaIssue {
369 severity: FdaSeverity::Warning,
370 category: "Component",
371 message: format!(
372 "{}/{} components have only weak hash algorithms (MD5/SHA-1). FDA recommends SHA-256 or stronger",
373 stats.without_strong_hash, stats.total
374 ),
375 });
376 }
377
378 if stats.without_identifier > 0 {
379 issues.push(FdaIssue {
380 severity: FdaSeverity::Error,
381 category: "Component",
382 message: format!(
383 "{}/{} components missing unique identifier (PURL/CPE/SWID)",
384 stats.without_identifier, stats.total
385 ),
386 });
387 }
388
389 if stats.without_support_info > 0 && stats.total > 0 {
390 let percentage = (stats.without_support_info as f64 / stats.total as f64) * 100.0;
391 if percentage > 50.0 {
392 issues.push(FdaIssue {
393 severity: FdaSeverity::Info,
394 category: "Component",
395 message: format!(
396 "{}/{} components ({:.0}%) lack support/contact information",
397 stats.without_support_info, stats.total, percentage
398 ),
399 });
400 }
401 }
402
403 stats
404}
405
406fn validate_fda_relationships(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
407 let total = sbom.component_count();
408
409 if sbom.edges.is_empty() && total > 1 {
410 issues.push(FdaIssue {
411 severity: FdaSeverity::Error,
412 category: "Dependency",
413 message: format!(
414 "No dependency relationships defined for {total} components"
415 ),
416 });
417 }
418
419 if !sbom.edges.is_empty() {
421 let mut connected: HashSet<String> = HashSet::new();
422 for edge in &sbom.edges {
423 connected.insert(edge.from.value().to_string());
424 connected.insert(edge.to.value().to_string());
425 }
426 let orphan_count = sbom
427 .components
428 .keys()
429 .filter(|id| !connected.contains(id.value()))
430 .count();
431
432 if orphan_count > 0 && orphan_count < total {
433 issues.push(FdaIssue {
434 severity: FdaSeverity::Warning,
435 category: "Dependency",
436 message: format!(
437 "{orphan_count}/{total} components have no dependency relationships (orphaned)"
438 ),
439 });
440 }
441 }
442}
443
444fn validate_fda_vulnerabilities(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
445 let vuln_info = sbom.all_vulnerabilities();
446 if !vuln_info.is_empty() {
447 let critical_vulns = vuln_info
448 .iter()
449 .filter(|(_, v)| matches!(v.severity, Some(Severity::Critical)))
450 .count();
451 let high_vulns = vuln_info
452 .iter()
453 .filter(|(_, v)| matches!(v.severity, Some(Severity::High)))
454 .count();
455
456 if critical_vulns > 0 || high_vulns > 0 {
457 issues.push(FdaIssue {
458 severity: FdaSeverity::Warning,
459 category: "Security",
460 message: format!(
461 "SBOM contains {critical_vulns} critical and {high_vulns} high severity vulnerabilities"
462 ),
463 });
464 }
465 }
466}
467
468fn output_fda_results(sbom: &NormalizedSbom, issues: &mut [FdaIssue], _stats: &ComponentStats) {
469 issues.sort_by(|a, b| a.severity.cmp(&b.severity));
471
472 let error_count = issues
473 .iter()
474 .filter(|i| i.severity == FdaSeverity::Error)
475 .count();
476 let warning_count = issues
477 .iter()
478 .filter(|i| i.severity == FdaSeverity::Warning)
479 .count();
480 let info_count = issues
481 .iter()
482 .filter(|i| i.severity == FdaSeverity::Info)
483 .count();
484
485 println!();
487 println!("===================================================================");
488 println!(" FDA Medical Device SBOM Validation Report");
489 println!("===================================================================");
490 println!();
491
492 println!(
494 "SBOM: {}",
495 sbom.document.name.as_deref().unwrap_or("(unnamed)")
496 );
497 println!(
498 "Format: {} {}",
499 sbom.document.format, sbom.document.format_version
500 );
501 println!("Components: {}", sbom.component_count());
502 println!("Dependencies: {}", sbom.edges.len());
503 println!();
504
505 if issues.is_empty() {
507 println!("PASSED - SBOM meets FDA premarket submission requirements");
508 println!();
509 } else {
510 if error_count > 0 {
511 println!(
512 "FAILED - {error_count} error(s), {warning_count} warning(s), {info_count} info"
513 );
514 } else {
515 println!(
516 "PASSED with warnings - {warning_count} warning(s), {info_count} info"
517 );
518 }
519 println!();
520
521 let categories: Vec<&str> = issues
523 .iter()
524 .map(|i| i.category)
525 .collect::<HashSet<_>>()
526 .into_iter()
527 .collect();
528
529 for category in categories {
530 println!("--- {category} ---");
531 for issue in issues.iter().filter(|i| i.category == category) {
532 let symbol = match issue.severity {
533 FdaSeverity::Error => "X",
534 FdaSeverity::Warning => "!",
535 FdaSeverity::Info => "i",
536 };
537 println!(" {} [{}] {}", symbol, issue.severity, issue.message);
538 }
539 println!();
540 }
541 }
542
543 println!("-------------------------------------------------------------------");
545 println!("Reference: FDA \"Cybersecurity in Medical Devices\" Guidance (2023)");
546 println!();
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[test]
554 fn test_fda_severity_order() {
555 assert!(FdaSeverity::Error < FdaSeverity::Warning);
556 assert!(FdaSeverity::Warning < FdaSeverity::Info);
557 }
558
559 #[test]
560 fn test_fda_severity_display() {
561 assert_eq!(format!("{}", FdaSeverity::Error), "ERROR");
562 assert_eq!(format!("{}", FdaSeverity::Warning), "WARNING");
563 assert_eq!(format!("{}", FdaSeverity::Info), "INFO");
564 }
565
566 #[test]
567 fn test_validate_empty_sbom() {
568 let sbom = NormalizedSbom::default();
569 let _ = validate_ntia_elements(&sbom);
571 }
572
573 #[test]
574 fn test_fda_document_validation() {
575 let sbom = NormalizedSbom::default();
576 let mut issues = Vec::new();
577 validate_fda_document(&sbom, &mut issues);
578 assert!(!issues.is_empty());
580 }
581}