1use 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#[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 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 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 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
95fn 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 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
185fn 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
297fn 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
334fn 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 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
435fn 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 assert!(tech.contains("Components in scope: 1"));
595 assert!(tech.contains("Compliance check summary"));
596 }
597}