1use std::time::Duration;
33
34use serde::{Deserialize, Serialize};
35
36use crate::error::Result;
37use crate::http::{FetchedResponse, GuardedFetcher};
38use crate::validation::normalize_host;
39
40pub use crate::posture::PostureVerdict as HeaderVerdict;
44
45impl HeaderVerdict {
46 fn score_pct(self) -> u32 {
49 match self {
50 HeaderVerdict::Strict | HeaderVerdict::Present => 100,
51 HeaderVerdict::Moderate => 60,
52 HeaderVerdict::Weak => 30,
53 HeaderVerdict::Absent => 0,
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct HeaderFinding {
61 pub header: String,
63 pub present: bool,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub value: Option<String>,
67 pub verdict: HeaderVerdict,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub note: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct CookieFinding {
76 pub name: String,
77 pub secure: bool,
78 pub http_only: bool,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub same_site: Option<String>,
81 pub verdict: HeaderVerdict,
82 #[serde(skip_serializing_if = "Vec::is_empty")]
84 pub issues: Vec<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Disclosure {
90 pub header: String,
91 pub value: String,
92 pub versioned: bool,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct HeaderReport {
100 pub domain: String,
101 pub url: String,
103 pub status: u16,
104 pub redirects: usize,
106 pub grade: String,
108 pub score: u32,
110 pub headers: Vec<HeaderFinding>,
111 #[serde(skip_serializing_if = "Vec::is_empty")]
112 pub cookies: Vec<CookieFinding>,
113 #[serde(skip_serializing_if = "Vec::is_empty")]
114 pub disclosures: Vec<Disclosure>,
115 pub notes: Vec<String>,
117}
118
119pub const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(10);
122
123const HEADER_WEIGHTS: &[(&str, u32)] = &[
131 ("strict-transport-security", 20),
132 ("content-security-policy", 20),
133 ("x-frame-options", 15),
134 ("x-content-type-options", 10),
135 ("referrer-policy", 10),
136 ("permissions-policy", 10),
137 ("cross-origin-opener-policy", 5),
138 ("cross-origin-embedder-policy", 5),
139 ("cross-origin-resource-policy", 5),
140];
141
142const HSTS_STRONG_MAX_AGE: u64 = 31_536_000;
145const HSTS_MODERATE_MAX_AGE: u64 = 15_552_000;
148
149const COOKIE_PENALTY: u32 = 4;
151const COOKIE_PENALTY_CAP: u32 = 12;
152const DISCLOSURE_PENALTY: u32 = 3;
154const DISCLOSURE_PENALTY_CAP: u32 = 6;
155
156fn parse_attributes(value: &str) -> Vec<(String, String)> {
162 value
163 .split(';')
164 .filter_map(|part| {
165 let part = part.trim();
166 if part.is_empty() {
167 return None;
168 }
169 Some(match part.split_once('=') {
170 Some((k, v)) => (k.trim().to_ascii_lowercase(), v.trim().to_string()),
171 None => (part.to_ascii_lowercase(), String::new()),
172 })
173 })
174 .collect()
175}
176
177fn attr<'a>(attrs: &'a [(String, String)], key: &str) -> Option<&'a str> {
179 attrs
180 .iter()
181 .find(|(k, _)| k == key)
182 .map(|(_, v)| v.as_str())
183}
184
185fn grade_hsts(value: Option<&str>, over_https: bool) -> (HeaderVerdict, Option<String>) {
186 let Some(value) = value else {
187 return (
188 HeaderVerdict::Absent,
189 Some("Add Strict-Transport-Security to stop protocol-downgrade and cookie-stripping attacks.".into()),
190 );
191 };
192 if !over_https {
195 return (
196 HeaderVerdict::Absent,
197 Some("Strict-Transport-Security was served over plain HTTP, where browsers ignore it; serve the final page over HTTPS.".into()),
198 );
199 }
200 let attrs = parse_attributes(value);
201 let max_age: Option<u64> =
203 attr(&attrs, "max-age").and_then(|v| v.trim().trim_matches('"').parse().ok());
204 let include_subdomains = attr(&attrs, "includesubdomains").is_some();
205
206 match max_age {
207 None | Some(0) => (
208 HeaderVerdict::Weak,
209 Some("max-age is missing or zero, which disables HSTS entirely.".into()),
210 ),
211 Some(age) if age >= HSTS_STRONG_MAX_AGE && include_subdomains => {
212 (HeaderVerdict::Strict, None)
213 }
214 Some(age) if age >= HSTS_STRONG_MAX_AGE => (
215 HeaderVerdict::Moderate,
216 Some("max-age is strong but includeSubDomains is missing — subdomains stay downgradable.".into()),
217 ),
218 Some(age) if age >= HSTS_MODERATE_MAX_AGE => (
219 HeaderVerdict::Moderate,
220 Some(format!(
221 "max-age={age} is below the 31536000 (1 year) that preload requires."
222 )),
223 ),
224 Some(age) => (
225 HeaderVerdict::Weak,
226 Some(format!(
227 "max-age={age} is a short window; use at least 15552000 (6 months)."
228 )),
229 ),
230 }
231}
232
233type CspPolicy = Vec<(String, Vec<String>)>;
235
236fn parse_csp_policies<'a>(values: impl IntoIterator<Item = &'a str>) -> Vec<CspPolicy> {
244 values
245 .into_iter()
246 .flat_map(|v| v.split(','))
247 .map(|policy| {
248 policy
249 .split(';')
250 .filter_map(|directive| {
251 let mut tokens = directive.split_ascii_whitespace();
252 let name = tokens.next()?.to_ascii_lowercase();
253 Some((name, tokens.map(str::to_ascii_lowercase).collect()))
254 })
255 .collect::<CspPolicy>()
256 })
257 .filter(|p| !p.is_empty())
258 .collect()
259}
260
261fn csp_directive<'a>(policy: &'a CspPolicy, name: &str) -> Option<&'a [String]> {
262 policy
263 .iter()
264 .find(|(n, _)| n == name)
265 .map(|(_, sources)| sources.as_slice())
266}
267
268enum ScriptControl {
270 None,
272 Unsafe(Vec<&'static str>),
274 Safe,
276}
277
278fn script_control(policy: &CspPolicy) -> ScriptControl {
279 let Some(sources) =
281 csp_directive(policy, "script-src").or_else(|| csp_directive(policy, "default-src"))
282 else {
283 return ScriptControl::None;
284 };
285 let has = |t: &str| sources.iter().any(|s| s == t);
286 let inline_neutralized = has("'strict-dynamic'")
290 || sources.iter().any(|s| {
291 s.starts_with("'nonce-")
292 || s.starts_with("'sha256-")
293 || s.starts_with("'sha384-")
294 || s.starts_with("'sha512-")
295 });
296 let mut unsafe_kw = Vec::new();
297 if has("'unsafe-inline'") && !inline_neutralized {
298 unsafe_kw.push("'unsafe-inline'");
299 }
300 if has("'unsafe-eval'") {
301 unsafe_kw.push("'unsafe-eval'");
302 }
303 if unsafe_kw.is_empty() {
304 ScriptControl::Safe
305 } else {
306 ScriptControl::Unsafe(unsafe_kw)
307 }
308}
309
310fn grade_csp(policies: &[CspPolicy], report_only: bool) -> (HeaderVerdict, Option<String>) {
311 if policies.is_empty() {
312 if report_only {
313 return (
314 HeaderVerdict::Weak,
315 Some("Only Content-Security-Policy-Report-Only is set — it reports violations but blocks nothing.".into()),
316 );
317 }
318 return (
319 HeaderVerdict::Absent,
320 Some(
321 "Add Content-Security-Policy — it is the primary defense against injected script."
322 .into(),
323 ),
324 );
325 };
326
327 let controls: Vec<ScriptControl> = policies.iter().map(script_control).collect();
330 if controls.iter().any(|c| matches!(c, ScriptControl::Safe)) {
331 return (HeaderVerdict::Strict, None);
332 }
333
334 let mut which: Vec<&str> = Vec::new();
335 for control in &controls {
336 if let ScriptControl::Unsafe(kw) = control {
337 for k in kw {
338 if !which.contains(k) {
339 which.push(k);
340 }
341 }
342 }
343 }
344 if !which.is_empty() {
345 return (
346 HeaderVerdict::Weak,
347 Some(format!(
348 "Script policy allows {} — this reopens the injection hole CSP exists to close.",
349 which.join(" and ")
350 )),
351 );
352 }
353
354 (
355 HeaderVerdict::Moderate,
356 Some(
357 "Policy sets neither default-src nor script-src, so script loading is unrestricted."
358 .into(),
359 ),
360 )
361}
362
363fn grade_frame_options(
364 value: Option<&str>,
365 csp_policies: &[CspPolicy],
366) -> (HeaderVerdict, Option<String>) {
367 let csp_frame_ancestors = csp_policies
371 .iter()
372 .any(|p| csp_directive(p, "frame-ancestors").is_some());
373
374 let Some(value) = value else {
375 if csp_frame_ancestors {
376 return (
377 HeaderVerdict::Present,
378 Some(
379 "No X-Frame-Options, but CSP frame-ancestors is set, which supersedes it."
380 .into(),
381 ),
382 );
383 }
384 return (
385 HeaderVerdict::Absent,
386 Some(
387 "Add X-Frame-Options (or CSP frame-ancestors) to prevent clickjacking via framing."
388 .into(),
389 ),
390 );
391 };
392
393 match value.trim().to_ascii_uppercase().as_str() {
394 "DENY" | "SAMEORIGIN" => (HeaderVerdict::Strict, None),
395 other if csp_frame_ancestors => (
398 HeaderVerdict::Present,
399 Some(format!(
400 "X-Frame-Options '{other}' is not a valid value, but CSP frame-ancestors is set and supersedes it."
401 )),
402 ),
403 v if v.starts_with("ALLOW-FROM") => (
404 HeaderVerdict::Weak,
405 Some(
406 "ALLOW-FROM is obsolete and ignored by modern browsers; use CSP frame-ancestors."
407 .into(),
408 ),
409 ),
410 other => (
411 HeaderVerdict::Weak,
412 Some(format!(
413 "Unrecognized value '{other}' — use DENY or SAMEORIGIN."
414 )),
415 ),
416 }
417}
418
419fn grade_content_type_options(value: Option<&str>) -> (HeaderVerdict, Option<String>) {
420 match value.map(|v| v.trim().to_ascii_lowercase()) {
421 None => (
422 HeaderVerdict::Absent,
423 Some(
424 "Add X-Content-Type-Options: nosniff to stop MIME-sniffing of user-supplied files."
425 .into(),
426 ),
427 ),
428 Some(ref v) if v == "nosniff" => (HeaderVerdict::Strict, None),
429 Some(other) => (
430 HeaderVerdict::Weak,
431 Some(format!(
432 "Value '{other}' is not recognized; the only valid value is nosniff."
433 )),
434 ),
435 }
436}
437
438fn grade_referrer_policy(value: Option<&str>) -> (HeaderVerdict, Option<String>) {
439 let Some(value) = value else {
440 return (
441 HeaderVerdict::Absent,
442 Some(
443 "Add Referrer-Policy so URLs (and any secrets in them) do not leak cross-origin."
444 .into(),
445 ),
446 );
447 };
448 let strict = [
449 "no-referrer",
450 "same-origin",
451 "strict-origin",
452 "strict-origin-when-cross-origin",
453 ];
454 let moderate = [
455 "origin",
456 "origin-when-cross-origin",
457 "no-referrer-when-downgrade",
458 ];
459
460 let effective = value
464 .split(',')
465 .map(|t| t.trim().to_ascii_lowercase())
466 .rev()
467 .find(|t| {
468 strict.contains(&t.as_str()) || moderate.contains(&t.as_str()) || t == "unsafe-url"
469 });
470
471 if effective.as_deref().is_some_and(|t| strict.contains(&t)) {
472 (HeaderVerdict::Strict, None)
473 } else if effective.as_deref().is_some_and(|t| moderate.contains(&t)) {
474 (
475 HeaderVerdict::Moderate,
476 Some("Policy still sends the origin cross-site; strict-origin-when-cross-origin is tighter.".into()),
477 )
478 } else if effective.as_deref() == Some("unsafe-url") {
479 (
480 HeaderVerdict::Weak,
481 Some(
482 "unsafe-url sends the full URL to every destination, including over plain HTTP."
483 .into(),
484 ),
485 )
486 } else {
487 (
488 HeaderVerdict::Weak,
489 Some(format!("Unrecognized policy '{}'.", value.trim())),
490 )
491 }
492}
493
494fn grade_presence(value: Option<&str>, advice: &str) -> (HeaderVerdict, Option<String>) {
496 match value {
497 Some(v) if !v.trim().is_empty() => (HeaderVerdict::Present, None),
498 _ => (HeaderVerdict::Absent, Some(advice.to_string())),
499 }
500}
501
502fn grade_enum(
504 value: Option<&str>,
505 table: &[(&str, HeaderVerdict)],
506 absent_advice: &str,
507) -> (HeaderVerdict, Option<String>) {
508 let Some(value) = value else {
509 return (HeaderVerdict::Absent, Some(absent_advice.to_string()));
510 };
511 let v = value
514 .split(';')
515 .next()
516 .unwrap_or_default()
517 .trim()
518 .to_ascii_lowercase();
519 match table.iter().find(|(k, _)| *k == v) {
520 Some((_, verdict)) => (*verdict, None),
521 None => (
522 HeaderVerdict::Weak,
523 Some(format!("Unrecognized value '{}'.", value.trim())),
524 ),
525 }
526}
527
528fn grade_cookies(cookies: &[String]) -> Vec<CookieFinding> {
530 cookies
531 .iter()
532 .map(|raw| {
533 let attrs = parse_attributes(raw.split_once(';').map_or("", |(_, rest)| rest));
537 let name = raw
541 .split(';')
542 .next()
543 .map(|first| first.split_once('=').map_or(first, |(k, _)| k).trim())
544 .filter(|n| !n.is_empty())
545 .unwrap_or("(unnamed)")
546 .to_string();
547
548 let secure = attr(&attrs, "secure").is_some();
549 let http_only = attr(&attrs, "httponly").is_some();
550 let same_site = attr(&attrs, "samesite").map(|v| v.to_string());
551
552 let mut issues = Vec::new();
553 if !secure {
554 issues.push("missing Secure (may be sent over plain HTTP)".to_string());
555 }
556 if !http_only {
557 issues.push("missing HttpOnly (readable by JavaScript/XSS)".to_string());
558 }
559 match same_site.as_deref().map(|s| s.to_ascii_lowercase()) {
560 None => issues.push("missing SameSite (CSRF exposure)".to_string()),
561 Some(ref s) if s == "none" && !secure => {
562 issues.push("SameSite=None without Secure is rejected by browsers".to_string());
563 }
564 _ => {}
565 }
566
567 let verdict = match issues.len() {
568 0 => HeaderVerdict::Strict,
569 1 => HeaderVerdict::Moderate,
570 _ => HeaderVerdict::Weak,
571 };
572
573 CookieFinding {
574 name,
575 secure,
576 http_only,
577 same_site,
578 verdict,
579 issues,
580 }
581 })
582 .collect()
583}
584
585fn looks_versioned(value: &str) -> bool {
588 let bytes = value.as_bytes();
589 bytes.windows(2).any(|w| {
590 (w[0] == b'/' && w[1].is_ascii_digit())
592 || (w[0].is_ascii_digit() && w[1] == b'.')
593 || (w[0] == b'.' && w[1].is_ascii_digit())
594 })
595}
596
597const DISCLOSURE_HEADERS: &[&str] = &[
599 "server",
600 "x-powered-by",
601 "x-aspnet-version",
602 "x-aspnetmvc-version",
603 "x-generator",
604 "x-drupal-cache",
605];
606
607fn collect_disclosures(response: &FetchedResponse) -> Vec<Disclosure> {
608 DISCLOSURE_HEADERS
609 .iter()
610 .filter_map(|name| {
611 let value = response.header(name)?;
612 if value.trim().is_empty() {
613 return None;
614 }
615 Some(Disclosure {
616 header: (*name).to_string(),
617 value: value.to_string(),
618 versioned: looks_versioned(value),
619 })
620 })
621 .collect()
622}
623
624fn compute_score(
626 headers: &[HeaderFinding],
627 cookies: &[CookieFinding],
628 disclosures: &[Disclosure],
629) -> u32 {
630 let earned: u32 = headers
631 .iter()
632 .map(|f| {
633 let weight = HEADER_WEIGHTS
634 .iter()
635 .find(|(name, _)| *name == f.header)
636 .map(|(_, w)| *w)
637 .unwrap_or(0);
638 weight * f.verdict.score_pct() / 100
639 })
640 .sum();
641
642 let cookie_penalty = (cookies.iter().filter(|c| !c.issues.is_empty()).count() as u32
643 * COOKIE_PENALTY)
644 .min(COOKIE_PENALTY_CAP);
645
646 let disclosure_penalty = (disclosures.iter().filter(|d| d.versioned).count() as u32
647 * DISCLOSURE_PENALTY)
648 .min(DISCLOSURE_PENALTY_CAP);
649
650 earned.saturating_sub(cookie_penalty + disclosure_penalty)
651}
652
653fn grade_letter(score: u32) -> &'static str {
655 match score {
656 95..=u32::MAX => "A+",
657 85..=94 => "A",
658 75..=84 => "B",
659 65..=74 => "C",
660 50..=64 => "D",
661 30..=49 => "E",
662 _ => "F",
663 }
664}
665
666fn build_notes(
668 headers: &[HeaderFinding],
669 cookies: &[CookieFinding],
670 disclosures: &[Disclosure],
671) -> Vec<String> {
672 let mut notes = Vec::new();
673
674 for finding in headers {
677 if let Some(note) = &finding.note {
678 notes.push(format!("{}: {}", finding.header, note));
679 }
680 }
681
682 for cookie in cookies {
683 if !cookie.issues.is_empty() {
684 notes.push(format!(
685 "cookie '{}': {}",
686 cookie.name,
687 cookie.issues.join("; ")
688 ));
689 }
690 }
691
692 for d in disclosures.iter().filter(|d| d.versioned) {
693 notes.push(format!(
694 "{} discloses a version ('{}') — an attacker can match it against known CVEs.",
695 d.header, d.value
696 ));
697 }
698
699 notes
700}
701
702fn build_report(domain: String, response: &FetchedResponse) -> HeaderReport {
705 let csp_policies = parse_csp_policies(response.header_all("content-security-policy"));
706 let csp_report_only = response
707 .header("content-security-policy-report-only")
708 .is_some();
709 let over_https = response
710 .final_url
711 .get(..8)
712 .is_some_and(|scheme| scheme.eq_ignore_ascii_case("https://"));
713
714 let graded: Vec<(&str, (HeaderVerdict, Option<String>))> = vec![
715 (
716 "strict-transport-security",
717 grade_hsts(response.header("strict-transport-security"), over_https),
718 ),
719 (
720 "content-security-policy",
721 grade_csp(&csp_policies, csp_report_only),
722 ),
723 (
724 "x-frame-options",
725 grade_frame_options(response.header("x-frame-options"), &csp_policies),
726 ),
727 (
728 "x-content-type-options",
729 grade_content_type_options(response.header("x-content-type-options")),
730 ),
731 (
732 "referrer-policy",
733 grade_referrer_policy(response.header("referrer-policy")),
734 ),
735 (
736 "permissions-policy",
737 grade_presence(
738 response.header("permissions-policy"),
739 "Add Permissions-Policy to disable powerful browser features the site does not use.",
740 ),
741 ),
742 (
743 "cross-origin-opener-policy",
744 grade_enum(
745 response.header("cross-origin-opener-policy"),
746 &[
747 ("same-origin", HeaderVerdict::Strict),
748 ("same-origin-allow-popups", HeaderVerdict::Moderate),
749 ("unsafe-none", HeaderVerdict::Weak),
750 ],
751 "Add Cross-Origin-Opener-Policy: same-origin to isolate the browsing context.",
752 ),
753 ),
754 (
755 "cross-origin-embedder-policy",
756 grade_enum(
757 response.header("cross-origin-embedder-policy"),
758 &[
759 ("require-corp", HeaderVerdict::Strict),
760 ("credentialless", HeaderVerdict::Strict),
761 ("unsafe-none", HeaderVerdict::Weak),
762 ],
763 "Add Cross-Origin-Embedder-Policy: require-corp to block un-opted-in cross-origin loads.",
764 ),
765 ),
766 (
767 "cross-origin-resource-policy",
768 grade_enum(
769 response.header("cross-origin-resource-policy"),
770 &[
771 ("same-origin", HeaderVerdict::Strict),
772 ("same-site", HeaderVerdict::Moderate),
773 ("cross-origin", HeaderVerdict::Weak),
774 ],
775 "Add Cross-Origin-Resource-Policy to control who may embed this origin's resources.",
776 ),
777 ),
778 ];
779
780 let headers: Vec<HeaderFinding> = HEADER_WEIGHTS
782 .iter()
783 .filter_map(|(name, _)| {
784 let (_, (verdict, note)) = graded.iter().find(|(n, _)| n == name)?;
785 let values: Vec<&str> = response.header_all(name).collect();
789 let value = (!values.is_empty()).then(|| values.join(", "));
790 Some(HeaderFinding {
791 header: (*name).to_string(),
792 present: value.is_some(),
793 value,
794 verdict: *verdict,
795 note: note.clone(),
796 })
797 })
798 .collect();
799
800 let set_cookies: Vec<String> = response
801 .header_all("set-cookie")
802 .map(|v| v.to_string())
803 .collect();
804 let cookies = grade_cookies(&set_cookies);
805 let disclosures = collect_disclosures(response);
806
807 let score = compute_score(&headers, &cookies, &disclosures);
808 let notes = build_notes(&headers, &cookies, &disclosures);
809
810 HeaderReport {
811 domain,
812 url: response.final_url.clone(),
813 status: response.status,
814 redirects: response.redirects,
815 grade: grade_letter(score).to_string(),
816 score,
817 headers,
818 cookies,
819 disclosures,
820 notes,
821 }
822}
823
824pub async fn audit_headers(domain: &str, timeout: Duration) -> Result<HeaderReport> {
848 let domain = normalize_host(domain)?;
851 let fetcher = GuardedFetcher::new().with_timeout(timeout);
852 let response = fetcher.get(&format!("https://{domain}/")).await?;
853 Ok(build_report(domain, &response))
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859
860 fn response_with(headers: &[(&str, &str)]) -> FetchedResponse {
863 FetchedResponse {
864 final_url: "https://example.com/".to_string(),
865 status: 200,
866 headers: headers
867 .iter()
868 .map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
869 .collect(),
870 body: String::new(),
871 redirects: 0,
872 }
873 }
874
875 fn csp(values: &[&str]) -> Vec<CspPolicy> {
877 parse_csp_policies(values.iter().copied())
878 }
879
880 #[test]
881 fn hsts_grading_bands() {
882 assert_eq!(grade_hsts(None, true).0, HeaderVerdict::Absent);
883 assert_eq!(
884 grade_hsts(Some("max-age=31536000; includeSubDomains"), true).0,
885 HeaderVerdict::Strict
886 );
887 assert_eq!(
889 grade_hsts(Some("max-age=31536000"), true).0,
890 HeaderVerdict::Moderate
891 );
892 assert_eq!(
893 grade_hsts(Some("max-age=15552000; includeSubDomains"), true).0,
894 HeaderVerdict::Moderate
895 );
896 assert_eq!(grade_hsts(Some("max-age=300"), true).0, HeaderVerdict::Weak);
897 assert_eq!(grade_hsts(Some("max-age=0"), true).0, HeaderVerdict::Weak);
899 assert_eq!(
900 grade_hsts(Some("includeSubDomains"), true).0,
901 HeaderVerdict::Weak
902 );
903 }
904
905 #[test]
906 fn csp_unsafe_directives_are_weak() {
907 assert_eq!(
908 grade_csp(&csp(&["default-src 'self' 'unsafe-inline'"]), false).0,
909 HeaderVerdict::Weak
910 );
911 assert_eq!(
912 grade_csp(&csp(&["script-src 'unsafe-eval'"]), false).0,
913 HeaderVerdict::Weak
914 );
915 assert_eq!(
916 grade_csp(&csp(&["default-src 'self'"]), false).0,
917 HeaderVerdict::Strict
918 );
919 assert_eq!(
921 grade_csp(&csp(&["img-src 'self'"]), false).0,
922 HeaderVerdict::Moderate
923 );
924 assert_eq!(grade_csp(&[], false).0, HeaderVerdict::Absent);
925 assert_eq!(grade_csp(&[], true).0, HeaderVerdict::Weak);
927 }
928
929 #[test]
930 fn csp_frame_ancestors_substitutes_for_x_frame_options() {
931 let (verdict, note) =
934 grade_frame_options(None, &csp(&["default-src 'self'; frame-ancestors 'none'"]));
935 assert_eq!(verdict, HeaderVerdict::Present);
936 assert!(note.unwrap_or_default().contains("frame-ancestors"));
937
938 assert_eq!(
940 grade_frame_options(None, &csp(&["default-src 'self'"])).0,
941 HeaderVerdict::Absent
942 );
943 assert_eq!(
944 grade_frame_options(Some("DENY"), &[]).0,
945 HeaderVerdict::Strict
946 );
947 assert_eq!(
948 grade_frame_options(Some("SAMEORIGIN"), &[]).0,
949 HeaderVerdict::Strict
950 );
951 assert_eq!(
953 grade_frame_options(Some("ALLOW-FROM https://x.com"), &[]).0,
954 HeaderVerdict::Weak
955 );
956 }
957
958 #[test]
959 fn hsts_quoted_max_age_and_insecure_transport() {
960 assert_eq!(
962 grade_hsts(Some("max-age=\"31536000\"; includeSubDomains"), true).0,
963 HeaderVerdict::Strict
964 );
965 assert_eq!(
967 grade_hsts(Some("max-age=31536000; includeSubDomains"), false).0,
968 HeaderVerdict::Absent
969 );
970 let mut response = response_with(&[(
971 "Strict-Transport-Security",
972 "max-age=31536000; includeSubDomains",
973 )]);
974 response.final_url = "http://example.com/".to_string();
975 let report = build_report("example.com".into(), &response);
976 let hsts = report
977 .headers
978 .iter()
979 .find(|h| h.header == "strict-transport-security")
980 .unwrap();
981 assert_eq!(hsts.verdict, HeaderVerdict::Absent);
982 }
983
984 #[test]
985 fn csp_nonce_hash_and_strict_dynamic_neutralize_unsafe_inline() {
986 assert_eq!(
989 grade_csp(
990 &csp(&["script-src 'nonce-r4nd' 'unsafe-inline' 'strict-dynamic' https:; object-src 'none'; base-uri 'none'"]),
991 false
992 )
993 .0,
994 HeaderVerdict::Strict
995 );
996 assert_eq!(
997 grade_csp(&csp(&["script-src 'sha256-abc=' 'unsafe-inline'"]), false).0,
998 HeaderVerdict::Strict
999 );
1000 assert_eq!(
1002 grade_csp(&csp(&["script-src 'nonce-x' 'unsafe-eval'"]), false).0,
1003 HeaderVerdict::Weak
1004 );
1005 assert_eq!(
1008 grade_csp(
1009 &csp(&["default-src 'self'; style-src 'self' 'unsafe-inline'"]),
1010 false
1011 )
1012 .0,
1013 HeaderVerdict::Strict
1014 );
1015 assert_eq!(
1017 grade_csp(
1018 &csp(&["default-src 'self'; script-src 'self' 'unsafe-inline'"]),
1019 false
1020 )
1021 .0,
1022 HeaderVerdict::Weak
1023 );
1024 }
1025
1026 #[test]
1027 fn csp_is_graded_across_every_delivered_policy() {
1028 let response = response_with(&[
1030 ("Content-Security-Policy", "frame-ancestors 'self'"),
1031 ("Content-Security-Policy", "default-src 'self'"),
1032 ]);
1033 let report = build_report("example.com".into(), &response);
1034 let find = |name: &str| {
1035 report
1036 .headers
1037 .iter()
1038 .find(|h| h.header == name)
1039 .unwrap()
1040 .clone()
1041 };
1042 assert_eq!(
1043 find("content-security-policy").verdict,
1044 HeaderVerdict::Strict
1045 );
1046 assert_eq!(find("x-frame-options").verdict, HeaderVerdict::Present);
1048 assert_eq!(
1050 find("content-security-policy").value.as_deref(),
1051 Some("frame-ancestors 'self', default-src 'self'")
1052 );
1053 assert_eq!(
1056 grade_csp(
1057 &csp(&["script-src 'unsafe-inline', script-src 'self'"]),
1058 false
1059 )
1060 .0,
1061 HeaderVerdict::Strict
1062 );
1063 }
1064
1065 #[test]
1066 fn invalid_xfo_is_superseded_by_frame_ancestors() {
1067 let (verdict, note) = grade_frame_options(
1069 Some("ALLOW-FROM https://x.example"),
1070 &csp(&["frame-ancestors 'self'"]),
1071 );
1072 assert_eq!(verdict, HeaderVerdict::Present);
1073 assert!(note.unwrap_or_default().contains("frame-ancestors"));
1074 assert_eq!(
1076 grade_frame_options(Some("ALLOW-FROM https://x.example"), &[]).0,
1077 HeaderVerdict::Weak
1078 );
1079 }
1080
1081 #[test]
1082 fn cross_origin_policies_ignore_structured_header_parameters() {
1083 let coep = [
1084 ("require-corp", HeaderVerdict::Strict),
1085 ("unsafe-none", HeaderVerdict::Weak),
1086 ];
1087 assert_eq!(
1088 grade_enum(Some("require-corp; report-to=\"default\""), &coep, "x").0,
1089 HeaderVerdict::Strict
1090 );
1091 let coop = [
1092 ("same-origin", HeaderVerdict::Strict),
1093 ("same-origin-allow-popups", HeaderVerdict::Moderate),
1094 ];
1095 assert_eq!(
1096 grade_enum(
1097 Some("same-origin-allow-popups; report-to=\"gws\""),
1098 &coop,
1099 "x"
1100 )
1101 .0,
1102 HeaderVerdict::Moderate
1103 );
1104 }
1105
1106 #[test]
1107 fn cookie_name_is_not_mistaken_for_an_attribute() {
1108 let findings = grade_cookies(&[
1109 "secure=1; Path=/".to_string(),
1110 "HttpOnly=yes; SameSite=Lax".to_string(),
1111 ]);
1112 assert_eq!(findings[0].name, "secure");
1113 assert!(!findings[0].secure, "a cookie NAMED secure is not Secure");
1114 assert!(!findings[1].http_only);
1115 assert_eq!(findings[1].same_site.as_deref(), Some("Lax"));
1116 }
1117
1118 #[test]
1119 fn referrer_policy_bands() {
1120 assert_eq!(
1121 grade_referrer_policy(Some("strict-origin-when-cross-origin")).0,
1122 HeaderVerdict::Strict
1123 );
1124 assert_eq!(
1125 grade_referrer_policy(Some("no-referrer")).0,
1126 HeaderVerdict::Strict
1127 );
1128 assert_eq!(
1129 grade_referrer_policy(Some("origin")).0,
1130 HeaderVerdict::Moderate
1131 );
1132 assert_eq!(
1133 grade_referrer_policy(Some("unsafe-url")).0,
1134 HeaderVerdict::Weak
1135 );
1136 assert_eq!(
1139 grade_referrer_policy(Some("no-referrer, strict-origin-when-cross-origin")).0,
1140 HeaderVerdict::Strict
1141 );
1142 assert_eq!(
1143 grade_referrer_policy(Some("no-referrer, unsafe-url")).0,
1144 HeaderVerdict::Weak
1145 );
1146 assert_eq!(
1147 grade_referrer_policy(Some("strict-origin, origin")).0,
1148 HeaderVerdict::Moderate
1149 );
1150 assert_eq!(
1152 grade_referrer_policy(Some("no-referrer, some-future-policy")).0,
1153 HeaderVerdict::Strict
1154 );
1155 assert_eq!(grade_referrer_policy(None).0, HeaderVerdict::Absent);
1156 }
1157
1158 #[test]
1159 fn content_type_options_only_accepts_nosniff() {
1160 assert_eq!(
1161 grade_content_type_options(Some("nosniff")).0,
1162 HeaderVerdict::Strict
1163 );
1164 assert_eq!(
1165 grade_content_type_options(Some("NOSNIFF")).0,
1166 HeaderVerdict::Strict
1167 );
1168 assert_eq!(
1169 grade_content_type_options(Some("sniff")).0,
1170 HeaderVerdict::Weak
1171 );
1172 assert_eq!(grade_content_type_options(None).0, HeaderVerdict::Absent);
1173 }
1174
1175 #[test]
1176 fn cookie_names_preserve_case() {
1177 let findings = grade_cookies(&[
1180 "JSESSIONID=abc; Secure".to_string(),
1181 "WMF-Last-Access=1; Secure; HttpOnly".to_string(),
1182 ]);
1183 assert_eq!(findings[0].name, "JSESSIONID");
1184 assert_eq!(findings[1].name, "WMF-Last-Access");
1185 assert!(findings[1].secure);
1187 assert!(findings[1].http_only);
1188 }
1189
1190 #[test]
1191 fn cookie_flags_are_graded_and_named() {
1192 let findings = grade_cookies(&[
1193 "session=abc; Secure; HttpOnly; SameSite=Strict".to_string(),
1194 "tracker=xyz".to_string(),
1195 "mixed=1; Secure".to_string(),
1196 ]);
1197 assert_eq!(findings[0].name, "session");
1198 assert_eq!(findings[0].verdict, HeaderVerdict::Strict);
1199 assert!(findings[0].issues.is_empty());
1200
1201 assert_eq!(findings[1].name, "tracker");
1203 assert_eq!(findings[1].verdict, HeaderVerdict::Weak);
1204 assert_eq!(findings[1].issues.len(), 3);
1205
1206 assert_eq!(findings[2].verdict, HeaderVerdict::Weak);
1208 assert!(findings[2].secure);
1209 assert!(!findings[2].http_only);
1210 }
1211
1212 #[test]
1213 fn same_site_none_without_secure_is_flagged() {
1214 let findings = grade_cookies(&["c=1; HttpOnly; SameSite=None".to_string()]);
1215 assert!(
1216 findings[0]
1217 .issues
1218 .iter()
1219 .any(|i| i.contains("SameSite=None without Secure")),
1220 "got {:?}",
1221 findings[0].issues
1222 );
1223 }
1224
1225 #[test]
1226 fn version_disclosure_detection() {
1227 assert!(looks_versioned("nginx/1.25.3"));
1228 assert!(looks_versioned("PHP/8.2.1"));
1229 assert!(looks_versioned("Apache/2.4"));
1230 assert!(!looks_versioned("cloudflare"));
1232 assert!(!looks_versioned("nginx"));
1233 }
1234
1235 #[test]
1236 fn perfect_headers_score_a_plus() {
1237 let response = response_with(&[
1238 (
1239 "strict-transport-security",
1240 "max-age=63072000; includeSubDomains; preload",
1241 ),
1242 ("content-security-policy", "default-src 'self'"),
1243 ("x-frame-options", "DENY"),
1244 ("x-content-type-options", "nosniff"),
1245 ("referrer-policy", "no-referrer"),
1246 ("permissions-policy", "geolocation=()"),
1247 ("cross-origin-opener-policy", "same-origin"),
1248 ("cross-origin-embedder-policy", "require-corp"),
1249 ("cross-origin-resource-policy", "same-origin"),
1250 ]);
1251 let report = build_report("example.com".to_string(), &response);
1252 assert_eq!(report.score, 100, "notes: {:?}", report.notes);
1253 assert_eq!(report.grade, "A+");
1254 assert!(report.notes.is_empty(), "got {:?}", report.notes);
1255 }
1256
1257 #[test]
1258 fn bare_response_scores_f_and_lists_every_gap() {
1259 let report = build_report("example.com".to_string(), &response_with(&[]));
1260 assert_eq!(report.score, 0);
1261 assert_eq!(report.grade, "F");
1262 assert_eq!(report.headers.len(), HEADER_WEIGHTS.len());
1264 assert_eq!(report.notes.len(), HEADER_WEIGHTS.len());
1265 assert!(report.headers.iter().all(|h| !h.present));
1266 }
1267
1268 #[test]
1269 fn penalties_apply_but_stay_bounded() {
1270 let cookies = grade_cookies(&(0..10).map(|i| format!("c{i}=1")).collect::<Vec<_>>());
1273 let disclosures = vec![
1274 Disclosure {
1275 header: "server".into(),
1276 value: "nginx/1.0".into(),
1277 versioned: true,
1278 },
1279 Disclosure {
1280 header: "x-powered-by".into(),
1281 value: "PHP/8.0".into(),
1282 versioned: true,
1283 },
1284 Disclosure {
1285 header: "x-generator".into(),
1286 value: "Drupal/9.1".into(),
1287 versioned: true,
1288 },
1289 ];
1290 let headers: Vec<HeaderFinding> = HEADER_WEIGHTS
1292 .iter()
1293 .map(|(name, _)| HeaderFinding {
1294 header: (*name).to_string(),
1295 present: true,
1296 value: None,
1297 verdict: HeaderVerdict::Strict,
1298 note: None,
1299 })
1300 .collect();
1301 let score = compute_score(&headers, &cookies, &disclosures);
1302 assert_eq!(score, 100 - COOKIE_PENALTY_CAP - DISCLOSURE_PENALTY_CAP);
1303
1304 let empty: Vec<HeaderFinding> = Vec::new();
1306 assert_eq!(compute_score(&empty, &cookies, &disclosures), 0);
1307 }
1308
1309 #[test]
1310 fn weights_sum_to_one_hundred() {
1311 let total: u32 = HEADER_WEIGHTS.iter().map(|(_, w)| *w).sum();
1314 assert_eq!(total, 100);
1315 }
1316
1317 #[test]
1318 fn grade_bands_are_ordered_and_total() {
1319 assert_eq!(grade_letter(100), "A+");
1320 assert_eq!(grade_letter(95), "A+");
1321 assert_eq!(grade_letter(94), "A");
1322 assert_eq!(grade_letter(85), "A");
1323 assert_eq!(grade_letter(75), "B");
1324 assert_eq!(grade_letter(65), "C");
1325 assert_eq!(grade_letter(50), "D");
1326 assert_eq!(grade_letter(30), "E");
1327 assert_eq!(grade_letter(29), "F");
1328 assert_eq!(grade_letter(0), "F");
1329 }
1330
1331 #[test]
1332 fn report_captures_cookies_and_disclosures_from_response() {
1333 let mut response = response_with(&[
1334 ("server", "nginx/1.25.3"),
1335 ("x-powered-by", "PHP/8.2"),
1336 ("x-content-type-options", "nosniff"),
1337 ]);
1338 response.headers.push((
1340 "set-cookie".into(),
1341 "a=1; Secure; HttpOnly; SameSite=Lax".into(),
1342 ));
1343 response.headers.push(("set-cookie".into(), "b=2".into()));
1344
1345 let report = build_report("example.com".to_string(), &response);
1346 assert_eq!(report.cookies.len(), 2);
1347 assert_eq!(report.disclosures.len(), 2);
1348 assert!(report.disclosures.iter().all(|d| d.versioned));
1349 assert!(report.notes.iter().any(|n| n.contains("cookie 'b'")));
1350 assert!(report.notes.iter().any(|n| n.contains("CVE")));
1351 }
1352
1353 #[test]
1354 fn header_values_are_preserved_for_display() {
1355 let response = response_with(&[("x-frame-options", "SAMEORIGIN")]);
1356 let report = build_report("example.com".to_string(), &response);
1357 let xfo = report
1358 .headers
1359 .iter()
1360 .find(|h| h.header == "x-frame-options")
1361 .expect("x-frame-options finding");
1362 assert!(xfo.present);
1363 assert_eq!(xfo.value.as_deref(), Some("SAMEORIGIN"));
1364 }
1365}