Skip to main content

sbom_diff/renderer/
sarif.rs

1use super::{
2    format_option, format_set, format_vec_or_none, kind_suffix, RenderOptions, Renderer,
3    SummaryRenderer,
4};
5use crate::{Diff, FieldChange};
6use sbom_model::Component;
7use serde::Serialize;
8use std::io::Write;
9
10// --- SARIF 2.1.0 output ---
11
12const SARIF_SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json";
13const SARIF_VERSION: &str = "2.1.0";
14
15// rule indices (must match order in SARIF_RULES)
16const RULE_COMPONENT_ADDED: usize = 0;
17const RULE_COMPONENT_REMOVED: usize = 1;
18const RULE_COMPONENT_CHANGED: usize = 2;
19const RULE_DEPENDENCY_CHANGED: usize = 3;
20const RULE_METADATA_CHANGED: usize = 4;
21const RULE_PARSER_WARNING: usize = 5;
22
23#[derive(Clone, Copy)]
24struct RuleInfo {
25    id: &'static str,
26    short_desc: &'static str,
27    full_desc: &'static str,
28    level: &'static str,
29}
30
31const SARIF_RULES: &[RuleInfo] = &[
32    RuleInfo {
33        id: "component-added",
34        short_desc: "Component added",
35        full_desc: "A new component was added to the SBOM",
36        level: "note",
37    },
38    RuleInfo {
39        id: "component-removed",
40        short_desc: "Component removed",
41        full_desc: "A component was removed from the SBOM",
42        level: "warning",
43    },
44    RuleInfo {
45        id: "component-changed",
46        short_desc: "Component changed",
47        full_desc: "A component's metadata changed between SBOMs",
48        level: "warning",
49    },
50    RuleInfo {
51        id: "dependency-changed",
52        short_desc: "Dependency changed",
53        full_desc: "A dependency edge was added, removed, or changed kind",
54        level: "note",
55    },
56    RuleInfo {
57        id: "metadata-changed",
58        short_desc: "Metadata changed",
59        full_desc: "Document metadata (timestamp, tools, or authors) changed between SBOMs",
60        level: "note",
61    },
62    RuleInfo {
63        id: "parser-warning",
64        short_desc: "Parser warning",
65        full_desc: "The SBOM parser emitted a warning about the input document",
66        level: "note",
67    },
68];
69
70#[derive(Serialize)]
71struct SarifLog {
72    #[serde(rename = "$schema")]
73    schema: &'static str,
74    version: &'static str,
75    runs: Vec<SarifRun>,
76}
77
78#[derive(Serialize)]
79struct SarifRun {
80    tool: SarifTool,
81    results: Vec<SarifResultEntry>,
82}
83
84#[derive(Serialize)]
85struct SarifTool {
86    driver: SarifDriverInfo,
87}
88
89#[derive(Serialize)]
90#[serde(rename_all = "camelCase")]
91struct SarifDriverInfo {
92    name: &'static str,
93    version: &'static str,
94    information_uri: &'static str,
95    rules: Vec<SarifRuleDescriptor>,
96}
97
98#[derive(Serialize)]
99#[serde(rename_all = "camelCase")]
100struct SarifRuleDescriptor {
101    id: &'static str,
102    short_description: SarifMultiformatMessage,
103    full_description: SarifMultiformatMessage,
104    default_configuration: SarifDefaultConfiguration,
105}
106
107#[derive(Serialize)]
108struct SarifDefaultConfiguration {
109    level: &'static str,
110}
111
112#[derive(Serialize)]
113struct SarifMultiformatMessage {
114    text: &'static str,
115}
116
117#[derive(Serialize)]
118#[serde(rename_all = "camelCase")]
119struct SarifResultEntry {
120    rule_id: &'static str,
121    rule_index: usize,
122    level: &'static str,
123    message: SarifTextMessage,
124    locations: Vec<SarifLocation>,
125}
126
127#[derive(Serialize)]
128struct SarifTextMessage {
129    text: String,
130}
131
132#[derive(Serialize)]
133#[serde(rename_all = "camelCase")]
134struct SarifLocation {
135    logical_locations: Vec<SarifLogicalLocation>,
136}
137
138#[derive(Serialize)]
139#[serde(rename_all = "camelCase")]
140struct SarifLogicalLocation {
141    fully_qualified_name: String,
142    kind: &'static str,
143}
144
145/// SARIF 2.1.0 renderer for GitHub Code Scanning integration.
146///
147/// produces a SARIF log with one run containing rules for each change type
148/// (component added/removed/changed, dependency changed, metadata changed)
149/// and a result entry per finding.
150pub struct SarifRenderer;
151
152impl SarifRenderer {
153    fn build_rules() -> Vec<SarifRuleDescriptor> {
154        SARIF_RULES
155            .iter()
156            .map(|r| SarifRuleDescriptor {
157                id: r.id,
158                short_description: SarifMultiformatMessage { text: r.short_desc },
159                full_description: SarifMultiformatMessage { text: r.full_desc },
160                default_configuration: SarifDefaultConfiguration { level: r.level },
161            })
162            .collect()
163    }
164
165    fn component_display(comp: &Component) -> &str {
166        comp.purl.as_deref().unwrap_or(comp.id.as_str())
167    }
168
169    fn component_location(comp: &Component) -> Vec<SarifLocation> {
170        vec![SarifLocation {
171            logical_locations: vec![SarifLogicalLocation {
172                fully_qualified_name: Self::component_display(comp).to_string(),
173                kind: "package",
174            }],
175        }]
176    }
177
178    fn format_field_change(fc: &FieldChange) -> String {
179        match fc {
180            FieldChange::Version(old, new) => {
181                format!("version: {} -> {}", format_option(old), format_option(new))
182            }
183            FieldChange::License(old, new) => {
184                format!("license: {} -> {}", format_set(old), format_set(new))
185            }
186            FieldChange::Supplier(old, new) => {
187                format!("supplier: {} -> {}", format_option(old), format_option(new))
188            }
189            FieldChange::Purl(old, new) => {
190                format!("purl: {} -> {}", format_option(old), format_option(new))
191            }
192            FieldChange::Description(old, new) => {
193                format!(
194                    "description: {} -> {}",
195                    format_option(old),
196                    format_option(new)
197                )
198            }
199            FieldChange::Hashes(_, _) => "hashes changed".to_string(),
200            FieldChange::Ecosystem(old, new) => {
201                format!(
202                    "ecosystem: {} -> {}",
203                    format_option(old),
204                    format_option(new)
205                )
206            }
207        }
208    }
209
210    fn build_results(diff: &Diff, opts: &RenderOptions) -> Vec<SarifResultEntry> {
211        let mut results = Vec::new();
212
213        if opts.has_warnings() {
214            for w in &opts.old_warnings {
215                results.push(SarifResultEntry {
216                    rule_id: SARIF_RULES[RULE_PARSER_WARNING].id,
217                    rule_index: RULE_PARSER_WARNING,
218                    level: SARIF_RULES[RULE_PARSER_WARNING].level,
219                    message: SarifTextMessage {
220                        text: format!("Parser warning (old SBOM): {}", w),
221                    },
222                    locations: vec![SarifLocation {
223                        logical_locations: vec![SarifLogicalLocation {
224                            fully_qualified_name: "old-sbom".to_string(),
225                            kind: "module",
226                        }],
227                    }],
228                });
229            }
230            for w in &opts.new_warnings {
231                results.push(SarifResultEntry {
232                    rule_id: SARIF_RULES[RULE_PARSER_WARNING].id,
233                    rule_index: RULE_PARSER_WARNING,
234                    level: SARIF_RULES[RULE_PARSER_WARNING].level,
235                    message: SarifTextMessage {
236                        text: format!("Parser warning (new SBOM): {}", w),
237                    },
238                    locations: vec![SarifLocation {
239                        logical_locations: vec![SarifLogicalLocation {
240                            fully_qualified_name: "new-sbom".to_string(),
241                            kind: "module",
242                        }],
243                    }],
244                });
245            }
246        }
247
248        for comp in &diff.added {
249            results.push(SarifResultEntry {
250                rule_id: SARIF_RULES[RULE_COMPONENT_ADDED].id,
251                rule_index: RULE_COMPONENT_ADDED,
252                level: SARIF_RULES[RULE_COMPONENT_ADDED].level,
253                message: SarifTextMessage {
254                    text: format!("Component added: {}", Self::component_display(comp)),
255                },
256                locations: Self::component_location(comp),
257            });
258        }
259
260        for comp in &diff.removed {
261            results.push(SarifResultEntry {
262                rule_id: SARIF_RULES[RULE_COMPONENT_REMOVED].id,
263                rule_index: RULE_COMPONENT_REMOVED,
264                level: SARIF_RULES[RULE_COMPONENT_REMOVED].level,
265                message: SarifTextMessage {
266                    text: format!("Component removed: {}", Self::component_display(comp)),
267                },
268                locations: Self::component_location(comp),
269            });
270        }
271
272        for change in &diff.changed {
273            let display = Self::component_display(&change.new);
274            let field_changes: Vec<String> = change
275                .changes
276                .iter()
277                .map(Self::format_field_change)
278                .collect();
279
280            results.push(SarifResultEntry {
281                rule_id: SARIF_RULES[RULE_COMPONENT_CHANGED].id,
282                rule_index: RULE_COMPONENT_CHANGED,
283                level: SARIF_RULES[RULE_COMPONENT_CHANGED].level,
284                message: SarifTextMessage {
285                    text: format!(
286                        "Component changed: {} ({})",
287                        display,
288                        field_changes.join("; "),
289                    ),
290                },
291                locations: Self::component_location(&change.new),
292            });
293        }
294
295        for edge in &diff.edge_diffs {
296            let parent = diff.display_name(&edge.parent);
297            let mut parts = Vec::new();
298
299            for (child, kind) in &edge.added {
300                parts.push(format!(
301                    "added {} -> {}{}",
302                    parent,
303                    diff.display_name(child),
304                    kind_suffix(kind)
305                ));
306            }
307            for (child, kind) in &edge.removed {
308                parts.push(format!(
309                    "removed {} -> {}{}",
310                    parent,
311                    diff.display_name(child),
312                    kind_suffix(kind)
313                ));
314            }
315            for (child, (old_kind, new_kind)) in &edge.kind_changed {
316                parts.push(format!(
317                    "{} -> {} kind: {} -> {}",
318                    parent,
319                    diff.display_name(child),
320                    old_kind,
321                    new_kind
322                ));
323            }
324
325            if !parts.is_empty() {
326                results.push(SarifResultEntry {
327                    rule_id: SARIF_RULES[RULE_DEPENDENCY_CHANGED].id,
328                    rule_index: RULE_DEPENDENCY_CHANGED,
329                    level: SARIF_RULES[RULE_DEPENDENCY_CHANGED].level,
330                    message: SarifTextMessage {
331                        text: format!("Dependency changed: {}", parts.join("; ")),
332                    },
333                    locations: vec![SarifLocation {
334                        logical_locations: vec![SarifLogicalLocation {
335                            fully_qualified_name: parent.to_string(),
336                            kind: "package",
337                        }],
338                    }],
339                });
340            }
341        }
342
343        if let Some(mc) = &diff.metadata_changed {
344            let mut parts = Vec::new();
345            if let Some((ref old, ref new)) = mc.timestamp {
346                parts.push(format!(
347                    "timestamp: {} -> {}",
348                    old.as_deref().unwrap_or("<none>"),
349                    new.as_deref().unwrap_or("<none>")
350                ));
351            }
352            if let Some((ref old, ref new)) = mc.tools {
353                parts.push(format!(
354                    "tools: {} -> {}",
355                    format_vec_or_none(old),
356                    format_vec_or_none(new)
357                ));
358            }
359            if let Some((ref old, ref new)) = mc.authors {
360                parts.push(format!(
361                    "authors: {} -> {}",
362                    format_vec_or_none(old),
363                    format_vec_or_none(new)
364                ));
365            }
366
367            if !parts.is_empty() {
368                results.push(SarifResultEntry {
369                    rule_id: SARIF_RULES[RULE_METADATA_CHANGED].id,
370                    rule_index: RULE_METADATA_CHANGED,
371                    level: SARIF_RULES[RULE_METADATA_CHANGED].level,
372                    message: SarifTextMessage {
373                        text: format!("Metadata changed: {}", parts.join("; ")),
374                    },
375                    locations: vec![SarifLocation {
376                        logical_locations: vec![SarifLogicalLocation {
377                            fully_qualified_name: "metadata".to_string(),
378                            kind: "module",
379                        }],
380                    }],
381                });
382            }
383        }
384
385        results
386    }
387}
388
389impl Renderer for SarifRenderer {
390    fn render<W: Write>(
391        &self,
392        diff: &Diff,
393        opts: &RenderOptions,
394        writer: &mut W,
395    ) -> anyhow::Result<()> {
396        let log = SarifLog {
397            schema: SARIF_SCHEMA,
398            version: SARIF_VERSION,
399            runs: vec![SarifRun {
400                tool: SarifTool {
401                    driver: SarifDriverInfo {
402                        name: "sbom-diff",
403                        version: env!("CARGO_PKG_VERSION"),
404                        information_uri: "https://github.com/cyberwitchery/sbom-diff",
405                        rules: Self::build_rules(),
406                    },
407                },
408                results: Self::build_results(diff, opts),
409            }],
410        };
411        serde_json::to_writer_pretty(writer, &log)?;
412        Ok(())
413    }
414}
415
416impl SummaryRenderer for SarifRenderer {
417    fn render_summary<W: Write>(
418        &self,
419        diff: &Diff,
420        opts: &RenderOptions,
421        writer: &mut W,
422    ) -> anyhow::Result<()> {
423        self.render(diff, opts, writer)
424    }
425}