1use super::escape::{
4 escape_markdown_inline, escape_markdown_list, escape_markdown_table, escape_md_opt,
5};
6use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
7use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
8use crate::model::NormalizedSbom;
9use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult, ViolationSeverity};
10use std::fmt::Write;
11
12pub struct MarkdownReporter {
14 include_toc: bool,
16}
17
18impl MarkdownReporter {
19 #[must_use]
21 pub const fn new() -> Self {
22 Self { include_toc: true }
23 }
24
25 #[must_use]
27 pub const fn include_toc(mut self, include: bool) -> Self {
28 self.include_toc = include;
29 self
30 }
31}
32
33impl Default for MarkdownReporter {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl ReportGenerator for MarkdownReporter {
40 fn generate_diff_report(
41 &self,
42 result: &DiffResult,
43 old_sbom: &NormalizedSbom,
44 new_sbom: &NormalizedSbom,
45 config: &ReportConfig,
46 ) -> Result<String, ReportError> {
47 let mut md = String::new();
48
49 let title = config
51 .title
52 .clone()
53 .unwrap_or_else(|| "SBOM Diff Report".to_string());
54 writeln!(md, "# {}\n", escape_markdown_inline(&title))?;
55
56 writeln!(
58 md,
59 "**Generated by:** sbom-tools v{}",
60 env!("CARGO_PKG_VERSION")
61 )?;
62 writeln!(
63 md,
64 "**Date:** {}\n",
65 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
66 )?;
67
68 if self.include_toc {
70 writeln!(md, "## Table of Contents\n")?;
71 writeln!(md, "- [Summary](#summary)")?;
72 if !result.metadata_changes.is_empty() {
73 writeln!(md, "- [Metadata Changes](#metadata-changes)")?;
74 }
75 if config.includes(ReportType::Components) {
76 writeln!(md, "- [Component Changes](#component-changes)")?;
77 }
78 if config.includes(ReportType::Dependencies) {
79 writeln!(md, "- [Dependency Changes](#dependency-changes)")?;
80 }
81 if config.includes(ReportType::Licenses)
82 && (!result.licenses.new_licenses.is_empty()
83 || !result.licenses.removed_licenses.is_empty()
84 || !result.licenses.conflicts.is_empty())
85 {
86 writeln!(md, "- [License Changes](#license-changes)")?;
87 }
88 if config.includes(ReportType::Vulnerabilities)
89 && (!result.vulnerabilities.introduced.is_empty()
90 || !result.vulnerabilities.resolved.is_empty())
91 {
92 writeln!(md, "- [Vulnerability Changes](#vulnerability-changes)")?;
93 }
94 if result
95 .graph_summary
96 .as_ref()
97 .is_some_and(|s| s.total_changes > 0)
98 {
99 writeln!(md, "- [Graph Changes](#graph-changes)")?;
100 }
101 writeln!(md, "- [CRA Compliance](#cra-compliance)")?;
102 writeln!(md)?;
103 }
104
105 writeln!(md, "## Summary\n")?;
107 writeln!(md, "| Metric | Old SBOM | New SBOM |")?;
108 writeln!(md, "|--------|----------|----------|")?;
109 writeln!(
110 md,
111 "| **Format** | {} | {} |",
112 old_sbom.document.format, new_sbom.document.format
113 )?;
114 writeln!(
115 md,
116 "| **Components** | {} | {} |",
117 old_sbom.component_count(),
118 new_sbom.component_count()
119 )?;
120 writeln!(
121 md,
122 "| **Dependencies** | {} | {} |",
123 old_sbom.edges.len(),
124 new_sbom.edges.len()
125 )?;
126 writeln!(md)?;
127
128 writeln!(md, "### Change Summary\n")?;
129 writeln!(md, "| Category | Count |")?;
130 writeln!(md, "|----------|-------|")?;
131 writeln!(
132 md,
133 "| Components Added | {} |",
134 result.summary.components_added
135 )?;
136 writeln!(
137 md,
138 "| Components Removed | {} |",
139 result.summary.components_removed
140 )?;
141 writeln!(
142 md,
143 "| Components Modified | {} |",
144 result.summary.components_modified
145 )?;
146 writeln!(
147 md,
148 "| Vulnerabilities Introduced | {} |",
149 result.summary.vulnerabilities_introduced
150 )?;
151 writeln!(
152 md,
153 "| Vulnerabilities Resolved | {} |",
154 result.summary.vulnerabilities_resolved
155 )?;
156 writeln!(md, "| **Semantic Score** | {:.1} |", result.semantic_score)?;
157 writeln!(md)?;
158
159 if !result.metadata_changes.is_empty() {
161 writeln!(md, "## Metadata Changes\n")?;
162 writeln!(md, "| Field | Old | New |")?;
163 writeln!(md, "|-------|-----|-----|")?;
164 for change in &result.metadata_changes {
165 writeln!(
166 md,
167 "| {} | {} | {} |",
168 escape_markdown_table(&change.field),
169 escape_md_opt(change.old_value.as_deref()),
170 escape_md_opt(change.new_value.as_deref()),
171 )?;
172 }
173 writeln!(md)?;
174 }
175
176 if config.includes(ReportType::Components) {
178 writeln!(md, "## Component Changes\n")?;
179
180 if !result.components.added.is_empty() {
181 writeln!(md, "### Added Components\n")?;
182 writeln!(md, "| Name | Version | Ecosystem |")?;
183 writeln!(md, "|------|---------|-----------|")?;
184 for comp in &result.components.added {
185 writeln!(
186 md,
187 "| {} | {} | {} |",
188 escape_markdown_table(&comp.name),
189 escape_md_opt(comp.new_version.as_deref()),
190 escape_md_opt(comp.ecosystem.as_deref())
191 )?;
192 }
193 writeln!(md)?;
194 }
195
196 if !result.components.removed.is_empty() {
197 writeln!(md, "### Removed Components\n")?;
198 writeln!(md, "| Name | Version | Ecosystem |")?;
199 writeln!(md, "|------|---------|-----------|")?;
200 for comp in &result.components.removed {
201 writeln!(
202 md,
203 "| {} | {} | {} |",
204 escape_markdown_table(&comp.name),
205 escape_md_opt(comp.old_version.as_deref()),
206 escape_md_opt(comp.ecosystem.as_deref())
207 )?;
208 }
209 writeln!(md)?;
210 }
211
212 if !result.components.modified.is_empty() {
213 writeln!(md, "### Modified Components\n")?;
214 writeln!(md, "| Name | Old Version | New Version | Changes |")?;
215 writeln!(md, "|------|-------------|-------------|---------|")?;
216 for comp in &result.components.modified {
217 let changes: Vec<String> = comp
218 .field_changes
219 .iter()
220 .map(|c| escape_markdown_table(&c.field))
221 .collect();
222 writeln!(
223 md,
224 "| {} | {} | {} | {} |",
225 escape_markdown_table(&comp.name),
226 escape_md_opt(comp.old_version.as_deref()),
227 escape_md_opt(comp.new_version.as_deref()),
228 changes.join(", ")
229 )?;
230 }
231 writeln!(md)?;
232 }
233 }
234
235 if config.includes(ReportType::Dependencies) && !result.dependencies.is_empty() {
237 writeln!(md, "## Dependency Changes\n")?;
238
239 if !result.dependencies.added.is_empty() {
240 writeln!(md, "### Added Dependencies\n")?;
241 writeln!(md, "| From | To | Relationship |")?;
242 writeln!(md, "|------|----|--------------|")?;
243 for dep in &result.dependencies.added {
244 writeln!(
245 md,
246 "| {} | {} | {} |",
247 escape_markdown_table(&dep.from),
248 escape_markdown_table(&dep.to),
249 escape_markdown_table(&dep.relationship)
250 )?;
251 }
252 writeln!(md)?;
253 }
254
255 if !result.dependencies.removed.is_empty() {
256 writeln!(md, "### Removed Dependencies\n")?;
257 writeln!(md, "| From | To | Relationship |")?;
258 writeln!(md, "|------|----|--------------|")?;
259 for dep in &result.dependencies.removed {
260 writeln!(
261 md,
262 "| {} | {} | {} |",
263 escape_markdown_table(&dep.from),
264 escape_markdown_table(&dep.to),
265 escape_markdown_table(&dep.relationship)
266 )?;
267 }
268 writeln!(md)?;
269 }
270 }
271
272 if config.includes(ReportType::Licenses)
274 && (!result.licenses.new_licenses.is_empty()
275 || !result.licenses.removed_licenses.is_empty()
276 || !result.licenses.conflicts.is_empty())
277 {
278 writeln!(md, "## License Changes\n")?;
279
280 if !result.licenses.new_licenses.is_empty() {
281 writeln!(md, "### New Licenses\n")?;
282 for lic in &result.licenses.new_licenses {
283 let escaped_components: Vec<String> = lic
284 .components
285 .iter()
286 .map(|c| escape_markdown_list(c))
287 .collect();
288 writeln!(
289 md,
290 "- **{}**: {}",
291 escape_markdown_list(&lic.license),
292 escaped_components.join(", ")
293 )?;
294 }
295 writeln!(md)?;
296 }
297
298 if !result.licenses.removed_licenses.is_empty() {
299 writeln!(md, "### Removed Licenses\n")?;
300 for lic in &result.licenses.removed_licenses {
301 let escaped_components: Vec<String> = lic
302 .components
303 .iter()
304 .map(|c| escape_markdown_list(c))
305 .collect();
306 writeln!(
307 md,
308 "- **{}**: {}",
309 escape_markdown_list(&lic.license),
310 escaped_components.join(", ")
311 )?;
312 }
313 writeln!(md)?;
314 }
315
316 if !result.licenses.conflicts.is_empty() {
317 writeln!(md, "### License Conflicts\n")?;
318 writeln!(md, "| License A | License B | Component | Description |")?;
319 writeln!(md, "|-----------|-----------|-----------|-------------|")?;
320 for conflict in &result.licenses.conflicts {
321 writeln!(
322 md,
323 "| {} | {} | {} | {} |",
324 escape_markdown_table(&conflict.license_a),
325 escape_markdown_table(&conflict.license_b),
326 escape_markdown_table(&conflict.component),
327 escape_markdown_table(&conflict.description)
328 )?;
329 }
330 writeln!(md)?;
331 }
332 }
333
334 if config.includes(ReportType::Vulnerabilities)
336 && (!result.vulnerabilities.introduced.is_empty()
337 || !result.vulnerabilities.resolved.is_empty())
338 {
339 writeln!(md, "## Vulnerability Changes\n")?;
340
341 if !result.vulnerabilities.introduced.is_empty() {
342 writeln!(md, "### Introduced Vulnerabilities\n")?;
343 writeln!(
344 md,
345 "| ID | Severity | CVSS | KEV | EPSS | SLA | Type | Component | Version | VEX |"
346 )?;
347 writeln!(
348 md,
349 "|----|----------|------|-----|------|-----|------|-----------|---------|-----|"
350 )?;
351 for vuln in &result.vulnerabilities.introduced {
352 let depth_label = match vuln.component_depth {
353 Some(1) => "Direct",
354 Some(_) => "Transitive",
355 None => "-",
356 };
357 let sla_display = format_sla_display(vuln);
358 let vex_display = format_vex_display(vuln.vex_state.as_ref());
359 let kev_display = if vuln.is_kev { "⚠ KEV" } else { "-" };
360 let epss_display = format_epss_display(vuln.epss_score);
361 writeln!(
362 md,
363 "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |",
364 escape_markdown_table(&vuln.id),
365 escape_markdown_table(&vuln.severity),
366 vuln.cvss_score
367 .map(|s| format!("{s:.1}"))
368 .as_deref()
369 .unwrap_or("-"),
370 kev_display,
371 epss_display,
372 escape_markdown_table(&sla_display),
373 depth_label,
374 escape_markdown_table(&vuln.component_name),
375 escape_md_opt(vuln.version.as_deref()),
376 vex_display,
377 )?;
378 }
379 writeln!(md)?;
380 }
381
382 if !result.vulnerabilities.resolved.is_empty() {
383 writeln!(md, "### Resolved Vulnerabilities\n")?;
384 writeln!(md, "| ID | Severity | SLA | Type | Component | VEX |")?;
385 writeln!(md, "|----|----------|-----|------|-----------|-----|")?;
386 for vuln in &result.vulnerabilities.resolved {
387 let depth_label = match vuln.component_depth {
388 Some(1) => "Direct",
389 Some(_) => "Transitive",
390 None => "-",
391 };
392 let sla_display = format_sla_display(vuln);
393 let vex_display = format_vex_display(vuln.vex_state.as_ref());
394 writeln!(
395 md,
396 "| {} | {} | {} | {} | {} | {} |",
397 escape_markdown_table(&vuln.id),
398 escape_markdown_table(&vuln.severity),
399 escape_markdown_table(&sla_display),
400 depth_label,
401 escape_markdown_table(&vuln.component_name),
402 vex_display,
403 )?;
404 }
405 writeln!(md)?;
406 }
407 }
408
409 {
411 let vex_summary = result.vulnerabilities.vex_summary();
412 if vex_summary.total_vulns > 0 {
413 writeln!(md, "### VEX Coverage\n")?;
414 writeln!(md, "| Metric | Value |")?;
415 writeln!(md, "|--------|-------|")?;
416 writeln!(
417 md,
418 "| Coverage | {:.1}% ({}/{}) |",
419 vex_summary.coverage_pct, vex_summary.with_vex, vex_summary.total_vulns
420 )?;
421 writeln!(md, "| Actionable | {} |", vex_summary.actionable)?;
422 for (state, count) in &vex_summary.by_state {
423 writeln!(md, "| {state} | {count} |")?;
424 }
425 writeln!(md)?;
426 }
427 }
428
429 if let Some(ref summary) = result.graph_summary
431 && summary.total_changes > 0
432 {
433 writeln!(md, "## Graph Changes\n")?;
434 writeln!(md, "| Type | Count |")?;
435 writeln!(md, "|------|-------|")?;
436 writeln!(
437 md,
438 "| Dependencies Added | {} |",
439 summary.dependencies_added
440 )?;
441 writeln!(
442 md,
443 "| Dependencies Removed | {} |",
444 summary.dependencies_removed
445 )?;
446 writeln!(
447 md,
448 "| Relationship Changed | {} |",
449 summary.relationship_changed
450 )?;
451 writeln!(md, "| Reparented | {} |", summary.reparented)?;
452 writeln!(md, "| Depth Changed | {} |", summary.depth_changed)?;
453 writeln!(md, "| **Total** | **{}** |", summary.total_changes)?;
454 writeln!(md)?;
455
456 if !result.graph_changes.is_empty() {
458 writeln!(md, "### Graph Change Details\n")?;
459 writeln!(md, "| Impact | Type | Component | Details |")?;
460 writeln!(md, "|--------|------|-----------|---------|")?;
461 for change in &result.graph_changes {
462 let impact = change.impact.as_str().to_uppercase();
463 let (change_type, details) = match &change.change {
464 crate::diff::DependencyChangeType::DependencyAdded {
465 dependency_name,
466 ..
467 } => ("Added", format!("+ {dependency_name}")),
468 crate::diff::DependencyChangeType::DependencyRemoved {
469 dependency_name,
470 ..
471 } => ("Removed", format!("- {dependency_name}")),
472 crate::diff::DependencyChangeType::RelationshipChanged {
473 dependency_name,
474 old_relationship,
475 new_relationship,
476 ..
477 } => (
478 "Rel Changed",
479 format!("{dependency_name}: {old_relationship} → {new_relationship}"),
480 ),
481 crate::diff::DependencyChangeType::Reparented {
482 old_parent_name,
483 new_parent_name,
484 ..
485 } => (
486 "Reparented",
487 format!("{old_parent_name} → {new_parent_name}"),
488 ),
489 crate::diff::DependencyChangeType::DepthChanged {
490 old_depth,
491 new_depth,
492 } => {
493 let od = if *old_depth == u32::MAX {
494 "unreachable".to_string()
495 } else {
496 old_depth.to_string()
497 };
498 let nd = if *new_depth == u32::MAX {
499 "unreachable".to_string()
500 } else {
501 new_depth.to_string()
502 };
503 ("Depth", format!("{od} → {nd}"))
504 }
505 };
506 writeln!(
507 md,
508 "| {} | {} | {} | {} |",
509 escape_markdown_table(&impact),
510 change_type,
511 escape_markdown_table(&change.component_name),
512 escape_markdown_table(&details),
513 )?;
514 }
515 writeln!(md)?;
516 }
517
518 if summary.by_impact.critical > 0 || summary.by_impact.high > 0 {
519 writeln!(md, "### Impact Summary\n")?;
520 writeln!(md, "| Impact | Count |")?;
521 writeln!(md, "|--------|-------|")?;
522 if summary.by_impact.critical > 0 {
523 writeln!(md, "| Critical | {} |", summary.by_impact.critical)?;
524 }
525 if summary.by_impact.high > 0 {
526 writeln!(md, "| High | {} |", summary.by_impact.high)?;
527 }
528 if summary.by_impact.medium > 0 {
529 writeln!(md, "| Medium | {} |", summary.by_impact.medium)?;
530 }
531 if summary.by_impact.low > 0 {
532 writeln!(md, "| Low | {} |", summary.by_impact.low)?;
533 }
534 writeln!(md)?;
535 }
536 }
537
538 {
540 let eol_components: Vec<_> = new_sbom
541 .components
542 .values()
543 .filter(|c| {
544 c.eol.as_ref().is_some_and(|e| {
545 matches!(
546 e.status,
547 crate::model::EolStatus::EndOfLife
548 | crate::model::EolStatus::ApproachingEol
549 )
550 })
551 })
552 .collect();
553
554 if !eol_components.is_empty() {
555 writeln!(md, "## End-of-Life Components\n")?;
556 writeln!(md, "| Component | Version | Status | Product | EOL Date |")?;
557 writeln!(md, "|-----------|---------|--------|---------|----------|")?;
558 for comp in &eol_components {
559 let eol = comp.eol.as_ref().expect("filtered to eol.is_some()");
560 writeln!(
561 md,
562 "| {} | {} | {} | {} | {} |",
563 escape_markdown_table(&comp.name),
564 escape_md_opt(comp.version.as_deref()),
565 escape_markdown_table(eol.status.label()),
566 escape_markdown_table(&eol.product),
567 eol.eol_date
568 .map_or_else(|| "-".to_string(), |d| d.to_string()),
569 )?;
570 }
571 writeln!(md)?;
572 }
573 }
574
575 {
577 let old_cra = config.old_cra_compliance.clone().unwrap_or_else(|| {
578 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(old_sbom)
579 });
580 let new_cra = config.new_cra_compliance.clone().unwrap_or_else(|| {
581 ComplianceChecker::new(ComplianceLevel::CraPhase2).check(new_sbom)
582 });
583 write_cra_compliance_diff(&mut md, &old_cra, &new_cra)?;
584 }
585
586 writeln!(md, "---\n")?;
588 writeln!(md, "*Generated by sbom-tools*")?;
589
590 Ok(md)
591 }
592
593 fn generate_view_report(
594 &self,
595 sbom: &NormalizedSbom,
596 config: &ReportConfig,
597 ) -> Result<String, ReportError> {
598 let mut md = String::new();
599
600 let title = config
602 .title
603 .clone()
604 .unwrap_or_else(|| "SBOM Report".to_string());
605 writeln!(md, "# {}\n", escape_markdown_inline(&title))?;
606
607 writeln!(md, "**Format:** {}", sbom.document.format)?;
609 writeln!(md, "**Version:** {}", sbom.document.format_version)?;
610 if let Some(name) = &sbom.document.name {
611 writeln!(md, "**Name:** {}", escape_markdown_inline(name))?;
612 }
613 writeln!(md)?;
614
615 writeln!(md, "## Summary\n")?;
617 writeln!(md, "| Metric | Value |")?;
618 writeln!(md, "|--------|-------|")?;
619 writeln!(md, "| Total Components | {} |", sbom.component_count())?;
620 writeln!(md, "| Total Dependencies | {} |", sbom.edges.len())?;
621
622 let vuln_counts = sbom.vulnerability_counts();
623 writeln!(md, "| Total Vulnerabilities | {} |", vuln_counts.total())?;
624 writeln!(md, "| Critical | {} |", vuln_counts.critical)?;
625 writeln!(md, "| High | {} |", vuln_counts.high)?;
626 writeln!(md, "| Medium | {} |", vuln_counts.medium)?;
627 writeln!(md, "| Low | {} |", vuln_counts.low)?;
628 writeln!(md)?;
629
630 writeln!(md, "## Components\n")?;
632 writeln!(
633 md,
634 "| Name | Version | Ecosystem | License | Vulnerabilities |"
635 )?;
636 writeln!(
637 md,
638 "|------|---------|-----------|---------|-----------------|"
639 )?;
640
641 for comp in sbom.components.values() {
642 let license = comp
643 .licenses
644 .declared
645 .first()
646 .map(|l| escape_markdown_table(&l.expression));
647 let license = license.as_deref().unwrap_or("-");
648 writeln!(
649 md,
650 "| {} | {} | {} | {} | {} |",
651 escape_markdown_table(&comp.name),
652 escape_md_opt(comp.version.as_deref()),
653 comp.ecosystem
654 .as_ref()
655 .map(|e| escape_markdown_table(&e.to_string()))
656 .as_deref()
657 .unwrap_or("-"),
658 license,
659 comp.vulnerabilities.len()
660 )?;
661 }
662
663 {
665 let crypto_comps: Vec<_> = sbom
666 .components
667 .values()
668 .filter(|c| c.component_type == crate::model::ComponentType::Cryptographic)
669 .collect();
670 if !crypto_comps.is_empty() {
671 writeln!(md, "\n## Cryptographic Inventory\n")?;
672 writeln!(
673 md,
674 "| Name | Asset Type | Family | Primitive | Security Level | Quantum Level |"
675 )?;
676 writeln!(
677 md,
678 "|------|-----------|--------|-----------|---------------|--------------|"
679 )?;
680 for comp in &crypto_comps {
681 if let Some(cp) = &comp.crypto_properties {
682 let family = cp
683 .algorithm_properties
684 .as_ref()
685 .and_then(|a| a.algorithm_family.as_deref())
686 .unwrap_or("-");
687 let primitive = cp
688 .algorithm_properties
689 .as_ref()
690 .map(|a| a.primitive.to_string())
691 .unwrap_or_else(|| "-".to_string());
692 let sec_level = cp
693 .algorithm_properties
694 .as_ref()
695 .and_then(|a| a.classical_security_level)
696 .map(|l| format!("{l}"))
697 .unwrap_or_else(|| "-".to_string());
698 let quantum = cp
699 .algorithm_properties
700 .as_ref()
701 .and_then(|a| a.nist_quantum_security_level)
702 .map(|l| format!("{l}"))
703 .unwrap_or_else(|| "-".to_string());
704 writeln!(
705 md,
706 "| {} | {} | {} | {} | {} | {} |",
707 escape_markdown_table(&comp.name),
708 cp.asset_type,
709 escape_markdown_table(family),
710 primitive,
711 sec_level,
712 quantum,
713 )?;
714 } else {
715 writeln!(
716 md,
717 "| {} | - | - | - | - | - |",
718 escape_markdown_table(&comp.name),
719 )?;
720 }
721 }
722
723 let metrics = crate::quality::CryptographyMetrics::from_sbom(sbom);
725 if metrics.algorithms_count > 0 {
726 writeln!(
727 md,
728 "\n**Quantum Readiness:** {:.0}% ({} safe / {} total algorithms)",
729 metrics.quantum_readiness_score(),
730 metrics.quantum_safe_count,
731 metrics.algorithms_count,
732 )?;
733 }
734 if !metrics.weak_algorithm_names.is_empty() {
735 writeln!(
736 md,
737 "\n**Weak Algorithms:** {}",
738 metrics.weak_algorithm_names.join(", ")
739 )?;
740 }
741 }
742 }
743
744 {
746 let cra = config
747 .view_cra_compliance
748 .clone()
749 .unwrap_or_else(|| ComplianceChecker::new(ComplianceLevel::CraPhase2).check(sbom));
750 write_cra_compliance_view(&mut md, &cra)?;
751 }
752
753 Ok(md)
754 }
755
756 fn format(&self) -> ReportFormat {
757 ReportFormat::Markdown
758 }
759}
760
761fn delta_indicator(old_val: usize, new_val: usize, lower_is_better: bool) -> &'static str {
763 if old_val == new_val {
764 ""
765 } else if (new_val < old_val) == lower_is_better {
766 " (+)" } else {
768 " (!)" }
770}
771
772fn compliance_score(result: &ComplianceResult) -> u8 {
774 let total = result.violations.len() + 1; let issues = result.error_count + result.warning_count;
776 let score = if issues >= total {
777 0
778 } else {
779 ((total - issues) * 100) / total
780 };
781 score.min(100) as u8
782}
783
784fn write_cra_compliance_diff(
786 md: &mut String,
787 old: &ComplianceResult,
788 new: &ComplianceResult,
789) -> std::fmt::Result {
790 writeln!(md, "## CRA Compliance\n")?;
791
792 let old_status = if old.is_compliant {
794 "Compliant"
795 } else {
796 "Non-compliant"
797 };
798 let new_status = if new.is_compliant {
799 "Compliant"
800 } else {
801 "Non-compliant"
802 };
803 let old_score = compliance_score(old);
804 let new_score = compliance_score(new);
805 let err_delta = delta_indicator(old.error_count, new.error_count, true);
806 let warn_delta = delta_indicator(old.warning_count, new.warning_count, true);
807 let score_delta = delta_indicator(old_score.into(), new_score.into(), false);
808
809 writeln!(md, "| | Old SBOM | New SBOM | Trend |")?;
810 writeln!(md, "|--|----------|----------|-------|")?;
811 writeln!(md, "| **Status** | {old_status} | {new_status} | |")?;
812 writeln!(
813 md,
814 "| **Score** | {old_score}% | {new_score}% | {score_delta} |"
815 )?;
816 writeln!(
817 md,
818 "| **Level** | {} | {} | |",
819 old.level.name(),
820 new.level.name()
821 )?;
822 writeln!(
823 md,
824 "| **Errors** | {} | {} | {err_delta} |",
825 old.error_count, new.error_count
826 )?;
827 writeln!(
828 md,
829 "| **Warnings** | {} | {} | {warn_delta} |",
830 old.warning_count, new.warning_count
831 )?;
832 writeln!(md)?;
833
834 write_conformity_assessment_md(md, new)?;
836 write_reporting_channels_md(md, new)?;
838
839 write_compact_diff_violation_summary(md, new)?;
840
841 Ok(())
842}
843
844fn write_compact_diff_violation_summary(
845 md: &mut String,
846 result: &ComplianceResult,
847) -> std::fmt::Result {
848 if result.violations.is_empty() {
849 return Ok(());
850 }
851
852 let group_count = count_violation_groups(&result.violations);
853 writeln!(md, "### Violation Summary (New SBOM)\n")?;
854 writeln!(
855 md,
856 "- {} total findings across {group_count} distinct requirement groups.",
857 result.violations.len(),
858 )?;
859 writeln!(
860 md,
861 "- Re-run with `sbom-tools diff ... -o json` or `-o sarif` for the full CRA violation detail.\n"
862 )?;
863
864 Ok(())
865}
866
867fn write_cra_compliance_view(md: &mut String, result: &ComplianceResult) -> std::fmt::Result {
869 writeln!(md, "## CRA Compliance\n")?;
870
871 let status = if result.is_compliant {
872 "Compliant"
873 } else {
874 "Non-compliant"
875 };
876 let score = compliance_score(result);
877 writeln!(md, "**Status:** {status} ")?;
878 writeln!(md, "**Score:** {score}% ")?;
879 writeln!(md, "**Level:** {} ", result.level.name())?;
880 writeln!(
881 md,
882 "**Issues:** {} errors, {} warnings\n",
883 result.error_count, result.warning_count
884 )?;
885
886 write_conformity_assessment_md(md, result)?;
887 write_reporting_channels_md(md, result)?;
888
889 if !result.violations.is_empty() {
890 write_violation_table(md, &result.violations)?;
891 }
892
893 Ok(())
894}
895
896fn write_conformity_assessment_md(md: &mut String, result: &ComplianceResult) -> std::fmt::Result {
900 let Some(summary) = result.conformity_summary.as_ref() else {
901 return Ok(());
902 };
903 writeln!(md, "### Conformity Assessment (CRA Annex VIII)\n")?;
904 writeln!(md, "- **Product class:** {}", summary.product_class.name())?;
905 writeln!(md, "- **Conformity route:** {}\n", summary.route.name())?;
906 writeln!(md, "| Evidence | Status | Detail |")?;
907 writeln!(md, "|----------|--------|--------|")?;
908 for ev in &summary.evidence {
909 let status = if ev.satisfied {
910 "✅ Present"
911 } else {
912 "❌ Missing"
913 };
914 writeln!(md, "| {} | {} | {} |", ev.label, status, ev.detail)?;
915 }
916 writeln!(md)?;
917 Ok(())
918}
919
920fn write_reporting_channels_md(md: &mut String, result: &ComplianceResult) -> std::fmt::Result {
930 if !result.level.is_cra() {
931 return Ok(());
932 }
933
934 let psirt = channel_status(result, "Art. 14: PSIRT");
935 let early = channel_status(result, "Art. 14(1)");
936 let incident = channel_status(result, "Art. 14(2)");
937 let enisa = channel_status(result, "Art. 14(7)");
938
939 writeln!(md, "### Reporting Channels (CRA Art. 14)\n")?;
940 writeln!(md, "| Channel | Status |")?;
941 writeln!(md, "|---------|--------|")?;
942 writeln!(md, "| PSIRT contact | {} |", psirt.label())?;
943 writeln!(
944 md,
945 "| 24-hour early warning (Art. 14(1)) | {} |",
946 early.label()
947 )?;
948 writeln!(
949 md,
950 "| 72-hour incident report (Art. 14(2)) | {} |",
951 incident.label()
952 )?;
953 writeln!(
954 md,
955 "| ENISA single reporting platform (Art. 14(7)) | {} |",
956 enisa.label()
957 )?;
958 writeln!(md)?;
959 writeln!(
960 md,
961 "_Article 14 reporting obligations apply from 11 September 2026. \
962 Channels marked 'Missing (pre-deadline)' surface as Info; \
963 after the deadline they become Warnings._\n"
964 )?;
965 Ok(())
966}
967
968fn channel_status(result: &ComplianceResult, needle: &str) -> ChannelStatus {
969 match result
970 .violations
971 .iter()
972 .find(|v| v.requirement.contains(needle))
973 {
974 None => ChannelStatus::Documented,
975 Some(v) => match v.severity {
976 ViolationSeverity::Warning | ViolationSeverity::Error => {
977 ChannelStatus::MissingPostDeadline
978 }
979 ViolationSeverity::Info => ChannelStatus::MissingPreDeadline,
980 },
981 }
982}
983
984#[derive(Debug, Clone, Copy, PartialEq, Eq)]
985enum ChannelStatus {
986 Documented,
987 MissingPreDeadline,
988 MissingPostDeadline,
989}
990
991impl ChannelStatus {
992 fn label(self) -> &'static str {
993 match self {
994 Self::Documented => "Documented",
995 Self::MissingPreDeadline => "Missing (pre-deadline 2026-09-11)",
996 Self::MissingPostDeadline => "Missing",
997 }
998 }
999}
1000
1001fn count_violation_groups(violations: &[crate::quality::Violation]) -> usize {
1004 use std::collections::HashSet;
1005 let mut groups: HashSet<(u8, &str, &str)> = HashSet::new();
1006 for v in violations {
1007 let sev_ord = match v.severity {
1008 ViolationSeverity::Error => 0,
1009 ViolationSeverity::Warning => 1,
1010 ViolationSeverity::Info => 2,
1011 };
1012 groups.insert((sev_ord, v.category.name(), v.requirement.as_str()));
1013 }
1014 groups.len()
1015}
1016
1017fn aggregate_violations(violations: &[crate::quality::Violation]) -> Vec<AggregatedViolation<'_>> {
1021 use std::collections::BTreeMap;
1022
1023 let mut groups: BTreeMap<(u8, &str, &str), Vec<&crate::quality::Violation>> = BTreeMap::new();
1025 for v in violations {
1026 let sev_ord = match v.severity {
1027 ViolationSeverity::Error => 0,
1028 ViolationSeverity::Warning => 1,
1029 ViolationSeverity::Info => 2,
1030 };
1031 groups
1032 .entry((sev_ord, v.category.name(), v.requirement.as_str()))
1033 .or_default()
1034 .push(v);
1035 }
1036
1037 groups
1038 .into_values()
1039 .map(|group| {
1040 let standard_refs = format_standard_refs(&group[0].standard_refs);
1041 if group.len() == 1 {
1042 AggregatedViolation {
1043 severity: group[0].severity,
1044 category: group[0].category.name(),
1045 requirement: &group[0].requirement,
1046 message: group[0].message.clone(),
1047 remediation: group[0].remediation_guidance(),
1048 count: 1,
1049 standard_refs,
1050 }
1051 } else {
1052 let elements: Vec<&str> =
1053 group.iter().filter_map(|v| v.element.as_deref()).collect();
1054 let message = if elements.is_empty() {
1055 group[0].message.clone()
1056 } else {
1057 let preview: Vec<&str> = elements.iter().take(5).copied().collect();
1058 let suffix = if elements.len() > 5 {
1059 format!(", ... +{} more", elements.len() - 5)
1060 } else {
1061 String::new()
1062 };
1063 format!(
1064 "{} components affected ({}{})",
1065 elements.len(),
1066 preview.join(", "),
1067 suffix
1068 )
1069 };
1070 AggregatedViolation {
1071 severity: group[0].severity,
1072 category: group[0].category.name(),
1073 requirement: &group[0].requirement,
1074 message,
1075 remediation: group[0].remediation_guidance(),
1076 count: group.len(),
1077 standard_refs,
1078 }
1079 }
1080 })
1081 .collect()
1082}
1083
1084struct AggregatedViolation<'a> {
1085 severity: ViolationSeverity,
1086 category: &'a str,
1087 requirement: &'a str,
1088 message: String,
1089 remediation: &'static str,
1090 count: usize,
1091 standard_refs: String,
1094}
1095
1096fn format_standard_refs(refs: &[crate::quality::StandardRef]) -> String {
1098 use std::fmt::Write;
1099 let mut out = String::new();
1100 for (i, r) in refs.iter().enumerate() {
1101 if i > 0 {
1102 out.push_str(", ");
1103 }
1104 let _ = write!(out, "{}: {}", r.standard.label(), r.id);
1105 }
1106 out
1107}
1108
1109fn write_violation_table(
1111 md: &mut String,
1112 violations: &[crate::quality::Violation],
1113) -> std::fmt::Result {
1114 let aggregated = aggregate_violations(violations);
1115 writeln!(
1116 md,
1117 "| Severity | Category | Standard refs | Requirement | Message | Remediation |"
1118 )?;
1119 writeln!(
1120 md,
1121 "|----------|----------|---------------|-------------|---------|-------------|"
1122 )?;
1123 for v in &aggregated {
1124 let severity = match v.severity {
1125 ViolationSeverity::Error => "Error",
1126 ViolationSeverity::Warning => "Warning",
1127 ViolationSeverity::Info => "Info",
1128 };
1129 let count_suffix = if v.count > 1 {
1130 format!(" (x{})", v.count)
1131 } else {
1132 String::new()
1133 };
1134 writeln!(
1135 md,
1136 "| {}{} | {} | {} | {} | {} | {} |",
1137 severity,
1138 escape_markdown_table(&count_suffix),
1139 escape_markdown_table(v.category),
1140 escape_markdown_table(&v.standard_refs),
1141 escape_markdown_table(v.requirement),
1142 escape_markdown_table(&v.message),
1143 escape_markdown_table(v.remediation),
1144 )?;
1145 }
1146 writeln!(md)?;
1147 Ok(())
1148}
1149
1150fn format_sla_display(vuln: &VulnerabilityDetail) -> String {
1152 match vuln.sla_status() {
1153 SlaStatus::Overdue(days) => format!("{days}d late"),
1154 SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => format!("{days}d left"),
1155 SlaStatus::NoDueDate => vuln
1156 .days_since_published
1157 .map_or_else(|| "-".to_string(), |d| format!("{d}d old")),
1158 }
1159}
1160
1161fn format_vex_display(vex_state: Option<&crate::model::VexState>) -> &'static str {
1162 match vex_state {
1163 Some(crate::model::VexState::NotAffected) => "Not Affected",
1164 Some(crate::model::VexState::Fixed) => "Fixed",
1165 Some(crate::model::VexState::Affected) => "Affected",
1166 Some(crate::model::VexState::UnderInvestigation) => "Under Investigation",
1167 None => "-",
1168 }
1169}
1170
1171fn format_epss_display(epss_score: Option<f64>) -> String {
1173 epss_score.map_or_else(|| "-".to_string(), |s| format!("{:.0}%", s * 100.0))
1174}