Skip to main content

ssg/plugins/
sbom.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Build-time SBOM generation (issue #457).
5//!
6//! Emits a `CycloneDX` 1.5 JSON Software Bill of Materials at the
7//! root of the generated site (`sbom.cdx.json`) and links to it
8//! from every HTML page via `<link rel="sbom" type="application/vnd.cyclonedx+json">`.
9//!
10//! # Why ship an SBOM with the static site?
11//!
12//! Procurement teams in regulated industries (finance, healthcare,
13//! government) increasingly require SBOMs for any deployed software
14//! — including the build pipeline that produced static assets. The
15//! `scheduled.yml` workflow already generates a `CycloneDX` SBOM via
16//! `cargo cyclonedx` and attaches a Sigstore provenance attestation,
17//! but those artifacts live in CI; they're not discoverable from
18//! the deployed site. This plugin fixes that gap by **embedding**
19//! the SBOM into every site, making the supply chain machine-
20//! introspectable from the consumer's browser.
21//!
22//! # Format
23//!
24//! Minimal `CycloneDX` 1.5 (the JSON Schema is documented at
25//! <https://cyclonedx.org/docs/1.5/json/>). The component list
26//! covers the SSG package itself; transitive Cargo dependencies
27//! are out of scope here (they're in the CI-generated SBOM
28//! published as a release artifact). The rendered SBOM declares:
29//!
30//! - `bomFormat`: "`CycloneDX`"
31//! - `specVersion`: "1.5"
32//! - `version`: 1
33//! - `metadata.timestamp`: build time (ISO 8601, UTC)
34//! - `metadata.tools[]`: SSG name + version
35//! - `metadata.component`: the site itself (type: "application")
36//! - `components[]`: SSG generator
37//!
38//! # Discoverability
39//!
40//! Every HTML page emitted by the build receives a
41//! `<link rel="sbom" type="application/vnd.cyclonedx+json"
42//!  href="<base-url-path>/sbom.cdx.json">` element in `<head>`.
43//! This is the
44//! IANA-registered link relation for SBOM discovery (registered
45//! 2023; see <https://www.iana.org/assignments/link-relations/>).
46//!
47//! # Idempotency
48//!
49//! The HTML transform is idempotent — pages that already contain
50//! `rel="sbom"` are left unchanged. The JSON file is rewritten on
51//! every build (so timestamps stay current).
52
53use crate::error::{PathErrorExt, SsgError};
54use crate::plugin::{Plugin, PluginContext};
55use crate::util::head_dom::inject_before_head_close;
56use std::fs;
57use std::path::Path;
58
59/// Plugin that emits a `CycloneDX` SBOM and links to it from every
60/// HTML page.
61#[derive(Debug, Clone, Copy, Default)]
62pub struct SbomPlugin;
63
64impl SbomPlugin {
65    /// Returns the relative path of the SBOM file under `site_dir`.
66    ///
67    /// # Examples
68    ///
69    /// ```rust
70    /// use ssg::sbom::SbomPlugin;
71    ///
72    /// assert_eq!(SbomPlugin::sbom_path(), "sbom.cdx.json");
73    /// ```
74    pub const fn sbom_path() -> &'static str {
75        "sbom.cdx.json"
76    }
77}
78
79impl Plugin for SbomPlugin {
80    fn name(&self) -> &'static str {
81        "sbom"
82    }
83
84    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
85        if !ctx.site_dir.exists() {
86            return Ok(());
87        }
88        let sbom = build_sbom();
89        let path = ctx.site_dir.join(Self::sbom_path());
90        let json = serialize_sbom(&sbom).map_err(|e| SsgError::Io {
91            path: path.clone(),
92            source: std::io::Error::other(e),
93        })?;
94        fs::write(&path, json).with_path(&path)?;
95        log::info!("[sbom] Wrote CycloneDX SBOM to {}", path.display());
96        Ok(())
97    }
98
99    fn has_transform(&self) -> bool {
100        true
101    }
102
103    fn transform_html(
104        &self,
105        html: &str,
106        _path: &Path,
107        ctx: &PluginContext,
108    ) -> Result<String, SsgError> {
109        // Idempotent: skip if an SBOM link is already present.
110        if html.contains("rel=\"sbom\"") || html.contains("rel='sbom'") {
111            return Ok(html.to_string());
112        }
113        // The SBOM sits at the *site* root, which is only the server root
114        // when `base_url` has no path. On a project site served from a
115        // sub-path, a bare `/sbom.cdx.json` points at the domain apex and
116        // 404s, so carry the prefix across.
117        let link = format!(
118            "<link rel=\"sbom\" type=\"application/vnd.cyclonedx+json\" \
119             href=\"{}/{}\">\n",
120            ctx.config.as_ref().map_or_else(String::new, |c| {
121                crate::plugins_group::csp::base_url_path_prefix(&c.base_url)
122            }),
123            Self::sbom_path()
124        );
125        Ok(inject_before_head_close(html, &link))
126    }
127}
128
129/// Builds the minimal `CycloneDX` 1.5 SBOM document for this site.
130fn build_sbom() -> serde_json::Value {
131    let now = current_iso_timestamp();
132    let ssg_version = env!("CARGO_PKG_VERSION");
133    serde_json::json!({
134        "bomFormat": "CycloneDX",
135        "specVersion": "1.5",
136        "version": 1,
137        "metadata": {
138            "timestamp": now,
139            "tools": [{
140                "vendor": "SSG Contributors",
141                "name": "ssg",
142                "version": ssg_version,
143            }],
144            "component": {
145                "type": "application",
146                "bom-ref": "site",
147                "name": "static-site",
148                "description": "Site generated by SSG",
149            }
150        },
151        "components": [{
152            "type": "application",
153            "bom-ref": format!("ssg@{ssg_version}"),
154            "name": "ssg",
155            "version": ssg_version,
156            "description": "Static site generator",
157            "purl": format!("pkg:cargo/ssg@{ssg_version}"),
158            "licenses": [
159                {"license": {"id": "MIT"}},
160                {"license": {"id": "Apache-2.0"}}
161            ],
162            "externalReferences": [
163                {"type": "vcs", "url": "https://github.com/sebastienrousseau/static-site-generator"},
164                {"type": "documentation", "url": "https://docs.rs/ssg"}
165            ]
166        }]
167    })
168}
169
170/// Serialize the SBOM with a fault-injection hook so tests can drive
171/// the error branch (pretty-printing a `Value` built from hardcoded
172/// strings and numbers cannot fail in practice).
173fn serialize_sbom(sbom: &serde_json::Value) -> serde_json::Result<String> {
174    fail_point!("sbom::serialize", |_| Err(
175        <serde_json::Error as serde::ser::Error>::custom(
176            "injected: sbom::serialize"
177        )
178    ));
179    serde_json::to_string_pretty(sbom)
180}
181
182/// Cheap ISO 8601 timestamp without pulling in a date crate.
183/// Uses `std::time::SystemTime` and converts `UNIX_EPOCH` seconds to
184/// `YYYY-MM-DDTHH:MM:SSZ` via the proleptic Gregorian calendar.
185///
186/// Reproducible builds (SECURITY.md convention, determinism.yml CI
187/// gate): a wall-clock timestamp makes `sbom.cdx.json` differ across
188/// otherwise-identical builds, so `SOURCE_DATE_EPOCH` wins when set —
189/// same convention as `postprocess::sbom::current_timestamp`.
190fn current_iso_timestamp() -> String {
191    use std::time::{SystemTime, UNIX_EPOCH};
192    if let Ok(epoch) = std::env::var("SOURCE_DATE_EPOCH") {
193        if let Ok(secs) = epoch.trim().parse::<u64>() {
194            return epoch_to_iso(secs);
195        }
196    }
197    let secs = SystemTime::now()
198        .duration_since(UNIX_EPOCH)
199        .map_or(0, |d| d.as_secs());
200    epoch_to_iso(secs)
201}
202
203/// Converts seconds since UNIX epoch to ISO 8601 `YYYY-MM-DDTHH:MM:SSZ`.
204fn epoch_to_iso(secs: u64) -> String {
205    // Days since 1970-01-01 + seconds within day.
206    let days = secs / 86_400;
207    let sec_in_day = secs % 86_400;
208    let hour = (sec_in_day / 3600) as u32;
209    let minute = ((sec_in_day % 3600) / 60) as u32;
210    let second = (sec_in_day % 60) as u32;
211
212    // Convert `days` to YYYY-MM-DD via proleptic Gregorian rules.
213    // Algorithm from Howard Hinnant's date library (public domain).
214    let z = days as i64 + 719_468;
215    let era = z.div_euclid(146_097);
216    let doe = (z - era * 146_097) as u64;
217    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
218    let y = (yoe as i64) + era * 400;
219    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
220    let mp = (5 * doy + 2) / 153;
221    let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
222    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
223    let year = if month <= 2 { y + 1 } else { y };
224
225    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::cmd::SsgConfig;
232    use std::path::Path;
233    use tempfile::tempdir;
234
235    #[test]
236    fn epoch_to_iso_handles_unix_epoch() {
237        assert_eq!(epoch_to_iso(0), "1970-01-01T00:00:00Z");
238    }
239
240    #[test]
241    fn epoch_to_iso_handles_known_timestamps() {
242        // 1700000000 = 2023-11-14 22:13:20 UTC
243        assert_eq!(epoch_to_iso(1_700_000_000), "2023-11-14T22:13:20Z");
244        // 1577836800 = 2020-01-01 00:00:00 UTC
245        assert_eq!(epoch_to_iso(1_577_836_800), "2020-01-01T00:00:00Z");
246    }
247
248    #[test]
249    #[serial_test::serial(source_date_epoch)]
250    fn current_iso_timestamp_honours_source_date_epoch() {
251        // determinism.yml gate: SOURCE_DATE_EPOCH must pin this SBOM's
252        // timestamp too — crate::sbom::SbomPlugin runs after (and
253        // overwrites the output of) postprocess::SbomPlugin, so this
254        // is the timestamp that actually survives into sbom.cdx.json.
255        let prev = std::env::var("SOURCE_DATE_EPOCH").ok();
256        std::env::set_var("SOURCE_DATE_EPOCH", "1700000000");
257        let pinned = current_iso_timestamp();
258        std::env::set_var("SOURCE_DATE_EPOCH", "not-a-number");
259        let fallback = current_iso_timestamp();
260        match prev {
261            Some(v) => std::env::set_var("SOURCE_DATE_EPOCH", v),
262            None => std::env::remove_var("SOURCE_DATE_EPOCH"),
263        }
264        assert_eq!(pinned, "2023-11-14T22:13:20Z");
265        // Unparseable epoch falls back to wall clock — assert only the
266        // shape so the test never depends on today's date.
267        assert!(fallback.ends_with('Z') && fallback.len() == 20);
268    }
269
270    #[test]
271    fn build_sbom_includes_required_cyclonedx_fields() {
272        let sbom = build_sbom();
273        assert_eq!(sbom["bomFormat"], "CycloneDX");
274        assert_eq!(sbom["specVersion"], "1.5");
275        assert_eq!(sbom["version"], 1);
276        assert!(sbom["metadata"]["timestamp"].as_str().is_some());
277        assert!(sbom["metadata"]["tools"].as_array().is_some());
278        let components = sbom["components"].as_array().unwrap();
279        assert!(!components.is_empty());
280        // Every component must have a name and a purl.
281        for c in components {
282            assert!(c["name"].as_str().is_some());
283            assert!(c["purl"].as_str().is_some());
284        }
285    }
286
287    #[test]
288    #[serial_test::parallel]
289    fn sbom_plugin_writes_file_after_compile() {
290        let dir = tempdir().unwrap();
291        let site = dir.path().join("site");
292        fs::create_dir_all(&site).unwrap();
293        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
294        SbomPlugin.after_compile(&ctx).unwrap();
295        let sbom_file = site.join(SbomPlugin::sbom_path());
296        assert!(sbom_file.exists());
297        let body = fs::read_to_string(&sbom_file).unwrap();
298        assert!(body.contains("\"CycloneDX\""));
299        assert!(body.contains("\"specVersion\": \"1.5\""));
300    }
301
302    /// The SBOM sits at the site root, which is only the server root when
303    /// `base_url` has no path. On a project site a bare `/sbom.cdx.json`
304    /// points at the domain apex and 404s on every page that carries it.
305    #[test]
306    fn sbom_link_carries_the_base_url_path_prefix() {
307        let dir = tempdir().unwrap();
308        let config = SsgConfig::builder()
309            .base_url("https://example.com/ssg-themes.github.io/apex".into())
310            .build()
311            .unwrap();
312        let ctx = PluginContext::with_config(
313            dir.path(),
314            dir.path(),
315            dir.path(),
316            dir.path(),
317            config,
318        );
319        let out = SbomPlugin
320            .transform_html(
321                "<html><head></head><body></body></html>",
322                Path::new("x.html"),
323                &ctx,
324            )
325            .unwrap();
326        assert!(
327            out.contains(r#"href="/ssg-themes.github.io/apex/sbom.cdx.json""#),
328            "{out}"
329        );
330    }
331
332    #[test]
333    fn sbom_plugin_injects_link_into_head() {
334        let dir = tempdir().unwrap();
335        let ctx =
336            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
337        let html = "<html><head><title>x</title></head><body></body></html>";
338        let out = SbomPlugin
339            .transform_html(html, Path::new("x.html"), &ctx)
340            .unwrap();
341        assert!(out.contains("rel=\"sbom\""));
342        assert!(out.contains("application/vnd.cyclonedx+json"));
343        assert!(out.contains("href=\"/sbom.cdx.json\""));
344    }
345
346    #[test]
347    fn sbom_plugin_is_idempotent() {
348        let dir = tempdir().unwrap();
349        let ctx =
350            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
351        let html = r#"<html><head><link rel="sbom" type="application/vnd.cyclonedx+json" href="/sbom.cdx.json"></head><body></body></html>"#;
352        let out = SbomPlugin
353            .transform_html(html, Path::new("x.html"), &ctx)
354            .unwrap();
355        assert_eq!(out, html);
356    }
357
358    #[test]
359    fn sbom_plugin_is_idempotent_with_single_quoted_attribute() {
360        // Covers the `rel='sbom'` disjunct of the idempotency check —
361        // every other test only exercises the double-quoted form.
362        let dir = tempdir().unwrap();
363        let ctx =
364            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
365        let html = r"<html><head><link rel='sbom' type='application/vnd.cyclonedx+json' href='/sbom.cdx.json'></head><body></body></html>";
366        let out = SbomPlugin
367            .transform_html(html, Path::new("x.html"), &ctx)
368            .unwrap();
369        assert_eq!(out, html);
370    }
371
372    #[test]
373    fn sbom_plugin_skips_pages_without_head_tag() {
374        let dir = tempdir().unwrap();
375        let ctx =
376            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
377        let html = "<p>orphan content with no head</p>";
378        let out = SbomPlugin
379            .transform_html(html, Path::new("x.html"), &ctx)
380            .unwrap();
381        assert_eq!(out, html);
382    }
383
384    #[test]
385    #[serial_test::parallel]
386    fn sbom_plugin_after_compile_noop_when_site_missing() {
387        let dir = tempdir().unwrap();
388        let missing = dir.path().join("missing");
389        let ctx =
390            PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
391        SbomPlugin.after_compile(&ctx).unwrap();
392        assert!(!missing.exists());
393    }
394
395    #[test]
396    fn sbom_path_constant() {
397        assert_eq!(SbomPlugin::sbom_path(), "sbom.cdx.json");
398    }
399
400    #[test]
401    #[serial_test::parallel]
402    fn after_compile_write_failure_returns_io_error() {
403        let dir = tempdir().unwrap();
404        let site = dir.path().join("site");
405        fs::create_dir_all(&site).unwrap();
406
407        // Create a directory where the SBOM is expected to be written, causing fs::write to fail.
408        let sbom_dir = site.join(SbomPlugin::sbom_path());
409        fs::create_dir(&sbom_dir).unwrap();
410
411        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
412        let res = SbomPlugin.after_compile(&ctx);
413        assert!(res.is_err());
414        let err = res.unwrap_err();
415        assert!(
416            matches!(err, SsgError::Io { ref path, .. } if path == &sbom_dir)
417        );
418    }
419}
420
421#[cfg(all(test, feature = "test-fault-injection"))]
422mod fault_tests {
423    use super::*;
424    use crate::plugin::PluginContext;
425    use serial_test::serial;
426    use tempfile::tempdir;
427
428    /// RAII guard that disables a failpoint on drop.
429    struct FailGuard(&'static str);
430
431    impl Drop for FailGuard {
432        fn drop(&mut self) {
433            let _ = fail::cfg(self.0, "off");
434        }
435    }
436
437    #[test]
438    #[serial]
439    fn after_compile_maps_serialize_failure_to_io_error() {
440        // `serde_json::to_string_pretty` on the hardcoded `build_sbom()`
441        // literal cannot fail in practice, so the only way to exercise
442        // `after_compile`'s serialize-error branch is fault injection.
443        let _guard = FailGuard("sbom::serialize");
444        fail::cfg("sbom::serialize", "return").expect("activate failpoint");
445
446        let dir = tempdir().unwrap();
447        let site = dir.path().join("site");
448        fs::create_dir_all(&site).unwrap();
449        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
450
451        let err = SbomPlugin
452            .after_compile(&ctx)
453            .expect_err("injected serialize failure must propagate");
454        let msg = format!("{err}");
455        assert!(msg.contains("sbom.cdx.json"), "got: {msg}");
456        assert!(msg.contains("injected: sbom::serialize"), "got: {msg}");
457    }
458}