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
13const 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
143pub 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}