Skip to main content

sbom_tools/reports/
sarif.rs

1//! SARIF 2.1.0 report generator for CI/CD integration.
2
3use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
4use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
5use crate::model::NormalizedSbom;
6use crate::quality::{
7    ComplianceChecker, ComplianceLevel, ComplianceResult, ViolationSeverity, rule_meta,
8};
9use serde::Serialize;
10
11/// SARIF report generator
12pub struct SarifReporter {
13    /// Include informational results
14    include_info: bool,
15}
16
17impl SarifReporter {
18    /// Create a new SARIF reporter
19    #[must_use]
20    pub const fn new() -> Self {
21        Self { include_info: true }
22    }
23
24    /// Set whether to include informational results
25    #[must_use]
26    pub const fn include_info(mut self, include: bool) -> Self {
27        self.include_info = include;
28        self
29    }
30}
31
32impl Default for SarifReporter {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl ReportGenerator for SarifReporter {
39    fn generate_diff_report(
40        &self,
41        result: &DiffResult,
42        old_sbom: &NormalizedSbom,
43        new_sbom: &NormalizedSbom,
44        config: &ReportConfig,
45    ) -> Result<String, ReportError> {
46        let mut results = Vec::new();
47
48        // Add component change results
49        if config.includes(ReportType::Components) {
50            for comp in &result.components.added {
51                if self.include_info {
52                    results.push(SarifResult {
53                        rule_id: "SBOM-TOOLS-001".to_string(),
54                        level: SarifLevel::Note,
55                        message: SarifMessage {
56                            text: format!(
57                                "Component added: {} {}",
58                                comp.name,
59                                comp.new_version.as_deref().unwrap_or("")
60                            ),
61                        },
62                        locations: vec![],
63                        properties: None,
64                    });
65                }
66            }
67
68            for comp in &result.components.removed {
69                results.push(SarifResult {
70                    rule_id: "SBOM-TOOLS-002".to_string(),
71                    level: SarifLevel::Warning,
72                    message: SarifMessage {
73                        text: format!(
74                            "Component removed: {} {}",
75                            comp.name,
76                            comp.old_version.as_deref().unwrap_or("")
77                        ),
78                    },
79                    locations: vec![],
80                    properties: None,
81                });
82            }
83
84            for comp in &result.components.modified {
85                if self.include_info {
86                    results.push(SarifResult {
87                        rule_id: "SBOM-TOOLS-003".to_string(),
88                        level: SarifLevel::Note,
89                        message: SarifMessage {
90                            text: format!(
91                                "Component modified: {} {} -> {}",
92                                comp.name,
93                                comp.old_version.as_deref().unwrap_or("unknown"),
94                                comp.new_version.as_deref().unwrap_or("unknown")
95                            ),
96                        },
97                        locations: vec![],
98                        properties: None,
99                    });
100                }
101            }
102        }
103
104        // Add vulnerability results
105        if config.includes(ReportType::Vulnerabilities) {
106            for vuln in &result.vulnerabilities.introduced {
107                let depth_label = match vuln.component_depth {
108                    Some(1) => " [Direct]",
109                    Some(_) => " [Transitive]",
110                    None => "",
111                };
112                let sla_label = format_sla_label(vuln);
113                let vex_label = format_vex_label(vuln.vex_state.as_ref());
114                results.push(SarifResult {
115                    rule_id: "SBOM-TOOLS-005".to_string(),
116                    level: severity_to_level(&vuln.severity),
117                    message: SarifMessage {
118                        text: format!(
119                            "Vulnerability introduced: {} ({}){}{}{} in {} {}",
120                            vuln.id,
121                            vuln.severity,
122                            depth_label,
123                            sla_label,
124                            vex_label,
125                            vuln.component_name,
126                            vuln.version.as_deref().unwrap_or("")
127                        ),
128                    },
129                    locations: vec![],
130                    properties: None,
131                });
132            }
133
134            for vuln in &result.vulnerabilities.resolved {
135                if self.include_info {
136                    let depth_label = match vuln.component_depth {
137                        Some(1) => " [Direct]",
138                        Some(_) => " [Transitive]",
139                        None => "",
140                    };
141                    let sla_label = format_sla_label(vuln);
142                    let vex_label = format_vex_label(vuln.vex_state.as_ref());
143                    results.push(SarifResult {
144                        rule_id: "SBOM-TOOLS-006".to_string(),
145                        level: SarifLevel::Note,
146                        message: SarifMessage {
147                            text: format!(
148                                "Vulnerability resolved: {} ({}){}{}{} was in {}",
149                                vuln.id,
150                                vuln.severity,
151                                depth_label,
152                                sla_label,
153                                vex_label,
154                                vuln.component_name
155                            ),
156                        },
157                        locations: vec![],
158                        properties: None,
159                    });
160                }
161            }
162        }
163
164        // Add license change results
165        if config.includes(ReportType::Licenses) {
166            for license in &result.licenses.new_licenses {
167                results.push(SarifResult {
168                    rule_id: "SBOM-TOOLS-004".to_string(),
169                    level: SarifLevel::Warning,
170                    message: SarifMessage {
171                        text: format!(
172                            "New license introduced: {} in components: {}",
173                            license.license,
174                            license.components.join(", ")
175                        ),
176                    },
177                    locations: vec![],
178                    properties: None,
179                });
180            }
181        }
182
183        // Add document-metadata change results (author/tool/timestamp/spec-version/etc.)
184        for change in &result.metadata_changes {
185            let old = change.old_value.as_deref().unwrap_or("(none)");
186            let new = change.new_value.as_deref().unwrap_or("(none)");
187            results.push(SarifResult {
188                rule_id: "SBOM-TOOLS-008".to_string(),
189                level: SarifLevel::Note,
190                message: SarifMessage {
191                    text: format!(
192                        "Metadata {}: {} ({old} -> {new})",
193                        change.kind, change.field
194                    ),
195                },
196                locations: vec![],
197                properties: None,
198            });
199        }
200
201        // Add EOL results (from new SBOM)
202        for comp in new_sbom.components.values() {
203            if let Some(eol) = &comp.eol {
204                match eol.status {
205                    crate::model::EolStatus::EndOfLife => {
206                        let eol_date_str = eol
207                            .eol_date
208                            .map_or_else(String::new, |d| format!(" (EOL: {d})"));
209                        results.push(SarifResult {
210                            rule_id: "SBOM-EOL-001".to_string(),
211                            level: SarifLevel::Error,
212                            message: SarifMessage {
213                                text: format!(
214                                    "Component '{}' version '{}' has reached end-of-life{} (product: {})",
215                                    comp.name,
216                                    comp.version.as_deref().unwrap_or("unknown"),
217                                    eol_date_str,
218                                    eol.product,
219                                ),
220                            },
221                            locations: vec![],
222                            properties: None,
223                        });
224                    }
225                    crate::model::EolStatus::ApproachingEol => {
226                        let days_str = eol
227                            .days_until_eol
228                            .map_or_else(String::new, |d| format!(" ({d} days remaining)"));
229                        results.push(SarifResult {
230                            rule_id: "SBOM-EOL-002".to_string(),
231                            level: SarifLevel::Warning,
232                            message: SarifMessage {
233                                text: format!(
234                                    "Component '{}' version '{}' is approaching end-of-life{} (product: {})",
235                                    comp.name,
236                                    comp.version.as_deref().unwrap_or("unknown"),
237                                    days_str,
238                                    eol.product,
239                                ),
240                            },
241                            locations: vec![],
242                            properties: None,
243                        });
244                    }
245                    _ => {}
246                }
247            }
248        }
249
250        // Add CRA compliance results for old and new SBOMs (use pre-computed if available)
251        let cra_old = config
252            .old_cra_compliance
253            .clone()
254            .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom));
255        let cra_new = config
256            .new_cra_compliance
257            .clone()
258            .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom));
259        results.extend(compliance_results_to_sarif(&cra_old, Some("Old SBOM")));
260        results.extend(compliance_results_to_sarif(&cra_new, Some("New SBOM")));
261
262        let sarif = SarifReport {
263            schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
264            version: "2.1.0".to_string(),
265            runs: vec![SarifRun {
266                tool: SarifTool {
267                    driver: SarifDriver {
268                        name: "sbom-tools".to_string(),
269                        version: env!("CARGO_PKG_VERSION").to_string(),
270                        information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
271                        rules: SarifRuleWithUri::wrap_all(get_sarif_rules()),
272                    },
273                },
274                results,
275                properties: None,
276            }],
277        };
278
279        serde_json::to_string_pretty(&sarif)
280            .map_err(|e| ReportError::SerializationError(e.to_string()))
281    }
282
283    fn generate_view_report(
284        &self,
285        sbom: &NormalizedSbom,
286        config: &ReportConfig,
287    ) -> Result<String, ReportError> {
288        let mut results = Vec::new();
289
290        // Report vulnerabilities in the SBOM
291        for (comp, vuln) in sbom.all_vulnerabilities() {
292            let severity_str = vuln
293                .severity
294                .as_ref()
295                .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
296            let vex_state = vuln
297                .vex_status
298                .as_ref()
299                .map(|v| &v.status)
300                .or_else(|| comp.vex_status.as_ref().map(|v| &v.status));
301            let vex_label = format_vex_label(vex_state);
302            results.push(SarifResult {
303                rule_id: "SBOM-VIEW-001".to_string(),
304                level: severity_to_level(&severity_str),
305                message: SarifMessage {
306                    text: format!(
307                        "Vulnerability {} ({}){} in {} {}",
308                        vuln.id,
309                        severity_str,
310                        vex_label,
311                        comp.name,
312                        comp.version.as_deref().unwrap_or("")
313                    ),
314                },
315                locations: vec![],
316                properties: None,
317            });
318        }
319
320        // Add CRA compliance results (use pre-computed if available)
321        let cra_result = config
322            .view_cra_compliance
323            .clone()
324            .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
325        results.extend(compliance_results_to_sarif(&cra_result, None));
326
327        let sarif = SarifReport {
328            schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
329            version: "2.1.0".to_string(),
330            runs: vec![SarifRun {
331                tool: SarifTool {
332                    driver: SarifDriver {
333                        name: "sbom-tools".to_string(),
334                        version: env!("CARGO_PKG_VERSION").to_string(),
335                        information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
336                        rules: SarifRuleWithUri::wrap_all(get_sarif_view_rules()),
337                    },
338                },
339                results,
340                properties: None,
341            }],
342        };
343
344        serde_json::to_string_pretty(&sarif)
345            .map_err(|e| ReportError::SerializationError(e.to_string()))
346    }
347
348    fn format(&self) -> ReportFormat {
349        ReportFormat::Sarif
350    }
351}
352
353/// Map an AI-readiness check ID (`AI-001`..`AI-011`) to its SARIF rule ID.
354/// Unknown IDs fall back to the `SBOM-AIBOM-GENERAL` rule so a future check
355/// never silently drops (`AiCheck`/`AiReadinessMetrics` are `#[non_exhaustive]`).
356fn ai_check_to_rule_id(check_id: &str) -> &'static str {
357    match check_id {
358        "AI-001" => "SBOM-AIBOM-001",
359        "AI-002" => "SBOM-AIBOM-002",
360        "AI-003" => "SBOM-AIBOM-003",
361        "AI-004" => "SBOM-AIBOM-004",
362        "AI-005" => "SBOM-AIBOM-005",
363        "AI-006" => "SBOM-AIBOM-006",
364        "AI-007" => "SBOM-AIBOM-007",
365        "AI-008" => "SBOM-AIBOM-008",
366        "AI-009" => "SBOM-AIBOM-009",
367        "AI-010" => "SBOM-AIBOM-010",
368        "AI-011" => "SBOM-AIBOM-011",
369        _ => "SBOM-AIBOM-GENERAL",
370    }
371}
372
373/// Default SARIF severity for each AI-readiness check. AI transparency is a
374/// best-practice (not a mandated minimum element), so there are no hard
375/// `error`s; documentation gaps are `warning`, softer/contextual gaps `note`.
376/// This is the single source of truth shared by the rule table and the results.
377fn aibom_level(check_id: &str) -> SarifLevel {
378    match check_id {
379        // AI-010 is the weight-hash integrity check and AI-011 the
380        // exploitability/advisory-reference check: both are load-bearing
381        // security signals (tamper verification and vulnerability tooling
382        // linkage), so they are `warning` like the other load-bearing checks
383        // rather than a soft `note`.
384        "AI-001" | "AI-002" | "AI-003" | "AI-005" | "AI-009" | "AI-010" | "AI-011" => {
385            SarifLevel::Warning
386        }
387        _ => SarifLevel::Note,
388    }
389}
390
391/// SARIF rule table for the AI BOM model-card completeness checks. The
392/// `short_description` text matches the scorer's `CHECK_DEFS` names exactly.
393fn get_sarif_aibom_rules() -> Vec<SarifRule> {
394    // (rule id, AI check id for level lookup, PascalCase name, description).
395    // Descriptions match the scorer's CHECK_DEFS names exactly.
396    [
397        (
398            "SBOM-AIBOM-001",
399            "AI-001",
400            "AibomModelCardUrl",
401            "Model card URL present",
402        ),
403        (
404            "SBOM-AIBOM-002",
405            "AI-002",
406            "AibomArchitectureFamily",
407            "Architecture family declared",
408        ),
409        (
410            "SBOM-AIBOM-003",
411            "AI-003",
412            "AibomTrainingDatasets",
413            "Training datasets referenced",
414        ),
415        (
416            "SBOM-AIBOM-004",
417            "AI-004",
418            "AibomQuantitativeAnalysis",
419            "Quantitative analysis present",
420        ),
421        (
422            "SBOM-AIBOM-005",
423            "AI-005",
424            "AibomFairnessAssessment",
425            "Fairness assessments included",
426        ),
427        (
428            "SBOM-AIBOM-006",
429            "AI-006",
430            "AibomEnergyConsumption",
431            "Energy consumption disclosed",
432        ),
433        (
434            "SBOM-AIBOM-007",
435            "AI-007",
436            "AibomUseCases",
437            "Use-cases documented",
438        ),
439        (
440            "SBOM-AIBOM-008",
441            "AI-008",
442            "AibomLimitations",
443            "Known limitations stated",
444        ),
445        (
446            "SBOM-AIBOM-009",
447            "AI-009",
448            "AibomEthicalConsiderations",
449            "Ethical considerations present",
450        ),
451        (
452            "SBOM-AIBOM-010",
453            "AI-010",
454            "AibomModelWeightHashes",
455            "Model weight hashes present",
456        ),
457        (
458            "SBOM-AIBOM-011",
459            "AI-011",
460            "AibomExploitabilityReference",
461            "Exploitability/advisory reference present",
462        ),
463        (
464            "SBOM-AIBOM-GENERAL",
465            "AI-GENERAL",
466            "AibomGeneral",
467            "AI BOM model-card completeness",
468        ),
469    ]
470    .into_iter()
471    .map(|(rule_id, check_id, name, desc)| SarifRule {
472        id: rule_id.to_string(),
473        name: name.to_string(),
474        short_description: SarifMessage {
475            text: desc.to_string(),
476        },
477        default_configuration: SarifConfiguration {
478            level: aibom_level(check_id),
479        },
480    })
481    .collect()
482}
483
484/// Generate a SARIF 2.1.0 report for an AI-readiness assessment, emitting one
485/// `SBOM-AIBOM-*` result per failing check (findings-only, mirroring the
486/// compliance SARIF). The rule table and run-level properties are always
487/// emitted, including for the not-applicable (no ML components) case.
488pub fn generate_ai_readiness_sarif(
489    metrics: &crate::quality::AiReadinessMetrics,
490    sbom_name: &str,
491    profile: &str,
492    overall_score: Option<f32>,
493    grade: &str,
494) -> Result<String, ReportError> {
495    let results: Vec<SarifResult> = metrics
496        .checks
497        .iter()
498        .filter(|check| !check.passed)
499        .map(|check| {
500            let rule_id = ai_check_to_rule_id(&check.id);
501            let detail_suffix = check
502                .detail
503                .as_ref()
504                .map(|d| format!(" — {d}"))
505                .unwrap_or_default();
506            SarifResult {
507                rule_id: rule_id.to_string(),
508                level: aibom_level(&check.id),
509                message: SarifMessage {
510                    text: format!(
511                        "AIBOM check {} failed: {} ({:.0}% weight){detail_suffix}",
512                        check.id,
513                        check.name,
514                        check.weight * 100.0
515                    ),
516                },
517                locations: vec![],
518                properties: Some(SarifResultProperties {
519                    standard_ids: vec![format!("AIBOM:{}", check.id)],
520                    standard_help_uris: rule_help_uri(rule_id)
521                        .map(|u| vec![u.to_string()])
522                        .unwrap_or_default(),
523                }),
524            }
525        })
526        .collect();
527
528    let sarif = SarifReport {
529        schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
530        version: "2.1.0".to_string(),
531        runs: vec![SarifRun {
532            tool: SarifTool {
533                driver: SarifDriver {
534                    name: "sbom-tools".to_string(),
535                    version: env!("CARGO_PKG_VERSION").to_string(),
536                    information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
537                    rules: SarifRuleWithUri::wrap_all(get_sarif_aibom_rules()),
538                },
539            },
540            results,
541            properties: Some(SarifRunProperties {
542                applicable: !metrics.is_not_applicable(),
543                overall_score,
544                grade: grade.to_string(),
545                sbom: sbom_name.to_string(),
546                profile: profile.to_string(),
547            }),
548        }],
549    };
550
551    serde_json::to_string_pretty(&sarif).map_err(|e| ReportError::SerializationError(e.to_string()))
552}
553
554pub fn generate_compliance_sarif(result: &ComplianceResult) -> Result<String, ReportError> {
555    let rules = SarifRuleWithUri::wrap_all(get_sarif_rules_for_standard(result.level));
556    let sarif = SarifReport {
557        schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
558        version: "2.1.0".to_string(),
559        runs: vec![SarifRun {
560            tool: SarifTool {
561                driver: SarifDriver {
562                    name: "sbom-tools".to_string(),
563                    version: env!("CARGO_PKG_VERSION").to_string(),
564                    information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
565                    rules,
566                },
567            },
568            results: compliance_results_to_sarif(result, None),
569            properties: None,
570        }],
571    };
572
573    serde_json::to_string_pretty(&sarif).map_err(|e| ReportError::SerializationError(e.to_string()))
574}
575
576/// Generate SARIF output for multiple compliance standards merged into one report.
577pub fn generate_multi_compliance_sarif(
578    results: &[ComplianceResult],
579) -> Result<String, ReportError> {
580    // Merge rules from all standards
581    let mut all_rules = Vec::new();
582    let mut all_results = Vec::new();
583
584    for result in results {
585        let rules = get_sarif_rules_for_standard(result.level);
586        all_rules.extend(rules);
587        all_results.extend(compliance_results_to_sarif(result, None));
588    }
589
590    let sarif = SarifReport {
591        schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
592        version: "2.1.0".to_string(),
593        runs: vec![SarifRun {
594            tool: SarifTool {
595                driver: SarifDriver {
596                    name: "sbom-tools".to_string(),
597                    version: env!("CARGO_PKG_VERSION").to_string(),
598                    information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
599                    rules: SarifRuleWithUri::wrap_all(all_rules),
600                },
601            },
602            results: all_results,
603            properties: None,
604        }],
605    };
606
607    serde_json::to_string_pretty(&sarif).map_err(|e| ReportError::SerializationError(e.to_string()))
608}
609
610fn severity_to_level(severity: &str) -> SarifLevel {
611    match severity.to_lowercase().as_str() {
612        "critical" | "high" => SarifLevel::Error,
613        "low" | "info" => SarifLevel::Note,
614        _ => SarifLevel::Warning,
615    }
616}
617
618/// Format SLA status for SARIF message
619fn format_sla_label(vuln: &VulnerabilityDetail) -> String {
620    match vuln.sla_status() {
621        SlaStatus::Overdue(days) => format!(" [SLA: {days}d late]"),
622        SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => format!(" [SLA: {days}d left]"),
623        SlaStatus::NoDueDate => vuln
624            .days_since_published
625            .map(|d| format!(" [Age: {d}d]"))
626            .unwrap_or_default(),
627    }
628}
629
630fn format_vex_label(vex_state: Option<&crate::model::VexState>) -> String {
631    match vex_state {
632        Some(crate::model::VexState::NotAffected) => " [VEX: Not Affected]".to_string(),
633        Some(crate::model::VexState::Fixed) => " [VEX: Fixed]".to_string(),
634        Some(crate::model::VexState::Affected) => " [VEX: Affected]".to_string(),
635        Some(crate::model::VexState::UnderInvestigation) => {
636            " [VEX: Under Investigation]".to_string()
637        }
638        None => String::new(),
639    }
640}
641
642const fn violation_severity_to_level(severity: ViolationSeverity) -> SarifLevel {
643    match severity {
644        ViolationSeverity::Error => SarifLevel::Error,
645        ViolationSeverity::Warning => SarifLevel::Warning,
646        ViolationSeverity::Info => SarifLevel::Note,
647    }
648}
649
650fn compliance_results_to_sarif(result: &ComplianceResult, label: Option<&str>) -> Vec<SarifResult> {
651    let prefix = label.map(|l| format!("{l} - ")).unwrap_or_default();
652    result
653        .violations
654        .iter()
655        .map(|v| {
656            let element = v.element.as_deref().unwrap_or("unknown");
657            let standard_ids: Vec<String> = v
658                .standard_refs
659                .iter()
660                .map(|sr| format!("{}:{}", sarif_standard_label(sr.standard), sr.id))
661                .collect();
662            let standard_help_uris: Vec<String> = v
663                .standard_refs
664                .iter()
665                .filter_map(|sr| sr.help_uri.clone())
666                .collect();
667            let properties = if standard_ids.is_empty() && standard_help_uris.is_empty() {
668                None
669            } else {
670                Some(SarifResultProperties {
671                    standard_ids,
672                    standard_help_uris,
673                })
674            };
675            // The externally-visible SARIF rule ID comes from the rule
676            // registry keyed by the violation's stable `rule_id` — never from
677            // re-parsing the human-readable requirement string. Unregistered
678            // keys fall back to the generic CRA rule.
679            let sarif_rule_id = rule_meta(v.rule_id)
680                .map_or("SBOM-CRA-GENERAL", |m| m.sarif_id)
681                .to_string();
682            SarifResult {
683                rule_id: sarif_rule_id,
684                level: violation_severity_to_level(v.severity),
685                message: SarifMessage {
686                    text: format!(
687                        "{}{}: {} (Requirement: {}) [Element: {}]",
688                        prefix,
689                        result.level.name(),
690                        v.message,
691                        v.requirement,
692                        element
693                    ),
694                },
695                locations: vec![],
696                properties,
697            }
698        })
699        .collect()
700}
701
702/// Canonical URL for a SARIF rule, derived from its ID prefix. Returns
703/// `None` for rule families that do not map to a single regulation /
704/// specification (e.g., `SBOM-TOOLS-*` change-tracking rules).
705fn rule_help_uri(rule_id: &str) -> Option<&'static str> {
706    if rule_id.starts_with("SBOM-CRA-") {
707        Some("https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng")
708    } else if rule_id.starts_with("SBOM-BSI-") {
709        Some(
710            "https://www.bsi.bund.de/EN/Themen/Unternehmen-und-Organisationen/Standards-und-Zertifizierung/Technische-Richtlinien/TR-nach-Thema-sortiert/tr03183/TR-03183_node.html",
711        )
712    } else if rule_id.starts_with("SBOM-NIST-SSDF-") || rule_id.starts_with("SBOM-SSDF-") {
713        Some("https://doi.org/10.6028/NIST.SP.800-218")
714    } else if rule_id.starts_with("SBOM-EO14028-") || rule_id.starts_with("SBOM-EO-14028-") {
715        Some("https://www.federalregister.gov/d/2021-10460")
716    } else if rule_id.starts_with("SBOM-FDA-") {
717        Some(
718            "https://www.fda.gov/regulatory-information/search-fda-guidance-documents/cybersecurity-medical-devices-quality-system-considerations-and-content-premarket-submissions",
719        )
720    } else if rule_id.starts_with("SBOM-NTIA-") {
721        Some("https://www.ntia.doc.gov/files/ntia/publications/sbom_minimum_elements_report.pdf")
722    } else if rule_id.starts_with("SBOM-PQC-") || rule_id.starts_with("SBOM-NIST-PQC-") {
723        Some("https://csrc.nist.gov/projects/post-quantum-cryptography")
724    } else if rule_id.starts_with("SBOM-CNSA-") {
725        Some(
726            "https://media.defense.gov/2022/Sep/07/2003071834/-1/-1/0/CSA_CNSA_2.0_ALGORITHMS_.PDF",
727        )
728    } else if rule_id.starts_with("SBOM-CSAF-") {
729        Some("https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html")
730    } else if rule_id.starts_with("SBOM-AIBOM-") {
731        Some("https://cyclonedx.org/capabilities/mlbom/")
732    } else if rule_id.starts_with("SBOM-AIACT-") {
733        Some("https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng")
734    } else if rule_id.starts_with("SBOM-BSIAI-") {
735        Some("https://www.bsi.bund.de")
736    } else {
737        None
738    }
739}
740
741/// Compact, hyphen-safe label for a `StandardKind` used in SARIF
742/// `properties.standardIds` strings.
743fn sarif_standard_label(kind: crate::quality::StandardKind) -> &'static str {
744    use crate::quality::StandardKind;
745    match kind {
746        StandardKind::CraArticle => "CRA",
747        StandardKind::CraAnnex => "CRA-Annex",
748        StandardKind::Pren40000_1_3 => "prEN-40000-1-3",
749        StandardKind::BsiTr03183_2 => "BSI-TR-03183-2",
750        StandardKind::NistSsdf => "NIST-SSDF",
751        StandardKind::Eo14028 => "EO-14028",
752        StandardKind::FdaPremarket => "FDA",
753        StandardKind::NtiaMinimum => "NTIA",
754        StandardKind::Csaf2 => "CSAF",
755        StandardKind::Cnsa2 => "CNSA-2.0",
756        StandardKind::NistPqc => "NIST-PQC",
757        StandardKind::EuAiAct => "EU-AI-Act",
758        StandardKind::BsiSbomForAi => "BSI-G7-SBOM-for-AI",
759        StandardKind::Other => "Other",
760    }
761}
762
763fn get_sarif_rules() -> Vec<SarifRule> {
764    let mut rules = vec![
765        SarifRule {
766            id: "SBOM-TOOLS-001".to_string(),
767            name: "ComponentAdded".to_string(),
768            short_description: SarifMessage {
769                text: "A new component was added to the SBOM".to_string(),
770            },
771            default_configuration: SarifConfiguration {
772                level: SarifLevel::Note,
773            },
774        },
775        SarifRule {
776            id: "SBOM-TOOLS-002".to_string(),
777            name: "ComponentRemoved".to_string(),
778            short_description: SarifMessage {
779                text: "A component was removed from the SBOM".to_string(),
780            },
781            default_configuration: SarifConfiguration {
782                level: SarifLevel::Warning,
783            },
784        },
785        SarifRule {
786            id: "SBOM-TOOLS-003".to_string(),
787            name: "VersionChanged".to_string(),
788            short_description: SarifMessage {
789                text: "A component version was changed".to_string(),
790            },
791            default_configuration: SarifConfiguration {
792                level: SarifLevel::Note,
793            },
794        },
795        SarifRule {
796            id: "SBOM-TOOLS-004".to_string(),
797            name: "LicenseChanged".to_string(),
798            short_description: SarifMessage {
799                text: "A license was added or changed".to_string(),
800            },
801            default_configuration: SarifConfiguration {
802                level: SarifLevel::Warning,
803            },
804        },
805        SarifRule {
806            id: "SBOM-TOOLS-005".to_string(),
807            name: "VulnerabilityIntroduced".to_string(),
808            short_description: SarifMessage {
809                text: "A new vulnerability was introduced".to_string(),
810            },
811            default_configuration: SarifConfiguration {
812                level: SarifLevel::Error,
813            },
814        },
815        SarifRule {
816            id: "SBOM-TOOLS-006".to_string(),
817            name: "VulnerabilityResolved".to_string(),
818            short_description: SarifMessage {
819                text: "A vulnerability was resolved".to_string(),
820            },
821            default_configuration: SarifConfiguration {
822                level: SarifLevel::Note,
823            },
824        },
825        SarifRule {
826            id: "SBOM-TOOLS-007".to_string(),
827            name: "SupplierChanged".to_string(),
828            short_description: SarifMessage {
829                text: "A component supplier was changed".to_string(),
830            },
831            default_configuration: SarifConfiguration {
832                level: SarifLevel::Warning,
833            },
834        },
835        SarifRule {
836            id: "SBOM-TOOLS-008".to_string(),
837            name: "MetadataChanged".to_string(),
838            short_description: SarifMessage {
839                text: "A document-level metadata field was changed".to_string(),
840            },
841            default_configuration: SarifConfiguration {
842                level: SarifLevel::Note,
843            },
844        },
845        SarifRule {
846            id: "SBOM-EOL-001".to_string(),
847            name: "ComponentEndOfLife".to_string(),
848            short_description: SarifMessage {
849                text: "A component has reached end-of-life".to_string(),
850            },
851            default_configuration: SarifConfiguration {
852                level: SarifLevel::Error,
853            },
854        },
855        SarifRule {
856            id: "SBOM-EOL-002".to_string(),
857            name: "ComponentApproachingEol".to_string(),
858            short_description: SarifMessage {
859                text: "A component is approaching end-of-life".to_string(),
860            },
861            default_configuration: SarifConfiguration {
862                level: SarifLevel::Warning,
863            },
864        },
865    ];
866    rules.extend(get_sarif_compliance_rules());
867    rules
868}
869
870fn get_sarif_view_rules() -> Vec<SarifRule> {
871    let mut rules = vec![SarifRule {
872        id: "SBOM-VIEW-001".to_string(),
873        name: "VulnerabilityPresent".to_string(),
874        short_description: SarifMessage {
875            text: "A vulnerability is present in a component".to_string(),
876        },
877        default_configuration: SarifConfiguration {
878            level: SarifLevel::Warning,
879        },
880    }];
881    rules.extend(get_sarif_compliance_rules());
882    rules
883}
884
885/// Get the appropriate compliance rules based on the standard being checked.
886fn get_sarif_rules_for_standard(level: ComplianceLevel) -> Vec<SarifRule> {
887    match level {
888        ComplianceLevel::NtiaMinimum => get_sarif_ntia_rules(),
889        ComplianceLevel::FdaMedicalDevice => get_sarif_fda_rules(),
890        ComplianceLevel::NistSsdf => get_sarif_ssdf_rules(),
891        ComplianceLevel::Eo14028 => get_sarif_eo14028_rules(),
892        _ => get_sarif_compliance_rules(),
893    }
894}
895
896fn get_sarif_ntia_rules() -> Vec<SarifRule> {
897    vec![
898        SarifRule {
899            id: "SBOM-NTIA-AUTHOR".to_string(),
900            name: "NtiaAuthor".to_string(),
901            short_description: SarifMessage {
902                text: "NTIA Minimum Elements: Author/creator information".to_string(),
903            },
904            default_configuration: SarifConfiguration {
905                level: SarifLevel::Error,
906            },
907        },
908        SarifRule {
909            id: "SBOM-NTIA-NAME".to_string(),
910            name: "NtiaComponentName".to_string(),
911            short_description: SarifMessage {
912                text: "NTIA Minimum Elements: Component name".to_string(),
913            },
914            default_configuration: SarifConfiguration {
915                level: SarifLevel::Error,
916            },
917        },
918        SarifRule {
919            id: "SBOM-NTIA-VERSION".to_string(),
920            name: "NtiaVersion".to_string(),
921            short_description: SarifMessage {
922                text: "NTIA Minimum Elements: Component version string".to_string(),
923            },
924            default_configuration: SarifConfiguration {
925                level: SarifLevel::Warning,
926            },
927        },
928        SarifRule {
929            id: "SBOM-NTIA-SUPPLIER".to_string(),
930            name: "NtiaSupplier".to_string(),
931            short_description: SarifMessage {
932                text: "NTIA Minimum Elements: Supplier name".to_string(),
933            },
934            default_configuration: SarifConfiguration {
935                level: SarifLevel::Warning,
936            },
937        },
938        SarifRule {
939            id: "SBOM-NTIA-IDENTIFIER".to_string(),
940            name: "NtiaUniqueIdentifier".to_string(),
941            short_description: SarifMessage {
942                text: "NTIA Minimum Elements: Unique identifier (PURL/CPE/SWID)".to_string(),
943            },
944            default_configuration: SarifConfiguration {
945                level: SarifLevel::Warning,
946            },
947        },
948        SarifRule {
949            id: "SBOM-NTIA-DEPENDENCY".to_string(),
950            name: "NtiaDependency".to_string(),
951            short_description: SarifMessage {
952                text: "NTIA Minimum Elements: Dependency relationship".to_string(),
953            },
954            default_configuration: SarifConfiguration {
955                level: SarifLevel::Error,
956            },
957        },
958        SarifRule {
959            id: "SBOM-NTIA-GENERAL".to_string(),
960            name: "NtiaGeneralRequirement".to_string(),
961            short_description: SarifMessage {
962                text: "NTIA Minimum Elements: General requirement".to_string(),
963            },
964            default_configuration: SarifConfiguration {
965                level: SarifLevel::Warning,
966            },
967        },
968    ]
969}
970
971fn get_sarif_fda_rules() -> Vec<SarifRule> {
972    vec![
973        SarifRule {
974            id: "SBOM-FDA-CREATOR".to_string(),
975            name: "FdaCreator".to_string(),
976            short_description: SarifMessage {
977                text: "FDA Medical Device: SBOM creator/manufacturer information".to_string(),
978            },
979            default_configuration: SarifConfiguration {
980                level: SarifLevel::Warning,
981            },
982        },
983        SarifRule {
984            id: "SBOM-FDA-NAMESPACE".to_string(),
985            name: "FdaNamespace".to_string(),
986            short_description: SarifMessage {
987                text: "FDA Medical Device: SBOM serial number or document namespace".to_string(),
988            },
989            default_configuration: SarifConfiguration {
990                level: SarifLevel::Warning,
991            },
992        },
993        SarifRule {
994            id: "SBOM-FDA-NAME".to_string(),
995            name: "FdaDocumentName".to_string(),
996            short_description: SarifMessage {
997                text: "FDA Medical Device: SBOM document name/title".to_string(),
998            },
999            default_configuration: SarifConfiguration {
1000                level: SarifLevel::Warning,
1001            },
1002        },
1003        SarifRule {
1004            id: "SBOM-FDA-SUPPLIER".to_string(),
1005            name: "FdaSupplier".to_string(),
1006            short_description: SarifMessage {
1007                text: "FDA Medical Device: Component supplier/manufacturer information".to_string(),
1008            },
1009            default_configuration: SarifConfiguration {
1010                level: SarifLevel::Error,
1011            },
1012        },
1013        SarifRule {
1014            id: "SBOM-FDA-HASH".to_string(),
1015            name: "FdaHash".to_string(),
1016            short_description: SarifMessage {
1017                text: "FDA Medical Device: Component cryptographic hash".to_string(),
1018            },
1019            default_configuration: SarifConfiguration {
1020                level: SarifLevel::Error,
1021            },
1022        },
1023        SarifRule {
1024            id: "SBOM-FDA-IDENTIFIER".to_string(),
1025            name: "FdaIdentifier".to_string(),
1026            short_description: SarifMessage {
1027                text: "FDA Medical Device: Component unique identifier (PURL/CPE/SWID)".to_string(),
1028            },
1029            default_configuration: SarifConfiguration {
1030                level: SarifLevel::Error,
1031            },
1032        },
1033        SarifRule {
1034            id: "SBOM-FDA-VERSION".to_string(),
1035            name: "FdaVersion".to_string(),
1036            short_description: SarifMessage {
1037                text: "FDA Medical Device: Component version information".to_string(),
1038            },
1039            default_configuration: SarifConfiguration {
1040                level: SarifLevel::Error,
1041            },
1042        },
1043        SarifRule {
1044            id: "SBOM-FDA-DEPENDENCY".to_string(),
1045            name: "FdaDependency".to_string(),
1046            short_description: SarifMessage {
1047                text: "FDA Medical Device: Dependency relationships".to_string(),
1048            },
1049            default_configuration: SarifConfiguration {
1050                level: SarifLevel::Error,
1051            },
1052        },
1053        SarifRule {
1054            id: "SBOM-FDA-SUPPORT".to_string(),
1055            name: "FdaSupport".to_string(),
1056            short_description: SarifMessage {
1057                text: "FDA Medical Device: Component support/contact information".to_string(),
1058            },
1059            default_configuration: SarifConfiguration {
1060                level: SarifLevel::Note,
1061            },
1062        },
1063        SarifRule {
1064            id: "SBOM-FDA-SECURITY".to_string(),
1065            name: "FdaSecurity".to_string(),
1066            short_description: SarifMessage {
1067                text: "FDA Medical Device: Security vulnerability information".to_string(),
1068            },
1069            default_configuration: SarifConfiguration {
1070                level: SarifLevel::Warning,
1071            },
1072        },
1073        SarifRule {
1074            id: "SBOM-FDA-GENERAL".to_string(),
1075            name: "FdaGeneralRequirement".to_string(),
1076            short_description: SarifMessage {
1077                text: "FDA Medical Device: General SBOM requirement".to_string(),
1078            },
1079            default_configuration: SarifConfiguration {
1080                level: SarifLevel::Warning,
1081            },
1082        },
1083    ]
1084}
1085
1086fn get_sarif_ssdf_rules() -> Vec<SarifRule> {
1087    vec![
1088        SarifRule {
1089            id: "SBOM-SSDF-PS1".to_string(),
1090            name: "SsdfProvenance".to_string(),
1091            short_description: SarifMessage {
1092                text: "NIST SSDF PS.1: Provenance and creator identification".to_string(),
1093            },
1094            default_configuration: SarifConfiguration {
1095                level: SarifLevel::Error,
1096            },
1097        },
1098        SarifRule {
1099            id: "SBOM-SSDF-PS2".to_string(),
1100            name: "SsdfBuildIntegrity".to_string(),
1101            short_description: SarifMessage {
1102                text: "NIST SSDF PS.2: Build integrity — component cryptographic hashes"
1103                    .to_string(),
1104            },
1105            default_configuration: SarifConfiguration {
1106                level: SarifLevel::Warning,
1107            },
1108        },
1109        SarifRule {
1110            id: "SBOM-SSDF-PS3".to_string(),
1111            name: "SsdfSupplierIdentification".to_string(),
1112            short_description: SarifMessage {
1113                text: "NIST SSDF PS.3: Supplier identification for components".to_string(),
1114            },
1115            default_configuration: SarifConfiguration {
1116                level: SarifLevel::Warning,
1117            },
1118        },
1119        SarifRule {
1120            id: "SBOM-SSDF-PO1".to_string(),
1121            name: "SsdfSourceProvenance".to_string(),
1122            short_description: SarifMessage {
1123                text: "NIST SSDF PO.1: Source code provenance — VCS references".to_string(),
1124            },
1125            default_configuration: SarifConfiguration {
1126                level: SarifLevel::Warning,
1127            },
1128        },
1129        SarifRule {
1130            id: "SBOM-SSDF-PO3".to_string(),
1131            name: "SsdfBuildMetadata".to_string(),
1132            short_description: SarifMessage {
1133                text: "NIST SSDF PO.3: Build provenance — build system metadata".to_string(),
1134            },
1135            default_configuration: SarifConfiguration {
1136                level: SarifLevel::Note,
1137            },
1138        },
1139        SarifRule {
1140            id: "SBOM-SSDF-PW4".to_string(),
1141            name: "SsdfDependencyManagement".to_string(),
1142            short_description: SarifMessage {
1143                text: "NIST SSDF PW.4: Dependency management — relationships".to_string(),
1144            },
1145            default_configuration: SarifConfiguration {
1146                level: SarifLevel::Error,
1147            },
1148        },
1149        SarifRule {
1150            id: "SBOM-SSDF-PW6".to_string(),
1151            name: "SsdfVulnerabilityInfo".to_string(),
1152            short_description: SarifMessage {
1153                text: "NIST SSDF PW.6: Vulnerability information and security references"
1154                    .to_string(),
1155            },
1156            default_configuration: SarifConfiguration {
1157                level: SarifLevel::Note,
1158            },
1159        },
1160        SarifRule {
1161            id: "SBOM-SSDF-RV1".to_string(),
1162            name: "SsdfComponentIdentification".to_string(),
1163            short_description: SarifMessage {
1164                text: "NIST SSDF RV.1: Component identification — unique identifiers".to_string(),
1165            },
1166            default_configuration: SarifConfiguration {
1167                level: SarifLevel::Warning,
1168            },
1169        },
1170        SarifRule {
1171            id: "SBOM-SSDF-GENERAL".to_string(),
1172            name: "SsdfGeneralRequirement".to_string(),
1173            short_description: SarifMessage {
1174                text: "NIST SSDF: General secure development requirement".to_string(),
1175            },
1176            default_configuration: SarifConfiguration {
1177                level: SarifLevel::Warning,
1178            },
1179        },
1180    ]
1181}
1182
1183fn get_sarif_eo14028_rules() -> Vec<SarifRule> {
1184    vec![
1185        SarifRule {
1186            id: "SBOM-EO14028-FORMAT".to_string(),
1187            name: "Eo14028MachineReadable".to_string(),
1188            short_description: SarifMessage {
1189                text: "EO 14028 Sec 4(e): Machine-readable SBOM format requirement".to_string(),
1190            },
1191            default_configuration: SarifConfiguration {
1192                level: SarifLevel::Error,
1193            },
1194        },
1195        SarifRule {
1196            id: "SBOM-EO14028-AUTOGEN".to_string(),
1197            name: "Eo14028AutoGeneration".to_string(),
1198            short_description: SarifMessage {
1199                text: "EO 14028 Sec 4(e): Automated SBOM generation".to_string(),
1200            },
1201            default_configuration: SarifConfiguration {
1202                level: SarifLevel::Warning,
1203            },
1204        },
1205        SarifRule {
1206            id: "SBOM-EO14028-CREATOR".to_string(),
1207            name: "Eo14028Creator".to_string(),
1208            short_description: SarifMessage {
1209                text: "EO 14028 Sec 4(e): SBOM creator identification".to_string(),
1210            },
1211            default_configuration: SarifConfiguration {
1212                level: SarifLevel::Error,
1213            },
1214        },
1215        SarifRule {
1216            id: "SBOM-EO14028-IDENTIFIER".to_string(),
1217            name: "Eo14028Identifier".to_string(),
1218            short_description: SarifMessage {
1219                text: "EO 14028 Sec 4(e): Component unique identification".to_string(),
1220            },
1221            default_configuration: SarifConfiguration {
1222                level: SarifLevel::Error,
1223            },
1224        },
1225        SarifRule {
1226            id: "SBOM-EO14028-DEPENDENCY".to_string(),
1227            name: "Eo14028Dependency".to_string(),
1228            short_description: SarifMessage {
1229                text: "EO 14028 Sec 4(e): Dependency relationship information".to_string(),
1230            },
1231            default_configuration: SarifConfiguration {
1232                level: SarifLevel::Error,
1233            },
1234        },
1235        SarifRule {
1236            id: "SBOM-EO14028-VERSION".to_string(),
1237            name: "Eo14028Version".to_string(),
1238            short_description: SarifMessage {
1239                text: "EO 14028 Sec 4(e): Component version information".to_string(),
1240            },
1241            default_configuration: SarifConfiguration {
1242                level: SarifLevel::Error,
1243            },
1244        },
1245        SarifRule {
1246            id: "SBOM-EO14028-INTEGRITY".to_string(),
1247            name: "Eo14028Integrity".to_string(),
1248            short_description: SarifMessage {
1249                text: "EO 14028 Sec 4(e): Component integrity verification (hashes)".to_string(),
1250            },
1251            default_configuration: SarifConfiguration {
1252                level: SarifLevel::Warning,
1253            },
1254        },
1255        SarifRule {
1256            id: "SBOM-EO14028-DISCLOSURE".to_string(),
1257            name: "Eo14028Disclosure".to_string(),
1258            short_description: SarifMessage {
1259                text: "EO 14028 Sec 4(g): Vulnerability disclosure process".to_string(),
1260            },
1261            default_configuration: SarifConfiguration {
1262                level: SarifLevel::Warning,
1263            },
1264        },
1265        SarifRule {
1266            id: "SBOM-EO14028-SUPPLIER".to_string(),
1267            name: "Eo14028Supplier".to_string(),
1268            short_description: SarifMessage {
1269                text: "EO 14028 Sec 4(e): Supplier identification".to_string(),
1270            },
1271            default_configuration: SarifConfiguration {
1272                level: SarifLevel::Warning,
1273            },
1274        },
1275        SarifRule {
1276            id: "SBOM-EO14028-GENERAL".to_string(),
1277            name: "Eo14028GeneralRequirement".to_string(),
1278            short_description: SarifMessage {
1279                text: "EO 14028: General SBOM requirement".to_string(),
1280            },
1281            default_configuration: SarifConfiguration {
1282                level: SarifLevel::Warning,
1283            },
1284        },
1285    ]
1286}
1287
1288fn get_sarif_compliance_rules() -> Vec<SarifRule> {
1289    vec![
1290        SarifRule {
1291            id: "SBOM-CRA-ART-13-3".to_string(),
1292            name: "CraUpdateFrequency".to_string(),
1293            short_description: SarifMessage {
1294                text: "CRA Art. 13(3): SBOM update frequency — timely regeneration after changes".to_string(),
1295            },
1296            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1297        },
1298        SarifRule {
1299            id: "SBOM-CRA-ART-13-4".to_string(),
1300            name: "CraMachineReadableFormat".to_string(),
1301            short_description: SarifMessage {
1302                text: "CRA Art. 13(4): SBOM must be in a machine-readable format (CycloneDX 1.4+, SPDX 2.3+, or SPDX 3.0+)".to_string(),
1303            },
1304            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1305        },
1306        SarifRule {
1307            id: "SBOM-CRA-ART-13-6".to_string(),
1308            name: "CraVulnerabilityDisclosure".to_string(),
1309            short_description: SarifMessage {
1310                text: "CRA Art. 13(6): Vulnerability disclosure contact and metadata completeness".to_string(),
1311            },
1312            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1313        },
1314        SarifRule {
1315            id: "SBOM-CRA-ART-13-5".to_string(),
1316            name: "CraLicensedComponentTracking".to_string(),
1317            short_description: SarifMessage {
1318                text: "CRA Art. 13(5): Licensed component tracking — license information for all components".to_string(),
1319            },
1320            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1321        },
1322        SarifRule {
1323            id: "SBOM-CRA-ART-13-7".to_string(),
1324            name: "CraCoordinatedDisclosure".to_string(),
1325            short_description: SarifMessage {
1326                text: "CRA Art. 13(7): Coordinated vulnerability disclosure policy reference".to_string(),
1327            },
1328            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1329        },
1330        SarifRule {
1331            id: "SBOM-CRA-ART-13-8".to_string(),
1332            name: "CraSupportPeriod".to_string(),
1333            short_description: SarifMessage {
1334                text: "CRA Art. 13(8): Support period and security update end date".to_string(),
1335            },
1336            default_configuration: SarifConfiguration { level: SarifLevel::Note },
1337        },
1338        SarifRule {
1339            id: "SBOM-CRA-ART-13-11".to_string(),
1340            name: "CraComponentLifecycle".to_string(),
1341            short_description: SarifMessage {
1342                text: "CRA Art. 13(11): Component lifecycle and end-of-support status".to_string(),
1343            },
1344            default_configuration: SarifConfiguration { level: SarifLevel::Note },
1345        },
1346        SarifRule {
1347            id: "SBOM-CRA-ART-13-12".to_string(),
1348            name: "CraProductIdentification".to_string(),
1349            short_description: SarifMessage {
1350                text: "CRA Art. 13(12): Product name and version identification".to_string(),
1351            },
1352            default_configuration: SarifConfiguration { level: SarifLevel::Error },
1353        },
1354        SarifRule {
1355            id: "SBOM-CRA-ART-13-15".to_string(),
1356            name: "CraManufacturerIdentification".to_string(),
1357            short_description: SarifMessage {
1358                text: "CRA Art. 13(15): Manufacturer identification and contact information".to_string(),
1359            },
1360            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1361        },
1362        SarifRule {
1363            id: "SBOM-CRA-ART-13-9".to_string(),
1364            name: "CraKnownVulnerabilities".to_string(),
1365            short_description: SarifMessage {
1366                text: "CRA Art. 13(9): Known vulnerabilities statement — vulnerability data or assertion".to_string(),
1367            },
1368            default_configuration: SarifConfiguration { level: SarifLevel::Note },
1369        },
1370        SarifRule {
1371            id: "SBOM-CRA-ANNEX-I".to_string(),
1372            name: "CraTechnicalDocumentation".to_string(),
1373            short_description: SarifMessage {
1374                text: "CRA Annex I: Technical documentation (unique identifiers, dependencies, primary component)".to_string(),
1375            },
1376            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1377        },
1378        SarifRule {
1379            id: "SBOM-CRA-ANNEX-III".to_string(),
1380            name: "CraDocumentIntegrity".to_string(),
1381            short_description: SarifMessage {
1382                text: "CRA Annex III: Document signature/integrity — serial number, hash, or digital signature".to_string(),
1383            },
1384            default_configuration: SarifConfiguration { level: SarifLevel::Note },
1385        },
1386        SarifRule {
1387            id: "SBOM-CRA-ANNEX-VII".to_string(),
1388            name: "CraDeclarationOfConformity".to_string(),
1389            short_description: SarifMessage {
1390                text: "CRA Annex VII: EU Declaration of Conformity reference".to_string(),
1391            },
1392            default_configuration: SarifConfiguration { level: SarifLevel::Note },
1393        },
1394        SarifRule {
1395            id: "SBOM-CRA-GENERAL".to_string(),
1396            name: "CraGeneralRequirement".to_string(),
1397            short_description: SarifMessage {
1398                text: "CRA general SBOM readiness requirement".to_string(),
1399            },
1400            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1401        },
1402        SarifRule {
1403            id: "SBOM-CRA-PRE-8-RQ-02".to_string(),
1404            name: "CraHardwareInventory".to_string(),
1405            short_description: SarifMessage {
1406                text: "CRA prEN 40000-1-3 [PRE-8-RQ-02]: Hardware components must be inventoried with producer, name, identifier, and firmware version"
1407                    .to_string(),
1408            },
1409            default_configuration: SarifConfiguration { level: SarifLevel::Error },
1410        },
1411        SarifRule {
1412            id: "SBOM-CRA-PRE-7-RQ-07-RE".to_string(),
1413            name: "CraVendorHashCarryThrough".to_string(),
1414            short_description: SarifMessage {
1415                text: "CRA prEN 40000-1-3 [PRE-7-RQ-07-RE]: Upstream vendor-supplied component hashes must be carried through into the SBOM"
1416                    .to_string(),
1417            },
1418            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1419        },
1420        SarifRule {
1421            id: "SBOM-BSI-TR-03183-2-5-1".to_string(),
1422            name: "BsiTr03183AuthorTool".to_string(),
1423            short_description: SarifMessage {
1424                text: "BSI TR-03183-2 §5.1: SBOM author/tool identification".to_string(),
1425            },
1426            default_configuration: SarifConfiguration { level: SarifLevel::Error },
1427        },
1428        SarifRule {
1429            id: "SBOM-BSI-TR-03183-2-5-2".to_string(),
1430            name: "BsiTr03183Timestamp".to_string(),
1431            short_description: SarifMessage {
1432                text: "BSI TR-03183-2 §5.2: ISO-8601 timestamp".to_string(),
1433            },
1434            default_configuration: SarifConfiguration { level: SarifLevel::Error },
1435        },
1436        SarifRule {
1437            id: "SBOM-BSI-TR-03183-2-5-3".to_string(),
1438            name: "BsiTr03183ComponentIdentifier".to_string(),
1439            short_description: SarifMessage {
1440                text: "BSI TR-03183-2 §5.3: Component name and unique identifier".to_string(),
1441            },
1442            default_configuration: SarifConfiguration { level: SarifLevel::Error },
1443        },
1444        SarifRule {
1445            id: "SBOM-BSI-TR-03183-2-5-4".to_string(),
1446            name: "BsiTr03183ComponentHash".to_string(),
1447            short_description: SarifMessage {
1448                text: "BSI TR-03183-2 §5.4: Component cryptographic hash (SHA-256+)".to_string(),
1449            },
1450            default_configuration: SarifConfiguration { level: SarifLevel::Error },
1451        },
1452        SarifRule {
1453            id: "SBOM-BSI-TR-03183-2-5-5".to_string(),
1454            name: "BsiTr03183Dependencies".to_string(),
1455            short_description: SarifMessage {
1456                text: "BSI TR-03183-2 §5.5: Dependency relationships".to_string(),
1457            },
1458            default_configuration: SarifConfiguration { level: SarifLevel::Error },
1459        },
1460        SarifRule {
1461            id: "SBOM-BSI-TR-03183-2-6".to_string(),
1462            name: "BsiTr03183Recommended".to_string(),
1463            short_description: SarifMessage {
1464                text: "BSI TR-03183-2 §6: Recommended fields (license, supplier, lifecycle)".to_string(),
1465            },
1466            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1467        },
1468        SarifRule {
1469            id: "SBOM-BSI-TR-03183-2-GENERAL".to_string(),
1470            name: "BsiTr03183General".to_string(),
1471            short_description: SarifMessage {
1472                text: "BSI TR-03183-2 general SBOM requirement".to_string(),
1473            },
1474            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1475        },
1476        // EU AI Act Annex IV technical-documentation readiness (AI3).
1477        SarifRule {
1478            id: "SBOM-AIACT-NA".to_string(),
1479            name: "AiActNotApplicable".to_string(),
1480            short_description: SarifMessage {
1481                text: "EU AI Act Annex IV readiness not applicable — SBOM has no ML-model or dataset components".to_string(),
1482            },
1483            default_configuration: SarifConfiguration { level: SarifLevel::Note },
1484        },
1485        SarifRule {
1486            id: "SBOM-AIACT-ANNEX-IV-1".to_string(),
1487            name: "AiActGeneralDescription".to_string(),
1488            short_description: SarifMessage {
1489                text: "EU AI Act Annex IV §1: general description of the AI system (architecture, intended purpose)".to_string(),
1490            },
1491            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1492        },
1493        SarifRule {
1494            id: "SBOM-AIACT-ANNEX-IV-2D".to_string(),
1495            name: "AiActTrainingData".to_string(),
1496            short_description: SarifMessage {
1497                text: "EU AI Act Annex IV §2(d): training-data characteristics, provenance, and sensitivity classification".to_string(),
1498            },
1499            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1500        },
1501        SarifRule {
1502            id: "SBOM-AIACT-ANNEX-IV-2G".to_string(),
1503            name: "AiActValidationMetrics".to_string(),
1504            short_description: SarifMessage {
1505                text: "EU AI Act Annex IV §2(g): validation/testing metrics and computational-resources disclosure".to_string(),
1506            },
1507            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1508        },
1509        SarifRule {
1510            id: "SBOM-AIACT-ANNEX-IV-3".to_string(),
1511            name: "AiActLimitations".to_string(),
1512            short_description: SarifMessage {
1513                text: "EU AI Act Annex IV §3: foreseeable limitations and risks".to_string(),
1514            },
1515            default_configuration: SarifConfiguration { level: SarifLevel::Note },
1516        },
1517        // BSI/G7 SBOM-for-AI Minimum Elements readiness (7 clusters).
1518        SarifRule {
1519            id: "SBOM-BSIAI-NA".to_string(),
1520            name: "BsiSbomForAiNotApplicable".to_string(),
1521            short_description: SarifMessage {
1522                text: "BSI/G7 SBOM-for-AI minimum-elements readiness not applicable — SBOM has no ML-model or dataset components".to_string(),
1523            },
1524            default_configuration: SarifConfiguration { level: SarifLevel::Note },
1525        },
1526        SarifRule {
1527            id: "SBOM-BSIAI-META".to_string(),
1528            name: "BsiSbomForAiMetadata".to_string(),
1529            short_description: SarifMessage {
1530                text: "BSI/G7 SBOM-for-AI Metadata cluster: author, data-format name + version, timestamp, generation tool, signature".to_string(),
1531            },
1532            default_configuration: SarifConfiguration { level: SarifLevel::Error },
1533        },
1534        SarifRule {
1535            id: "SBOM-BSIAI-SYS".to_string(),
1536            name: "BsiSbomForAiSystemLevel".to_string(),
1537            short_description: SarifMessage {
1538                text: "BSI/G7 SBOM-for-AI System-Level cluster: primary AI system, producer, data flow & usage".to_string(),
1539            },
1540            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
1541        },
1542        SarifRule {
1543            id: "SBOM-BSIAI-MODEL".to_string(),
1544            name: "BsiSbomForAiModels".to_string(),
1545            short_description: SarifMessage {
1546                text: "BSI/G7 SBOM-for-AI Models cluster: name, version, identifier, weight hash (NIST-approved algorithm), model card, architecture, datasets, limitations, license".to_string(),
1547            },
1548            default_configuration: SarifConfiguration { level: SarifLevel::Error },
1549        },
1550        SarifRule {
1551            id: "SBOM-BSIAI-DATASET".to_string(),
1552            name: "BsiSbomForAiDatasets".to_string(),
1553            short_description: SarifMessage {
1554                text: "BSI/G7 SBOM-for-AI Datasets cluster: name, identifier, hash, license, sensitivity classification, provenance & intended use".to_string(),
1555            },
1556            default_configuration: SarifConfiguration { level: SarifLevel::Error },
1557        },
1558        SarifRule {
1559            id: "SBOM-BSIAI-INFRA".to_string(),
1560            name: "BsiSbomForAiInfrastructure".to_string(),
1561            short_description: SarifMessage {
1562                text: "BSI/G7 SBOM-for-AI Infrastructure cluster: runtime / framework dependency links".to_string(),
1563            },
1564            default_configuration: SarifConfiguration { level: SarifLevel::Note },
1565        },
1566        SarifRule {
1567            id: "SBOM-BSIAI-SEC".to_string(),
1568            name: "BsiSbomForAiSecurity".to_string(),
1569            short_description: SarifMessage {
1570                text: "BSI/G7 SBOM-for-AI Security cluster: AI-specific security controls, exploitability references".to_string(),
1571            },
1572            default_configuration: SarifConfiguration { level: SarifLevel::Note },
1573        },
1574    ]
1575}
1576
1577// SARIF structures
1578
1579#[derive(Serialize)]
1580#[serde(rename_all = "camelCase")]
1581struct SarifReport {
1582    #[serde(rename = "$schema")]
1583    schema: String,
1584    version: String,
1585    runs: Vec<SarifRun>,
1586}
1587
1588#[derive(Serialize)]
1589#[serde(rename_all = "camelCase")]
1590struct SarifRun {
1591    tool: SarifTool,
1592    results: Vec<SarifResult>,
1593    #[serde(skip_serializing_if = "Option::is_none")]
1594    properties: Option<SarifRunProperties>,
1595}
1596
1597/// Run-level properties for an AI-readiness SARIF report. Mirrors the run
1598/// properties the hand-rolled quality SARIF emitted (minus `compliant`, which
1599/// is not meaningful for an AI-only report). `overall_score` is always emitted
1600/// (as `null` for the not-applicable case) so machine consumers can distinguish
1601/// "score 0" from "not applicable".
1602#[derive(Serialize)]
1603#[serde(rename_all = "camelCase")]
1604struct SarifRunProperties {
1605    applicable: bool,
1606    overall_score: Option<f32>,
1607    grade: String,
1608    sbom: String,
1609    profile: String,
1610}
1611
1612#[derive(Serialize)]
1613#[serde(rename_all = "camelCase")]
1614struct SarifTool {
1615    driver: SarifDriver,
1616}
1617
1618#[derive(Serialize)]
1619#[serde(rename_all = "camelCase")]
1620struct SarifDriver {
1621    name: String,
1622    version: String,
1623    information_uri: String,
1624    rules: Vec<SarifRuleWithUri>,
1625}
1626
1627#[derive(Serialize)]
1628#[serde(rename_all = "camelCase")]
1629struct SarifRule {
1630    id: String,
1631    name: String,
1632    short_description: SarifMessage,
1633    default_configuration: SarifConfiguration,
1634}
1635
1636/// SarifRule plus a derived `helpUri` (CRA-P5.1). Wraps the existing rule
1637/// definitions at serialization time so we don't need to thread the URL
1638/// through every call site that constructs a `SarifRule`. Computed via
1639/// `rule_help_uri()` based on the rule-ID prefix.
1640#[derive(Serialize)]
1641#[serde(rename_all = "camelCase")]
1642struct SarifRuleWithUri {
1643    #[serde(flatten)]
1644    inner: SarifRule,
1645    #[serde(skip_serializing_if = "Option::is_none")]
1646    help_uri: Option<&'static str>,
1647}
1648
1649impl SarifRuleWithUri {
1650    fn wrap(inner: SarifRule) -> Self {
1651        let help_uri = rule_help_uri(&inner.id);
1652        Self { inner, help_uri }
1653    }
1654
1655    fn wrap_all(rules: Vec<SarifRule>) -> Vec<Self> {
1656        rules.into_iter().map(Self::wrap).collect()
1657    }
1658}
1659
1660#[derive(Serialize)]
1661#[serde(rename_all = "camelCase")]
1662struct SarifConfiguration {
1663    level: SarifLevel,
1664}
1665
1666#[derive(Serialize)]
1667#[serde(rename_all = "camelCase")]
1668struct SarifResult {
1669    rule_id: String,
1670    level: SarifLevel,
1671    message: SarifMessage,
1672    locations: Vec<SarifLocation>,
1673    /// Standard references (CRA Article, prEN 40000-1-3 ID, BSI section, …)
1674    /// Surfaced in `properties.standardIds` so downstream GRC/CI tooling can
1675    /// map a finding to the exact harmonised-standard clause.
1676    #[serde(skip_serializing_if = "Option::is_none")]
1677    properties: Option<SarifResultProperties>,
1678}
1679
1680#[derive(Serialize)]
1681#[serde(rename_all = "camelCase")]
1682struct SarifResultProperties {
1683    /// Standard reference IDs in the form `<standard>:<id>`
1684    /// (e.g., `prEN-40000-1-3:PRE-7-RQ-07`, `CRA:Art. 13(4)`).
1685    /// Plural to match SARIF `properties` extensibility convention.
1686    #[serde(skip_serializing_if = "Vec::is_empty")]
1687    standard_ids: Vec<String>,
1688    /// Canonical URLs for each standard the violation references — lifted
1689    /// from `StandardRef::help_uri`. Order parallels `standard_ids`. Empty
1690    /// when none of the references have a canonical home.
1691    #[serde(skip_serializing_if = "Vec::is_empty")]
1692    standard_help_uris: Vec<String>,
1693}
1694
1695#[derive(Serialize)]
1696#[serde(rename_all = "camelCase")]
1697struct SarifMessage {
1698    text: String,
1699}
1700
1701#[derive(Serialize)]
1702#[serde(rename_all = "camelCase")]
1703struct SarifLocation {
1704    physical_location: Option<SarifPhysicalLocation>,
1705}
1706
1707#[derive(Serialize)]
1708#[serde(rename_all = "camelCase")]
1709struct SarifPhysicalLocation {
1710    artifact_location: SarifArtifactLocation,
1711}
1712
1713#[derive(Serialize)]
1714#[serde(rename_all = "camelCase")]
1715struct SarifArtifactLocation {
1716    uri: String,
1717}
1718
1719#[derive(Serialize, Clone, Copy)]
1720#[serde(rename_all = "lowercase")]
1721enum SarifLevel {
1722    #[allow(dead_code)]
1723    None,
1724    Note,
1725    Warning,
1726    Error,
1727}