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::LicenseExpression(old, new) => {
193                format!(
194                    "license expression: {} -> {}",
195                    format_option(old),
196                    format_option(new)
197                )
198            }
199            FieldChange::Supplier(old, new) => {
200                format!("supplier: {} -> {}", format_option(old), format_option(new))
201            }
202            FieldChange::Purl(old, new) => {
203                format!("purl: {} -> {}", format_option(old), format_option(new))
204            }
205            FieldChange::Description(old, new) => {
206                format!(
207                    "description: {} -> {}",
208                    format_option(old),
209                    format_option(new)
210                )
211            }
212            FieldChange::Hashes(old, new) => {
213                let mut parts = Vec::new();
214                for (algo, digest) in old {
215                    if !new.contains_key(algo) {
216                        parts.push(format!("removed {}={}", algo, digest));
217                    } else if new[algo] != *digest {
218                        parts.push(format!("changed {}: {} -> {}", algo, digest, new[algo]));
219                    }
220                }
221                for (algo, digest) in new {
222                    if !old.contains_key(algo) {
223                        parts.push(format!("added {}={}", algo, digest));
224                    }
225                }
226                let label = if is_hash_algorithm_downgrade(old, new) {
227                    "hashes (algorithm downgrade)"
228                } else {
229                    "hashes"
230                };
231                format!("{}: {}", label, parts.join(", "))
232            }
233            FieldChange::Ecosystem(old, new) => {
234                format!(
235                    "ecosystem: {} -> {}",
236                    format_option(old),
237                    format_option(new)
238                )
239            }
240        }
241    }
242
243    fn build_results(diff: &Diff, opts: &RenderOptions) -> Vec<SarifResultEntry> {
244        let mut results = Vec::new();
245
246        if opts.has_warnings() {
247            for w in &opts.old_warnings {
248                results.push(SarifResultEntry {
249                    rule_id: SARIF_RULES[RULE_PARSER_WARNING].id,
250                    rule_index: RULE_PARSER_WARNING,
251                    level: SARIF_RULES[RULE_PARSER_WARNING].level,
252                    message: SarifTextMessage {
253                        text: format!("Parser warning (old SBOM): {}", w),
254                    },
255                    locations: vec![SarifLocation {
256                        logical_locations: vec![SarifLogicalLocation {
257                            fully_qualified_name: "old-sbom".to_string(),
258                            kind: "module",
259                        }],
260                    }],
261                });
262            }
263            for w in &opts.new_warnings {
264                results.push(SarifResultEntry {
265                    rule_id: SARIF_RULES[RULE_PARSER_WARNING].id,
266                    rule_index: RULE_PARSER_WARNING,
267                    level: SARIF_RULES[RULE_PARSER_WARNING].level,
268                    message: SarifTextMessage {
269                        text: format!("Parser warning (new SBOM): {}", w),
270                    },
271                    locations: vec![SarifLocation {
272                        logical_locations: vec![SarifLogicalLocation {
273                            fully_qualified_name: "new-sbom".to_string(),
274                            kind: "module",
275                        }],
276                    }],
277                });
278            }
279        }
280
281        for comp in &diff.added {
282            results.push(SarifResultEntry {
283                rule_id: SARIF_RULES[RULE_COMPONENT_ADDED].id,
284                rule_index: RULE_COMPONENT_ADDED,
285                level: SARIF_RULES[RULE_COMPONENT_ADDED].level,
286                message: SarifTextMessage {
287                    text: format!("Component added: {}", Self::component_display(comp)),
288                },
289                locations: Self::component_location(comp),
290            });
291        }
292
293        for comp in &diff.removed {
294            results.push(SarifResultEntry {
295                rule_id: SARIF_RULES[RULE_COMPONENT_REMOVED].id,
296                rule_index: RULE_COMPONENT_REMOVED,
297                level: SARIF_RULES[RULE_COMPONENT_REMOVED].level,
298                message: SarifTextMessage {
299                    text: format!("Component removed: {}", Self::component_display(comp)),
300                },
301                locations: Self::component_location(comp),
302            });
303        }
304
305        for change in &diff.changed {
306            let display = Self::component_display(&change.new);
307            let is_downgrade = change.is_downgrade;
308            let field_changes: Vec<String> = change
309                .changes
310                .iter()
311                .map(|fc| Self::format_field_change(fc, is_downgrade))
312                .collect();
313
314            let hash_downgrade = change.changes.iter().any(|fc| match fc {
315                FieldChange::Hashes(old, new) => is_hash_algorithm_downgrade(old, new),
316                _ => false,
317            });
318
319            let level = if is_downgrade || hash_downgrade {
320                "error"
321            } else {
322                SARIF_RULES[RULE_COMPONENT_CHANGED].level
323            };
324            results.push(SarifResultEntry {
325                rule_id: SARIF_RULES[RULE_COMPONENT_CHANGED].id,
326                rule_index: RULE_COMPONENT_CHANGED,
327                level,
328                message: SarifTextMessage {
329                    text: format!(
330                        "Component changed: {} ({})",
331                        display,
332                        field_changes.join("; "),
333                    ),
334                },
335                locations: Self::component_location(&change.new),
336            });
337        }
338
339        for edge in &diff.edge_diffs {
340            let parent = diff.display_name(&edge.parent);
341            let mut parts = Vec::new();
342
343            for (child, kind) in &edge.added {
344                parts.push(format!(
345                    "added {} -> {}{}",
346                    parent,
347                    diff.display_name(child),
348                    kind_suffix(kind)
349                ));
350            }
351            for (child, kind) in &edge.removed {
352                parts.push(format!(
353                    "removed {} -> {}{}",
354                    parent,
355                    diff.display_name(child),
356                    kind_suffix(kind)
357                ));
358            }
359            for (child, (old_kind, new_kind)) in &edge.kind_changed {
360                parts.push(format!(
361                    "{} -> {} kind: {} -> {}",
362                    parent,
363                    diff.display_name(child),
364                    old_kind,
365                    new_kind
366                ));
367            }
368
369            if !parts.is_empty() {
370                results.push(SarifResultEntry {
371                    rule_id: SARIF_RULES[RULE_DEPENDENCY_CHANGED].id,
372                    rule_index: RULE_DEPENDENCY_CHANGED,
373                    level: SARIF_RULES[RULE_DEPENDENCY_CHANGED].level,
374                    message: SarifTextMessage {
375                        text: format!("Dependency changed: {}", parts.join("; ")),
376                    },
377                    locations: vec![SarifLocation {
378                        logical_locations: vec![SarifLogicalLocation {
379                            fully_qualified_name: parent.to_string(),
380                            kind: "package",
381                        }],
382                    }],
383                });
384            }
385        }
386
387        if let Some(mc) = &diff.metadata_changed {
388            let mut parts = Vec::new();
389            if let Some((ref old, ref new)) = mc.timestamp {
390                parts.push(format!(
391                    "timestamp: {} -> {}",
392                    old.as_deref().unwrap_or("<none>"),
393                    new.as_deref().unwrap_or("<none>")
394                ));
395            }
396            if let Some((ref old, ref new)) = mc.tools {
397                parts.push(format!(
398                    "tools: {} -> {}",
399                    format_vec_or_none(old),
400                    format_vec_or_none(new)
401                ));
402            }
403            if let Some((ref old, ref new)) = mc.authors {
404                parts.push(format!(
405                    "authors: {} -> {}",
406                    format_vec_or_none(old),
407                    format_vec_or_none(new)
408                ));
409            }
410
411            if !parts.is_empty() {
412                results.push(SarifResultEntry {
413                    rule_id: SARIF_RULES[RULE_METADATA_CHANGED].id,
414                    rule_index: RULE_METADATA_CHANGED,
415                    level: SARIF_RULES[RULE_METADATA_CHANGED].level,
416                    message: SarifTextMessage {
417                        text: format!("Metadata changed: {}", parts.join("; ")),
418                    },
419                    locations: vec![SarifLocation {
420                        logical_locations: vec![SarifLogicalLocation {
421                            fully_qualified_name: "metadata".to_string(),
422                            kind: "module",
423                        }],
424                    }],
425                });
426            }
427        }
428
429        results
430    }
431}
432
433impl Renderer for SarifRenderer {
434    fn render<W: Write>(
435        &self,
436        diff: &Diff,
437        opts: &RenderOptions,
438        writer: &mut W,
439    ) -> anyhow::Result<()> {
440        let log = SarifLog {
441            schema: SARIF_SCHEMA,
442            version: SARIF_VERSION,
443            runs: vec![SarifRun {
444                tool: SarifTool {
445                    driver: SarifDriverInfo {
446                        name: "sbom-diff",
447                        version: env!("CARGO_PKG_VERSION"),
448                        information_uri: "https://github.com/cyberwitchery/sbom-diff",
449                        rules: Self::build_rules(),
450                    },
451                },
452                results: Self::build_results(diff, opts),
453            }],
454        };
455        serde_json::to_writer_pretty(writer, &log)?;
456        Ok(())
457    }
458}
459
460impl SummaryRenderer for SarifRenderer {
461    fn render_summary<W: Write>(
462        &self,
463        diff: &Diff,
464        opts: &RenderOptions,
465        writer: &mut W,
466    ) -> anyhow::Result<()> {
467        self.render(diff, opts, writer)
468    }
469}