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