Skip to main content

ssg/audit/gates/
pqc_tls.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Post-quantum-crypto TLS readiness gate (depends on E6 — issue #550).
5//!
6//! Inspects the deploy adapter output for `_headers` (Cloudflare /
7//! Netlify) or `wrangler.toml` and asserts the TLS+HSTS posture
8//! required for PQC roll-forward:
9//! - TLS 1.3 declared (`min-version`, `tls-1.3` etc.).
10//! - HSTS header present with `max-age >= 31536000` (one year).
11//!
12//! When neither file exists (the common case before E6 merges) the
13//! gate emits a single *info* finding noting the skip rather than
14//! failing — exactly as the issue body specifies.
15
16use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
17use std::fs;
18
19const NAME: &str = "pqc_tls";
20const HSTS_MIN_AGE: u64 = 31_536_000; // 1 year
21
22/// PQC TLS readiness gate.
23///
24/// # Examples
25///
26/// ```
27/// use ssg::audit::AuditGate;
28/// use ssg::audit::gates::pqc_tls::PqcTlsGate;
29/// assert_eq!(PqcTlsGate.name(), "pqc_tls");
30/// ```
31#[derive(Debug, Clone, Copy)]
32pub struct PqcTlsGate;
33
34impl AuditGate for PqcTlsGate {
35    fn name(&self) -> &'static str {
36        NAME
37    }
38
39    fn explain(&self) -> &'static str {
40        "Inspects _headers / wrangler.toml emitted by the deploy \
41         adapter (E6) for TLS 1.3 + HSTS posture: max-age must be \
42         >= 31536000 (one year). When neither file exists, the gate \
43         emits an info note and skips — it upgrades to enforcement \
44         once issue #550 (E6) merges and the files appear."
45    }
46
47    fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
48        let mut findings = Vec::new();
49
50        let headers_path = site.root.join("_headers");
51        let wrangler_path = site.root.join("wrangler.toml");
52        let parent_wrangler =
53            site.root.parent().map(|p| p.join("wrangler.toml"));
54
55        let has_headers = headers_path.exists();
56        let has_wrangler = wrangler_path.exists()
57            || parent_wrangler.as_ref().is_some_and(|p| p.exists());
58
59        if !has_headers && !has_wrangler {
60            findings.push(
61                Finding::new(
62                    NAME,
63                    Severity::Info,
64                    "No _headers or wrangler.toml found; gate skipped (depends on E6 — issue #550)",
65                )
66                .with_code("PQC-INPUT-MISSING"),
67            );
68            return findings;
69        }
70
71        if let Ok(text) = fs::read_to_string(&headers_path) {
72            check_text(&text, "_headers", &mut findings);
73        }
74        if let Ok(text) = fs::read_to_string(&wrangler_path) {
75            check_text(&text, "wrangler.toml", &mut findings);
76        } else if let Some(pw) = parent_wrangler {
77            if let Ok(text) = fs::read_to_string(&pw) {
78                check_text(&text, "wrangler.toml", &mut findings);
79            }
80        }
81
82        findings
83    }
84}
85
86fn check_text(text: &str, source: &str, findings: &mut Vec<Finding>) {
87    let lower = text.to_lowercase();
88
89    // HSTS presence + max-age threshold
90    if !lower.contains("strict-transport-security") {
91        findings.push(
92            Finding::new(
93                NAME,
94                Severity::Error,
95                format!(
96                    "{source} declares no Strict-Transport-Security header"
97                ),
98            )
99            .with_code("PQC-HSTS-MISSING")
100            .with_path(source.to_string()),
101        );
102    } else if let Some(age) = extract_max_age(&lower) {
103        if age < HSTS_MIN_AGE {
104            findings.push(
105                Finding::new(
106                    NAME,
107                    Severity::Error,
108                    format!(
109                        "{source} HSTS max-age={age} below the 1-year (31536000) PQC-roll-forward threshold"
110                    ),
111                )
112                .with_code("PQC-HSTS-SHORT")
113                .with_path(source.to_string()),
114            );
115        }
116    } else {
117        findings.push(
118            Finding::new(
119                NAME,
120                Severity::Warn,
121                format!("{source} HSTS header present but max-age could not be parsed"),
122            )
123            .with_code("PQC-HSTS-UNPARSEABLE")
124            .with_path(source.to_string()),
125        );
126    }
127
128    // TLS 1.3 declared anywhere.
129    //
130    // Note: a `min_version = "tlsv1.3"` wrangler.toml entry is already
131    // caught by the `tlsv1.3` clause below (the former is a strict
132    // substring of the latter), so it isn't listed as its own
133    // alternative — doing so would be unreachable dead code.
134    let mentions_tls13 = lower.contains("tls-1.3")
135        || lower.contains("tlsv1.3")
136        || lower.contains("tls13")
137        // The spelling ssg's own `_headers` generator writes.
138        || lower.contains("tls 1.3")
139        || lower.contains("\"1.3\"");
140    if !mentions_tls13 {
141        findings.push(
142            Finding::new(
143                NAME,
144                Severity::Warn,
145                format!(
146                    "{source} does not declare TLS 1.3 (required for PQC kex)"
147                ),
148            )
149            .with_code("PQC-TLS13-MISSING")
150            .with_path(source.to_string()),
151        );
152    }
153}
154
155fn extract_max_age(lower: &str) -> Option<u64> {
156    let key = "max-age=";
157    let idx = lower.find(key)?;
158    let after = &lower[idx + key.len()..];
159    let digits: String =
160        after.chars().take_while(|c| c.is_ascii_digit()).collect();
161    digits.parse().ok()
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use std::path::PathBuf;
168
169    fn site_with_file(name: &str, content: &str) -> Site {
170        let tmp = tempfile::tempdir().unwrap();
171        fs::write(tmp.path().join(name), content).unwrap();
172        let root = tmp.path().to_path_buf();
173        std::mem::forget(tmp);
174        Site {
175            root,
176            html_files: Vec::new(),
177        }
178    }
179
180    fn empty_site() -> Site {
181        Site {
182            root: PathBuf::from("/nonexistent"),
183            html_files: Vec::new(),
184        }
185    }
186
187    #[test]
188    fn absent_inputs_emit_info_skip() {
189        let f = PqcTlsGate.run(&empty_site(), &AuditOptions::default());
190        assert_eq!(f.len(), 1);
191        assert_eq!(f[0].severity, Severity::Info);
192        assert_eq!(f[0].code.as_deref(), Some("PQC-INPUT-MISSING"));
193    }
194
195    #[test]
196    fn good_headers_file_passes() {
197        let content = "/*\n  Strict-Transport-Security: max-age=31536000; includeSubDomains\n  TLS: TLSv1.3\n";
198        let s = site_with_file("_headers", content);
199        let f = PqcTlsGate.run(&s, &AuditOptions::default());
200        assert!(f.is_empty(), "got {f:?}");
201    }
202
203    #[test]
204    fn short_hsts_max_age_flags_error() {
205        let content =
206            "/*\n  Strict-Transport-Security: max-age=3600\n  TLS: TLSv1.3\n";
207        let s = site_with_file("_headers", content);
208        let f = PqcTlsGate.run(&s, &AuditOptions::default());
209        assert!(f
210            .iter()
211            .any(|x| x.code.as_deref() == Some("PQC-HSTS-SHORT")));
212    }
213
214    #[test]
215    fn missing_hsts_header_flags_error() {
216        let content = "/*\n  TLS: TLSv1.3\n";
217        let s = site_with_file("_headers", content);
218        let f = PqcTlsGate.run(&s, &AuditOptions::default());
219        assert!(f
220            .iter()
221            .any(|x| x.code.as_deref() == Some("PQC-HSTS-MISSING")
222                && x.severity == Severity::Error));
223    }
224
225    #[test]
226    fn missing_tls_13_declaration_warns() {
227        let content = "/*\n  Strict-Transport-Security: max-age=63072000\n";
228        let s = site_with_file("_headers", content);
229        let f = PqcTlsGate.run(&s, &AuditOptions::default());
230        let tls = f
231            .iter()
232            .find(|x| x.code.as_deref() == Some("PQC-TLS13-MISSING"))
233            .expect("TLS 1.3 finding");
234        assert_eq!(tls.severity, Severity::Warn);
235    }
236
237    #[test]
238    fn unparseable_max_age_warns() {
239        let content = "/*\n  Strict-Transport-Security: max-age=not-a-number\n  TLS: TLSv1.3\n";
240        let s = site_with_file("_headers", content);
241        let f = PqcTlsGate.run(&s, &AuditOptions::default());
242        assert!(f
243            .iter()
244            .any(|x| x.code.as_deref() == Some("PQC-HSTS-UNPARSEABLE")
245                && x.severity == Severity::Warn));
246    }
247
248    #[test]
249    fn wrangler_toml_is_scanned() {
250        let content =
251            "[headers]\nstrict-transport-security = \"max-age=63072000\"\nmin_version = \"tlsv1.3\"\n";
252        let s = site_with_file("wrangler.toml", content);
253        let f = PqcTlsGate.run(&s, &AuditOptions::default());
254        assert!(f.is_empty(), "got {f:?}");
255    }
256
257    #[test]
258    fn tls13_alt_spellings_accepted() {
259        // "TLS 1.3" with a space is what ssg's own generated _headers
260        // writes, so leaving it out made the generator emit a file its
261        // own auditor reported as missing a TLS 1.3 declaration.
262        for spelling in ["tlsv1.3", "tls13", "\"1.3\"", "tls 1.3", "tls-1.3"] {
263            // Short max-age yields PQC-HSTS-SHORT, keeping `f`
264            // non-empty so the no-TLS13-MISSING predicate evaluates.
265            let content = format!(
266                "/*\n  Strict-Transport-Security: max-age=3600\n  TLS: {spelling}\n"
267            );
268            let s = site_with_file("_headers", &content);
269            let f = PqcTlsGate.run(&s, &AuditOptions::default());
270            assert!(f
271                .iter()
272                .any(|x| x.code.as_deref() == Some("PQC-HSTS-SHORT")));
273            assert!(
274                f.iter()
275                    .all(|x| x.code.as_deref() != Some("PQC-TLS13-MISSING")),
276                "spelling `{spelling}` did not satisfy TLS 1.3 check: {f:?}"
277            );
278        }
279    }
280
281    #[test]
282    fn parent_dir_wrangler_toml_is_scanned() {
283        // wrangler.toml lives beside (not inside) the site root — the
284        // usual layout when `public/` is the deploy output.
285        let tmp = tempfile::tempdir().unwrap();
286        let root = tmp.path().join("public");
287        fs::create_dir_all(&root).unwrap();
288        fs::write(
289            tmp.path().join("wrangler.toml"),
290            "strict-transport-security = \"max-age=63072000\"\n\
291             min_version = \"tlsv1.3\"\n",
292        )
293        .unwrap();
294        std::mem::forget(tmp);
295        let s = Site {
296            root,
297            html_files: Vec::new(),
298        };
299        let f = PqcTlsGate.run(&s, &AuditOptions::default());
300        assert!(f.is_empty(), "parent wrangler.toml must be scanned: {f:?}");
301    }
302
303    #[test]
304    fn hsts_without_max_age_key_warns_unparseable() {
305        // No `max-age=` at all: extract_max_age's find() misses.
306        let content = "/*\n  Strict-Transport-Security: includeSubDomains\n  TLS: TLSv1.3\n";
307        let s = site_with_file("_headers", content);
308        let f = PqcTlsGate.run(&s, &AuditOptions::default());
309        assert!(f
310            .iter()
311            .any(|x| x.code.as_deref() == Some("PQC-HSTS-UNPARSEABLE")));
312    }
313
314    #[test]
315    fn root_without_parent_short_circuits_parent_wrangler_lookup() {
316        // `/` has no parent, so `site.root.parent()` is `None` and
317        // `parent_wrangler` is `None` — drives the `None` arm of
318        // `parent_wrangler.as_ref().is_some_and(...)` inside
319        // `has_wrangler`, which every other test (all rooted under a
320        // tempdir, which always has a parent) never reaches.
321        let s = Site {
322            root: PathBuf::from("/"),
323            html_files: Vec::new(),
324        };
325        let f = PqcTlsGate.run(&s, &AuditOptions::default());
326        if !std::path::Path::new("/_headers").exists()
327            && !std::path::Path::new("/wrangler.toml").exists()
328        {
329            assert_eq!(f.len(), 1);
330            assert_eq!(f[0].code.as_deref(), Some("PQC-INPUT-MISSING"));
331        }
332    }
333
334    #[test]
335    fn metadata_methods_exposed() {
336        let g = PqcTlsGate;
337        assert_eq!(g.name(), "pqc_tls");
338        assert!(g.explain().contains("HSTS"));
339        let _copy: PqcTlsGate = g;
340        let _clone = g;
341        assert!(format!("{g:?}").contains("PqcTlsGate"));
342    }
343
344    #[test]
345    fn hsts_accepts_trailing_directives() {
346        let content = "/*\n  Strict-Transport-Security: max-age=63072000; includeSubDomains; preload\n  TLS: TLSv1.3\n";
347        let s = site_with_file("_headers", content);
348        let f = PqcTlsGate.run(&s, &AuditOptions::default());
349        assert!(f.is_empty(), "should accept trailing directives: {f:?}");
350    }
351}