Skip to main content

sbom_tools/cli/
cra_docs.rs

1//! `cra-docs` command handler.
2//!
3//! Generates a CRA technical-documentation dossier (Annex V templates)
4//! prefilled from the SBOM and an optional CRA sidecar. Output is a
5//! directory containing three Markdown files that a notified body or
6//! auditor can use as a starting point:
7//!
8//! - `eu-declaration-of-conformity.md` — Annex V Declaration of Conformity
9//! - `technical-documentation.md`      — Annex V technical-documentation summary
10//! - `vulnerability-handling-policy.md` — Annex I Part II policy stub
11//!
12//! Fields the SBOM/sidecar can supply are filled in; everything else is
13//! left as `_TBD_` so the operator can complete the document by hand.
14
15use crate::model::{ConformityRoute, CraProductClass, CraSidecarMetadata, NormalizedSbom};
16use crate::pipeline::parse_sbom_with_context;
17use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult};
18use anyhow::{Context, Result};
19use std::path::PathBuf;
20
21/// Run the `cra-docs` command. Generates 3 Markdown files in `output_dir`.
22#[allow(clippy::needless_pass_by_value)]
23pub fn run_cra_docs(
24    sbom_path: PathBuf,
25    output_dir: PathBuf,
26    cra_sidecar_path: Option<PathBuf>,
27    cra_product_class: Option<String>,
28) -> Result<()> {
29    let parsed = parse_sbom_with_context(&sbom_path, false)?;
30    let sbom = parsed.sbom();
31
32    // Sidecar resolution: explicit path → auto-discover.
33    let sidecar = match cra_sidecar_path {
34        Some(p) => Some(CraSidecarMetadata::from_file(&p).map_err(|e| {
35            anyhow::anyhow!("Failed to load CRA sidecar from {}: {e}", p.display())
36        })?),
37        None => CraSidecarMetadata::find_for_sbom(&sbom_path),
38    };
39
40    // Effective product class: sidecar wins, else CLI flag, else Default.
41    let cli_class = cra_product_class
42        .as_deref()
43        .and_then(CraProductClass::parse_cli);
44    let sidecar_class = sidecar.as_ref().and_then(|s| s.product_class);
45    if let (Some(cli), Some(side)) = (cli_class, sidecar_class)
46        && cli != side
47    {
48        tracing::warn!(
49            "CRA product class mismatch: --cra-product-class={} but sidecar says {}; using sidecar.",
50            cli.label(),
51            side.label()
52        );
53    }
54    let effective_class = sidecar_class
55        .or(cli_class)
56        .unwrap_or(CraProductClass::Default);
57
58    // Build a compliance result so the dossier can summarise readiness.
59    let mut checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
60    if let Some(sc) = sidecar.clone() {
61        checker = checker.with_sidecar(sc);
62    }
63    checker = checker.with_product_class(effective_class);
64    let compliance = checker.check(sbom);
65    let route = checker.effective_route();
66
67    std::fs::create_dir_all(&output_dir)
68        .with_context(|| format!("creating output directory {}", output_dir.display()))?;
69
70    write_doc(
71        &output_dir.join("eu-declaration-of-conformity.md"),
72        &render_doc(sbom, sidecar.as_ref(), effective_class, route),
73    )?;
74    write_doc(
75        &output_dir.join("technical-documentation.md"),
76        &render_tech_doc(sbom, sidecar.as_ref(), effective_class, route, &compliance),
77    )?;
78    write_doc(
79        &output_dir.join("vulnerability-handling-policy.md"),
80        &render_vuln_policy(sbom, sidecar.as_ref()),
81    )?;
82
83    println!(
84        "CRA dossier written to {} ({} files)",
85        output_dir.display(),
86        3
87    );
88    Ok(())
89}
90
91fn write_doc(path: &std::path::Path, content: &str) -> Result<()> {
92    std::fs::write(path, content).with_context(|| format!("writing {}", path.display()))
93}
94
95/// Render the EU Declaration of Conformity (Annex V) template.
96fn render_doc(
97    sbom: &NormalizedSbom,
98    sidecar: Option<&CraSidecarMetadata>,
99    class: CraProductClass,
100    route: ConformityRoute,
101) -> String {
102    let manufacturer = sidecar
103        .and_then(|s| s.manufacturer_name.as_deref())
104        .or_else(|| {
105            sbom.document
106                .creators
107                .iter()
108                .find(|c| matches!(c.creator_type, crate::model::CreatorType::Organization))
109                .map(|c| c.name.as_str())
110        })
111        .unwrap_or("_TBD: manufacturer name_");
112    let manufacturer_email = sidecar
113        .and_then(|s| s.manufacturer_email.as_deref())
114        .unwrap_or("_TBD: manufacturer email_");
115    let product_name = sidecar
116        .and_then(|s| s.product_name.as_deref())
117        .or(sbom.document.name.as_deref())
118        .unwrap_or("_TBD: product name_");
119    let product_version = sidecar
120        .and_then(|s| s.product_version.as_deref())
121        .unwrap_or("_TBD: product version_");
122    let ce_marking = sidecar
123        .and_then(|s| s.ce_marking_reference.as_deref())
124        .unwrap_or("_TBD: CE marking reference / DoC document ID_");
125
126    // §6 "other Union legislation". When the sidecar flags the product as a
127    // high-risk AI system, name the AI Act explicitly and point the operator at
128    // the Annex IV technical-documentation readiness check; otherwise keep the
129    // hand-completable placeholder.
130    let other_legislation = if sidecar.is_some_and(|s| s.is_high_risk_ai) {
131        "- Regulation (EU) 2024/1689 (AI Act) — high-risk AI system: \
132         draw up Annex IV technical documentation. Run \
133         `sbom-tools validate <sbom> --standard ai-act --cra-sidecar <sidecar>` \
134         for an Annex IV documentation-readiness check (readiness only, not a \
135         legal-conformity guarantee).\n\
136         - _TBD: list any other applicable EU regulations (NIS2, GDPR, …)_"
137    } else {
138        "- _TBD: list any other applicable EU regulations (NIS2, GDPR, AI Act, …)_"
139    };
140
141    format!(
142        "# EU Declaration of Conformity (Cyber Resilience Act, Annex V)\n\n\
143         > **Generated by sbom-tools cra-docs.** Review and complete the `_TBD_` \
144         placeholders before relying on this document for conformity assessment.\n\n\
145         **1. Product**\n\
146         - Name: {product_name}\n\
147         - Version: {product_version}\n\
148         - CE marking / DoC reference: {ce_marking}\n\
149         - CRA product class: {class_name}\n\n\
150         **2. Manufacturer**\n\
151         - Name: {manufacturer}\n\
152         - Contact: {manufacturer_email}\n\
153         - Address: _TBD: registered office address_\n\n\
154         **3. Conformity-assessment route (CRA Annex VIII)**\n\
155         - Route: {route_name}\n\
156         - Notified body (if applicable): _TBD: notified body name + 4-digit number_\n\
157         - Certificate / attestation reference: _TBD_\n\n\
158         **4. Applicable CRA requirements**\n\
159         - Regulation (EU) 2024/2847 — Annex I (Essential cybersecurity requirements)\n\
160         - Annex I Part I (1) — Cybersecurity properties of products with digital elements\n\
161         - Annex I Part I (2) — Vulnerability-handling requirements\n\
162         - Annex II — Information and instructions to the user\n\n\
163         **5. Harmonised standards / common specifications applied**\n\
164         - prEN 40000-1-3 (horizontal SBOM and vulnerability-handling requirements)\n\
165         - BSI TR-03183-2 (German national CRA-aligned SBOM technical guideline)\n\
166         - _TBD: any vertical EN 304-6xx product-class standards applied_\n\n\
167         **6. Other Union legislation in conjunction with which conformity is declared**\n\
168         {other_legislation}\n\n\
169         **7. Signed for and on behalf of the manufacturer**\n\
170         - Place: _TBD_\n\
171         - Date: _TBD_\n\
172         - Name and function: _TBD_\n\
173         - Signature: _TBD_\n",
174        product_name = product_name,
175        product_version = product_version,
176        ce_marking = ce_marking,
177        class_name = class.name(),
178        manufacturer = manufacturer,
179        manufacturer_email = manufacturer_email,
180        route_name = route.name(),
181        other_legislation = other_legislation,
182    )
183}
184
185/// Render the Annex V technical-documentation summary.
186fn render_tech_doc(
187    sbom: &NormalizedSbom,
188    sidecar: Option<&CraSidecarMetadata>,
189    class: CraProductClass,
190    route: ConformityRoute,
191    compliance: &ComplianceResult,
192) -> String {
193    let component_count = sbom.components.len();
194    let dependency_count = sbom.edges.len();
195    let format_label = format!("{:?} {}", sbom.document.format, sbom.document.spec_version);
196    let risk_assessment = sidecar
197        .and_then(|s| s.risk_assessment_url.as_deref())
198        .unwrap_or("_TBD: link to documented risk assessment (CRA Art. 13(2))_");
199    let methodology = sidecar
200        .and_then(|s| s.risk_assessment_methodology.as_deref())
201        .unwrap_or("_TBD: e.g., ISO/IEC 27005:2022_");
202    let psirt = sidecar
203        .and_then(|s| s.psirt_url.as_deref())
204        .unwrap_or("_TBD: PSIRT URL (CRA Art. 14)_");
205    let support_end = sidecar
206        .and_then(|s| s.support_end_date)
207        .map(|d| d.format("%Y-%m-%d").to_string())
208        .unwrap_or_else(|| "_TBD: support end date (CRA Art. 13(8))_".to_string());
209
210    let adjacent_regulation_md = render_adjacent_regulation_section(sidecar);
211    let controls_assertion_md = render_controls_assertion_section(sidecar);
212
213    let mut violations_md = String::new();
214    if compliance.violations.is_empty() {
215        violations_md.push_str("_No CRA compliance issues detected by sbom-tools._\n");
216    } else {
217        let errors = compliance.error_count;
218        let warnings = compliance.warning_count;
219        let infos = compliance.info_count;
220        violations_md.push_str(&format!(
221            "**Compliance check summary** ({errors} errors, {warnings} warnings, {infos} info):\n\n"
222        ));
223        for v in compliance.violations.iter().take(10) {
224            let sev = match v.severity {
225                crate::quality::ViolationSeverity::Error => "ERROR",
226                crate::quality::ViolationSeverity::Warning => "WARN",
227                crate::quality::ViolationSeverity::Info => "INFO",
228            };
229            violations_md.push_str(&format!(
230                "- **[{}] {}** — {}\n",
231                sev, v.requirement, v.message
232            ));
233        }
234        if compliance.violations.len() > 10 {
235            violations_md.push_str(&format!(
236                "- … and {} more findings (see SARIF / JSON output)\n",
237                compliance.violations.len() - 10
238            ));
239        }
240    }
241
242    format!(
243        "# Technical Documentation Summary (CRA Annex V)\n\n\
244         > **Generated by sbom-tools cra-docs.** Use this document as a \
245         starting point; complete the `_TBD_` fields and attach evidence \
246         before submission.\n\n\
247         ## 1. Product description\n\
248         - SBOM format: {format_label}\n\
249         - Components in scope: {component_count}\n\
250         - Declared dependency edges: {dependency_count}\n\
251         - CRA product class: {class_name}\n\
252         - Conformity route: {route_name}\n\n\
253         ## 2. Risk assessment (CRA Art. 13(2))\n\
254         - Risk-assessment document: {risk_assessment}\n\
255         - Methodology: {methodology}\n\
256         - Annex II Part 3 risk-acceptance criteria: _TBD_\n\n\
257         ## 3. Vulnerability-handling process (Annex I Part II)\n\
258         - Process description: see `vulnerability-handling-policy.md` in this dossier\n\
259         - PSIRT: {psirt}\n\
260         - Support / security-update end date: {support_end}\n\n\
261         ## 4. Software Bill of Materials\n\
262         - Embedded SBOM: provided as a separate file in this submission\n\
263         - Generated by sbom-tools v{tool_version}\n\
264         - SBOM serial number: {serial}\n\n\
265         ## 5. Compliance check summary\n\n\
266         {violations_md}\n\
267         ## 6. Test and evaluation reports\n\
268         - Penetration test report: _TBD_\n\
269         - Code review / SAST report: _TBD_\n\
270         - DAST / fuzzing report: _TBD_\n\
271         - Third-party attestation (Module B+C / H / EUCC): _TBD_\n\n\
272         ## 7. Cybersecurity-relevant changes since previous version\n\
273         - _TBD: changelog of security-relevant changes_\n\
274         {controls_assertion_md}\
275         {adjacent_regulation_md}",
276        format_label = format_label,
277        component_count = component_count,
278        dependency_count = dependency_count,
279        class_name = class.name(),
280        route_name = route.name(),
281        risk_assessment = risk_assessment,
282        methodology = methodology,
283        psirt = psirt,
284        support_end = support_end,
285        tool_version = env!("CARGO_PKG_VERSION"),
286        serial = sbom
287            .document
288            .serial_number
289            .as_deref()
290            .unwrap_or("_TBD: assign a unique serial / namespace_"),
291        violations_md = violations_md,
292        controls_assertion_md = controls_assertion_md,
293        adjacent_regulation_md = adjacent_regulation_md,
294    )
295}
296
297/// Render the prEN 40000-1-2/1-4 controls-assertion block (CRA-P5.5).
298/// Empty sidecars or sidecars without `annex_i_part_i_controls` skip the
299/// section entirely so the dossier stays clean for products that don't
300/// claim per-control assertions.
301fn render_controls_assertion_section(sidecar: Option<&CraSidecarMetadata>) -> String {
302    let Some(sc) = sidecar else {
303        return String::new();
304    };
305    if sc.annex_i_part_i_controls.is_empty() {
306        return String::new();
307    }
308    let mut s = String::from("\n## 8. Annex I Part I controls assertion (prEN 40000-1-2/1-4)\n\n");
309    s.push_str(
310        "Per-control assertions for CRA Annex I Part I, sourced from the \
311         sidecar `annex_i_part_i_controls` block. `Satisfied` rows must \
312         carry an evidence URL — un-evidenced claims are flagged by \
313         `sbom-tools validate --standard cra` as Warnings.\n\n",
314    );
315    s.push_str("| Control | Satisfied | Methodology | Evidence | Note |\n");
316    s.push_str("|---------|-----------|-------------|----------|------|\n");
317    for (id, claim) in &sc.annex_i_part_i_controls {
318        let satisfied = if claim.satisfied { "✅" } else { "❌" };
319        let methodology = claim.methodology.as_deref().unwrap_or("_TBD_");
320        let evidence = claim
321            .evidence_url
322            .as_deref()
323            .map(|u| format!("[link]({u})"))
324            .unwrap_or_else(|| "_TBD_".to_string());
325        let note = claim.note.as_deref().unwrap_or("");
326        s.push_str(&format!(
327            "| {id} | {satisfied} | {methodology} | {evidence} | {note} |\n"
328        ));
329    }
330    s.push('\n');
331    s
332}
333
334/// Render the "Adjacent regulation" section (CRA-P4.4). Only fires for
335/// the regulatory overlap flags actually set on the sidecar, so an
336/// SBOM-only dossier (no sidecar) skips the section entirely.
337fn render_adjacent_regulation_section(sidecar: Option<&CraSidecarMetadata>) -> String {
338    let Some(sc) = sidecar else {
339        return String::new();
340    };
341    let any = sc.is_nis2_essential_entity
342        || sc.is_nis2_important_entity
343        || sc.processes_personal_data
344        || sc.is_high_risk_ai
345        || sc.red_repealed_until.is_some();
346    if !any {
347        return String::new();
348    }
349
350    // Numbered after the controls-assertion block so the dossier reads
351    // 7 → 8 → 9 when both are present (controls = §8, adjacent = §9).
352    let mut s = String::from("\n## 9. Adjacent regulation\n\n");
353    s.push_str(
354        "The CRA does not operate in isolation. The following adjacent EU \
355         legal acts apply to this product based on the sidecar declarations \
356         and must be coordinated with CRA conformity assessment.\n\n",
357    );
358
359    if sc.is_nis2_essential_entity || sc.is_nis2_important_entity {
360        let entity_kind = if sc.is_nis2_essential_entity {
361            "essential entity (NIS2 Annex I)"
362        } else {
363            "important entity (NIS2 Annex II)"
364        };
365        s.push_str(&format!(
366            "### NIS2 — Directive (EU) 2022/2555\n\n\
367             - Manufacturer is registered as an **{entity_kind}**.\n\
368             - **Art. 23 incident reporting** runs in parallel with CRA \
369             Art. 14: a 24-hour early warning to the national CSIRT *and* \
370             ENISA, followed by a 72-hour incident notification, and a \
371             1-month final report.\n\
372             - **Art. 21 risk-management measures** overlap with CRA \
373             Annex I Part I and the documented risk assessment in §2 \
374             above.\n\
375             - National competent authority registration (NIS2 Art. 27) \
376             is a precondition for the Art. 23 reporting channels listed \
377             in `vulnerability-handling-policy.md`.\n\n",
378        ));
379    }
380
381    if sc.processes_personal_data {
382        s.push_str(
383            "### GDPR — Regulation (EU) 2016/679\n\n\
384             - The product processes personal data, so **GDPR Art. 32 \
385             (security of processing)** applies alongside CRA Annex I \
386             Part I (1) cybersecurity properties.\n\
387             - Personal-data breaches must additionally be reported to \
388             the supervisory authority under **Art. 33** (within 72 h) \
389             and to data subjects under **Art. 34** when the breach is \
390             likely to result in a high risk.\n\
391             - The CRA technical-documentation set (this dossier) should \
392             cross-reference the Data Protection Impact Assessment \
393             (DPIA) when one has been performed under Art. 35.\n\n",
394        );
395    }
396
397    if sc.is_high_risk_ai {
398        s.push_str(
399            "### AI Act — Regulation (EU) 2024/1689\n\n\
400             - The product is a **high-risk AI system** within the meaning \
401             of the AI Act.\n\
402             - AI-Act conformity assessment runs **in addition to** CRA \
403             Annex VIII; the CE marking covers both regulations \
404             simultaneously and the EU Declaration of Conformity must \
405             list both legal bases.\n\
406             - The post-market monitoring plan (AI Act Art. 72) and \
407             serious-incident reporting (Art. 73) are coordinated with \
408             CRA Art. 14 reporting; use the same channels listed in \
409             `vulnerability-handling-policy.md`.\n\
410             - For an Annex IV technical-documentation **readiness** check \
411             over the AI-BOM (model description, training-data \
412             characteristics, validation metrics), run \
413             `sbom-tools validate <sbom> --standard ai-act --cra-sidecar <sidecar>`. \
414             This is a documentation-readiness aid, not a legal-conformity \
415             guarantee.\n\n",
416        );
417    }
418
419    if let Some(until) = sc.red_repealed_until {
420        s.push_str(&format!(
421            "### Radio Equipment Directive (RED) — Directive 2014/53/EU\n\n\
422             - The cybersecurity provisions in **RED Art. 3(3)(d/e/f)** \
423             apply to this product until **{}** (sidecar field \
424             `red_repealed_until`).\n\
425             - Once superseded by the CRA, RED references in the SBOM / \
426             technical documentation should be retired and replaced with \
427             the matching CRA Annex I requirement.\n\n",
428            until.format("%Y-%m-%d"),
429        ));
430    }
431
432    s
433}
434
435/// Render the Annex I Part II vulnerability-handling policy stub.
436fn render_vuln_policy(sbom: &NormalizedSbom, sidecar: Option<&CraSidecarMetadata>) -> String {
437    let psirt = sidecar
438        .and_then(|s| s.psirt_url.as_deref())
439        .unwrap_or("_TBD: PSIRT URL_");
440    let security_contact = sidecar
441        .and_then(|s| s.security_contact.as_deref())
442        .unwrap_or("_TBD: security contact email_");
443    let cvd_policy = sidecar
444        .and_then(|s| s.coordinated_disclosure_policy_url.as_deref())
445        .unwrap_or("_TBD: coordinated vulnerability-disclosure policy URL_");
446    let early = sidecar
447        .and_then(|s| s.early_warning_contact.as_deref())
448        .unwrap_or("_TBD: 24-hour early-warning channel (CRA Art. 14(1))_");
449    let incident = sidecar
450        .and_then(|s| s.incident_report_contact.as_deref())
451        .unwrap_or("_TBD: 72-hour incident-report channel (CRA Art. 14(2))_");
452    let enisa = sidecar
453        .and_then(|s| s.enisa_reporting_platform_id.as_deref())
454        .unwrap_or("_TBD: ENISA single-reporting-platform manufacturer ID (Art. 14(7))_");
455
456    format!(
457        "# Vulnerability-Handling Policy (CRA Annex I Part II)\n\n\
458         > **Generated by sbom-tools cra-docs.** Replace `_TBD_` placeholders \
459         with your operational details before publishing this document.\n\n\
460         ## 1. Scope\n\
461         This policy applies to all products with digital elements identified in \
462         the accompanying SBOM ({components} components, primary identifier \
463         {primary_id}).\n\n\
464         ## 2. Reporting channels\n\
465         | Channel | Endpoint |\n\
466         |---|---|\n\
467         | PSIRT (public reporting portal) | {psirt} |\n\
468         | Security contact (encrypted email) | {security_contact} |\n\
469         | Coordinated disclosure policy | {cvd_policy} |\n\
470         | 24-hour early warning (CRA Art. 14(1)) | {early} |\n\
471         | 72-hour incident report (CRA Art. 14(2)) | {incident} |\n\
472         | ENISA single reporting platform (Art. 14(7)) | {enisa} |\n\n\
473         ## 3. Process commitments\n\
474         - **Acknowledgement**: we will acknowledge receipt of any vulnerability \
475         report within _TBD_ business days.\n\
476         - **Assessment**: we will perform an initial impact assessment within \
477         _TBD_ business days.\n\
478         - **Disclosure**: we follow ISO/IEC 29147 / 30111 coordinated-disclosure \
479         practice (target embargo: _TBD_).\n\
480         - **Patch availability**: security patches are made available free of \
481         charge for the duration of the support period.\n\n\
482         ## 4. Active-exploitation handling\n\
483         - We monitor CISA KEV, ENISA EU-VDB, and OSV.dev for actively-exploited \
484         vulnerabilities affecting components in our SBOMs.\n\
485         - When an actively-exploited vulnerability is confirmed, the early-warning \
486         channel above is engaged within 24 hours and a CSAF v2.0 advisory is \
487         published as soon as a remediation or mitigation is available.\n\n\
488         ## 5. SBOM update commitment\n\
489         - The SBOM accompanying this product is regenerated and re-signed on \
490         every release that introduces, removes, or upgrades a tracked \
491         component (CRA Art. 13(3)).\n\
492         - VEX statements (CSAF v2.0 / OpenVEX / CycloneDX VEX) are published \
493         alongside the SBOM whenever a vulnerability affecting a tracked \
494         component is acknowledged.\n",
495        components = sbom.components.len(),
496        primary_id = sbom
497            .primary_component_id
498            .as_ref()
499            .map(|c| c.value().to_string())
500            .unwrap_or_else(|| "_TBD: primary component ID_".to_string()),
501        psirt = psirt,
502        security_contact = security_contact,
503        cvd_policy = cvd_policy,
504        early = early,
505        incident = incident,
506        enisa = enisa,
507    )
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::model::Component;
514    use tempfile::tempdir;
515
516    #[test]
517    fn dossier_files_created_for_minimal_sbom() {
518        let dir = tempdir().unwrap();
519        let sbom_path = dir.path().join("app.cdx.json");
520        std::fs::write(
521            &sbom_path,
522            r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
523        )
524        .unwrap();
525        let out = dir.path().join("dossier");
526
527        run_cra_docs(sbom_path, out.clone(), None, None).expect("cra-docs runs");
528
529        assert!(out.join("eu-declaration-of-conformity.md").exists());
530        assert!(out.join("technical-documentation.md").exists());
531        assert!(out.join("vulnerability-handling-policy.md").exists());
532    }
533
534    #[test]
535    fn doc_template_is_filled_from_sidecar() {
536        let mut sbom = NormalizedSbom::default();
537        sbom.add_component(Component::new("c".to_string(), "c".to_string()));
538        let sidecar = CraSidecarMetadata {
539            manufacturer_name: Some("ExCorp".to_string()),
540            manufacturer_email: Some("legal@example.com".to_string()),
541            product_name: Some("ExProduct".to_string()),
542            product_version: Some("1.0".to_string()),
543            ce_marking_reference: Some("EU-DoC-2026-001".to_string()),
544            ..Default::default()
545        };
546        let doc = render_doc(
547            &sbom,
548            Some(&sidecar),
549            CraProductClass::ImportantClass1,
550            ConformityRoute::ModuleA,
551        );
552        assert!(doc.contains("ExCorp"));
553        assert!(doc.contains("legal@example.com"));
554        assert!(doc.contains("ExProduct"));
555        assert!(doc.contains("EU-DoC-2026-001"));
556        assert!(doc.contains("Important Class I"));
557        assert!(doc.contains("Module A"));
558    }
559
560    #[test]
561    fn vuln_policy_filled_from_sidecar() {
562        let sbom = NormalizedSbom::default();
563        let sidecar = CraSidecarMetadata {
564            psirt_url: Some("https://example.com/psirt".to_string()),
565            security_contact: Some("security@example.com".to_string()),
566            coordinated_disclosure_policy_url: Some("https://example.com/security/cvd".to_string()),
567            early_warning_contact: Some("ew@example.com".to_string()),
568            incident_report_contact: Some("incidents@example.com".to_string()),
569            enisa_reporting_platform_id: Some("EU-MFR-1".to_string()),
570            ..Default::default()
571        };
572        let policy = render_vuln_policy(&sbom, Some(&sidecar));
573        assert!(policy.contains("https://example.com/psirt"));
574        assert!(policy.contains("security@example.com"));
575        assert!(policy.contains("https://example.com/security/cvd"));
576        assert!(policy.contains("ew@example.com"));
577        assert!(policy.contains("incidents@example.com"));
578        assert!(policy.contains("EU-MFR-1"));
579    }
580
581    #[test]
582    fn tech_doc_includes_compliance_summary() {
583        let mut sbom = NormalizedSbom::default();
584        sbom.add_component(Component::new("c".to_string(), "c".to_string()));
585        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
586        let tech = render_tech_doc(
587            &sbom,
588            None,
589            CraProductClass::Default,
590            ConformityRoute::ModuleA,
591            &result,
592        );
593        // should mention component count + compliance summary header
594        assert!(tech.contains("Components in scope: 1"));
595        assert!(tech.contains("Compliance check summary"));
596    }
597}