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
10const SARIF_SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json";
13const SARIF_VERSION: &str = "2.1.0";
14
15const 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
145pub 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}