1use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
17use std::fs;
18
19const NAME: &str = "pqc_tls";
20const HSTS_MIN_AGE: u64 = 31_536_000; #[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 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 let mentions_tls13 = lower.contains("tls-1.3")
135 || lower.contains("tlsv1.3")
136 || lower.contains("tls13")
137 || 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 for spelling in ["tlsv1.3", "tls13", "\"1.3\"", "tls 1.3", "tls-1.3"] {
263 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 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 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 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}