1use anyhow::Result;
15use serde::Serialize;
16use std::time::Duration;
17
18#[derive(Debug, Clone, Serialize)]
20pub struct TrackedStandard {
21 pub id: &'static str,
22 pub title: &'static str,
23 pub body: &'static str,
24 pub status: &'static str,
25 pub last_known_version: &'static str,
26 pub last_known_date: &'static str,
27 pub url: &'static str,
28 pub watch_reason: &'static str,
29}
30
31pub fn cra_catalogue() -> &'static [TrackedStandard] {
35 CATALOGUE
36}
37
38pub fn probe_cra_standards(entries: &[TrackedStandard], timeout: Duration) -> Vec<OnlineProbe> {
42 probe_urls(entries, timeout)
43}
44
45const CATALOGUE: &[TrackedStandard] = &[
49 TrackedStandard {
50 id: "prEN-40000-1-3",
51 title: "prEN 40000-1-3 — SBOM and vulnerability-handling requirements",
52 body: "CEN-CENELEC JTC 13",
53 status: "Draft (not freely available)",
54 last_known_version: "Draft",
55 last_known_date: "2025-Q4",
56 url: "https://www.cencenelec.eu/areas-of-work/cen-cenelec-topics/cybersecurity-and-data-protection/",
57 watch_reason: "Normative requirement IDs (PRE-7-RQ-*, PRE-8-RQ-*, RLS-2-RQ-*) referenced by sbom-tools",
58 },
59 TrackedStandard {
60 id: "prEN-40000-1-2",
61 title: "prEN 40000-1-2 — Cybersecurity properties (Annex I Part I)",
62 body: "CEN-CENELEC JTC 13",
63 status: "Draft (not freely available)",
64 last_known_version: "Draft",
65 last_known_date: "2025-Q4",
66 url: "https://www.cencenelec.eu/areas-of-work/cen-cenelec-topics/cybersecurity-and-data-protection/",
67 watch_reason: "Drives Annex I Part I controls-assertion sidecar block (CRA-P5.5)",
68 },
69 TrackedStandard {
70 id: "BSI-TR-03183-2",
71 title: "BSI TR-03183-2 — Technical Guideline (national CRA-aligned SBOM)",
72 body: "BSI (Germany)",
73 status: "Public",
74 last_known_version: "2.0.0",
75 last_known_date: "2024-09",
76 url: "https://www.bsi.bund.de/EN/Themen/Unternehmen-und-Organisationen/Standards-und-Zertifizierung/Technische-Richtlinien/TR-nach-Thema-sortiert/tr03183/TR-03183_node.html",
77 watch_reason: "Free, ENISA-cited; sbom-tools `--standard bsi` runs §5/§6 checks",
78 },
79 TrackedStandard {
80 id: "CSAF-v2.0",
81 title: "CSAF v2.0 — Common Security Advisory Framework (ISO/IEC 20153:2025)",
82 body: "OASIS / ISO",
83 status: "Final",
84 last_known_version: "2.0",
85 last_known_date: "2022-11",
86 url: "https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html",
87 watch_reason: "Advisory format named in CRA prEN 40000-1-3 [RLS-2-RQ-03-RE]",
88 },
89 TrackedStandard {
90 id: "ENISA-SBOM-Guidance",
91 title: "ENISA SBOM Implementation Guidance",
92 body: "ENISA",
93 status: "Public",
94 last_known_version: "v1.0",
95 last_known_date: "2024",
96 url: "https://www.enisa.europa.eu/publications/sbom-implementation-guidance",
97 watch_reason: "ENISA's reference for CRA-aligned SBOM practice",
98 },
99 TrackedStandard {
100 id: "EUCC",
101 title: "EUCC scheme — Common Criteria (Reg. (EU) 2024/482)",
102 body: "European Commission / ENISA",
103 status: "Final",
104 last_known_version: "Reg. 2024/482",
105 last_known_date: "2024-01-31",
106 url: "https://eur-lex.europa.eu/eli/reg/2024/482/oj/eng",
107 watch_reason: "Mandatory for CRA Annex IV (Critical) products",
108 },
109 TrackedStandard {
110 id: "STAN4CRA",
111 title: "STAN4CRA — CEN-CENELEC standardisation hub for CRA",
112 body: "CEN-CENELEC",
113 status: "Hub",
114 last_known_version: "Live",
115 last_known_date: "n/a",
116 url: "https://www.stan4cra.eu/",
117 watch_reason: "Aggregates harmonised standards under the CRA mandate",
118 },
119 TrackedStandard {
120 id: "ETSI-EN-303-6xx",
121 title: "ETSI EN 303 6xx — vertical product-class cybersecurity",
122 body: "ETSI TC CYBER",
123 status: "Mixed",
124 last_known_version: "Various",
125 last_known_date: "ongoing",
126 url: "https://docbox.etsi.org/CYBER/EUSR/Open/",
127 watch_reason: "Product-class verticals (browsers, AV, OS, password managers) under CRA",
128 },
129];
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum WatchOutputFormat {
134 Table,
135 Json,
136}
137
138impl WatchOutputFormat {
139 pub fn parse(s: &str) -> anyhow::Result<Self> {
140 match s.to_lowercase().as_str() {
141 "table" | "auto" => Ok(Self::Table),
142 "json" => Ok(Self::Json),
143 other => anyhow::bail!("Unsupported format '{other}'. Valid: table, json"),
144 }
145 }
146}
147
148pub fn run_cra_standards_watch(
150 format: WatchOutputFormat,
151 check_online: bool,
152 timeout_secs: u64,
153) -> Result<()> {
154 let entries = CATALOGUE.to_vec();
155 let online_status = if check_online {
156 Some(probe_urls(&entries, Duration::from_secs(timeout_secs)))
157 } else {
158 None
159 };
160
161 match format {
162 WatchOutputFormat::Json => {
163 let payload = serde_json::json!({
164 "tool": "sbom-tools",
165 "version": env!("CARGO_PKG_VERSION"),
166 "catalogue": entries,
167 "online_status": online_status,
168 });
169 println!("{}", serde_json::to_string_pretty(&payload)?);
170 }
171 WatchOutputFormat::Table => {
172 println!("CRA standards watch — last-known versions");
173 println!("{}", "=".repeat(60));
174 for s in &entries {
175 println!("\n[{}] {}", s.id, s.title);
176 println!(" Body: {}", s.body);
177 println!(" Status: {}", s.status);
178 println!(
179 " Version: {} ({})",
180 s.last_known_version, s.last_known_date
181 );
182 println!(" URL: {}", s.url);
183 println!(" Watch: {}", s.watch_reason);
184 if let Some(ref probes) = online_status
185 && let Some(probe) = probes.iter().find(|p| p.id == s.id)
186 {
187 println!(" HTTP status: {}", probe.status);
188 }
189 }
190 println!();
191 println!(
192 "Catalogue is curated and shipped with sbom-tools v{}; \
193 update via PR when standards bodies publish new versions.",
194 env!("CARGO_PKG_VERSION")
195 );
196 }
197 }
198 Ok(())
199}
200
201#[derive(Debug, Clone, Serialize)]
205pub struct OnlineProbe {
206 pub id: &'static str,
207 pub status: String,
208}
209
210fn probe_urls(entries: &[TrackedStandard], timeout: Duration) -> Vec<OnlineProbe> {
214 #[cfg(feature = "enrichment")]
215 {
216 let client = reqwest::blocking::Client::builder()
217 .timeout(timeout)
218 .user_agent(concat!("sbom-tools/", env!("CARGO_PKG_VERSION")))
219 .build();
220 let Ok(client) = client else {
221 return entries
222 .iter()
223 .map(|s| OnlineProbe {
224 id: s.id,
225 status: "client-init-failed".to_string(),
226 })
227 .collect();
228 };
229 entries
230 .iter()
231 .map(|s| {
232 let status = match client.head(s.url).send() {
233 Ok(resp) => format!("{}", resp.status()),
234 Err(e) => format!("error: {e}"),
235 };
236 OnlineProbe { id: s.id, status }
237 })
238 .collect()
239 }
240 #[cfg(not(feature = "enrichment"))]
241 {
242 let _ = timeout;
243 entries
244 .iter()
245 .map(|s| OnlineProbe {
246 id: s.id,
247 status: "online-checks require the 'enrichment' feature".to_string(),
248 })
249 .collect()
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn catalogue_has_no_empty_fields() {
259 for s in CATALOGUE {
260 assert!(!s.id.is_empty(), "catalogue id must not be empty");
261 assert!(!s.title.is_empty(), "catalogue title must not be empty");
262 assert!(
263 s.url.starts_with("https://"),
264 "catalogue URL must be https: {}",
265 s.url
266 );
267 }
268 }
269
270 #[test]
271 fn catalogue_ids_are_unique() {
272 let mut seen = std::collections::HashSet::new();
273 for s in CATALOGUE {
274 assert!(seen.insert(s.id), "duplicate catalogue id: {}", s.id);
275 }
276 }
277
278 #[test]
279 fn catalogue_covers_core_artefacts() {
280 let ids: std::collections::HashSet<&str> = CATALOGUE.iter().map(|s| s.id).collect();
281 for required in [
282 "prEN-40000-1-3",
283 "BSI-TR-03183-2",
284 "CSAF-v2.0",
285 "EUCC",
286 "STAN4CRA",
287 ] {
288 assert!(ids.contains(required), "catalogue must include {required}");
289 }
290 }
291
292 #[test]
293 fn output_format_parser_is_strict() {
294 assert!(matches!(
295 WatchOutputFormat::parse("table").unwrap(),
296 WatchOutputFormat::Table
297 ));
298 assert!(matches!(
299 WatchOutputFormat::parse("json").unwrap(),
300 WatchOutputFormat::Json
301 ));
302 assert!(WatchOutputFormat::parse("xml").is_err());
303 }
304}