1use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
4use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
5use crate::model::NormalizedSbom;
6use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult, ViolationSeverity};
7use serde::Serialize;
8
9pub struct SarifReporter {
11 include_info: bool,
13}
14
15impl SarifReporter {
16 pub fn new() -> Self {
18 Self { include_info: true }
19 }
20
21 pub fn include_info(mut self, include: bool) -> Self {
23 self.include_info = include;
24 self
25 }
26}
27
28impl Default for SarifReporter {
29 fn default() -> Self {
30 Self::new()
31 }
32}
33
34impl ReportGenerator for SarifReporter {
35 fn generate_diff_report(
36 &self,
37 result: &DiffResult,
38 old_sbom: &NormalizedSbom,
39 new_sbom: &NormalizedSbom,
40 config: &ReportConfig,
41 ) -> Result<String, ReportError> {
42 let mut results = Vec::new();
43
44 if config.includes(ReportType::Components) {
46 for comp in &result.components.added {
47 if self.include_info {
48 results.push(SarifResult {
49 rule_id: "SBOM-TOOLS-001".to_string(),
50 level: SarifLevel::Note,
51 message: SarifMessage {
52 text: format!(
53 "Component added: {} {}",
54 comp.name,
55 comp.new_version.as_deref().unwrap_or("")
56 ),
57 },
58 locations: vec![],
59 });
60 }
61 }
62
63 for comp in &result.components.removed {
64 results.push(SarifResult {
65 rule_id: "SBOM-TOOLS-002".to_string(),
66 level: SarifLevel::Warning,
67 message: SarifMessage {
68 text: format!(
69 "Component removed: {} {}",
70 comp.name,
71 comp.old_version.as_deref().unwrap_or("")
72 ),
73 },
74 locations: vec![],
75 });
76 }
77
78 for comp in &result.components.modified {
79 if self.include_info {
80 results.push(SarifResult {
81 rule_id: "SBOM-TOOLS-003".to_string(),
82 level: SarifLevel::Note,
83 message: SarifMessage {
84 text: format!(
85 "Component modified: {} {} -> {}",
86 comp.name,
87 comp.old_version.as_deref().unwrap_or("unknown"),
88 comp.new_version.as_deref().unwrap_or("unknown")
89 ),
90 },
91 locations: vec![],
92 });
93 }
94 }
95 }
96
97 if config.includes(ReportType::Vulnerabilities) {
99 for vuln in &result.vulnerabilities.introduced {
100 let depth_label = match vuln.component_depth {
101 Some(1) => " [Direct]",
102 Some(_) => " [Transitive]",
103 None => "",
104 };
105 let sla_label = format_sla_label(vuln);
106 results.push(SarifResult {
107 rule_id: "SBOM-TOOLS-005".to_string(),
108 level: severity_to_level(&vuln.severity),
109 message: SarifMessage {
110 text: format!(
111 "Vulnerability introduced: {} ({}){}{} in {} {}",
112 vuln.id,
113 vuln.severity,
114 depth_label,
115 sla_label,
116 vuln.component_name,
117 vuln.version.as_deref().unwrap_or("")
118 ),
119 },
120 locations: vec![],
121 });
122 }
123
124 for vuln in &result.vulnerabilities.resolved {
125 if self.include_info {
126 let depth_label = match vuln.component_depth {
127 Some(1) => " [Direct]",
128 Some(_) => " [Transitive]",
129 None => "",
130 };
131 let sla_label = format_sla_label(vuln);
132 results.push(SarifResult {
133 rule_id: "SBOM-TOOLS-006".to_string(),
134 level: SarifLevel::Note,
135 message: SarifMessage {
136 text: format!(
137 "Vulnerability resolved: {} ({}){}{} was in {}",
138 vuln.id, vuln.severity, depth_label, sla_label, vuln.component_name
139 ),
140 },
141 locations: vec![],
142 });
143 }
144 }
145 }
146
147 if config.includes(ReportType::Licenses) {
149 for license in &result.licenses.new_licenses {
150 results.push(SarifResult {
151 rule_id: "SBOM-TOOLS-004".to_string(),
152 level: SarifLevel::Warning,
153 message: SarifMessage {
154 text: format!(
155 "New license introduced: {} in components: {}",
156 license.license,
157 license.components.join(", ")
158 ),
159 },
160 locations: vec![],
161 });
162 }
163 }
164
165 let cra_old = config.old_cra_compliance.clone().unwrap_or_else(|| {
167 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
168 });
169 let cra_new = config.new_cra_compliance.clone().unwrap_or_else(|| {
170 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
171 });
172 results.extend(compliance_results_to_sarif(&cra_old, Some("Old SBOM")));
173 results.extend(compliance_results_to_sarif(&cra_new, Some("New SBOM")));
174
175 let sarif = SarifReport {
176 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
177 version: "2.1.0".to_string(),
178 runs: vec![SarifRun {
179 tool: SarifTool {
180 driver: SarifDriver {
181 name: "sbom-tools".to_string(),
182 version: env!("CARGO_PKG_VERSION").to_string(),
183 information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
184 rules: get_sarif_rules(),
185 },
186 },
187 results,
188 }],
189 };
190
191 serde_json::to_string_pretty(&sarif)
192 .map_err(|e| ReportError::SerializationError(e.to_string()))
193 }
194
195 fn generate_view_report(
196 &self,
197 sbom: &NormalizedSbom,
198 config: &ReportConfig,
199 ) -> Result<String, ReportError> {
200 let mut results = Vec::new();
201
202 for (comp, vuln) in sbom.all_vulnerabilities() {
204 results.push(SarifResult {
205 rule_id: "SBOM-VIEW-001".to_string(),
206 level: severity_to_level(
207 &vuln
208 .severity
209 .as_ref()
210 .map(|s| s.to_string())
211 .unwrap_or_else(|| "Unknown".to_string()),
212 ),
213 message: SarifMessage {
214 text: format!(
215 "Vulnerability {} ({}) in {} {}",
216 vuln.id,
217 vuln.severity
218 .as_ref()
219 .map(|s| s.to_string())
220 .unwrap_or_else(|| "Unknown".to_string()),
221 comp.name,
222 comp.version.as_deref().unwrap_or("")
223 ),
224 },
225 locations: vec![],
226 });
227 }
228
229 let cra_result = config.view_cra_compliance.clone().unwrap_or_else(|| {
231 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom)
232 });
233 results.extend(compliance_results_to_sarif(&cra_result, None));
234
235 let sarif = SarifReport {
236 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
237 version: "2.1.0".to_string(),
238 runs: vec![SarifRun {
239 tool: SarifTool {
240 driver: SarifDriver {
241 name: "sbom-tools".to_string(),
242 version: env!("CARGO_PKG_VERSION").to_string(),
243 information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
244 rules: get_sarif_view_rules(),
245 },
246 },
247 results,
248 }],
249 };
250
251 serde_json::to_string_pretty(&sarif)
252 .map_err(|e| ReportError::SerializationError(e.to_string()))
253 }
254
255 fn format(&self) -> ReportFormat {
256 ReportFormat::Sarif
257 }
258}
259
260pub fn generate_compliance_sarif(result: &ComplianceResult) -> Result<String, ReportError> {
261 let sarif = SarifReport {
262 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
263 version: "2.1.0".to_string(),
264 runs: vec![SarifRun {
265 tool: SarifTool {
266 driver: SarifDriver {
267 name: "sbom-tools".to_string(),
268 version: env!("CARGO_PKG_VERSION").to_string(),
269 information_uri: "https://github.com/binarly-io/sbom-tools".to_string(),
270 rules: get_sarif_compliance_rules(),
271 },
272 },
273 results: compliance_results_to_sarif(result, None),
274 }],
275 };
276
277 serde_json::to_string_pretty(&sarif)
278 .map_err(|e| ReportError::SerializationError(e.to_string()))
279}
280
281fn severity_to_level(severity: &str) -> SarifLevel {
282 match severity.to_lowercase().as_str() {
283 "critical" | "high" => SarifLevel::Error,
284 "medium" => SarifLevel::Warning,
285 "low" | "info" => SarifLevel::Note,
286 _ => SarifLevel::Warning,
287 }
288}
289
290fn format_sla_label(vuln: &VulnerabilityDetail) -> String {
292 match vuln.sla_status() {
293 SlaStatus::Overdue(days) => format!(" [SLA: {}d late]", days),
294 SlaStatus::DueSoon(days) => format!(" [SLA: {}d left]", days),
295 SlaStatus::OnTrack(days) => format!(" [SLA: {}d left]", days),
296 SlaStatus::NoDueDate => vuln
297 .days_since_published
298 .map(|d| format!(" [Age: {}d]", d))
299 .unwrap_or_default(),
300 }
301}
302
303fn violation_severity_to_level(severity: ViolationSeverity) -> SarifLevel {
304 match severity {
305 ViolationSeverity::Error => SarifLevel::Error,
306 ViolationSeverity::Warning => SarifLevel::Warning,
307 ViolationSeverity::Info => SarifLevel::Note,
308 }
309}
310
311fn violation_to_rule_id(requirement: &str) -> &'static str {
313 let req = requirement.to_lowercase();
314 if req.contains("art. 13(4)") || req.contains("art.13(4)") {
315 "SBOM-CRA-ART-13-4"
316 } else if req.contains("art. 13(6)") || req.contains("art.13(6)") {
317 "SBOM-CRA-ART-13-6"
318 } else if req.contains("art. 13(7)") || req.contains("art.13(7)") {
319 "SBOM-CRA-ART-13-7"
320 } else if req.contains("art. 13(8)") || req.contains("art.13(8)") {
321 "SBOM-CRA-ART-13-8"
322 } else if req.contains("art. 13(11)") || req.contains("art.13(11)") {
323 "SBOM-CRA-ART-13-11"
324 } else if req.contains("art. 13(12)") || req.contains("art.13(12)") {
325 "SBOM-CRA-ART-13-12"
326 } else if req.contains("art. 13(15)") || req.contains("art.13(15)") {
327 "SBOM-CRA-ART-13-15"
328 } else if req.contains("annex vii") {
329 "SBOM-CRA-ANNEX-VII"
330 } else if req.contains("annex i") || req.contains("annex_i") {
331 "SBOM-CRA-ANNEX-I"
332 } else {
333 "SBOM-CRA-GENERAL"
334 }
335}
336
337fn compliance_results_to_sarif(result: &ComplianceResult, label: Option<&str>) -> Vec<SarifResult> {
338 let prefix = label.map(|l| format!("{} - ", l)).unwrap_or_default();
339 result
340 .violations
341 .iter()
342 .map(|v| {
343 let element = v.element.as_deref().unwrap_or("unknown");
344 SarifResult {
345 rule_id: violation_to_rule_id(&v.requirement).to_string(),
346 level: violation_severity_to_level(v.severity),
347 message: SarifMessage {
348 text: format!(
349 "{}{}: {} (Requirement: {}) [Element: {}]",
350 prefix,
351 result.level.name(),
352 v.message,
353 v.requirement,
354 element
355 ),
356 },
357 locations: vec![],
358 }
359 })
360 .collect()
361}
362
363fn get_sarif_rules() -> Vec<SarifRule> {
364 let mut rules = vec![
365 SarifRule {
366 id: "SBOM-TOOLS-001".to_string(),
367 name: "ComponentAdded".to_string(),
368 short_description: SarifMessage {
369 text: "A new component was added to the SBOM".to_string(),
370 },
371 default_configuration: SarifConfiguration {
372 level: SarifLevel::Note,
373 },
374 },
375 SarifRule {
376 id: "SBOM-TOOLS-002".to_string(),
377 name: "ComponentRemoved".to_string(),
378 short_description: SarifMessage {
379 text: "A component was removed from the SBOM".to_string(),
380 },
381 default_configuration: SarifConfiguration {
382 level: SarifLevel::Warning,
383 },
384 },
385 SarifRule {
386 id: "SBOM-TOOLS-003".to_string(),
387 name: "VersionChanged".to_string(),
388 short_description: SarifMessage {
389 text: "A component version was changed".to_string(),
390 },
391 default_configuration: SarifConfiguration {
392 level: SarifLevel::Note,
393 },
394 },
395 SarifRule {
396 id: "SBOM-TOOLS-004".to_string(),
397 name: "LicenseChanged".to_string(),
398 short_description: SarifMessage {
399 text: "A license was added or changed".to_string(),
400 },
401 default_configuration: SarifConfiguration {
402 level: SarifLevel::Warning,
403 },
404 },
405 SarifRule {
406 id: "SBOM-TOOLS-005".to_string(),
407 name: "VulnerabilityIntroduced".to_string(),
408 short_description: SarifMessage {
409 text: "A new vulnerability was introduced".to_string(),
410 },
411 default_configuration: SarifConfiguration {
412 level: SarifLevel::Error,
413 },
414 },
415 SarifRule {
416 id: "SBOM-TOOLS-006".to_string(),
417 name: "VulnerabilityResolved".to_string(),
418 short_description: SarifMessage {
419 text: "A vulnerability was resolved".to_string(),
420 },
421 default_configuration: SarifConfiguration {
422 level: SarifLevel::Note,
423 },
424 },
425 SarifRule {
426 id: "SBOM-TOOLS-007".to_string(),
427 name: "SupplierChanged".to_string(),
428 short_description: SarifMessage {
429 text: "A component supplier was changed".to_string(),
430 },
431 default_configuration: SarifConfiguration {
432 level: SarifLevel::Warning,
433 },
434 },
435 ];
436 rules.extend(get_sarif_compliance_rules());
437 rules
438}
439
440fn get_sarif_view_rules() -> Vec<SarifRule> {
441 let mut rules = vec![SarifRule {
442 id: "SBOM-VIEW-001".to_string(),
443 name: "VulnerabilityPresent".to_string(),
444 short_description: SarifMessage {
445 text: "A vulnerability is present in a component".to_string(),
446 },
447 default_configuration: SarifConfiguration {
448 level: SarifLevel::Warning,
449 },
450 }];
451 rules.extend(get_sarif_compliance_rules());
452 rules
453}
454
455fn get_sarif_compliance_rules() -> Vec<SarifRule> {
456 vec![
457 SarifRule {
458 id: "SBOM-CRA-ART-13-4".to_string(),
459 name: "CraMachineReadableFormat".to_string(),
460 short_description: SarifMessage {
461 text: "CRA Art. 13(4): SBOM must be in a machine-readable format (CycloneDX 1.4+ or SPDX 2.3+)".to_string(),
462 },
463 default_configuration: SarifConfiguration { level: SarifLevel::Warning },
464 },
465 SarifRule {
466 id: "SBOM-CRA-ART-13-6".to_string(),
467 name: "CraVulnerabilityDisclosure".to_string(),
468 short_description: SarifMessage {
469 text: "CRA Art. 13(6): Vulnerability disclosure contact and metadata completeness".to_string(),
470 },
471 default_configuration: SarifConfiguration { level: SarifLevel::Warning },
472 },
473 SarifRule {
474 id: "SBOM-CRA-ART-13-7".to_string(),
475 name: "CraCoordinatedDisclosure".to_string(),
476 short_description: SarifMessage {
477 text: "CRA Art. 13(7): Coordinated vulnerability disclosure policy reference".to_string(),
478 },
479 default_configuration: SarifConfiguration { level: SarifLevel::Warning },
480 },
481 SarifRule {
482 id: "SBOM-CRA-ART-13-8".to_string(),
483 name: "CraSupportPeriod".to_string(),
484 short_description: SarifMessage {
485 text: "CRA Art. 13(8): Support period and security update end date".to_string(),
486 },
487 default_configuration: SarifConfiguration { level: SarifLevel::Note },
488 },
489 SarifRule {
490 id: "SBOM-CRA-ART-13-11".to_string(),
491 name: "CraComponentLifecycle".to_string(),
492 short_description: SarifMessage {
493 text: "CRA Art. 13(11): Component lifecycle and end-of-support status".to_string(),
494 },
495 default_configuration: SarifConfiguration { level: SarifLevel::Note },
496 },
497 SarifRule {
498 id: "SBOM-CRA-ART-13-12".to_string(),
499 name: "CraProductIdentification".to_string(),
500 short_description: SarifMessage {
501 text: "CRA Art. 13(12): Product name and version identification".to_string(),
502 },
503 default_configuration: SarifConfiguration { level: SarifLevel::Error },
504 },
505 SarifRule {
506 id: "SBOM-CRA-ART-13-15".to_string(),
507 name: "CraManufacturerIdentification".to_string(),
508 short_description: SarifMessage {
509 text: "CRA Art. 13(15): Manufacturer identification and contact information".to_string(),
510 },
511 default_configuration: SarifConfiguration { level: SarifLevel::Warning },
512 },
513 SarifRule {
514 id: "SBOM-CRA-ANNEX-I".to_string(),
515 name: "CraTechnicalDocumentation".to_string(),
516 short_description: SarifMessage {
517 text: "CRA Annex I: Technical documentation (unique identifiers, dependencies, primary component)".to_string(),
518 },
519 default_configuration: SarifConfiguration { level: SarifLevel::Warning },
520 },
521 SarifRule {
522 id: "SBOM-CRA-ANNEX-VII".to_string(),
523 name: "CraDeclarationOfConformity".to_string(),
524 short_description: SarifMessage {
525 text: "CRA Annex VII: EU Declaration of Conformity reference".to_string(),
526 },
527 default_configuration: SarifConfiguration { level: SarifLevel::Note },
528 },
529 SarifRule {
530 id: "SBOM-CRA-GENERAL".to_string(),
531 name: "CraGeneralRequirement".to_string(),
532 short_description: SarifMessage {
533 text: "CRA general SBOM readiness requirement".to_string(),
534 },
535 default_configuration: SarifConfiguration { level: SarifLevel::Warning },
536 },
537 ]
538}
539
540#[derive(Serialize)]
543#[serde(rename_all = "camelCase")]
544struct SarifReport {
545 #[serde(rename = "$schema")]
546 schema: String,
547 version: String,
548 runs: Vec<SarifRun>,
549}
550
551#[derive(Serialize)]
552#[serde(rename_all = "camelCase")]
553struct SarifRun {
554 tool: SarifTool,
555 results: Vec<SarifResult>,
556}
557
558#[derive(Serialize)]
559#[serde(rename_all = "camelCase")]
560struct SarifTool {
561 driver: SarifDriver,
562}
563
564#[derive(Serialize)]
565#[serde(rename_all = "camelCase")]
566struct SarifDriver {
567 name: String,
568 version: String,
569 information_uri: String,
570 rules: Vec<SarifRule>,
571}
572
573#[derive(Serialize)]
574#[serde(rename_all = "camelCase")]
575struct SarifRule {
576 id: String,
577 name: String,
578 short_description: SarifMessage,
579 default_configuration: SarifConfiguration,
580}
581
582#[derive(Serialize)]
583#[serde(rename_all = "camelCase")]
584struct SarifConfiguration {
585 level: SarifLevel,
586}
587
588#[derive(Serialize)]
589#[serde(rename_all = "camelCase")]
590struct SarifResult {
591 rule_id: String,
592 level: SarifLevel,
593 message: SarifMessage,
594 locations: Vec<SarifLocation>,
595}
596
597#[derive(Serialize)]
598#[serde(rename_all = "camelCase")]
599struct SarifMessage {
600 text: String,
601}
602
603#[derive(Serialize)]
604#[serde(rename_all = "camelCase")]
605struct SarifLocation {
606 physical_location: Option<SarifPhysicalLocation>,
607}
608
609#[derive(Serialize)]
610#[serde(rename_all = "camelCase")]
611struct SarifPhysicalLocation {
612 artifact_location: SarifArtifactLocation,
613}
614
615#[derive(Serialize)]
616#[serde(rename_all = "camelCase")]
617struct SarifArtifactLocation {
618 uri: String,
619}
620
621#[derive(Serialize, Clone, Copy)]
622#[serde(rename_all = "lowercase")]
623enum SarifLevel {
624 #[allow(dead_code)]
625 None,
626 Note,
627 Warning,
628 Error,
629}