1use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
4use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
5use crate::model::NormalizedSbom;
6use crate::quality::{
7 ComplianceLevel, ComplianceResult, StandardRef, ViolationSeverity, generic_rule_id_for_level,
8 rule_meta,
9};
10use serde::Serialize;
11
12pub struct SarifReporter {
14 include_info: bool,
16}
17
18impl SarifReporter {
19 #[must_use]
21 pub const fn new() -> Self {
22 Self { include_info: true }
23 }
24
25 #[must_use]
27 pub const fn include_info(mut self, include: bool) -> Self {
28 self.include_info = include;
29 self
30 }
31}
32
33impl Default for SarifReporter {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl ReportGenerator for SarifReporter {
40 fn generate_diff_report(
41 &self,
42 result: &DiffResult,
43 old_sbom: &NormalizedSbom,
44 new_sbom: &NormalizedSbom,
45 config: &ReportConfig,
46 ) -> Result<String, ReportError> {
47 let mut results = Vec::new();
48
49 if config.includes(ReportType::Components) {
51 for comp in &result.components.added {
52 if self.include_info {
53 results.push(SarifResult {
54 rule_id: "SBOM-TOOLS-001".to_string(),
55 level: SarifLevel::Note,
56 message: SarifMessage {
57 text: format!(
58 "Component added: {} {}",
59 comp.name,
60 comp.new_version.as_deref().unwrap_or("")
61 ),
62 },
63 locations: vec![],
64 properties: None,
65 });
66 }
67 }
68
69 for comp in &result.components.removed {
70 results.push(SarifResult {
71 rule_id: "SBOM-TOOLS-002".to_string(),
72 level: SarifLevel::Warning,
73 message: SarifMessage {
74 text: format!(
75 "Component removed: {} {}",
76 comp.name,
77 comp.old_version.as_deref().unwrap_or("")
78 ),
79 },
80 locations: vec![],
81 properties: None,
82 });
83 }
84
85 for comp in &result.components.modified {
86 if comp.change_type == crate::diff::ChangeType::Unchanged {
90 continue;
91 }
92 if self.include_info {
93 results.push(SarifResult {
94 rule_id: "SBOM-TOOLS-003".to_string(),
95 level: SarifLevel::Note,
96 message: SarifMessage {
97 text: format!(
98 "Component modified: {} {} -> {}",
99 comp.name,
100 comp.old_version.as_deref().unwrap_or("unknown"),
101 comp.new_version.as_deref().unwrap_or("unknown")
102 ),
103 },
104 locations: vec![],
105 properties: None,
106 });
107 }
108 }
109 }
110
111 if config.includes(ReportType::Vulnerabilities) {
113 for vuln in &result.vulnerabilities.introduced {
114 let depth_label = match vuln.component_depth {
115 Some(1) => " [Direct]",
116 Some(_) => " [Transitive]",
117 None => "",
118 };
119 let sla_label = format_sla_label(vuln);
120 let vex_label = format_vex_label(vuln.vex_state.as_ref());
121 results.push(SarifResult {
122 rule_id: "SBOM-TOOLS-005".to_string(),
123 level: severity_to_level(&vuln.severity),
124 message: SarifMessage {
125 text: format!(
126 "Vulnerability introduced: {} ({}){}{}{} in {} {}",
127 vuln.id,
128 vuln.severity,
129 depth_label,
130 sla_label,
131 vex_label,
132 vuln.component_name,
133 vuln.version.as_deref().unwrap_or("")
134 ),
135 },
136 locations: vec![],
137 properties: None,
138 });
139 }
140
141 for vuln in &result.vulnerabilities.resolved {
142 if self.include_info {
143 let depth_label = match vuln.component_depth {
144 Some(1) => " [Direct]",
145 Some(_) => " [Transitive]",
146 None => "",
147 };
148 let sla_label = format_sla_label(vuln);
149 let vex_label = format_vex_label(vuln.vex_state.as_ref());
150 results.push(SarifResult {
151 rule_id: "SBOM-TOOLS-006".to_string(),
152 level: SarifLevel::Note,
153 message: SarifMessage {
154 text: format!(
155 "Vulnerability resolved: {} ({}){}{}{} was in {}",
156 vuln.id,
157 vuln.severity,
158 depth_label,
159 sla_label,
160 vex_label,
161 vuln.component_name
162 ),
163 },
164 locations: vec![],
165 properties: None,
166 });
167 }
168 }
169 }
170
171 if config.includes(ReportType::Licenses) {
173 for license in &result.licenses.new_licenses {
174 results.push(SarifResult {
175 rule_id: "SBOM-TOOLS-004".to_string(),
176 level: SarifLevel::Warning,
177 message: SarifMessage {
178 text: format!(
179 "New license introduced: {} in components: {}",
180 license.license,
181 license.components.join(", ")
182 ),
183 },
184 locations: vec![],
185 properties: None,
186 });
187 }
188 }
189
190 for change in &result.metadata_changes {
192 let old = change.old_value.as_deref().unwrap_or("(none)");
193 let new = change.new_value.as_deref().unwrap_or("(none)");
194 results.push(SarifResult {
195 rule_id: "SBOM-TOOLS-008".to_string(),
196 level: SarifLevel::Note,
197 message: SarifMessage {
198 text: format!(
199 "Metadata {}: {} ({old} -> {new})",
200 change.kind, change.field
201 ),
202 },
203 locations: vec![],
204 properties: None,
205 });
206 }
207
208 for comp in new_sbom.components.values() {
210 if let Some(eol) = &comp.eol {
211 match eol.status {
212 crate::model::EolStatus::EndOfLife => {
213 let eol_date_str = eol
214 .eol_date
215 .map_or_else(String::new, |d| format!(" (EOL: {d})"));
216 results.push(SarifResult {
217 rule_id: "SBOM-EOL-001".to_string(),
218 level: SarifLevel::Error,
219 message: SarifMessage {
220 text: format!(
221 "Component '{}' version '{}' has reached end-of-life{} (product: {})",
222 comp.name,
223 comp.version.as_deref().unwrap_or("unknown"),
224 eol_date_str,
225 eol.product,
226 ),
227 },
228 locations: vec![],
229 properties: None,
230 });
231 }
232 crate::model::EolStatus::ApproachingEol => {
233 let days_str = eol
234 .days_until_eol
235 .map_or_else(String::new, |d| format!(" ({d} days remaining)"));
236 results.push(SarifResult {
237 rule_id: "SBOM-EOL-002".to_string(),
238 level: SarifLevel::Warning,
239 message: SarifMessage {
240 text: format!(
241 "Component '{}' version '{}' is approaching end-of-life{} (product: {})",
242 comp.name,
243 comp.version.as_deref().unwrap_or("unknown"),
244 days_str,
245 eol.product,
246 ),
247 },
248 locations: vec![],
249 properties: None,
250 });
251 }
252 _ => {}
253 }
254 }
255 }
256
257 let cra_old = config.old_cra_compliance_or_bare(old_sbom);
259 let cra_new = config.new_cra_compliance_or_bare(new_sbom);
260 results.extend(compliance_results_to_sarif(&cra_old, Some("Old SBOM")));
261 results.extend(compliance_results_to_sarif(&cra_new, Some("New SBOM")));
262
263 let rules = complete_rule_catalogue(get_sarif_rules(), &results);
266
267 let sarif = SarifReport {
268 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
269 version: "2.1.0".to_string(),
270 runs: vec![SarifRun {
271 tool: SarifTool {
272 driver: SarifDriver {
273 name: "sbom-tools".to_string(),
274 version: env!("CARGO_PKG_VERSION").to_string(),
275 information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
276 rules: SarifRuleWithUri::wrap_all(rules),
277 },
278 },
279 results,
280 properties: None,
281 }],
282 };
283
284 serde_json::to_string_pretty(&sarif)
285 .map_err(|e| ReportError::SerializationError(e.to_string()))
286 }
287
288 fn generate_view_report(
289 &self,
290 sbom: &NormalizedSbom,
291 config: &ReportConfig,
292 ) -> Result<String, ReportError> {
293 let mut results = Vec::new();
294
295 for (comp, vuln) in sbom.all_vulnerabilities() {
297 let severity_str = vuln
298 .severity
299 .as_ref()
300 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
301 let vex_state = vuln
302 .vex_status
303 .as_ref()
304 .map(|v| &v.status)
305 .or_else(|| comp.vex_status.as_ref().map(|v| &v.status));
306 let vex_label = format_vex_label(vex_state);
307 results.push(SarifResult {
308 rule_id: "SBOM-VIEW-001".to_string(),
309 level: severity_to_level(&severity_str),
310 message: SarifMessage {
311 text: format!(
312 "Vulnerability {} ({}){} in {} {}",
313 vuln.id,
314 severity_str,
315 vex_label,
316 comp.name,
317 comp.version.as_deref().unwrap_or("")
318 ),
319 },
320 locations: vec![],
321 properties: None,
322 });
323 }
324
325 let cra_result = config.view_cra_compliance_or_bare(sbom);
327 results.extend(compliance_results_to_sarif(&cra_result, None));
328
329 let rules = complete_rule_catalogue(get_sarif_view_rules(), &results);
331
332 let sarif = SarifReport {
333 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
334 version: "2.1.0".to_string(),
335 runs: vec![SarifRun {
336 tool: SarifTool {
337 driver: SarifDriver {
338 name: "sbom-tools".to_string(),
339 version: env!("CARGO_PKG_VERSION").to_string(),
340 information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
341 rules: SarifRuleWithUri::wrap_all(rules),
342 },
343 },
344 results,
345 properties: None,
346 }],
347 };
348
349 serde_json::to_string_pretty(&sarif)
350 .map_err(|e| ReportError::SerializationError(e.to_string()))
351 }
352
353 fn format(&self) -> ReportFormat {
354 ReportFormat::Sarif
355 }
356}
357
358fn ai_check_to_rule_id(check_id: &str) -> &'static str {
362 match check_id {
363 "AI-001" => "SBOM-AIBOM-001",
364 "AI-002" => "SBOM-AIBOM-002",
365 "AI-003" => "SBOM-AIBOM-003",
366 "AI-004" => "SBOM-AIBOM-004",
367 "AI-005" => "SBOM-AIBOM-005",
368 "AI-006" => "SBOM-AIBOM-006",
369 "AI-007" => "SBOM-AIBOM-007",
370 "AI-008" => "SBOM-AIBOM-008",
371 "AI-009" => "SBOM-AIBOM-009",
372 "AI-010" => "SBOM-AIBOM-010",
373 "AI-011" => "SBOM-AIBOM-011",
374 _ => "SBOM-AIBOM-GENERAL",
375 }
376}
377
378fn aibom_level(check_id: &str) -> SarifLevel {
383 match check_id {
384 "AI-001" | "AI-002" | "AI-003" | "AI-005" | "AI-009" | "AI-010" | "AI-011" => {
390 SarifLevel::Warning
391 }
392 _ => SarifLevel::Note,
393 }
394}
395
396fn get_sarif_aibom_rules() -> Vec<SarifRule> {
399 [
402 (
403 "SBOM-AIBOM-001",
404 "AI-001",
405 "AibomModelCardUrl",
406 "Model card URL present",
407 ),
408 (
409 "SBOM-AIBOM-002",
410 "AI-002",
411 "AibomArchitectureFamily",
412 "Architecture family declared",
413 ),
414 (
415 "SBOM-AIBOM-003",
416 "AI-003",
417 "AibomTrainingDatasets",
418 "Training datasets referenced",
419 ),
420 (
421 "SBOM-AIBOM-004",
422 "AI-004",
423 "AibomQuantitativeAnalysis",
424 "Quantitative analysis present",
425 ),
426 (
427 "SBOM-AIBOM-005",
428 "AI-005",
429 "AibomFairnessAssessment",
430 "Fairness assessments included",
431 ),
432 (
433 "SBOM-AIBOM-006",
434 "AI-006",
435 "AibomEnergyConsumption",
436 "Energy consumption disclosed",
437 ),
438 (
439 "SBOM-AIBOM-007",
440 "AI-007",
441 "AibomUseCases",
442 "Use-cases documented",
443 ),
444 (
445 "SBOM-AIBOM-008",
446 "AI-008",
447 "AibomLimitations",
448 "Known limitations stated",
449 ),
450 (
451 "SBOM-AIBOM-009",
452 "AI-009",
453 "AibomEthicalConsiderations",
454 "Ethical considerations present",
455 ),
456 (
457 "SBOM-AIBOM-010",
458 "AI-010",
459 "AibomModelWeightHashes",
460 "Model weight hashes present",
461 ),
462 (
463 "SBOM-AIBOM-011",
464 "AI-011",
465 "AibomExploitabilityReference",
466 "Exploitability/advisory reference present",
467 ),
468 (
469 "SBOM-AIBOM-GENERAL",
470 "AI-GENERAL",
471 "AibomGeneral",
472 "AI BOM model-card completeness",
473 ),
474 ]
475 .into_iter()
476 .map(|(rule_id, check_id, name, desc)| SarifRule {
477 id: rule_id.to_string(),
478 name: name.to_string(),
479 short_description: SarifMessage {
480 text: desc.to_string(),
481 },
482 default_configuration: SarifConfiguration {
483 level: aibom_level(check_id),
484 },
485 })
486 .collect()
487}
488
489pub fn generate_ai_readiness_sarif(
494 metrics: &crate::quality::AiReadinessMetrics,
495 sbom_name: &str,
496 profile: &str,
497 overall_score: Option<f32>,
498 grade: &str,
499) -> Result<String, ReportError> {
500 let results: Vec<SarifResult> = metrics
501 .checks
502 .iter()
503 .filter(|check| !check.passed)
504 .map(|check| {
505 let rule_id = ai_check_to_rule_id(&check.id);
506 let detail_suffix = check
507 .detail
508 .as_ref()
509 .map(|d| format!(" — {d}"))
510 .unwrap_or_default();
511 SarifResult {
512 rule_id: rule_id.to_string(),
513 level: aibom_level(&check.id),
514 message: SarifMessage {
515 text: format!(
516 "AIBOM check {} failed: {} ({:.0}% weight){detail_suffix}",
517 check.id,
518 check.name,
519 check.weight * 100.0
520 ),
521 },
522 locations: vec![],
523 properties: Some(SarifResultProperties {
524 standard_ids: vec![format!("AIBOM:{}", check.id)],
525 standard_help_uris: rule_help_uri(rule_id)
526 .map(|u| vec![u.to_string()])
527 .unwrap_or_default(),
528 ..SarifResultProperties::default()
529 }),
530 }
531 })
532 .collect();
533
534 let sarif = SarifReport {
535 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
536 version: "2.1.0".to_string(),
537 runs: vec![SarifRun {
538 tool: SarifTool {
539 driver: SarifDriver {
540 name: "sbom-tools".to_string(),
541 version: env!("CARGO_PKG_VERSION").to_string(),
542 information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
543 rules: SarifRuleWithUri::wrap_all(get_sarif_aibom_rules()),
544 },
545 },
546 results,
547 properties: Some(SarifRunProperties {
548 applicable: !metrics.is_not_applicable(),
549 not_applicable_reason: metrics.na_reason.clone(),
553 overall_score,
554 grade: Some(grade.to_string()),
555 sbom: Some(sbom_name.to_string()),
556 profile: Some(profile.to_string()),
557 compliant: None,
558 standards: Vec::new(),
559 }),
560 }],
561 };
562
563 serde_json::to_string_pretty(&sarif).map_err(|e| ReportError::SerializationError(e.to_string()))
564}
565
566fn complete_rule_catalogue(mut rules: Vec<SarifRule>, results: &[SarifResult]) -> Vec<SarifRule> {
572 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
573 rules.retain(|r| seen.insert(r.id.clone()));
574 for res in results {
575 if seen.contains(&res.rule_id) {
576 continue;
577 }
578 seen.insert(res.rule_id.clone());
579 if let Some(meta) = rule_meta(&res.rule_id)
583 && meta.sarif_id == res.rule_id
584 {
585 rules.push(registry_sarif_rule(res.rule_id.clone(), meta));
586 continue;
587 }
588 let name: String = res
590 .rule_id
591 .split(|c: char| !c.is_ascii_alphanumeric())
592 .filter(|seg| !seg.is_empty())
593 .map(|seg| {
594 let mut cs = seg.chars();
595 cs.next()
596 .map(|f| f.to_ascii_uppercase().to_string() + &cs.as_str().to_ascii_lowercase())
597 .unwrap_or_default()
598 })
599 .collect();
600 rules.push(SarifRule {
601 id: res.rule_id.clone(),
602 name,
603 short_description: SarifMessage {
604 text: format!("Compliance rule {}", res.rule_id),
605 },
606 default_configuration: SarifConfiguration { level: res.level },
607 });
608 }
609 rules
610}
611
612pub fn generate_compliance_sarif(result: &ComplianceResult) -> Result<String, ReportError> {
613 let results = compliance_results_to_sarif(result, None);
614 let not_applicable_reason = match &result.applicability {
617 crate::quality::Applicability::NotApplicable(reason) => Some(reason.clone()),
618 crate::quality::Applicability::Applicable => None,
619 };
620 let run_properties = Some(SarifRunProperties {
621 applicable: result.is_applicable(),
622 not_applicable_reason,
623 overall_score: result.score().map(f32::from),
624 grade: None,
625 sbom: None,
626 profile: Some(result.level.name().to_string()),
627 compliant: None,
628 standards: Vec::new(),
629 });
630 let rules = SarifRuleWithUri::wrap_all(complete_rule_catalogue(
631 get_sarif_rules_for_standard(result.level),
632 &results,
633 ));
634 let sarif = SarifReport {
635 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
636 version: "2.1.0".to_string(),
637 runs: vec![SarifRun {
638 tool: SarifTool {
639 driver: SarifDriver {
640 name: "sbom-tools".to_string(),
641 version: env!("CARGO_PKG_VERSION").to_string(),
642 information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
643 rules,
644 },
645 },
646 results,
647 properties: run_properties,
648 }],
649 };
650
651 serde_json::to_string_pretty(&sarif).map_err(|e| ReportError::SerializationError(e.to_string()))
652}
653
654pub fn generate_multi_compliance_sarif(
656 results: &[ComplianceResult],
657) -> Result<String, ReportError> {
658 let mut all_rules = Vec::new();
663 let mut all_results = Vec::new();
664
665 for result in results {
666 all_rules.extend(get_sarif_rules_for_standard(result.level));
667 all_results.extend(compliance_results_to_sarif(result, None));
668 }
669 let all_rules = complete_rule_catalogue(all_rules, &all_results);
670
671 let run_properties = Some(SarifRunProperties {
677 applicable: results.iter().any(ComplianceResult::is_applicable),
678 not_applicable_reason: None,
679 overall_score: None,
680 grade: None,
681 sbom: None,
682 profile: None,
683 compliant: None,
684 standards: results
685 .iter()
686 .map(StandardRunSummary::from_result)
687 .collect(),
688 });
689
690 let sarif = SarifReport {
691 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
692 version: "2.1.0".to_string(),
693 runs: vec![SarifRun {
694 tool: SarifTool {
695 driver: SarifDriver {
696 name: "sbom-tools".to_string(),
697 version: env!("CARGO_PKG_VERSION").to_string(),
698 information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
699 rules: SarifRuleWithUri::wrap_all(all_rules),
700 },
701 },
702 results: all_results,
703 properties: run_properties,
704 }],
705 };
706
707 serde_json::to_string_pretty(&sarif).map_err(|e| ReportError::SerializationError(e.to_string()))
708}
709
710pub fn generate_quality_sarif(
719 report: &crate::quality::QualityReport,
720 sbom_name: &str,
721 profile: &str,
722) -> Result<String, ReportError> {
723 let mut results = compliance_results_to_sarif(&report.compliance, None);
724 let mut rules = get_sarif_rules_for_standard(report.compliance.level);
725
726 let mut rec_rule_ids: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
729 for rec in &report.recommendations {
730 let rule_id = format!(
731 "SBOM-QUALITY-REC-{}",
732 rec.category.name().to_uppercase().replace(' ', "-")
733 );
734 if rec_rule_ids.insert(rule_id.clone()) {
735 rules.push(SarifRule {
736 id: rule_id.clone(),
737 name: format!(
738 "QualityRecommendation{}",
739 rec.category.name().replace(' ', "")
740 ),
741 short_description: SarifMessage {
742 text: format!("Quality recommendation: {}", rec.category.name()),
743 },
744 default_configuration: SarifConfiguration {
745 level: SarifLevel::Note,
746 },
747 });
748 }
749 results.push(SarifResult {
750 rule_id,
751 level: recommendation_level(rec.priority),
752 message: SarifMessage {
753 text: format!(
754 "{} ({} affected, +{:.1} impact)",
755 rec.message, rec.affected_count, rec.impact
756 ),
757 },
758 locations: vec![],
759 properties: Some(SarifResultProperties {
760 priority: Some(rec.priority),
761 affected_count: Some(rec.affected_count),
762 impact: Some(rec.impact),
763 ..SarifResultProperties::default()
764 }),
765 });
766 }
767
768 let rules = SarifRuleWithUri::wrap_all(complete_rule_catalogue(rules, &results));
769 let not_applicable_reason = match &report.compliance.applicability {
770 crate::quality::Applicability::NotApplicable(reason) => Some(reason.clone()),
771 crate::quality::Applicability::Applicable => None,
772 };
773 let sarif = SarifReport {
774 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
775 version: "2.1.0".to_string(),
776 runs: vec![SarifRun {
777 tool: SarifTool {
778 driver: SarifDriver {
779 name: "sbom-tools".to_string(),
780 version: env!("CARGO_PKG_VERSION").to_string(),
781 information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
782 rules,
783 },
784 },
785 results,
786 properties: Some(SarifRunProperties {
787 applicable: report.compliance.is_applicable(),
788 not_applicable_reason,
789 overall_score: Some(report.overall_score),
790 grade: Some(report.grade.letter().to_string()),
791 sbom: Some(sbom_name.to_string()),
792 profile: Some(profile.to_string()),
793 compliant: Some(report.compliance.is_compliant),
794 standards: Vec::new(),
795 }),
796 }],
797 };
798
799 serde_json::to_string_pretty(&sarif).map_err(|e| ReportError::SerializationError(e.to_string()))
800}
801
802const fn recommendation_level(priority: u8) -> SarifLevel {
806 match priority {
807 1 | 2 => SarifLevel::Warning,
808 _ => SarifLevel::Note,
809 }
810}
811
812fn severity_to_level(severity: &str) -> SarifLevel {
813 match severity.to_lowercase().as_str() {
814 "critical" | "high" => SarifLevel::Error,
815 "low" | "info" => SarifLevel::Note,
816 _ => SarifLevel::Warning,
817 }
818}
819
820fn format_sla_label(vuln: &VulnerabilityDetail) -> String {
822 match vuln.sla_status() {
823 SlaStatus::Overdue(days) => format!(" [SLA: {days}d late]"),
824 SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => format!(" [SLA: {days}d left]"),
825 SlaStatus::NoDueDate => vuln
826 .days_since_published
827 .map(|d| format!(" [Age: {d}d]"))
828 .unwrap_or_default(),
829 }
830}
831
832fn format_vex_label(vex_state: Option<&crate::model::VexState>) -> String {
833 match vex_state {
834 Some(crate::model::VexState::NotAffected) => " [VEX: Not Affected]".to_string(),
835 Some(crate::model::VexState::Fixed) => " [VEX: Fixed]".to_string(),
836 Some(crate::model::VexState::Affected) => " [VEX: Affected]".to_string(),
837 Some(crate::model::VexState::UnderInvestigation) => {
838 " [VEX: Under Investigation]".to_string()
839 }
840 None => String::new(),
841 }
842}
843
844const fn violation_severity_to_level(severity: ViolationSeverity) -> SarifLevel {
845 match severity {
846 ViolationSeverity::Error => SarifLevel::Error,
847 ViolationSeverity::Warning => SarifLevel::Warning,
848 ViolationSeverity::Info => SarifLevel::Note,
849 }
850}
851
852fn compliance_results_to_sarif(result: &ComplianceResult, label: Option<&str>) -> Vec<SarifResult> {
853 let prefix = label.map(|l| format!("{l} - ")).unwrap_or_default();
854 result
855 .violations
856 .iter()
857 .map(|v| {
858 let element = v.element.as_deref().unwrap_or("unknown");
859 let is_unmapped = v.rule_id == "SBOM-CRA-GENERAL" || rule_meta(v.rule_id).is_none();
868 let sarif_rule_id = if is_unmapped {
869 generic_rule_id_for_level(result.level)
870 } else {
871 v.sarif_rule_id()
872 };
873 let fallback_refs: Vec<StandardRef>;
878 let refs: &[StandardRef] = if is_unmapped && v.standard_refs.is_empty() {
879 fallback_refs = rule_meta(sarif_rule_id)
880 .map(|m| {
881 m.refs
882 .iter()
883 .map(|(kind, id)| StandardRef::new(*kind, *id))
884 .collect()
885 })
886 .unwrap_or_default();
887 &fallback_refs
888 } else {
889 &v.standard_refs
890 };
891 let standard_ids: Vec<String> = refs
892 .iter()
893 .map(|sr| format!("{}:{}", sarif_standard_label(sr.standard), sr.id))
894 .collect();
895 let standard_help_uris: Vec<String> =
896 refs.iter().filter_map(|sr| sr.help_uri.clone()).collect();
897 let properties = if standard_ids.is_empty()
898 && standard_help_uris.is_empty()
899 && v.component_id.is_none()
900 && v.counts.is_none()
901 {
902 None
903 } else {
904 Some(SarifResultProperties {
905 standard_ids,
906 standard_help_uris,
907 component_id: v.component_id.clone(),
908 affected: v.counts.map(|c| c.affected),
909 total: v.counts.map(|c| c.total),
910 ..SarifResultProperties::default()
911 })
912 };
913 SarifResult {
914 rule_id: sarif_rule_id.to_string(),
915 level: violation_severity_to_level(v.severity),
916 message: SarifMessage {
917 text: format!(
918 "{}{}: {} (Requirement: {}) [Element: {}]",
919 prefix,
920 result.level.name(),
921 v.message,
922 v.requirement,
923 element
924 ),
925 },
926 locations: vec![],
927 properties,
928 }
929 })
930 .collect()
931}
932
933fn rule_help_uri(rule_id: &str) -> Option<&'static str> {
937 if rule_id.starts_with("SBOM-EUCC") {
940 Some("https://eur-lex.europa.eu/eli/reg_impl/2024/482/oj/eng")
941 } else if rule_id.starts_with("SBOM-CRA-") {
942 Some("https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng")
943 } else if rule_id.starts_with("SBOM-BSI-") {
944 Some("https://bsi.bund.de/dok/TR-03183-en")
947 } else if rule_id.starts_with("SBOM-NIST-SSDF-") || rule_id.starts_with("SBOM-SSDF-") {
948 Some("https://doi.org/10.6028/NIST.SP.800-218")
949 } else if rule_id.starts_with("SBOM-EO14028-") || rule_id.starts_with("SBOM-EO-14028-") {
950 Some("https://www.federalregister.gov/d/2021-10460")
951 } else if rule_id.starts_with("SBOM-FDA-") {
952 Some("https://www.fda.gov/media/119933/download")
955 } else if rule_id.starts_with("SBOM-NTIA-") {
956 Some("https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom")
957 } else if rule_id.starts_with("SBOM-PQC-") || rule_id.starts_with("SBOM-NIST-PQC-") {
958 Some("https://csrc.nist.gov/projects/post-quantum-cryptography")
959 } else if rule_id.starts_with("SBOM-CNSA-") {
960 Some(
961 "https://media.defense.gov/2022/Sep/07/2003071834/-1/-1/0/CSA_CNSA_2.0_ALGORITHMS_.PDF",
962 )
963 } else if rule_id.starts_with("SBOM-CSAF-") {
964 Some("https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html")
965 } else if rule_id.starts_with("SBOM-AIBOM-") {
966 Some("https://cyclonedx.org/capabilities/mlbom/")
967 } else if rule_id.starts_with("SBOM-AIACT-") {
968 Some("https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng")
969 } else if rule_id.starts_with("SBOM-BSIAI-") {
970 Some(
971 "https://www.cisa.gov/resources-tools/resources/software-bill-materials-ai-minimum-elements",
972 )
973 } else if rule_id.starts_with("SBOM-CISA2026-") {
974 Some(
977 "https://www.cisa.gov/resources-tools/resources/2026-minimum-elements-software-bill-materials-sbom",
978 )
979 } else if rule_id.starts_with("SBOM-PCI-") {
980 Some("https://www.pcisecuritystandards.org/document_library/")
983 } else if rule_id.starts_with("SBOM-FSCT-") {
984 Some(
986 "https://www.cisa.gov/resources-tools/resources/framing-software-component-transparency-2024",
987 )
988 } else {
989 None
990 }
991}
992
993fn sarif_standard_label(kind: crate::quality::StandardKind) -> &'static str {
996 use crate::quality::StandardKind;
997 match kind {
998 StandardKind::CraArticle => "CRA",
999 StandardKind::CraAnnex => "CRA-Annex",
1000 StandardKind::Pren40000_1_3 => "prEN-40000-1-3",
1001 StandardKind::BsiTr03183_2 => "BSI-TR-03183-2",
1002 StandardKind::NistSsdf => "NIST-SSDF",
1003 StandardKind::Eo14028 => "EO-14028",
1004 StandardKind::FdaPremarket => "FDA",
1005 StandardKind::NtiaMinimum => "NTIA",
1006 StandardKind::Csaf2 => "CSAF",
1007 StandardKind::Cnsa2 => "CNSA-2.0",
1008 StandardKind::NistPqc => "NIST-PQC",
1009 StandardKind::EuAiAct => "EU-AI-Act",
1010 StandardKind::BsiSbomForAi => "BSI-G7-SBOM-for-AI",
1011 StandardKind::Eucc => "EUCC",
1012 StandardKind::CisaMinimum2026 => "CISA-2026",
1013 StandardKind::PciDss4 => "PCI-DSS-v4",
1014 StandardKind::CisaFsct => "CISA-FSCT-3e",
1015 StandardKind::Other => "Other",
1016 }
1017}
1018
1019fn get_sarif_rules() -> Vec<SarifRule> {
1020 let mut rules = vec![
1021 SarifRule {
1022 id: "SBOM-TOOLS-001".to_string(),
1023 name: "ComponentAdded".to_string(),
1024 short_description: SarifMessage {
1025 text: "A new component was added to the SBOM".to_string(),
1026 },
1027 default_configuration: SarifConfiguration {
1028 level: SarifLevel::Note,
1029 },
1030 },
1031 SarifRule {
1032 id: "SBOM-TOOLS-002".to_string(),
1033 name: "ComponentRemoved".to_string(),
1034 short_description: SarifMessage {
1035 text: "A component was removed from the SBOM".to_string(),
1036 },
1037 default_configuration: SarifConfiguration {
1038 level: SarifLevel::Warning,
1039 },
1040 },
1041 SarifRule {
1042 id: "SBOM-TOOLS-003".to_string(),
1043 name: "VersionChanged".to_string(),
1044 short_description: SarifMessage {
1045 text: "A component version was changed".to_string(),
1046 },
1047 default_configuration: SarifConfiguration {
1048 level: SarifLevel::Note,
1049 },
1050 },
1051 SarifRule {
1052 id: "SBOM-TOOLS-004".to_string(),
1053 name: "LicenseChanged".to_string(),
1054 short_description: SarifMessage {
1055 text: "A license was added or changed".to_string(),
1056 },
1057 default_configuration: SarifConfiguration {
1058 level: SarifLevel::Warning,
1059 },
1060 },
1061 SarifRule {
1062 id: "SBOM-TOOLS-005".to_string(),
1063 name: "VulnerabilityIntroduced".to_string(),
1064 short_description: SarifMessage {
1065 text: "A new vulnerability was introduced".to_string(),
1066 },
1067 default_configuration: SarifConfiguration {
1068 level: SarifLevel::Error,
1069 },
1070 },
1071 SarifRule {
1072 id: "SBOM-TOOLS-006".to_string(),
1073 name: "VulnerabilityResolved".to_string(),
1074 short_description: SarifMessage {
1075 text: "A vulnerability was resolved".to_string(),
1076 },
1077 default_configuration: SarifConfiguration {
1078 level: SarifLevel::Note,
1079 },
1080 },
1081 SarifRule {
1082 id: "SBOM-TOOLS-007".to_string(),
1083 name: "SupplierChanged".to_string(),
1084 short_description: SarifMessage {
1085 text: "A component supplier was changed".to_string(),
1086 },
1087 default_configuration: SarifConfiguration {
1088 level: SarifLevel::Warning,
1089 },
1090 },
1091 SarifRule {
1092 id: "SBOM-TOOLS-008".to_string(),
1093 name: "MetadataChanged".to_string(),
1094 short_description: SarifMessage {
1095 text: "A document-level metadata field was changed".to_string(),
1096 },
1097 default_configuration: SarifConfiguration {
1098 level: SarifLevel::Note,
1099 },
1100 },
1101 SarifRule {
1102 id: "SBOM-EOL-001".to_string(),
1103 name: "ComponentEndOfLife".to_string(),
1104 short_description: SarifMessage {
1105 text: "A component has reached end-of-life".to_string(),
1106 },
1107 default_configuration: SarifConfiguration {
1108 level: SarifLevel::Error,
1109 },
1110 },
1111 SarifRule {
1112 id: "SBOM-EOL-002".to_string(),
1113 name: "ComponentApproachingEol".to_string(),
1114 short_description: SarifMessage {
1115 text: "A component is approaching end-of-life".to_string(),
1116 },
1117 default_configuration: SarifConfiguration {
1118 level: SarifLevel::Warning,
1119 },
1120 },
1121 ];
1122 rules.extend(get_sarif_compliance_rules());
1123 rules
1124}
1125
1126fn get_sarif_view_rules() -> Vec<SarifRule> {
1127 let mut rules = vec![SarifRule {
1128 id: "SBOM-VIEW-001".to_string(),
1129 name: "VulnerabilityPresent".to_string(),
1130 short_description: SarifMessage {
1131 text: "A vulnerability is present in a component".to_string(),
1132 },
1133 default_configuration: SarifConfiguration {
1134 level: SarifLevel::Warning,
1135 },
1136 }];
1137 rules.extend(get_sarif_compliance_rules());
1138 rules
1139}
1140
1141fn get_sarif_rules_for_standard(level: ComplianceLevel) -> Vec<SarifRule> {
1143 match level {
1144 ComplianceLevel::NtiaMinimum => get_sarif_ntia_rules(),
1145 ComplianceLevel::FdaMedicalDevice => get_sarif_fda_rules(),
1146 ComplianceLevel::NistSsdf => get_sarif_ssdf_rules(),
1147 ComplianceLevel::Eo14028 => get_sarif_eo14028_rules(),
1148 ComplianceLevel::Cnsa2 => get_sarif_cnsa2_rules(),
1149 ComplianceLevel::NistPqc => get_sarif_pqc_rules(),
1150 ComplianceLevel::Cisa2026 => get_sarif_cisa2026_rules(),
1151 ComplianceLevel::PciDss632 => get_sarif_pcidss_rules(),
1152 ComplianceLevel::Fsct => get_sarif_fsct_rules(),
1153 _ => get_sarif_compliance_rules(),
1157 }
1158}
1159
1160fn get_sarif_ntia_rules() -> Vec<SarifRule> {
1161 registry_sarif_rules(crate::quality::NTIA_SARIF_RULE_IDS)
1162}
1163
1164fn get_sarif_fda_rules() -> Vec<SarifRule> {
1165 registry_sarif_rules(crate::quality::FDA_SARIF_RULE_IDS)
1166}
1167
1168fn get_sarif_ssdf_rules() -> Vec<SarifRule> {
1169 registry_sarif_rules(crate::quality::SSDF_SARIF_RULE_IDS)
1170}
1171
1172fn get_sarif_eo14028_rules() -> Vec<SarifRule> {
1173 registry_sarif_rules(crate::quality::EO14028_SARIF_RULE_IDS)
1174}
1175
1176fn get_sarif_cnsa2_rules() -> Vec<SarifRule> {
1177 registry_sarif_rules(crate::quality::CNSA2_SARIF_RULE_IDS)
1178}
1179
1180fn get_sarif_pqc_rules() -> Vec<SarifRule> {
1181 registry_sarif_rules(crate::quality::PQC_SARIF_RULE_IDS)
1182}
1183
1184fn get_sarif_cisa2026_rules() -> Vec<SarifRule> {
1185 registry_sarif_rules(crate::quality::CISA2026_SARIF_RULE_IDS)
1186}
1187
1188fn get_sarif_pcidss_rules() -> Vec<SarifRule> {
1189 registry_sarif_rules(crate::quality::PCIDSS_SARIF_RULE_IDS)
1190}
1191
1192fn get_sarif_fsct_rules() -> Vec<SarifRule> {
1193 registry_sarif_rules(crate::quality::FSCT_SARIF_RULE_IDS)
1194}
1195
1196fn get_sarif_compliance_rules() -> Vec<SarifRule> {
1197 registry_sarif_rules(crate::quality::COMPLIANCE_SARIF_RULE_IDS)
1198}
1199
1200fn registry_sarif_rules(ids: &[&str]) -> Vec<SarifRule> {
1207 ids.iter()
1208 .filter_map(|id| {
1209 let Some(meta) = rule_meta(id) else {
1210 debug_assert!(false, "SARIF rule slice id {id} missing from registry");
1211 return None;
1212 };
1213 debug_assert_eq!(
1214 meta.sarif_id, *id,
1215 "SARIF rule slices must list self-descriptor ids"
1216 );
1217 Some(registry_sarif_rule((*id).to_string(), meta))
1218 })
1219 .collect()
1220}
1221
1222fn registry_sarif_rule(id: String, meta: crate::quality::RuleMeta) -> SarifRule {
1224 SarifRule {
1225 id,
1226 name: meta.name.to_string(),
1227 short_description: SarifMessage {
1228 text: meta.short_description.to_string(),
1229 },
1230 default_configuration: SarifConfiguration {
1231 level: violation_severity_to_level(meta.default_severity),
1232 },
1233 }
1234}
1235
1236#[derive(Serialize)]
1239#[serde(rename_all = "camelCase")]
1240struct SarifReport {
1241 #[serde(rename = "$schema")]
1242 schema: String,
1243 version: String,
1244 runs: Vec<SarifRun>,
1245}
1246
1247#[derive(Serialize)]
1248#[serde(rename_all = "camelCase")]
1249struct SarifRun {
1250 tool: SarifTool,
1251 results: Vec<SarifResult>,
1252 #[serde(skip_serializing_if = "Option::is_none")]
1253 properties: Option<SarifRunProperties>,
1254}
1255
1256#[derive(Serialize)]
1265#[serde(rename_all = "camelCase")]
1266struct SarifRunProperties {
1267 applicable: bool,
1271 #[serde(skip_serializing_if = "Option::is_none")]
1273 not_applicable_reason: Option<String>,
1274 #[serde(skip_serializing_if = "Option::is_none")]
1279 overall_score: Option<f32>,
1280 #[serde(skip_serializing_if = "Option::is_none")]
1281 grade: Option<String>,
1282 #[serde(skip_serializing_if = "Option::is_none")]
1283 sbom: Option<String>,
1284 #[serde(skip_serializing_if = "Option::is_none")]
1287 profile: Option<String>,
1288 #[serde(skip_serializing_if = "Option::is_none")]
1290 compliant: Option<bool>,
1291 #[serde(skip_serializing_if = "Vec::is_empty")]
1297 standards: Vec<StandardRunSummary>,
1298}
1299
1300#[derive(Serialize)]
1303#[serde(rename_all = "camelCase")]
1304struct StandardRunSummary {
1305 profile: String,
1308 applicable: bool,
1310 #[serde(skip_serializing_if = "Option::is_none")]
1312 not_applicable_reason: Option<String>,
1313 #[serde(skip_serializing_if = "Option::is_none")]
1316 overall_score: Option<f32>,
1317 compliant: bool,
1320}
1321
1322impl StandardRunSummary {
1323 fn from_result(result: &ComplianceResult) -> Self {
1324 let not_applicable_reason = match &result.applicability {
1325 crate::quality::Applicability::NotApplicable(reason) => Some(reason.clone()),
1326 crate::quality::Applicability::Applicable => None,
1327 };
1328 Self {
1329 profile: result.level.name().to_string(),
1330 applicable: result.is_applicable(),
1331 not_applicable_reason,
1332 overall_score: result.score().map(f32::from),
1333 compliant: result.is_compliant,
1334 }
1335 }
1336}
1337
1338#[derive(Serialize)]
1339#[serde(rename_all = "camelCase")]
1340struct SarifTool {
1341 driver: SarifDriver,
1342}
1343
1344#[derive(Serialize)]
1345#[serde(rename_all = "camelCase")]
1346struct SarifDriver {
1347 name: String,
1348 version: String,
1349 information_uri: String,
1350 rules: Vec<SarifRuleWithUri>,
1351}
1352
1353#[derive(Serialize)]
1354#[serde(rename_all = "camelCase")]
1355struct SarifRule {
1356 id: String,
1357 name: String,
1358 short_description: SarifMessage,
1359 default_configuration: SarifConfiguration,
1360}
1361
1362#[derive(Serialize)]
1367#[serde(rename_all = "camelCase")]
1368struct SarifRuleWithUri {
1369 #[serde(flatten)]
1370 inner: SarifRule,
1371 #[serde(skip_serializing_if = "Option::is_none")]
1372 help_uri: Option<&'static str>,
1373}
1374
1375impl SarifRuleWithUri {
1376 fn wrap(inner: SarifRule) -> Self {
1377 let help_uri = rule_help_uri(&inner.id);
1378 Self { inner, help_uri }
1379 }
1380
1381 fn wrap_all(rules: Vec<SarifRule>) -> Vec<Self> {
1382 rules.into_iter().map(Self::wrap).collect()
1383 }
1384}
1385
1386#[derive(Serialize)]
1387#[serde(rename_all = "camelCase")]
1388struct SarifConfiguration {
1389 level: SarifLevel,
1390}
1391
1392#[derive(Serialize)]
1393#[serde(rename_all = "camelCase")]
1394struct SarifResult {
1395 rule_id: String,
1396 level: SarifLevel,
1397 message: SarifMessage,
1398 locations: Vec<SarifLocation>,
1399 #[serde(skip_serializing_if = "Option::is_none")]
1403 properties: Option<SarifResultProperties>,
1404}
1405
1406#[derive(Serialize, Default)]
1407#[serde(rename_all = "camelCase")]
1408struct SarifResultProperties {
1409 #[serde(skip_serializing_if = "Vec::is_empty")]
1413 standard_ids: Vec<String>,
1414 #[serde(skip_serializing_if = "Vec::is_empty")]
1418 standard_help_uris: Vec<String>,
1419 #[serde(skip_serializing_if = "Option::is_none")]
1421 priority: Option<u8>,
1422 #[serde(skip_serializing_if = "Option::is_none")]
1424 affected_count: Option<usize>,
1425 #[serde(skip_serializing_if = "Option::is_none")]
1427 impact: Option<f32>,
1428 #[serde(skip_serializing_if = "Option::is_none")]
1432 component_id: Option<String>,
1433 #[serde(skip_serializing_if = "Option::is_none")]
1436 affected: Option<usize>,
1437 #[serde(skip_serializing_if = "Option::is_none")]
1440 total: Option<usize>,
1441}
1442
1443#[derive(Serialize)]
1444#[serde(rename_all = "camelCase")]
1445struct SarifMessage {
1446 text: String,
1447}
1448
1449#[derive(Serialize)]
1450#[serde(rename_all = "camelCase")]
1451struct SarifLocation {
1452 physical_location: Option<SarifPhysicalLocation>,
1453}
1454
1455#[derive(Serialize)]
1456#[serde(rename_all = "camelCase")]
1457struct SarifPhysicalLocation {
1458 artifact_location: SarifArtifactLocation,
1459}
1460
1461#[derive(Serialize)]
1462#[serde(rename_all = "camelCase")]
1463struct SarifArtifactLocation {
1464 uri: String,
1465}
1466
1467#[derive(Serialize, Clone, Copy, Debug)]
1468#[serde(rename_all = "lowercase")]
1469enum SarifLevel {
1470 #[allow(dead_code)]
1471 None,
1472 Note,
1473 Warning,
1474 Error,
1475}
1476
1477#[cfg(test)]
1478mod registry_consistency_tests {
1479 use super::*;
1480 use crate::quality::rule_meta;
1481
1482 fn expected_level(sev: crate::quality::ViolationSeverity) -> SarifLevel {
1483 use crate::quality::ViolationSeverity as V;
1484 match sev {
1485 V::Error => SarifLevel::Error,
1486 V::Warning => SarifLevel::Warning,
1487 V::Info => SarifLevel::Note,
1488 }
1489 }
1490
1491 #[test]
1499 fn registry_severity_matches_sarif_catalogue() {
1500 let mut tables: Vec<(&str, Vec<SarifRule>)> = vec![
1501 ("ntia", get_sarif_ntia_rules()),
1502 ("fda", get_sarif_fda_rules()),
1503 ("ssdf", get_sarif_ssdf_rules()),
1504 ("eo14028", get_sarif_eo14028_rules()),
1505 ("cnsa2", get_sarif_cnsa2_rules()),
1506 ("pqc", get_sarif_pqc_rules()),
1507 ("compliance", get_sarif_compliance_rules()),
1508 ];
1509 let mut mismatches = Vec::new();
1510 for (table_name, rules) in &mut tables {
1511 for rule in rules.iter() {
1512 let Some(meta) = rule_meta(&rule.id) else {
1513 continue;
1514 };
1515 if meta.sarif_id != rule.id {
1516 continue;
1517 }
1518 let expected = expected_level(meta.default_severity);
1519 let actual = rule.default_configuration.level;
1520 if !matches!(
1521 (&expected, &actual),
1522 (SarifLevel::Error, SarifLevel::Error)
1523 | (SarifLevel::Warning, SarifLevel::Warning)
1524 | (SarifLevel::Note, SarifLevel::Note)
1525 | (SarifLevel::None, SarifLevel::None)
1526 ) {
1527 mismatches.push(format!(
1528 "{table_name}: {} registry={expected:?} catalogue={actual:?}",
1529 rule.id
1530 ));
1531 }
1532 }
1533 }
1534 assert!(
1535 mismatches.is_empty(),
1536 "registry default_severity and SARIF catalogue drifted:\n{}",
1537 mismatches.join("\n")
1538 );
1539 }
1540}