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