Skip to main content

sbom_tools/cli/
cra_standards_watch.rs

1//! `cra-standards-watch` command handler.
2//!
3//! Curated, offline-first list of CRA-related standards bodies and their
4//! tracked artefacts (prEN 40000 series, BSI TR-03183, ETSI EN 303 6xx,
5//! STAN4CRA hub). The command prints the catalogue with last-known
6//! version dates so operators can spot-check freshness without firing
7//! HTTP requests; an optional `--check-online` flag follows the URLs
8//! and reports HTTP status codes (best-effort, may be rate-limited).
9//!
10//! Out of scope: scraping working-group draft contents (paywalled), and
11//! parsing PDF version histories. The CLI is informational — it does not
12//! mutate any project state.
13
14use anyhow::Result;
15use serde::Serialize;
16use std::time::Duration;
17
18/// One tracked artefact in the CRA standards landscape.
19#[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
31/// Borrow the curated catalogue. Watch-loop integration probes these URLs
32/// on a configurable interval and surfaces status drift through
33/// [`crate::watch::alerts::AlertSink`].
34pub fn cra_catalogue() -> &'static [TrackedStandard] {
35    CATALOGUE
36}
37
38/// Probe each entry's URL with `timeout` and return the resulting
39/// [`OnlineProbe`] list. Without the `enrichment` feature the probes are
40/// returned with a static "feature disabled" status string.
41pub fn probe_cra_standards(entries: &[TrackedStandard], timeout: Duration) -> Vec<OnlineProbe> {
42    probe_urls(entries, timeout)
43}
44
45/// Curated catalogue. Update entries here when standards bodies publish a
46/// new draft or final version. Dates are last-confirmed by hand; the
47/// command does not mutate this list at runtime.
48const 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/// Output format for `cra-standards-watch`.
132#[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
148/// Run the `cra-standards-watch` command.
149pub 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/// Result of a single URL probe against the CRA standards catalogue.
202/// `status` is a verbatim HTTP status string (e.g. "200 OK") or an
203/// error description prefixed with `error:`.
204#[derive(Debug, Clone, Serialize)]
205pub struct OnlineProbe {
206    pub id: &'static str,
207    pub status: String,
208}
209
210/// Fire HEAD requests at each catalogue URL with the supplied timeout.
211/// Best-effort — many of these endpoints don't accept HEAD; we report
212/// the resulting status string verbatim. No retries, no caching.
213fn 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}