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    ComplianceLevel, ComplianceResult, StandardRef, ViolationSeverity, generic_rule_id_for_level,
8    rule_meta,
9};
10use serde::Serialize;
11
12/// SARIF report generator
13pub struct SarifReporter {
14    /// Include informational results
15    include_info: bool,
16}
17
18impl SarifReporter {
19    /// Create a new SARIF reporter
20    #[must_use]
21    pub const fn new() -> Self {
22        Self { include_info: true }
23    }
24
25    /// Set whether to include informational results
26    #[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        // Add component change results
50        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                // Unchanged inventory entries (--include-unchanged) are not
87                // findings; emitting them as SBOM-TOOLS-003 pushed false
88                // "modified" results into code-scanning consumers.
89                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        // Add vulnerability results
112        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        // Add license change results
172        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        // Add document-metadata change results (author/tool/timestamp/spec-version/etc.)
191        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        // Add EOL results (from new SBOM)
209        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        // Add CRA compliance results for old and new SBOMs (use pre-computed if available)
258        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        // Guarantee every emitted ruleId has a descriptor (the static table
264        // predates rules like SBOM-CRA-CYCLES) and descriptor ids are unique.
265        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        // Report vulnerabilities in the SBOM
296        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        // Add CRA compliance results (use pre-computed if available)
326        let cra_result = config.view_cra_compliance_or_bare(sbom);
327        results.extend(compliance_results_to_sarif(&cra_result, None));
328
329        // Same descriptor guarantee as the diff path (see above).
330        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
358/// Map an AI-readiness check ID (`AI-001`..`AI-011`) to its SARIF rule ID.
359/// Unknown IDs fall back to the `SBOM-AIBOM-GENERAL` rule so a future check
360/// never silently drops (`AiCheck`/`AiReadinessMetrics` are `#[non_exhaustive]`).
361fn 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
378/// Default SARIF severity for each AI-readiness check. AI transparency is a
379/// best-practice (not a mandated minimum element), so there are no hard
380/// `error`s; documentation gaps are `warning`, softer/contextual gaps `note`.
381/// This is the single source of truth shared by the rule table and the results.
382fn aibom_level(check_id: &str) -> SarifLevel {
383    match check_id {
384        // AI-010 is the weight-hash integrity check and AI-011 the
385        // exploitability/advisory-reference check: both are load-bearing
386        // security signals (tamper verification and vulnerability tooling
387        // linkage), so they are `warning` like the other load-bearing checks
388        // rather than a soft `note`.
389        "AI-001" | "AI-002" | "AI-003" | "AI-005" | "AI-009" | "AI-010" | "AI-011" => {
390            SarifLevel::Warning
391        }
392        _ => SarifLevel::Note,
393    }
394}
395
396/// SARIF rule table for the AI BOM model-card completeness checks. The
397/// `short_description` text matches the scorer's `CHECK_DEFS` names exactly.
398fn get_sarif_aibom_rules() -> Vec<SarifRule> {
399    // (rule id, AI check id for level lookup, PascalCase name, description).
400    // Descriptions match the scorer's CHECK_DEFS names exactly.
401    [
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
489/// Generate a SARIF 2.1.0 report for an AI-readiness assessment, emitting one
490/// `SBOM-AIBOM-*` result per failing check (findings-only, mirroring the
491/// compliance SARIF). The rule table and run-level properties are always
492/// emitted, including for the not-applicable (no ML components) case.
493pub 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                // Same contract as the compliance N/A surfaces: an N/A run
550                // carries its human-readable reason (`AiReadinessMetrics`
551                // populates `na_reason` exactly when `not_applicable`).
552                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
566/// Dedup the rule catalogue by id and synthesize a reportingDescriptor for
567/// any result ruleId the hand-maintained per-standard tables don't declare.
568/// SARIF 2.1.0 requires descriptor ids to be unique, and GitHub code
569/// scanning drops rule metadata for results whose ruleId has no descriptor —
570/// this guarantees both invariants no matter which checks fired.
571fn 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        // Prefer the registry's descriptor identity when the emitted ruleId
580        // is a registered self-descriptor (e.g. the CNSA/PQC rule families,
581        // which have no curated per-standard slice).
582        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        // CamelCase-ish name derived from the id (SBOM-EO14028-NAME → SbomEo14028Name)
589        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    // Surface applicability at run level: a readiness standard that never
615    // evaluated the SBOM must be machine-distinguishable from a pass.
616    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
654/// Generate SARIF output for multiple compliance standards merged into one report.
655pub fn generate_multi_compliance_sarif(
656    results: &[ComplianceResult],
657) -> Result<String, ReportError> {
658    // Merge rules from all standards; `complete_rule_catalogue` dedups the
659    // descriptors (two standards sharing the generic table used to emit
660    // every descriptor twice, which SARIF validators reject) and covers any
661    // emitted ruleId the static tables miss.
662    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    // Per-standard applicability/score/verdict ride on `properties.standards`
672    // — without them a not-applicable standard in a merged run is
673    // machine-indistinguishable from a pass (the single-standard run's flat
674    // properties don't fit N standards). The flat per-standard fields stay
675    // unset; `applicable` reports whether any standard evaluated the SBOM.
676    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
710/// Generate SARIF 2.1.0 output for the `quality` command (non-AI-readiness
711/// profiles): a single run whose compliance violations flow through the same
712/// registry-driven renderer as `validate -o sarif` — the same violation
713/// carries the same external ruleId on both surfaces — plus the quality
714/// recommendations as advisory results (never `error`; priority 1-2 map to
715/// `warning`, lower priorities to `note`) under stable
716/// `SBOM-QUALITY-REC-<CATEGORY>` rule ids. Score/grade/verdict ride on the
717/// run-level properties.
718pub 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    // Quality recommendations: one advisory result each, plus a descriptor
727    // per emitted recommendation category.
728    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
802/// SARIF level for a quality recommendation. Recommendations are advisory —
803/// they must never be `error` (a P1 recommendation is not a compliance
804/// failure): priority 1-2 render as `warning`, everything else as `note`.
805const 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
820/// Format SLA status for SARIF message
821fn 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            // The externally-visible SARIF rule ID comes from the rule
860            // registry keyed by the violation's stable `rule_id` — never from
861            // re-parsing the human-readable requirement string. Check sites
862            // with no specific mapping stamp the generic `SBOM-CRA-GENERAL`
863            // key regardless of which checker ran; re-bucket that fallback
864            // (and any unregistered key) onto the running standard's own
865            // generic rule so NTIA/quality/... findings never surface under
866            // a CRA identity. Specifically-mapped rules keep their identity.
867            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            // Fallback violations carry no standard_refs (the generic CRA
874            // bucket has none); resolve them from the family-generic rule so
875            // fallback results still carry `properties.standardIds` when the
876            // running standard is known.
877            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
933/// Canonical URL for a SARIF rule, derived from its ID prefix. Returns
934/// `None` for rule families that do not map to a single regulation /
935/// specification (e.g., `SBOM-TOOLS-*` change-tracking rules).
936fn rule_help_uri(rule_id: &str) -> Option<&'static str> {
937    // EUCC before any more-generic prefix: these rules cite Implementing
938    // Regulation (EU) 2024/482, not the CRA regulation.
939    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        // BSI's stable English shortlink for TR-03183 (printed in the
945        // v2.1.0 document imprint).
946        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        // Current edition: "Quality Management System Considerations…"
953        // (final, 2026-02-03); FDA's media id is the stable handle.
954        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        // 2026 Minimum Elements for an SBOM — CISA's resource page is the
975        // stable handle (the PDF path under /sites/default/files churns).
976        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        // PCI DSS v4.0.1 is license-gated; the document library is the
981        // stable public home.
982        Some("https://www.pcisecuritystandards.org/document_library/")
983    } else if rule_id.starts_with("SBOM-FSCT-") {
984        // CISA Framing Software Component Transparency (3rd ed., 2024).
985        Some(
986            "https://www.cisa.gov/resources-tools/resources/framing-software-component-transparency-2024",
987        )
988    } else {
989        None
990    }
991}
992
993/// Compact, hyphen-safe label for a `StandardKind` used in SARIF
994/// `properties.standardIds` strings.
995fn 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
1141/// Get the appropriate compliance rules based on the standard being checked.
1142fn 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        // The remaining levels (Minimum/Standard/Comprehensive, the CRA
1154        // profiles, BSI TR-03183-2, EUCC, and the AI readiness profiles)
1155        // genuinely emit rules from the shared CRA-family catalogue.
1156        _ => 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
1200/// Render SARIF reportingDescriptors for a curated slice of registry rule
1201/// ids. The registry — not a hand-maintained table — is the single source of
1202/// truth for descriptor id, name, shortDescription, and default level, so
1203/// the catalogue can no longer drift from `rule_meta`. Slice ids must be
1204/// self-descriptors (`rule_meta(id).sarif_id == id`); this is enforced by
1205/// `sarif_rule_slices_are_self_descriptors` in the registry tests.
1206fn 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
1222/// Build one SARIF reportingDescriptor from its registry metadata.
1223fn 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// SARIF structures
1237
1238#[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/// Run-level properties shared by every sbom-tools SARIF surface (`validate`
1257/// single- and multi-standard, `quality`, and AI-readiness runs).
1258///
1259/// Every `Option`/`Vec` field is *omitted* when absent (`skip_serializing_if`)
1260/// — never emitted as `null`/`[]` — so machine consumers must treat a missing
1261/// key as "no value" and check `applicable` before reading any verdict field.
1262/// In particular, a run without an `overallScore` key is an unscored
1263/// (not-applicable) run, not a score of 0.
1264#[derive(Serialize)]
1265#[serde(rename_all = "camelCase")]
1266struct SarifRunProperties {
1267    /// Whether the evaluated standard/profile actually applied to the SBOM.
1268    /// For multi-standard runs: whether at least one standard applied (the
1269    /// per-standard verdicts live in `standards`).
1270    applicable: bool,
1271    /// Human-readable reason when `applicable` is false; omitted otherwise.
1272    #[serde(skip_serializing_if = "Option::is_none")]
1273    not_applicable_reason: Option<String>,
1274    /// Omitted when the run was not scored. Surface-dependent semantics: on
1275    /// `quality` runs this is the weighted 0-100 quality score, while on
1276    /// `validate` compliance runs it is the standard's compliance score
1277    /// (`ComplianceResult::score`) — the two are not comparable.
1278    #[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    /// CLI profile name on `quality` runs; standard display name on
1285    /// `validate` runs. Omitted on multi-standard runs (see `standards`).
1286    #[serde(skip_serializing_if = "Option::is_none")]
1287    profile: Option<String>,
1288    /// Compliance verdict for the embedded standard (quality reports only).
1289    #[serde(skip_serializing_if = "Option::is_none")]
1290    compliant: Option<bool>,
1291    /// Per-standard summaries for multi-standard compliance runs — one entry
1292    /// per checked standard, so a not-applicable standard stays
1293    /// machine-distinguishable from a pass when several standards share one
1294    /// run. Omitted on single-standard and quality runs, whose verdict rides
1295    /// on the flat fields above.
1296    #[serde(skip_serializing_if = "Vec::is_empty")]
1297    standards: Vec<StandardRunSummary>,
1298}
1299
1300/// One `properties.standards` entry of a multi-standard compliance run.
1301/// Field semantics mirror the flat fields a single-standard run carries.
1302#[derive(Serialize)]
1303#[serde(rename_all = "camelCase")]
1304struct StandardRunSummary {
1305    /// Standard display name (the value the single-standard run's `profile`
1306    /// property carries).
1307    profile: String,
1308    /// Whether the standard actually evaluated the SBOM.
1309    applicable: bool,
1310    /// Human-readable N/A reason; omitted for applicable standards.
1311    #[serde(skip_serializing_if = "Option::is_none")]
1312    not_applicable_reason: Option<String>,
1313    /// Compliance score (0-100); omitted when the standard did not evaluate
1314    /// the SBOM.
1315    #[serde(skip_serializing_if = "Option::is_none")]
1316    overall_score: Option<f32>,
1317    /// Raw verdict. Stays `true` for not-applicable standards by the
1318    /// documented `ComplianceResult` contract — check `applicable` first.
1319    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/// SarifRule plus a derived `helpUri` (CRA-P5.1). Wraps the existing rule
1363/// definitions at serialization time so we don't need to thread the URL
1364/// through every call site that constructs a `SarifRule`. Computed via
1365/// `rule_help_uri()` based on the rule-ID prefix.
1366#[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    /// Standard references (CRA Article, prEN 40000-1-3 ID, BSI section, …)
1400    /// Surfaced in `properties.standardIds` so downstream GRC/CI tooling can
1401    /// map a finding to the exact harmonised-standard clause.
1402    #[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    /// Standard reference IDs in the form `<standard>:<id>`
1410    /// (e.g., `prEN-40000-1-3:PRE-7-RQ-07`, `CRA:Art. 13(8)`).
1411    /// Plural to match SARIF `properties` extensibility convention.
1412    #[serde(skip_serializing_if = "Vec::is_empty")]
1413    standard_ids: Vec<String>,
1414    /// Canonical URLs for each standard the violation references — lifted
1415    /// from `StandardRef::help_uri`. Order parallels `standard_ids`. Empty
1416    /// when none of the references have a canonical home.
1417    #[serde(skip_serializing_if = "Vec::is_empty")]
1418    standard_help_uris: Vec<String>,
1419    /// Recommendation priority (1 = highest) — quality recommendations only.
1420    #[serde(skip_serializing_if = "Option::is_none")]
1421    priority: Option<u8>,
1422    /// Number of affected components — quality recommendations only.
1423    #[serde(skip_serializing_if = "Option::is_none")]
1424    affected_count: Option<usize>,
1425    /// Estimated score impact — quality recommendations only.
1426    #[serde(skip_serializing_if = "Option::is_none")]
1427    impact: Option<f32>,
1428    /// Canonical id of the offending component (compliance results only) —
1429    /// mirrors `Violation::component_id`, the machine-readable join key back
1430    /// to the SBOM component. Absent for document-level/aggregate findings.
1431    #[serde(skip_serializing_if = "Option::is_none")]
1432    component_id: Option<String>,
1433    /// Affected-component count for aggregate compliance findings — mirrors
1434    /// `Violation::counts.affected` (the numerator printed in the message).
1435    #[serde(skip_serializing_if = "Option::is_none")]
1436    affected: Option<usize>,
1437    /// Total-component count for aggregate compliance findings — mirrors
1438    /// `Violation::counts.total` (the message's denominator).
1439    #[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    /// The registry's `default_severity` and the hand-maintained SARIF rule
1492    /// catalogues must agree — the registry is documentation, the catalogue
1493    /// is what GitHub code scanning displays, and they had already drifted
1494    /// apart once (audit finding: registry.rs default_severity dead + wrong).
1495    /// Rules whose id is not a registry key (e.g. SBOM-TOOLS-* change
1496    /// tracking) are exempt, as are registry keys whose sarif_id differs
1497    /// from the key (aliased identities).
1498    #[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}