use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
use crate::model::NormalizedSbom;
use crate::quality::{
ComplianceLevel, ComplianceResult, StandardRef, ViolationSeverity, generic_rule_id_for_level,
rule_meta,
};
use serde::Serialize;
pub struct SarifReporter {
include_info: bool,
}
impl SarifReporter {
#[must_use]
pub const fn new() -> Self {
Self { include_info: true }
}
#[must_use]
pub const fn include_info(mut self, include: bool) -> Self {
self.include_info = include;
self
}
}
impl Default for SarifReporter {
fn default() -> Self {
Self::new()
}
}
impl ReportGenerator for SarifReporter {
fn generate_diff_report(
&self,
result: &DiffResult,
old_sbom: &NormalizedSbom,
new_sbom: &NormalizedSbom,
config: &ReportConfig,
) -> Result<String, ReportError> {
let mut results = Vec::new();
if config.includes(ReportType::Components) {
for comp in &result.components.added {
if self.include_info {
results.push(SarifResult {
rule_id: "SBOM-TOOLS-001".to_string(),
level: SarifLevel::Note,
message: SarifMessage {
text: format!(
"Component added: {} {}",
comp.name,
comp.new_version.as_deref().unwrap_or("")
),
},
locations: vec![],
properties: None,
});
}
}
for comp in &result.components.removed {
results.push(SarifResult {
rule_id: "SBOM-TOOLS-002".to_string(),
level: SarifLevel::Warning,
message: SarifMessage {
text: format!(
"Component removed: {} {}",
comp.name,
comp.old_version.as_deref().unwrap_or("")
),
},
locations: vec![],
properties: None,
});
}
for comp in &result.components.modified {
if comp.change_type == crate::diff::ChangeType::Unchanged {
continue;
}
if self.include_info {
results.push(SarifResult {
rule_id: "SBOM-TOOLS-003".to_string(),
level: SarifLevel::Note,
message: SarifMessage {
text: format!(
"Component modified: {} {} -> {}",
comp.name,
comp.old_version.as_deref().unwrap_or("unknown"),
comp.new_version.as_deref().unwrap_or("unknown")
),
},
locations: vec![],
properties: None,
});
}
}
}
if config.includes(ReportType::Vulnerabilities) {
for vuln in &result.vulnerabilities.introduced {
let depth_label = match vuln.component_depth {
Some(1) => " [Direct]",
Some(_) => " [Transitive]",
None => "",
};
let sla_label = format_sla_label(vuln);
let vex_label = format_vex_label(vuln.vex_state.as_ref());
results.push(SarifResult {
rule_id: "SBOM-TOOLS-005".to_string(),
level: severity_to_level(&vuln.severity),
message: SarifMessage {
text: format!(
"Vulnerability introduced: {} ({}){}{}{} in {} {}",
vuln.id,
vuln.severity,
depth_label,
sla_label,
vex_label,
vuln.component_name,
vuln.version.as_deref().unwrap_or("")
),
},
locations: vec![],
properties: None,
});
}
for vuln in &result.vulnerabilities.resolved {
if self.include_info {
let depth_label = match vuln.component_depth {
Some(1) => " [Direct]",
Some(_) => " [Transitive]",
None => "",
};
let sla_label = format_sla_label(vuln);
let vex_label = format_vex_label(vuln.vex_state.as_ref());
results.push(SarifResult {
rule_id: "SBOM-TOOLS-006".to_string(),
level: SarifLevel::Note,
message: SarifMessage {
text: format!(
"Vulnerability resolved: {} ({}){}{}{} was in {}",
vuln.id,
vuln.severity,
depth_label,
sla_label,
vex_label,
vuln.component_name
),
},
locations: vec![],
properties: None,
});
}
}
}
if config.includes(ReportType::Licenses) {
for license in &result.licenses.new_licenses {
results.push(SarifResult {
rule_id: "SBOM-TOOLS-004".to_string(),
level: SarifLevel::Warning,
message: SarifMessage {
text: format!(
"New license introduced: {} in components: {}",
license.license,
license.components.join(", ")
),
},
locations: vec![],
properties: None,
});
}
}
for change in &result.metadata_changes {
let old = change.old_value.as_deref().unwrap_or("(none)");
let new = change.new_value.as_deref().unwrap_or("(none)");
results.push(SarifResult {
rule_id: "SBOM-TOOLS-008".to_string(),
level: SarifLevel::Note,
message: SarifMessage {
text: format!(
"Metadata {}: {} ({old} -> {new})",
change.kind, change.field
),
},
locations: vec![],
properties: None,
});
}
for comp in new_sbom.components.values() {
if let Some(eol) = &comp.eol {
match eol.status {
crate::model::EolStatus::EndOfLife => {
let eol_date_str = eol
.eol_date
.map_or_else(String::new, |d| format!(" (EOL: {d})"));
results.push(SarifResult {
rule_id: "SBOM-EOL-001".to_string(),
level: SarifLevel::Error,
message: SarifMessage {
text: format!(
"Component '{}' version '{}' has reached end-of-life{} (product: {})",
comp.name,
comp.version.as_deref().unwrap_or("unknown"),
eol_date_str,
eol.product,
),
},
locations: vec![],
properties: None,
});
}
crate::model::EolStatus::ApproachingEol => {
let days_str = eol
.days_until_eol
.map_or_else(String::new, |d| format!(" ({d} days remaining)"));
results.push(SarifResult {
rule_id: "SBOM-EOL-002".to_string(),
level: SarifLevel::Warning,
message: SarifMessage {
text: format!(
"Component '{}' version '{}' is approaching end-of-life{} (product: {})",
comp.name,
comp.version.as_deref().unwrap_or("unknown"),
days_str,
eol.product,
),
},
locations: vec![],
properties: None,
});
}
_ => {}
}
}
}
let cra_old = config.old_cra_compliance_or_bare(old_sbom);
let cra_new = config.new_cra_compliance_or_bare(new_sbom);
results.extend(compliance_results_to_sarif(&cra_old, Some("Old SBOM")));
results.extend(compliance_results_to_sarif(&cra_new, Some("New SBOM")));
let rules = complete_rule_catalogue(get_sarif_rules(), &results);
let sarif = SarifReport {
schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
version: "2.1.0".to_string(),
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: "sbom-tools".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
rules: SarifRuleWithUri::wrap_all(rules),
},
},
results,
properties: None,
}],
};
serde_json::to_string_pretty(&sarif)
.map_err(|e| ReportError::SerializationError(e.to_string()))
}
fn generate_view_report(
&self,
sbom: &NormalizedSbom,
config: &ReportConfig,
) -> Result<String, ReportError> {
let mut results = Vec::new();
for (comp, vuln) in sbom.all_vulnerabilities() {
let severity_str = vuln
.severity
.as_ref()
.map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
let vex_state = vuln
.vex_status
.as_ref()
.map(|v| &v.status)
.or_else(|| comp.vex_status.as_ref().map(|v| &v.status));
let vex_label = format_vex_label(vex_state);
results.push(SarifResult {
rule_id: "SBOM-VIEW-001".to_string(),
level: severity_to_level(&severity_str),
message: SarifMessage {
text: format!(
"Vulnerability {} ({}){} in {} {}",
vuln.id,
severity_str,
vex_label,
comp.name,
comp.version.as_deref().unwrap_or("")
),
},
locations: vec![],
properties: None,
});
}
let cra_result = config.view_cra_compliance_or_bare(sbom);
results.extend(compliance_results_to_sarif(&cra_result, None));
let rules = complete_rule_catalogue(get_sarif_view_rules(), &results);
let sarif = SarifReport {
schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
version: "2.1.0".to_string(),
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: "sbom-tools".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
rules: SarifRuleWithUri::wrap_all(rules),
},
},
results,
properties: None,
}],
};
serde_json::to_string_pretty(&sarif)
.map_err(|e| ReportError::SerializationError(e.to_string()))
}
fn format(&self) -> ReportFormat {
ReportFormat::Sarif
}
}
fn ai_check_to_rule_id(check_id: &str) -> &'static str {
match check_id {
"AI-001" => "SBOM-AIBOM-001",
"AI-002" => "SBOM-AIBOM-002",
"AI-003" => "SBOM-AIBOM-003",
"AI-004" => "SBOM-AIBOM-004",
"AI-005" => "SBOM-AIBOM-005",
"AI-006" => "SBOM-AIBOM-006",
"AI-007" => "SBOM-AIBOM-007",
"AI-008" => "SBOM-AIBOM-008",
"AI-009" => "SBOM-AIBOM-009",
"AI-010" => "SBOM-AIBOM-010",
"AI-011" => "SBOM-AIBOM-011",
_ => "SBOM-AIBOM-GENERAL",
}
}
fn aibom_level(check_id: &str) -> SarifLevel {
match check_id {
"AI-001" | "AI-002" | "AI-003" | "AI-005" | "AI-009" | "AI-010" | "AI-011" => {
SarifLevel::Warning
}
_ => SarifLevel::Note,
}
}
fn get_sarif_aibom_rules() -> Vec<SarifRule> {
[
(
"SBOM-AIBOM-001",
"AI-001",
"AibomModelCardUrl",
"Model card URL present",
),
(
"SBOM-AIBOM-002",
"AI-002",
"AibomArchitectureFamily",
"Architecture family declared",
),
(
"SBOM-AIBOM-003",
"AI-003",
"AibomTrainingDatasets",
"Training datasets referenced",
),
(
"SBOM-AIBOM-004",
"AI-004",
"AibomQuantitativeAnalysis",
"Quantitative analysis present",
),
(
"SBOM-AIBOM-005",
"AI-005",
"AibomFairnessAssessment",
"Fairness assessments included",
),
(
"SBOM-AIBOM-006",
"AI-006",
"AibomEnergyConsumption",
"Energy consumption disclosed",
),
(
"SBOM-AIBOM-007",
"AI-007",
"AibomUseCases",
"Use-cases documented",
),
(
"SBOM-AIBOM-008",
"AI-008",
"AibomLimitations",
"Known limitations stated",
),
(
"SBOM-AIBOM-009",
"AI-009",
"AibomEthicalConsiderations",
"Ethical considerations present",
),
(
"SBOM-AIBOM-010",
"AI-010",
"AibomModelWeightHashes",
"Model weight hashes present",
),
(
"SBOM-AIBOM-011",
"AI-011",
"AibomExploitabilityReference",
"Exploitability/advisory reference present",
),
(
"SBOM-AIBOM-GENERAL",
"AI-GENERAL",
"AibomGeneral",
"AI BOM model-card completeness",
),
]
.into_iter()
.map(|(rule_id, check_id, name, desc)| SarifRule {
id: rule_id.to_string(),
name: name.to_string(),
short_description: SarifMessage {
text: desc.to_string(),
},
default_configuration: SarifConfiguration {
level: aibom_level(check_id),
},
})
.collect()
}
pub fn generate_ai_readiness_sarif(
metrics: &crate::quality::AiReadinessMetrics,
sbom_name: &str,
profile: &str,
overall_score: Option<f32>,
grade: &str,
) -> Result<String, ReportError> {
let results: Vec<SarifResult> = metrics
.checks
.iter()
.filter(|check| !check.passed)
.map(|check| {
let rule_id = ai_check_to_rule_id(&check.id);
let detail_suffix = check
.detail
.as_ref()
.map(|d| format!(" — {d}"))
.unwrap_or_default();
SarifResult {
rule_id: rule_id.to_string(),
level: aibom_level(&check.id),
message: SarifMessage {
text: format!(
"AIBOM check {} failed: {} ({:.0}% weight){detail_suffix}",
check.id,
check.name,
check.weight * 100.0
),
},
locations: vec![],
properties: Some(SarifResultProperties {
standard_ids: vec![format!("AIBOM:{}", check.id)],
standard_help_uris: rule_help_uri(rule_id)
.map(|u| vec![u.to_string()])
.unwrap_or_default(),
..SarifResultProperties::default()
}),
}
})
.collect();
let sarif = SarifReport {
schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
version: "2.1.0".to_string(),
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: "sbom-tools".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
rules: SarifRuleWithUri::wrap_all(get_sarif_aibom_rules()),
},
},
results,
properties: Some(SarifRunProperties {
applicable: !metrics.is_not_applicable(),
not_applicable_reason: metrics.na_reason.clone(),
overall_score,
grade: Some(grade.to_string()),
sbom: Some(sbom_name.to_string()),
profile: Some(profile.to_string()),
compliant: None,
standards: Vec::new(),
}),
}],
};
serde_json::to_string_pretty(&sarif).map_err(|e| ReportError::SerializationError(e.to_string()))
}
fn complete_rule_catalogue(mut rules: Vec<SarifRule>, results: &[SarifResult]) -> Vec<SarifRule> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
rules.retain(|r| seen.insert(r.id.clone()));
for res in results {
if seen.contains(&res.rule_id) {
continue;
}
seen.insert(res.rule_id.clone());
if let Some(meta) = rule_meta(&res.rule_id)
&& meta.sarif_id == res.rule_id
{
rules.push(registry_sarif_rule(res.rule_id.clone(), meta));
continue;
}
let name: String = res
.rule_id
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|seg| !seg.is_empty())
.map(|seg| {
let mut cs = seg.chars();
cs.next()
.map(|f| f.to_ascii_uppercase().to_string() + &cs.as_str().to_ascii_lowercase())
.unwrap_or_default()
})
.collect();
rules.push(SarifRule {
id: res.rule_id.clone(),
name,
short_description: SarifMessage {
text: format!("Compliance rule {}", res.rule_id),
},
default_configuration: SarifConfiguration { level: res.level },
});
}
rules
}
pub fn generate_compliance_sarif(result: &ComplianceResult) -> Result<String, ReportError> {
let results = compliance_results_to_sarif(result, None);
let not_applicable_reason = match &result.applicability {
crate::quality::Applicability::NotApplicable(reason) => Some(reason.clone()),
crate::quality::Applicability::Applicable => None,
};
let run_properties = Some(SarifRunProperties {
applicable: result.is_applicable(),
not_applicable_reason,
overall_score: result.score().map(f32::from),
grade: None,
sbom: None,
profile: Some(result.level.name().to_string()),
compliant: None,
standards: Vec::new(),
});
let rules = SarifRuleWithUri::wrap_all(complete_rule_catalogue(
get_sarif_rules_for_standard(result.level),
&results,
));
let sarif = SarifReport {
schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
version: "2.1.0".to_string(),
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: "sbom-tools".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
rules,
},
},
results,
properties: run_properties,
}],
};
serde_json::to_string_pretty(&sarif).map_err(|e| ReportError::SerializationError(e.to_string()))
}
pub fn generate_multi_compliance_sarif(
results: &[ComplianceResult],
) -> Result<String, ReportError> {
let mut all_rules = Vec::new();
let mut all_results = Vec::new();
for result in results {
all_rules.extend(get_sarif_rules_for_standard(result.level));
all_results.extend(compliance_results_to_sarif(result, None));
}
let all_rules = complete_rule_catalogue(all_rules, &all_results);
let run_properties = Some(SarifRunProperties {
applicable: results.iter().any(ComplianceResult::is_applicable),
not_applicable_reason: None,
overall_score: None,
grade: None,
sbom: None,
profile: None,
compliant: None,
standards: results
.iter()
.map(StandardRunSummary::from_result)
.collect(),
});
let sarif = SarifReport {
schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
version: "2.1.0".to_string(),
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: "sbom-tools".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
rules: SarifRuleWithUri::wrap_all(all_rules),
},
},
results: all_results,
properties: run_properties,
}],
};
serde_json::to_string_pretty(&sarif).map_err(|e| ReportError::SerializationError(e.to_string()))
}
pub fn generate_quality_sarif(
report: &crate::quality::QualityReport,
sbom_name: &str,
profile: &str,
) -> Result<String, ReportError> {
let mut results = compliance_results_to_sarif(&report.compliance, None);
let mut rules = get_sarif_rules_for_standard(report.compliance.level);
let mut rec_rule_ids: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for rec in &report.recommendations {
let rule_id = format!(
"SBOM-QUALITY-REC-{}",
rec.category.name().to_uppercase().replace(' ', "-")
);
if rec_rule_ids.insert(rule_id.clone()) {
rules.push(SarifRule {
id: rule_id.clone(),
name: format!(
"QualityRecommendation{}",
rec.category.name().replace(' ', "")
),
short_description: SarifMessage {
text: format!("Quality recommendation: {}", rec.category.name()),
},
default_configuration: SarifConfiguration {
level: SarifLevel::Note,
},
});
}
results.push(SarifResult {
rule_id,
level: recommendation_level(rec.priority),
message: SarifMessage {
text: format!(
"{} ({} affected, +{:.1} impact)",
rec.message, rec.affected_count, rec.impact
),
},
locations: vec![],
properties: Some(SarifResultProperties {
priority: Some(rec.priority),
affected_count: Some(rec.affected_count),
impact: Some(rec.impact),
..SarifResultProperties::default()
}),
});
}
let rules = SarifRuleWithUri::wrap_all(complete_rule_catalogue(rules, &results));
let not_applicable_reason = match &report.compliance.applicability {
crate::quality::Applicability::NotApplicable(reason) => Some(reason.clone()),
crate::quality::Applicability::Applicable => None,
};
let sarif = SarifReport {
schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
version: "2.1.0".to_string(),
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: "sbom-tools".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
rules,
},
},
results,
properties: Some(SarifRunProperties {
applicable: report.compliance.is_applicable(),
not_applicable_reason,
overall_score: Some(report.overall_score),
grade: Some(report.grade.letter().to_string()),
sbom: Some(sbom_name.to_string()),
profile: Some(profile.to_string()),
compliant: Some(report.compliance.is_compliant),
standards: Vec::new(),
}),
}],
};
serde_json::to_string_pretty(&sarif).map_err(|e| ReportError::SerializationError(e.to_string()))
}
const fn recommendation_level(priority: u8) -> SarifLevel {
match priority {
1 | 2 => SarifLevel::Warning,
_ => SarifLevel::Note,
}
}
fn severity_to_level(severity: &str) -> SarifLevel {
match severity.to_lowercase().as_str() {
"critical" | "high" => SarifLevel::Error,
"low" | "info" => SarifLevel::Note,
_ => SarifLevel::Warning,
}
}
fn format_sla_label(vuln: &VulnerabilityDetail) -> String {
match vuln.sla_status() {
SlaStatus::Overdue(days) => format!(" [SLA: {days}d late]"),
SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => format!(" [SLA: {days}d left]"),
SlaStatus::NoDueDate => vuln
.days_since_published
.map(|d| format!(" [Age: {d}d]"))
.unwrap_or_default(),
}
}
fn format_vex_label(vex_state: Option<&crate::model::VexState>) -> String {
match vex_state {
Some(crate::model::VexState::NotAffected) => " [VEX: Not Affected]".to_string(),
Some(crate::model::VexState::Fixed) => " [VEX: Fixed]".to_string(),
Some(crate::model::VexState::Affected) => " [VEX: Affected]".to_string(),
Some(crate::model::VexState::UnderInvestigation) => {
" [VEX: Under Investigation]".to_string()
}
None => String::new(),
}
}
const fn violation_severity_to_level(severity: ViolationSeverity) -> SarifLevel {
match severity {
ViolationSeverity::Error => SarifLevel::Error,
ViolationSeverity::Warning => SarifLevel::Warning,
ViolationSeverity::Info => SarifLevel::Note,
}
}
fn compliance_results_to_sarif(result: &ComplianceResult, label: Option<&str>) -> Vec<SarifResult> {
let prefix = label.map(|l| format!("{l} - ")).unwrap_or_default();
result
.violations
.iter()
.map(|v| {
let element = v.element.as_deref().unwrap_or("unknown");
let is_unmapped = v.rule_id == "SBOM-CRA-GENERAL" || rule_meta(v.rule_id).is_none();
let sarif_rule_id = if is_unmapped {
generic_rule_id_for_level(result.level)
} else {
v.sarif_rule_id()
};
let fallback_refs: Vec<StandardRef>;
let refs: &[StandardRef] = if is_unmapped && v.standard_refs.is_empty() {
fallback_refs = rule_meta(sarif_rule_id)
.map(|m| {
m.refs
.iter()
.map(|(kind, id)| StandardRef::new(*kind, *id))
.collect()
})
.unwrap_or_default();
&fallback_refs
} else {
&v.standard_refs
};
let standard_ids: Vec<String> = refs
.iter()
.map(|sr| format!("{}:{}", sarif_standard_label(sr.standard), sr.id))
.collect();
let standard_help_uris: Vec<String> =
refs.iter().filter_map(|sr| sr.help_uri.clone()).collect();
let properties = if standard_ids.is_empty()
&& standard_help_uris.is_empty()
&& v.component_id.is_none()
&& v.counts.is_none()
{
None
} else {
Some(SarifResultProperties {
standard_ids,
standard_help_uris,
component_id: v.component_id.clone(),
affected: v.counts.map(|c| c.affected),
total: v.counts.map(|c| c.total),
..SarifResultProperties::default()
})
};
SarifResult {
rule_id: sarif_rule_id.to_string(),
level: violation_severity_to_level(v.severity),
message: SarifMessage {
text: format!(
"{}{}: {} (Requirement: {}) [Element: {}]",
prefix,
result.level.name(),
v.message,
v.requirement,
element
),
},
locations: vec![],
properties,
}
})
.collect()
}
fn rule_help_uri(rule_id: &str) -> Option<&'static str> {
if rule_id.starts_with("SBOM-EUCC") {
Some("https://eur-lex.europa.eu/eli/reg_impl/2024/482/oj/eng")
} else if rule_id.starts_with("SBOM-CRA-") {
Some("https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng")
} else if rule_id.starts_with("SBOM-BSI-") {
Some("https://bsi.bund.de/dok/TR-03183-en")
} else if rule_id.starts_with("SBOM-NIST-SSDF-") || rule_id.starts_with("SBOM-SSDF-") {
Some("https://doi.org/10.6028/NIST.SP.800-218")
} else if rule_id.starts_with("SBOM-EO14028-") || rule_id.starts_with("SBOM-EO-14028-") {
Some("https://www.federalregister.gov/d/2021-10460")
} else if rule_id.starts_with("SBOM-FDA-") {
Some("https://www.fda.gov/media/119933/download")
} else if rule_id.starts_with("SBOM-NTIA-") {
Some("https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom")
} else if rule_id.starts_with("SBOM-PQC-") || rule_id.starts_with("SBOM-NIST-PQC-") {
Some("https://csrc.nist.gov/projects/post-quantum-cryptography")
} else if rule_id.starts_with("SBOM-CNSA-") {
Some(
"https://media.defense.gov/2022/Sep/07/2003071834/-1/-1/0/CSA_CNSA_2.0_ALGORITHMS_.PDF",
)
} else if rule_id.starts_with("SBOM-CSAF-") {
Some("https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html")
} else if rule_id.starts_with("SBOM-AIBOM-") {
Some("https://cyclonedx.org/capabilities/mlbom/")
} else if rule_id.starts_with("SBOM-AIACT-") {
Some("https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng")
} else if rule_id.starts_with("SBOM-BSIAI-") {
Some(
"https://www.cisa.gov/resources-tools/resources/software-bill-materials-ai-minimum-elements",
)
} else if rule_id.starts_with("SBOM-CISA2026-") {
Some(
"https://www.cisa.gov/resources-tools/resources/2026-minimum-elements-software-bill-materials-sbom",
)
} else if rule_id.starts_with("SBOM-PCI-") {
Some("https://www.pcisecuritystandards.org/document_library/")
} else if rule_id.starts_with("SBOM-FSCT-") {
Some(
"https://www.cisa.gov/resources-tools/resources/framing-software-component-transparency-2024",
)
} else {
None
}
}
fn sarif_standard_label(kind: crate::quality::StandardKind) -> &'static str {
use crate::quality::StandardKind;
match kind {
StandardKind::CraArticle => "CRA",
StandardKind::CraAnnex => "CRA-Annex",
StandardKind::Pren40000_1_3 => "prEN-40000-1-3",
StandardKind::BsiTr03183_2 => "BSI-TR-03183-2",
StandardKind::NistSsdf => "NIST-SSDF",
StandardKind::Eo14028 => "EO-14028",
StandardKind::FdaPremarket => "FDA",
StandardKind::NtiaMinimum => "NTIA",
StandardKind::Csaf2 => "CSAF",
StandardKind::Cnsa2 => "CNSA-2.0",
StandardKind::NistPqc => "NIST-PQC",
StandardKind::EuAiAct => "EU-AI-Act",
StandardKind::BsiSbomForAi => "BSI-G7-SBOM-for-AI",
StandardKind::Eucc => "EUCC",
StandardKind::CisaMinimum2026 => "CISA-2026",
StandardKind::PciDss4 => "PCI-DSS-v4",
StandardKind::CisaFsct => "CISA-FSCT-3e",
StandardKind::Other => "Other",
}
}
fn get_sarif_rules() -> Vec<SarifRule> {
let mut rules = vec![
SarifRule {
id: "SBOM-TOOLS-001".to_string(),
name: "ComponentAdded".to_string(),
short_description: SarifMessage {
text: "A new component was added to the SBOM".to_string(),
},
default_configuration: SarifConfiguration {
level: SarifLevel::Note,
},
},
SarifRule {
id: "SBOM-TOOLS-002".to_string(),
name: "ComponentRemoved".to_string(),
short_description: SarifMessage {
text: "A component was removed from the SBOM".to_string(),
},
default_configuration: SarifConfiguration {
level: SarifLevel::Warning,
},
},
SarifRule {
id: "SBOM-TOOLS-003".to_string(),
name: "VersionChanged".to_string(),
short_description: SarifMessage {
text: "A component version was changed".to_string(),
},
default_configuration: SarifConfiguration {
level: SarifLevel::Note,
},
},
SarifRule {
id: "SBOM-TOOLS-004".to_string(),
name: "LicenseChanged".to_string(),
short_description: SarifMessage {
text: "A license was added or changed".to_string(),
},
default_configuration: SarifConfiguration {
level: SarifLevel::Warning,
},
},
SarifRule {
id: "SBOM-TOOLS-005".to_string(),
name: "VulnerabilityIntroduced".to_string(),
short_description: SarifMessage {
text: "A new vulnerability was introduced".to_string(),
},
default_configuration: SarifConfiguration {
level: SarifLevel::Error,
},
},
SarifRule {
id: "SBOM-TOOLS-006".to_string(),
name: "VulnerabilityResolved".to_string(),
short_description: SarifMessage {
text: "A vulnerability was resolved".to_string(),
},
default_configuration: SarifConfiguration {
level: SarifLevel::Note,
},
},
SarifRule {
id: "SBOM-TOOLS-007".to_string(),
name: "SupplierChanged".to_string(),
short_description: SarifMessage {
text: "A component supplier was changed".to_string(),
},
default_configuration: SarifConfiguration {
level: SarifLevel::Warning,
},
},
SarifRule {
id: "SBOM-TOOLS-008".to_string(),
name: "MetadataChanged".to_string(),
short_description: SarifMessage {
text: "A document-level metadata field was changed".to_string(),
},
default_configuration: SarifConfiguration {
level: SarifLevel::Note,
},
},
SarifRule {
id: "SBOM-EOL-001".to_string(),
name: "ComponentEndOfLife".to_string(),
short_description: SarifMessage {
text: "A component has reached end-of-life".to_string(),
},
default_configuration: SarifConfiguration {
level: SarifLevel::Error,
},
},
SarifRule {
id: "SBOM-EOL-002".to_string(),
name: "ComponentApproachingEol".to_string(),
short_description: SarifMessage {
text: "A component is approaching end-of-life".to_string(),
},
default_configuration: SarifConfiguration {
level: SarifLevel::Warning,
},
},
];
rules.extend(get_sarif_compliance_rules());
rules
}
fn get_sarif_view_rules() -> Vec<SarifRule> {
let mut rules = vec![SarifRule {
id: "SBOM-VIEW-001".to_string(),
name: "VulnerabilityPresent".to_string(),
short_description: SarifMessage {
text: "A vulnerability is present in a component".to_string(),
},
default_configuration: SarifConfiguration {
level: SarifLevel::Warning,
},
}];
rules.extend(get_sarif_compliance_rules());
rules
}
fn get_sarif_rules_for_standard(level: ComplianceLevel) -> Vec<SarifRule> {
match level {
ComplianceLevel::NtiaMinimum => get_sarif_ntia_rules(),
ComplianceLevel::FdaMedicalDevice => get_sarif_fda_rules(),
ComplianceLevel::NistSsdf => get_sarif_ssdf_rules(),
ComplianceLevel::Eo14028 => get_sarif_eo14028_rules(),
ComplianceLevel::Cnsa2 => get_sarif_cnsa2_rules(),
ComplianceLevel::NistPqc => get_sarif_pqc_rules(),
ComplianceLevel::Cisa2026 => get_sarif_cisa2026_rules(),
ComplianceLevel::PciDss632 => get_sarif_pcidss_rules(),
ComplianceLevel::Fsct => get_sarif_fsct_rules(),
_ => get_sarif_compliance_rules(),
}
}
fn get_sarif_ntia_rules() -> Vec<SarifRule> {
registry_sarif_rules(crate::quality::NTIA_SARIF_RULE_IDS)
}
fn get_sarif_fda_rules() -> Vec<SarifRule> {
registry_sarif_rules(crate::quality::FDA_SARIF_RULE_IDS)
}
fn get_sarif_ssdf_rules() -> Vec<SarifRule> {
registry_sarif_rules(crate::quality::SSDF_SARIF_RULE_IDS)
}
fn get_sarif_eo14028_rules() -> Vec<SarifRule> {
registry_sarif_rules(crate::quality::EO14028_SARIF_RULE_IDS)
}
fn get_sarif_cnsa2_rules() -> Vec<SarifRule> {
registry_sarif_rules(crate::quality::CNSA2_SARIF_RULE_IDS)
}
fn get_sarif_pqc_rules() -> Vec<SarifRule> {
registry_sarif_rules(crate::quality::PQC_SARIF_RULE_IDS)
}
fn get_sarif_cisa2026_rules() -> Vec<SarifRule> {
registry_sarif_rules(crate::quality::CISA2026_SARIF_RULE_IDS)
}
fn get_sarif_pcidss_rules() -> Vec<SarifRule> {
registry_sarif_rules(crate::quality::PCIDSS_SARIF_RULE_IDS)
}
fn get_sarif_fsct_rules() -> Vec<SarifRule> {
registry_sarif_rules(crate::quality::FSCT_SARIF_RULE_IDS)
}
fn get_sarif_compliance_rules() -> Vec<SarifRule> {
registry_sarif_rules(crate::quality::COMPLIANCE_SARIF_RULE_IDS)
}
fn registry_sarif_rules(ids: &[&str]) -> Vec<SarifRule> {
ids.iter()
.filter_map(|id| {
let Some(meta) = rule_meta(id) else {
debug_assert!(false, "SARIF rule slice id {id} missing from registry");
return None;
};
debug_assert_eq!(
meta.sarif_id, *id,
"SARIF rule slices must list self-descriptor ids"
);
Some(registry_sarif_rule((*id).to_string(), meta))
})
.collect()
}
fn registry_sarif_rule(id: String, meta: crate::quality::RuleMeta) -> SarifRule {
SarifRule {
id,
name: meta.name.to_string(),
short_description: SarifMessage {
text: meta.short_description.to_string(),
},
default_configuration: SarifConfiguration {
level: violation_severity_to_level(meta.default_severity),
},
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifReport {
#[serde(rename = "$schema")]
schema: String,
version: String,
runs: Vec<SarifRun>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifRun {
tool: SarifTool,
results: Vec<SarifResult>,
#[serde(skip_serializing_if = "Option::is_none")]
properties: Option<SarifRunProperties>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifRunProperties {
applicable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
not_applicable_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
overall_score: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
grade: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sbom: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
compliant: Option<bool>,
#[serde(skip_serializing_if = "Vec::is_empty")]
standards: Vec<StandardRunSummary>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StandardRunSummary {
profile: String,
applicable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
not_applicable_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
overall_score: Option<f32>,
compliant: bool,
}
impl StandardRunSummary {
fn from_result(result: &ComplianceResult) -> Self {
let not_applicable_reason = match &result.applicability {
crate::quality::Applicability::NotApplicable(reason) => Some(reason.clone()),
crate::quality::Applicability::Applicable => None,
};
Self {
profile: result.level.name().to_string(),
applicable: result.is_applicable(),
not_applicable_reason,
overall_score: result.score().map(f32::from),
compliant: result.is_compliant,
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifTool {
driver: SarifDriver,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifDriver {
name: String,
version: String,
information_uri: String,
rules: Vec<SarifRuleWithUri>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifRule {
id: String,
name: String,
short_description: SarifMessage,
default_configuration: SarifConfiguration,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifRuleWithUri {
#[serde(flatten)]
inner: SarifRule,
#[serde(skip_serializing_if = "Option::is_none")]
help_uri: Option<&'static str>,
}
impl SarifRuleWithUri {
fn wrap(inner: SarifRule) -> Self {
let help_uri = rule_help_uri(&inner.id);
Self { inner, help_uri }
}
fn wrap_all(rules: Vec<SarifRule>) -> Vec<Self> {
rules.into_iter().map(Self::wrap).collect()
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifConfiguration {
level: SarifLevel,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifResult {
rule_id: String,
level: SarifLevel,
message: SarifMessage,
locations: Vec<SarifLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
properties: Option<SarifResultProperties>,
}
#[derive(Serialize, Default)]
#[serde(rename_all = "camelCase")]
struct SarifResultProperties {
#[serde(skip_serializing_if = "Vec::is_empty")]
standard_ids: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
standard_help_uris: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
priority: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
affected_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
impact: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
component_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
affected: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
total: Option<usize>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifMessage {
text: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifLocation {
physical_location: Option<SarifPhysicalLocation>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifPhysicalLocation {
artifact_location: SarifArtifactLocation,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifArtifactLocation {
uri: String,
}
#[derive(Serialize, Clone, Copy, Debug)]
#[serde(rename_all = "lowercase")]
enum SarifLevel {
#[allow(dead_code)]
None,
Note,
Warning,
Error,
}
#[cfg(test)]
mod registry_consistency_tests {
use super::*;
use crate::quality::rule_meta;
fn expected_level(sev: crate::quality::ViolationSeverity) -> SarifLevel {
use crate::quality::ViolationSeverity as V;
match sev {
V::Error => SarifLevel::Error,
V::Warning => SarifLevel::Warning,
V::Info => SarifLevel::Note,
}
}
#[test]
fn registry_severity_matches_sarif_catalogue() {
let mut tables: Vec<(&str, Vec<SarifRule>)> = vec![
("ntia", get_sarif_ntia_rules()),
("fda", get_sarif_fda_rules()),
("ssdf", get_sarif_ssdf_rules()),
("eo14028", get_sarif_eo14028_rules()),
("cnsa2", get_sarif_cnsa2_rules()),
("pqc", get_sarif_pqc_rules()),
("compliance", get_sarif_compliance_rules()),
];
let mut mismatches = Vec::new();
for (table_name, rules) in &mut tables {
for rule in rules.iter() {
let Some(meta) = rule_meta(&rule.id) else {
continue;
};
if meta.sarif_id != rule.id {
continue;
}
let expected = expected_level(meta.default_severity);
let actual = rule.default_configuration.level;
if !matches!(
(&expected, &actual),
(SarifLevel::Error, SarifLevel::Error)
| (SarifLevel::Warning, SarifLevel::Warning)
| (SarifLevel::Note, SarifLevel::Note)
| (SarifLevel::None, SarifLevel::None)
) {
mismatches.push(format!(
"{table_name}: {} registry={expected:?} catalogue={actual:?}",
rule.id
));
}
}
}
assert!(
mismatches.is_empty(),
"registry default_severity and SARIF catalogue drifted:\n{}",
mismatches.join("\n")
);
}
}