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::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}