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::{ComplianceChecker, ComplianceLevel, ComplianceResult, ViolationSeverity};
7use serde::Serialize;
8
9/// SARIF report generator
10pub struct SarifReporter {
11    /// Include informational results
12    include_info: bool,
13}
14
15impl SarifReporter {
16    /// Create a new SARIF reporter
17    pub fn new() -> Self {
18        Self { include_info: true }
19    }
20
21    /// Set whether to include informational results
22    pub fn include_info(mut self, include: bool) -> Self {
23        self.include_info = include;
24        self
25    }
26}
27
28impl Default for SarifReporter {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl ReportGenerator for SarifReporter {
35    fn generate_diff_report(
36        &self,
37        result: &DiffResult,
38        old_sbom: &NormalizedSbom,
39        new_sbom: &NormalizedSbom,
40        config: &ReportConfig,
41    ) -> Result<String, ReportError> {
42        let mut results = Vec::new();
43
44        // Add component change results
45        if config.includes(ReportType::Components) {
46            for comp in &result.components.added {
47                if self.include_info {
48                    results.push(SarifResult {
49                        rule_id: "SBOM-TOOLS-001".to_string(),
50                        level: SarifLevel::Note,
51                        message: SarifMessage {
52                            text: format!(
53                                "Component added: {} {}",
54                                comp.name,
55                                comp.new_version.as_deref().unwrap_or("")
56                            ),
57                        },
58                        locations: vec![],
59                    });
60                }
61            }
62
63            for comp in &result.components.removed {
64                results.push(SarifResult {
65                    rule_id: "SBOM-TOOLS-002".to_string(),
66                    level: SarifLevel::Warning,
67                    message: SarifMessage {
68                        text: format!(
69                            "Component removed: {} {}",
70                            comp.name,
71                            comp.old_version.as_deref().unwrap_or("")
72                        ),
73                    },
74                    locations: vec![],
75                });
76            }
77
78            for comp in &result.components.modified {
79                if self.include_info {
80                    results.push(SarifResult {
81                        rule_id: "SBOM-TOOLS-003".to_string(),
82                        level: SarifLevel::Note,
83                        message: SarifMessage {
84                            text: format!(
85                                "Component modified: {} {} -> {}",
86                                comp.name,
87                                comp.old_version.as_deref().unwrap_or("unknown"),
88                                comp.new_version.as_deref().unwrap_or("unknown")
89                            ),
90                        },
91                        locations: vec![],
92                    });
93                }
94            }
95        }
96
97        // Add vulnerability results
98        if config.includes(ReportType::Vulnerabilities) {
99            for vuln in &result.vulnerabilities.introduced {
100                let depth_label = match vuln.component_depth {
101                    Some(1) => " [Direct]",
102                    Some(_) => " [Transitive]",
103                    None => "",
104                };
105                let sla_label = format_sla_label(vuln);
106                results.push(SarifResult {
107                    rule_id: "SBOM-TOOLS-005".to_string(),
108                    level: severity_to_level(&vuln.severity),
109                    message: SarifMessage {
110                        text: format!(
111                            "Vulnerability introduced: {} ({}){}{} in {} {}",
112                            vuln.id,
113                            vuln.severity,
114                            depth_label,
115                            sla_label,
116                            vuln.component_name,
117                            vuln.version.as_deref().unwrap_or("")
118                        ),
119                    },
120                    locations: vec![],
121                });
122            }
123
124            for vuln in &result.vulnerabilities.resolved {
125                if self.include_info {
126                    let depth_label = match vuln.component_depth {
127                        Some(1) => " [Direct]",
128                        Some(_) => " [Transitive]",
129                        None => "",
130                    };
131                    let sla_label = format_sla_label(vuln);
132                    results.push(SarifResult {
133                        rule_id: "SBOM-TOOLS-006".to_string(),
134                        level: SarifLevel::Note,
135                        message: SarifMessage {
136                            text: format!(
137                                "Vulnerability resolved: {} ({}){}{} was in {}",
138                                vuln.id, vuln.severity, depth_label, sla_label, vuln.component_name
139                            ),
140                        },
141                        locations: vec![],
142                    });
143                }
144            }
145        }
146
147        // Add license change results
148        if config.includes(ReportType::Licenses) {
149            for license in &result.licenses.new_licenses {
150                results.push(SarifResult {
151                    rule_id: "SBOM-TOOLS-004".to_string(),
152                    level: SarifLevel::Warning,
153                    message: SarifMessage {
154                        text: format!(
155                            "New license introduced: {} in components: {}",
156                            license.license,
157                            license.components.join(", ")
158                        ),
159                    },
160                    locations: vec![],
161                });
162            }
163        }
164
165        // Add CRA compliance results for old and new SBOMs (use pre-computed if available)
166        let cra_old = config.old_cra_compliance.clone().unwrap_or_else(|| {
167            ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
168        });
169        let cra_new = config.new_cra_compliance.clone().unwrap_or_else(|| {
170            ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
171        });
172        results.extend(compliance_results_to_sarif(&cra_old, Some("Old SBOM")));
173        results.extend(compliance_results_to_sarif(&cra_new, Some("New SBOM")));
174
175        let sarif = SarifReport {
176            schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
177            version: "2.1.0".to_string(),
178            runs: vec![SarifRun {
179                tool: SarifTool {
180                    driver: SarifDriver {
181                        name: "sbom-tools".to_string(),
182                        version: env!("CARGO_PKG_VERSION").to_string(),
183                        information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
184                        rules: get_sarif_rules(),
185                    },
186                },
187                results,
188            }],
189        };
190
191        serde_json::to_string_pretty(&sarif)
192            .map_err(|e| ReportError::SerializationError(e.to_string()))
193    }
194
195    fn generate_view_report(
196        &self,
197        sbom: &NormalizedSbom,
198        config: &ReportConfig,
199    ) -> Result<String, ReportError> {
200        let mut results = Vec::new();
201
202        // Report vulnerabilities in the SBOM
203        for (comp, vuln) in sbom.all_vulnerabilities() {
204            results.push(SarifResult {
205                rule_id: "SBOM-VIEW-001".to_string(),
206                level: severity_to_level(
207                    &vuln
208                        .severity
209                        .as_ref()
210                        .map(|s| s.to_string())
211                        .unwrap_or_else(|| "Unknown".to_string()),
212                ),
213                message: SarifMessage {
214                    text: format!(
215                        "Vulnerability {} ({}) in {} {}",
216                        vuln.id,
217                        vuln.severity
218                            .as_ref()
219                            .map(|s| s.to_string())
220                            .unwrap_or_else(|| "Unknown".to_string()),
221                        comp.name,
222                        comp.version.as_deref().unwrap_or("")
223                    ),
224                },
225                locations: vec![],
226            });
227        }
228
229        // Add CRA compliance results (use pre-computed if available)
230        let cra_result = config.view_cra_compliance.clone().unwrap_or_else(|| {
231            ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom)
232        });
233        results.extend(compliance_results_to_sarif(&cra_result, None));
234
235        let sarif = SarifReport {
236            schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
237            version: "2.1.0".to_string(),
238            runs: vec![SarifRun {
239                tool: SarifTool {
240                    driver: SarifDriver {
241                        name: "sbom-tools".to_string(),
242                        version: env!("CARGO_PKG_VERSION").to_string(),
243                        information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
244                        rules: get_sarif_view_rules(),
245                    },
246                },
247                results,
248            }],
249        };
250
251        serde_json::to_string_pretty(&sarif)
252            .map_err(|e| ReportError::SerializationError(e.to_string()))
253    }
254
255    fn format(&self) -> ReportFormat {
256        ReportFormat::Sarif
257    }
258}
259
260pub fn generate_compliance_sarif(result: &ComplianceResult) -> Result<String, ReportError> {
261    let sarif = SarifReport {
262        schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
263        version: "2.1.0".to_string(),
264        runs: vec![SarifRun {
265            tool: SarifTool {
266                driver: SarifDriver {
267                    name: "sbom-tools".to_string(),
268                    version: env!("CARGO_PKG_VERSION").to_string(),
269                    information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
270                    rules: get_sarif_compliance_rules(),
271                },
272            },
273            results: compliance_results_to_sarif(result, None),
274        }],
275    };
276
277    serde_json::to_string_pretty(&sarif)
278        .map_err(|e| ReportError::SerializationError(e.to_string()))
279}
280
281fn severity_to_level(severity: &str) -> SarifLevel {
282    match severity.to_lowercase().as_str() {
283        "critical" | "high" => SarifLevel::Error,
284        "medium" => SarifLevel::Warning,
285        "low" | "info" => SarifLevel::Note,
286        _ => SarifLevel::Warning,
287    }
288}
289
290/// Format SLA status for SARIF message
291fn format_sla_label(vuln: &VulnerabilityDetail) -> String {
292    match vuln.sla_status() {
293        SlaStatus::Overdue(days) => format!(" [SLA: {}d late]", days),
294        SlaStatus::DueSoon(days) => format!(" [SLA: {}d left]", days),
295        SlaStatus::OnTrack(days) => format!(" [SLA: {}d left]", days),
296        SlaStatus::NoDueDate => vuln
297            .days_since_published
298            .map(|d| format!(" [Age: {}d]", d))
299            .unwrap_or_default(),
300    }
301}
302
303fn violation_severity_to_level(severity: ViolationSeverity) -> SarifLevel {
304    match severity {
305        ViolationSeverity::Error => SarifLevel::Error,
306        ViolationSeverity::Warning => SarifLevel::Warning,
307        ViolationSeverity::Info => SarifLevel::Note,
308    }
309}
310
311/// Map a violation's requirement string to a specific SARIF rule ID.
312fn violation_to_rule_id(requirement: &str) -> &'static str {
313    let req = requirement.to_lowercase();
314    if req.contains("art. 13(4)") || req.contains("art.13(4)") {
315        "SBOM-CRA-ART-13-4"
316    } else if req.contains("art. 13(6)") || req.contains("art.13(6)") {
317        "SBOM-CRA-ART-13-6"
318    } else if req.contains("art. 13(7)") || req.contains("art.13(7)") {
319        "SBOM-CRA-ART-13-7"
320    } else if req.contains("art. 13(8)") || req.contains("art.13(8)") {
321        "SBOM-CRA-ART-13-8"
322    } else if req.contains("art. 13(11)") || req.contains("art.13(11)") {
323        "SBOM-CRA-ART-13-11"
324    } else if req.contains("art. 13(12)") || req.contains("art.13(12)") {
325        "SBOM-CRA-ART-13-12"
326    } else if req.contains("art. 13(15)") || req.contains("art.13(15)") {
327        "SBOM-CRA-ART-13-15"
328    } else if req.contains("annex vii") {
329        "SBOM-CRA-ANNEX-VII"
330    } else if req.contains("annex i") || req.contains("annex_i") {
331        "SBOM-CRA-ANNEX-I"
332    } else {
333        "SBOM-CRA-GENERAL"
334    }
335}
336
337fn compliance_results_to_sarif(result: &ComplianceResult, label: Option<&str>) -> Vec<SarifResult> {
338    let prefix = label.map(|l| format!("{} - ", l)).unwrap_or_default();
339    result
340        .violations
341        .iter()
342        .map(|v| {
343            let element = v.element.as_deref().unwrap_or("unknown");
344            SarifResult {
345                rule_id: violation_to_rule_id(&v.requirement).to_string(),
346                level: violation_severity_to_level(v.severity),
347                message: SarifMessage {
348                    text: format!(
349                        "{}{}: {} (Requirement: {}) [Element: {}]",
350                        prefix,
351                        result.level.name(),
352                        v.message,
353                        v.requirement,
354                        element
355                    ),
356                },
357                locations: vec![],
358            }
359        })
360        .collect()
361}
362
363fn get_sarif_rules() -> Vec<SarifRule> {
364    let mut rules = vec![
365        SarifRule {
366            id: "SBOM-TOOLS-001".to_string(),
367            name: "ComponentAdded".to_string(),
368            short_description: SarifMessage {
369                text: "A new component was added to the SBOM".to_string(),
370            },
371            default_configuration: SarifConfiguration {
372                level: SarifLevel::Note,
373            },
374        },
375        SarifRule {
376            id: "SBOM-TOOLS-002".to_string(),
377            name: "ComponentRemoved".to_string(),
378            short_description: SarifMessage {
379                text: "A component was removed from the SBOM".to_string(),
380            },
381            default_configuration: SarifConfiguration {
382                level: SarifLevel::Warning,
383            },
384        },
385        SarifRule {
386            id: "SBOM-TOOLS-003".to_string(),
387            name: "VersionChanged".to_string(),
388            short_description: SarifMessage {
389                text: "A component version was changed".to_string(),
390            },
391            default_configuration: SarifConfiguration {
392                level: SarifLevel::Note,
393            },
394        },
395        SarifRule {
396            id: "SBOM-TOOLS-004".to_string(),
397            name: "LicenseChanged".to_string(),
398            short_description: SarifMessage {
399                text: "A license was added or changed".to_string(),
400            },
401            default_configuration: SarifConfiguration {
402                level: SarifLevel::Warning,
403            },
404        },
405        SarifRule {
406            id: "SBOM-TOOLS-005".to_string(),
407            name: "VulnerabilityIntroduced".to_string(),
408            short_description: SarifMessage {
409                text: "A new vulnerability was introduced".to_string(),
410            },
411            default_configuration: SarifConfiguration {
412                level: SarifLevel::Error,
413            },
414        },
415        SarifRule {
416            id: "SBOM-TOOLS-006".to_string(),
417            name: "VulnerabilityResolved".to_string(),
418            short_description: SarifMessage {
419                text: "A vulnerability was resolved".to_string(),
420            },
421            default_configuration: SarifConfiguration {
422                level: SarifLevel::Note,
423            },
424        },
425        SarifRule {
426            id: "SBOM-TOOLS-007".to_string(),
427            name: "SupplierChanged".to_string(),
428            short_description: SarifMessage {
429                text: "A component supplier was changed".to_string(),
430            },
431            default_configuration: SarifConfiguration {
432                level: SarifLevel::Warning,
433            },
434        },
435    ];
436    rules.extend(get_sarif_compliance_rules());
437    rules
438}
439
440fn get_sarif_view_rules() -> Vec<SarifRule> {
441    let mut rules = vec![SarifRule {
442        id: "SBOM-VIEW-001".to_string(),
443        name: "VulnerabilityPresent".to_string(),
444        short_description: SarifMessage {
445            text: "A vulnerability is present in a component".to_string(),
446        },
447        default_configuration: SarifConfiguration {
448            level: SarifLevel::Warning,
449        },
450    }];
451    rules.extend(get_sarif_compliance_rules());
452    rules
453}
454
455fn get_sarif_compliance_rules() -> Vec<SarifRule> {
456    vec![
457        SarifRule {
458            id: "SBOM-CRA-ART-13-4".to_string(),
459            name: "CraMachineReadableFormat".to_string(),
460            short_description: SarifMessage {
461                text: "CRA Art. 13(4): SBOM must be in a machine-readable format (CycloneDX 1.4+ or SPDX 2.3+)".to_string(),
462            },
463            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
464        },
465        SarifRule {
466            id: "SBOM-CRA-ART-13-6".to_string(),
467            name: "CraVulnerabilityDisclosure".to_string(),
468            short_description: SarifMessage {
469                text: "CRA Art. 13(6): Vulnerability disclosure contact and metadata completeness".to_string(),
470            },
471            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
472        },
473        SarifRule {
474            id: "SBOM-CRA-ART-13-7".to_string(),
475            name: "CraCoordinatedDisclosure".to_string(),
476            short_description: SarifMessage {
477                text: "CRA Art. 13(7): Coordinated vulnerability disclosure policy reference".to_string(),
478            },
479            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
480        },
481        SarifRule {
482            id: "SBOM-CRA-ART-13-8".to_string(),
483            name: "CraSupportPeriod".to_string(),
484            short_description: SarifMessage {
485                text: "CRA Art. 13(8): Support period and security update end date".to_string(),
486            },
487            default_configuration: SarifConfiguration { level: SarifLevel::Note },
488        },
489        SarifRule {
490            id: "SBOM-CRA-ART-13-11".to_string(),
491            name: "CraComponentLifecycle".to_string(),
492            short_description: SarifMessage {
493                text: "CRA Art. 13(11): Component lifecycle and end-of-support status".to_string(),
494            },
495            default_configuration: SarifConfiguration { level: SarifLevel::Note },
496        },
497        SarifRule {
498            id: "SBOM-CRA-ART-13-12".to_string(),
499            name: "CraProductIdentification".to_string(),
500            short_description: SarifMessage {
501                text: "CRA Art. 13(12): Product name and version identification".to_string(),
502            },
503            default_configuration: SarifConfiguration { level: SarifLevel::Error },
504        },
505        SarifRule {
506            id: "SBOM-CRA-ART-13-15".to_string(),
507            name: "CraManufacturerIdentification".to_string(),
508            short_description: SarifMessage {
509                text: "CRA Art. 13(15): Manufacturer identification and contact information".to_string(),
510            },
511            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
512        },
513        SarifRule {
514            id: "SBOM-CRA-ANNEX-I".to_string(),
515            name: "CraTechnicalDocumentation".to_string(),
516            short_description: SarifMessage {
517                text: "CRA Annex I: Technical documentation (unique identifiers, dependencies, primary component)".to_string(),
518            },
519            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
520        },
521        SarifRule {
522            id: "SBOM-CRA-ANNEX-VII".to_string(),
523            name: "CraDeclarationOfConformity".to_string(),
524            short_description: SarifMessage {
525                text: "CRA Annex VII: EU Declaration of Conformity reference".to_string(),
526            },
527            default_configuration: SarifConfiguration { level: SarifLevel::Note },
528        },
529        SarifRule {
530            id: "SBOM-CRA-GENERAL".to_string(),
531            name: "CraGeneralRequirement".to_string(),
532            short_description: SarifMessage {
533                text: "CRA general SBOM readiness requirement".to_string(),
534            },
535            default_configuration: SarifConfiguration { level: SarifLevel::Warning },
536        },
537    ]
538}
539
540// SARIF structures
541
542#[derive(Serialize)]
543#[serde(rename_all = "camelCase")]
544struct SarifReport {
545    #[serde(rename = "$schema")]
546    schema: String,
547    version: String,
548    runs: Vec<SarifRun>,
549}
550
551#[derive(Serialize)]
552#[serde(rename_all = "camelCase")]
553struct SarifRun {
554    tool: SarifTool,
555    results: Vec<SarifResult>,
556}
557
558#[derive(Serialize)]
559#[serde(rename_all = "camelCase")]
560struct SarifTool {
561    driver: SarifDriver,
562}
563
564#[derive(Serialize)]
565#[serde(rename_all = "camelCase")]
566struct SarifDriver {
567    name: String,
568    version: String,
569    information_uri: String,
570    rules: Vec<SarifRule>,
571}
572
573#[derive(Serialize)]
574#[serde(rename_all = "camelCase")]
575struct SarifRule {
576    id: String,
577    name: String,
578    short_description: SarifMessage,
579    default_configuration: SarifConfiguration,
580}
581
582#[derive(Serialize)]
583#[serde(rename_all = "camelCase")]
584struct SarifConfiguration {
585    level: SarifLevel,
586}
587
588#[derive(Serialize)]
589#[serde(rename_all = "camelCase")]
590struct SarifResult {
591    rule_id: String,
592    level: SarifLevel,
593    message: SarifMessage,
594    locations: Vec<SarifLocation>,
595}
596
597#[derive(Serialize)]
598#[serde(rename_all = "camelCase")]
599struct SarifMessage {
600    text: String,
601}
602
603#[derive(Serialize)]
604#[serde(rename_all = "camelCase")]
605struct SarifLocation {
606    physical_location: Option<SarifPhysicalLocation>,
607}
608
609#[derive(Serialize)]
610#[serde(rename_all = "camelCase")]
611struct SarifPhysicalLocation {
612    artifact_location: SarifArtifactLocation,
613}
614
615#[derive(Serialize)]
616#[serde(rename_all = "camelCase")]
617struct SarifArtifactLocation {
618    uri: String,
619}
620
621#[derive(Serialize, Clone, Copy)]
622#[serde(rename_all = "lowercase")]
623enum SarifLevel {
624    #[allow(dead_code)]
625    None,
626    Note,
627    Warning,
628    Error,
629}