Skip to main content

seer_core/
headers.rs

1//! HTTP security-header and cookie audit.
2//!
3//! Seer already inspects a domain's transport layer ([`crate::ssl`],
4//! [`crate::caa`]), its DNS/email layer ([`crate::posture`], [`crate::dns`]),
5//! and its registration layer ([`crate::whois`], [`crate::rdap`]). This module
6//! covers the remaining one: what the origin actually sends back over HTTP.
7//!
8//! A single SSRF-guarded GET (see [`crate::http`]) is issued against
9//! `https://<domain>`, and the response is graded on three axes:
10//!
11//! - **Security headers** — HSTS, CSP, X-Frame-Options, X-Content-Type-Options,
12//!   Referrer-Policy, Permissions-Policy, and the cross-origin isolation trio
13//!   (COOP/COEP/CORP). Each gets a [`HeaderVerdict`] plus an advisory note
14//!   explaining *why* it scored what it did.
15//! - **Cookies** — every `Set-Cookie` is checked for `Secure`, `HttpOnly`, and
16//!   `SameSite`, the flags whose absence turns a session cookie into an XSS or
17//!   CSRF primitive.
18//! - **Disclosure** — `Server`/`X-Powered-By`-style banners that hand an
19//!   attacker a version number to match against a CVE list.
20//!
21//! The verdict scale mirrors [`crate::posture::PostureVerdict`] so the two
22//! security reports read the same way. Scoring is a weighted sum over the
23//! headers plus bounded penalties for cookie and disclosure findings, mapped to
24//! a letter grade.
25//!
26//! Deliberately **one request, no probing**: this reports what the origin
27//! volunteers to any ordinary visitor. It sends no crafted payloads, tries no
28//! paths, and never authenticates — the same non-intrusive stance as
29//! [`crate::status`]. All grading is pure and unit-tested; only the fetch is
30//! async.
31
32use std::time::Duration;
33
34use serde::{Deserialize, Serialize};
35
36use crate::error::Result;
37use crate::http::{FetchedResponse, GuardedFetcher};
38use crate::validation::normalize_host;
39
40/// A coarse enforcement verdict for one header or cookie.
41///
42/// Mirrors [`crate::posture::PostureVerdict`] so `seer headers` and
43/// `seer posture` grade on the same scale.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "kebab-case")]
46pub enum HeaderVerdict {
47    /// Not sent at all.
48    Absent,
49    /// Sent, but configured so permissively it offers little protection.
50    Weak,
51    /// Sent with partial protection.
52    Moderate,
53    /// Sent with full protection.
54    Strict,
55    /// Sent; the mechanism has no weak/strict axis (presence is the signal).
56    Present,
57}
58
59impl HeaderVerdict {
60    /// Fraction of a header's weight this verdict earns, in percent. Integer
61    /// math keeps scoring exactly reproducible across platforms.
62    fn score_pct(self) -> u32 {
63        match self {
64            HeaderVerdict::Strict | HeaderVerdict::Present => 100,
65            HeaderVerdict::Moderate => 60,
66            HeaderVerdict::Weak => 30,
67            HeaderVerdict::Absent => 0,
68        }
69    }
70}
71
72/// The audit result for one security header.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct HeaderFinding {
75    /// Lowercase header name, e.g. `strict-transport-security`.
76    pub header: String,
77    pub present: bool,
78    /// The raw value as sent (untrusted — sanitize before display).
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub value: Option<String>,
81    pub verdict: HeaderVerdict,
82    /// Why this verdict, and what would improve it.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub note: Option<String>,
85}
86
87/// The audit result for one `Set-Cookie`.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CookieFinding {
90    pub name: String,
91    pub secure: bool,
92    pub http_only: bool,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub same_site: Option<String>,
95    pub verdict: HeaderVerdict,
96    /// The specific flags that are missing or unsafe.
97    #[serde(skip_serializing_if = "Vec::is_empty")]
98    pub issues: Vec<String>,
99}
100
101/// A header that discloses software or version information.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct Disclosure {
104    pub header: String,
105    pub value: String,
106    /// True when the value carries something that looks like a version number,
107    /// which is what makes a banner CVE-matchable rather than merely noisy.
108    pub versioned: bool,
109}
110
111/// The full HTTP security-header audit for a domain.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct HeaderReport {
114    pub domain: String,
115    /// The URL that finally answered, after redirects.
116    pub url: String,
117    pub status: u16,
118    /// Number of redirect hops followed to reach `url`.
119    pub redirects: usize,
120    /// Letter grade derived from `score` (A+ through F).
121    pub grade: String,
122    /// 0–100 weighted score.
123    pub score: u32,
124    pub headers: Vec<HeaderFinding>,
125    #[serde(skip_serializing_if = "Vec::is_empty")]
126    pub cookies: Vec<CookieFinding>,
127    #[serde(skip_serializing_if = "Vec::is_empty")]
128    pub disclosures: Vec<Disclosure>,
129    /// Ranked advisory findings, worst first.
130    pub notes: Vec<String>,
131}
132
133/// Default per-audit HTTP timeout, for callers with no configured value of
134/// their own (the Python bindings). The CLI passes `config.http_timeout()`.
135pub const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(10);
136
137/// Per-header weights, summing to 100. Ordering here is also the display order.
138///
139/// The weighting reflects blast radius: HSTS and CSP each defend against a
140/// whole class of attack (transport downgrade, injected script), framing and
141/// MIME-sniffing controls defend against one well-understood attack each, and
142/// the cross-origin isolation trio is a smaller, more situational hardening
143/// step that most sites legitimately do not need.
144const HEADER_WEIGHTS: &[(&str, u32)] = &[
145    ("strict-transport-security", 20),
146    ("content-security-policy", 20),
147    ("x-frame-options", 15),
148    ("x-content-type-options", 10),
149    ("referrer-policy", 10),
150    ("permissions-policy", 10),
151    ("cross-origin-opener-policy", 5),
152    ("cross-origin-embedder-policy", 5),
153    ("cross-origin-resource-policy", 5),
154];
155
156/// HSTS `max-age` at or above which the policy is considered fully enforcing
157/// (1 year — the floor the browser preload lists require).
158const HSTS_STRONG_MAX_AGE: u64 = 31_536_000;
159/// HSTS `max-age` below which the window is too short to be meaningful
160/// (6 months).
161const HSTS_MODERATE_MAX_AGE: u64 = 15_552_000;
162
163/// Penalty per cookie missing a protective flag, and the cap on that penalty.
164const COOKIE_PENALTY: u32 = 4;
165const COOKIE_PENALTY_CAP: u32 = 12;
166/// Penalty per version-disclosing banner, and the cap on that penalty.
167const DISCLOSURE_PENALTY: u32 = 3;
168const DISCLOSURE_PENALTY_CAP: u32 = 6;
169
170// --- Pure grading ------------------------------------------------------
171
172/// Splits a `k=v; k; k=v` header (HSTS, Set-Cookie, CSP directive) into
173/// lowercased-key / raw-value pairs. Valueless attributes map to an empty
174/// string, so `HttpOnly` and `Secure` are found by key alone.
175fn parse_attributes(value: &str) -> Vec<(String, String)> {
176    value
177        .split(';')
178        .filter_map(|part| {
179            let part = part.trim();
180            if part.is_empty() {
181                return None;
182            }
183            Some(match part.split_once('=') {
184                Some((k, v)) => (k.trim().to_ascii_lowercase(), v.trim().to_string()),
185                None => (part.to_ascii_lowercase(), String::new()),
186            })
187        })
188        .collect()
189}
190
191/// Looks up an attribute by lowercase key.
192fn attr<'a>(attrs: &'a [(String, String)], key: &str) -> Option<&'a str> {
193    attrs
194        .iter()
195        .find(|(k, _)| k == key)
196        .map(|(_, v)| v.as_str())
197}
198
199fn grade_hsts(value: Option<&str>, over_https: bool) -> (HeaderVerdict, Option<String>) {
200    let Some(value) = value else {
201        return (
202            HeaderVerdict::Absent,
203            Some("Add Strict-Transport-Security to stop protocol-downgrade and cookie-stripping attacks.".into()),
204        );
205    };
206    // RFC 6797 §8.1: a UA must ignore STS received over non-secure transport,
207    // so a header on a page that redirected down to http:// protects nothing.
208    if !over_https {
209        return (
210            HeaderVerdict::Absent,
211            Some("Strict-Transport-Security was served over plain HTTP, where browsers ignore it; serve the final page over HTTPS.".into()),
212        );
213    }
214    let attrs = parse_attributes(value);
215    // RFC 6797 allows the value as a token or a quoted-string.
216    let max_age: Option<u64> =
217        attr(&attrs, "max-age").and_then(|v| v.trim().trim_matches('"').parse().ok());
218    let include_subdomains = attr(&attrs, "includesubdomains").is_some();
219
220    match max_age {
221        None | Some(0) => (
222            HeaderVerdict::Weak,
223            Some("max-age is missing or zero, which disables HSTS entirely.".into()),
224        ),
225        Some(age) if age >= HSTS_STRONG_MAX_AGE && include_subdomains => {
226            (HeaderVerdict::Strict, None)
227        }
228        Some(age) if age >= HSTS_STRONG_MAX_AGE => (
229            HeaderVerdict::Moderate,
230            Some("max-age is strong but includeSubDomains is missing — subdomains stay downgradable.".into()),
231        ),
232        Some(age) if age >= HSTS_MODERATE_MAX_AGE => (
233            HeaderVerdict::Moderate,
234            Some(format!(
235                "max-age={age} is below the 31536000 (1 year) that preload requires."
236            )),
237        ),
238        Some(age) => (
239            HeaderVerdict::Weak,
240            Some(format!(
241                "max-age={age} is a short window; use at least 15552000 (6 months)."
242            )),
243        ),
244    }
245}
246
247/// One parsed CSP policy: lowercase directive name → lowercase source tokens.
248type CspPolicy = Vec<(String, Vec<String>)>;
249
250/// Parses every enforced `Content-Security-Policy` value into its policies.
251///
252/// Browsers enforce *every* policy delivered — across repeated header lines
253/// and comma-separated lists within one line — and a resource must satisfy
254/// all of them, so grading only the first header misjudges sites that split
255/// their policy (e.g. `frame-ancestors` in one header, `default-src` in the
256/// next).
257fn parse_csp_policies<'a>(values: impl IntoIterator<Item = &'a str>) -> Vec<CspPolicy> {
258    values
259        .into_iter()
260        .flat_map(|v| v.split(','))
261        .map(|policy| {
262            policy
263                .split(';')
264                .filter_map(|directive| {
265                    let mut tokens = directive.split_ascii_whitespace();
266                    let name = tokens.next()?.to_ascii_lowercase();
267                    Some((name, tokens.map(str::to_ascii_lowercase).collect()))
268                })
269                .collect::<CspPolicy>()
270        })
271        .filter(|p| !p.is_empty())
272        .collect()
273}
274
275fn csp_directive<'a>(policy: &'a CspPolicy, name: &str) -> Option<&'a [String]> {
276    policy
277        .iter()
278        .find(|(n, _)| n == name)
279        .map(|(_, sources)| sources.as_slice())
280}
281
282/// How one policy constrains script execution.
283enum ScriptControl {
284    /// Neither `script-src` nor `default-src`: scripts are unrestricted.
285    None,
286    /// Restricted, but through the named unsafe keywords.
287    Unsafe(Vec<&'static str>),
288    /// Restricted with no effective unsafe keyword.
289    Safe,
290}
291
292fn script_control(policy: &CspPolicy) -> ScriptControl {
293    // `script-src` governs scripts; `default-src` is only its fallback.
294    let Some(sources) =
295        csp_directive(policy, "script-src").or_else(|| csp_directive(policy, "default-src"))
296    else {
297        return ScriptControl::None;
298    };
299    let has = |t: &str| sources.iter().any(|s| s == t);
300    // CSP2+: a nonce or hash makes browsers ignore 'unsafe-inline' (it is
301    // kept only as a fallback for CSP1 browsers), and CSP3 'strict-dynamic'
302    // does the same — Google's recommended strict policy relies on this.
303    let inline_neutralized = has("'strict-dynamic'")
304        || sources.iter().any(|s| {
305            s.starts_with("'nonce-")
306                || s.starts_with("'sha256-")
307                || s.starts_with("'sha384-")
308                || s.starts_with("'sha512-")
309        });
310    let mut unsafe_kw = Vec::new();
311    if has("'unsafe-inline'") && !inline_neutralized {
312        unsafe_kw.push("'unsafe-inline'");
313    }
314    if has("'unsafe-eval'") {
315        unsafe_kw.push("'unsafe-eval'");
316    }
317    if unsafe_kw.is_empty() {
318        ScriptControl::Safe
319    } else {
320        ScriptControl::Unsafe(unsafe_kw)
321    }
322}
323
324fn grade_csp(policies: &[CspPolicy], report_only: bool) -> (HeaderVerdict, Option<String>) {
325    if policies.is_empty() {
326        if report_only {
327            return (
328                HeaderVerdict::Weak,
329                Some("Only Content-Security-Policy-Report-Only is set — it reports violations but blocks nothing.".into()),
330            );
331        }
332        return (
333            HeaderVerdict::Absent,
334            Some(
335                "Add Content-Security-Policy — it is the primary defense against injected script."
336                    .into(),
337            ),
338        );
339    };
340
341    // Every policy must allow a script for it to run, so one policy that
342    // restricts scripts safely protects the page regardless of the others.
343    let controls: Vec<ScriptControl> = policies.iter().map(script_control).collect();
344    if controls.iter().any(|c| matches!(c, ScriptControl::Safe)) {
345        return (HeaderVerdict::Strict, None);
346    }
347
348    let mut which: Vec<&str> = Vec::new();
349    for control in &controls {
350        if let ScriptControl::Unsafe(kw) = control {
351            for k in kw {
352                if !which.contains(k) {
353                    which.push(k);
354                }
355            }
356        }
357    }
358    if !which.is_empty() {
359        return (
360            HeaderVerdict::Weak,
361            Some(format!(
362                "Script policy allows {} — this reopens the injection hole CSP exists to close.",
363                which.join(" and ")
364            )),
365        );
366    }
367
368    (
369        HeaderVerdict::Moderate,
370        Some(
371            "Policy sets neither default-src nor script-src, so script loading is unrestricted."
372                .into(),
373        ),
374    )
375}
376
377fn grade_frame_options(
378    value: Option<&str>,
379    csp_policies: &[CspPolicy],
380) -> (HeaderVerdict, Option<String>) {
381    // CSP frame-ancestors supersedes X-Frame-Options in every current browser;
382    // a site that sets it is protected even with no XFO header at all — and
383    // browsers ignore XFO entirely when frame-ancestors is present.
384    let csp_frame_ancestors = csp_policies
385        .iter()
386        .any(|p| csp_directive(p, "frame-ancestors").is_some());
387
388    let Some(value) = value else {
389        if csp_frame_ancestors {
390            return (
391                HeaderVerdict::Present,
392                Some(
393                    "No X-Frame-Options, but CSP frame-ancestors is set, which supersedes it."
394                        .into(),
395                ),
396            );
397        }
398        return (
399            HeaderVerdict::Absent,
400            Some(
401                "Add X-Frame-Options (or CSP frame-ancestors) to prevent clickjacking via framing."
402                    .into(),
403            ),
404        );
405    };
406
407    match value.trim().to_ascii_uppercase().as_str() {
408        "DENY" | "SAMEORIGIN" => (HeaderVerdict::Strict, None),
409        // An obsolete or invalid XFO is harmless once frame-ancestors is set:
410        // that is exactly the migration the ALLOW-FROM advice recommends.
411        other if csp_frame_ancestors => (
412            HeaderVerdict::Present,
413            Some(format!(
414                "X-Frame-Options '{other}' is not a valid value, but CSP frame-ancestors is set and supersedes it."
415            )),
416        ),
417        v if v.starts_with("ALLOW-FROM") => (
418            HeaderVerdict::Weak,
419            Some(
420                "ALLOW-FROM is obsolete and ignored by modern browsers; use CSP frame-ancestors."
421                    .into(),
422            ),
423        ),
424        other => (
425            HeaderVerdict::Weak,
426            Some(format!(
427                "Unrecognized value '{other}' — use DENY or SAMEORIGIN."
428            )),
429        ),
430    }
431}
432
433fn grade_content_type_options(value: Option<&str>) -> (HeaderVerdict, Option<String>) {
434    match value.map(|v| v.trim().to_ascii_lowercase()) {
435        None => (
436            HeaderVerdict::Absent,
437            Some(
438                "Add X-Content-Type-Options: nosniff to stop MIME-sniffing of user-supplied files."
439                    .into(),
440            ),
441        ),
442        Some(ref v) if v == "nosniff" => (HeaderVerdict::Strict, None),
443        Some(other) => (
444            HeaderVerdict::Weak,
445            Some(format!(
446                "Value '{other}' is not recognized; the only valid value is nosniff."
447            )),
448        ),
449    }
450}
451
452fn grade_referrer_policy(value: Option<&str>) -> (HeaderVerdict, Option<String>) {
453    let Some(value) = value else {
454        return (
455            HeaderVerdict::Absent,
456            Some(
457                "Add Referrer-Policy so URLs (and any secrets in them) do not leak cross-origin."
458                    .into(),
459            ),
460        );
461    };
462    let strict = [
463        "no-referrer",
464        "same-origin",
465        "strict-origin",
466        "strict-origin-when-cross-origin",
467    ];
468    let moderate = [
469        "origin",
470        "origin-when-cross-origin",
471        "no-referrer-when-downgrade",
472    ];
473
474    // A list is a fallback chain: the browser applies the LAST token it
475    // understands (Referrer Policy §8.1), so `no-referrer, unsafe-url` is
476    // enforced as unsafe-url. Grading the strongest token would overstate it.
477    let effective = value
478        .split(',')
479        .map(|t| t.trim().to_ascii_lowercase())
480        .rev()
481        .find(|t| {
482            strict.contains(&t.as_str()) || moderate.contains(&t.as_str()) || t == "unsafe-url"
483        });
484
485    if effective.as_deref().is_some_and(|t| strict.contains(&t)) {
486        (HeaderVerdict::Strict, None)
487    } else if effective.as_deref().is_some_and(|t| moderate.contains(&t)) {
488        (
489            HeaderVerdict::Moderate,
490            Some("Policy still sends the origin cross-site; strict-origin-when-cross-origin is tighter.".into()),
491        )
492    } else if effective.as_deref() == Some("unsafe-url") {
493        (
494            HeaderVerdict::Weak,
495            Some(
496                "unsafe-url sends the full URL to every destination, including over plain HTTP."
497                    .into(),
498            ),
499        )
500    } else {
501        (
502            HeaderVerdict::Weak,
503            Some(format!("Unrecognized policy '{}'.", value.trim())),
504        )
505    }
506}
507
508/// Grades a header whose presence is the whole signal (Permissions-Policy).
509fn grade_presence(value: Option<&str>, advice: &str) -> (HeaderVerdict, Option<String>) {
510    match value {
511        Some(v) if !v.trim().is_empty() => (HeaderVerdict::Present, None),
512        _ => (HeaderVerdict::Absent, Some(advice.to_string())),
513    }
514}
515
516/// Grades a header against an ordered `(value, verdict)` table.
517fn grade_enum(
518    value: Option<&str>,
519    table: &[(&str, HeaderVerdict)],
520    absent_advice: &str,
521) -> (HeaderVerdict, Option<String>) {
522    let Some(value) = value else {
523        return (HeaderVerdict::Absent, Some(absent_advice.to_string()));
524    };
525    // COOP/COEP/CORP are structured headers: the token may carry parameters
526    // (`require-corp; report-to="default"`), which do not change the policy.
527    let v = value
528        .split(';')
529        .next()
530        .unwrap_or_default()
531        .trim()
532        .to_ascii_lowercase();
533    match table.iter().find(|(k, _)| *k == v) {
534        Some((_, verdict)) => (*verdict, None),
535        None => (
536            HeaderVerdict::Weak,
537            Some(format!("Unrecognized value '{}'.", value.trim())),
538        ),
539    }
540}
541
542/// Grades every `Set-Cookie` on the response.
543fn grade_cookies(cookies: &[String]) -> Vec<CookieFinding> {
544    cookies
545        .iter()
546        .map(|raw| {
547            // The first pair is `name=value`; only what follows it are
548            // attributes. Parsing the whole string would let a cookie named
549            // `secure` (or `HttpOnly`, `SameSite`) masquerade as the flag.
550            let attrs = parse_attributes(raw.split_once(';').map_or("", |(_, rest)| rest));
551            // Take the name from the raw header rather than the parsed attrs:
552            // cookie names are case-sensitive, and `parse_attributes`
553            // lowercases keys so attribute lookups can be case-insensitive.
554            let name = raw
555                .split(';')
556                .next()
557                .map(|first| first.split_once('=').map_or(first, |(k, _)| k).trim())
558                .filter(|n| !n.is_empty())
559                .unwrap_or("(unnamed)")
560                .to_string();
561
562            let secure = attr(&attrs, "secure").is_some();
563            let http_only = attr(&attrs, "httponly").is_some();
564            let same_site = attr(&attrs, "samesite").map(|v| v.to_string());
565
566            let mut issues = Vec::new();
567            if !secure {
568                issues.push("missing Secure (may be sent over plain HTTP)".to_string());
569            }
570            if !http_only {
571                issues.push("missing HttpOnly (readable by JavaScript/XSS)".to_string());
572            }
573            match same_site.as_deref().map(|s| s.to_ascii_lowercase()) {
574                None => issues.push("missing SameSite (CSRF exposure)".to_string()),
575                Some(ref s) if s == "none" && !secure => {
576                    issues.push("SameSite=None without Secure is rejected by browsers".to_string());
577                }
578                _ => {}
579            }
580
581            let verdict = match issues.len() {
582                0 => HeaderVerdict::Strict,
583                1 => HeaderVerdict::Moderate,
584                _ => HeaderVerdict::Weak,
585            };
586
587            CookieFinding {
588                name,
589                secure,
590                http_only,
591                same_site,
592                verdict,
593                issues,
594            }
595        })
596        .collect()
597}
598
599/// True when a banner carries something version-shaped (`nginx/1.25.3`,
600/// `PHP/8.2`), which is what makes it CVE-matchable.
601fn looks_versioned(value: &str) -> bool {
602    let bytes = value.as_bytes();
603    bytes.windows(2).any(|w| {
604        // A digit adjacent to '/' or '.' is the shape of a version token.
605        (w[0] == b'/' && w[1].is_ascii_digit())
606            || (w[0].is_ascii_digit() && w[1] == b'.')
607            || (w[0] == b'.' && w[1].is_ascii_digit())
608    })
609}
610
611/// Headers that routinely leak software identity.
612const DISCLOSURE_HEADERS: &[&str] = &[
613    "server",
614    "x-powered-by",
615    "x-aspnet-version",
616    "x-aspnetmvc-version",
617    "x-generator",
618    "x-drupal-cache",
619];
620
621fn collect_disclosures(response: &FetchedResponse) -> Vec<Disclosure> {
622    DISCLOSURE_HEADERS
623        .iter()
624        .filter_map(|name| {
625            let value = response.header(name)?;
626            if value.trim().is_empty() {
627                return None;
628            }
629            Some(Disclosure {
630                header: (*name).to_string(),
631                value: value.to_string(),
632                versioned: looks_versioned(value),
633            })
634        })
635        .collect()
636}
637
638/// Computes the 0–100 score from the graded headers plus bounded penalties.
639fn compute_score(
640    headers: &[HeaderFinding],
641    cookies: &[CookieFinding],
642    disclosures: &[Disclosure],
643) -> u32 {
644    let earned: u32 = headers
645        .iter()
646        .map(|f| {
647            let weight = HEADER_WEIGHTS
648                .iter()
649                .find(|(name, _)| *name == f.header)
650                .map(|(_, w)| *w)
651                .unwrap_or(0);
652            weight * f.verdict.score_pct() / 100
653        })
654        .sum();
655
656    let cookie_penalty = (cookies.iter().filter(|c| !c.issues.is_empty()).count() as u32
657        * COOKIE_PENALTY)
658        .min(COOKIE_PENALTY_CAP);
659
660    let disclosure_penalty = (disclosures.iter().filter(|d| d.versioned).count() as u32
661        * DISCLOSURE_PENALTY)
662        .min(DISCLOSURE_PENALTY_CAP);
663
664    earned.saturating_sub(cookie_penalty + disclosure_penalty)
665}
666
667/// Maps a score to a letter grade.
668fn grade_letter(score: u32) -> &'static str {
669    match score {
670        95..=u32::MAX => "A+",
671        85..=94 => "A",
672        75..=84 => "B",
673        65..=74 => "C",
674        50..=64 => "D",
675        30..=49 => "E",
676        _ => "F",
677    }
678}
679
680/// Builds the ranked advisory list from the graded findings.
681fn build_notes(
682    headers: &[HeaderFinding],
683    cookies: &[CookieFinding],
684    disclosures: &[Disclosure],
685) -> Vec<String> {
686    let mut notes = Vec::new();
687
688    // Missing headers first, in weight order (HEADER_WEIGHTS order), so the
689    // highest-impact gap is the first thing an operator reads.
690    for finding in headers {
691        if let Some(note) = &finding.note {
692            notes.push(format!("{}: {}", finding.header, note));
693        }
694    }
695
696    for cookie in cookies {
697        if !cookie.issues.is_empty() {
698            notes.push(format!(
699                "cookie '{}': {}",
700                cookie.name,
701                cookie.issues.join("; ")
702            ));
703        }
704    }
705
706    for d in disclosures.iter().filter(|d| d.versioned) {
707        notes.push(format!(
708            "{} discloses a version ('{}') — an attacker can match it against known CVEs.",
709            d.header, d.value
710        ));
711    }
712
713    notes
714}
715
716/// Grades an already-fetched response. Pure, so the whole ruleset is
717/// unit-testable without any network.
718fn build_report(domain: String, response: &FetchedResponse) -> HeaderReport {
719    let csp_policies = parse_csp_policies(response.header_all("content-security-policy"));
720    let csp_report_only = response
721        .header("content-security-policy-report-only")
722        .is_some();
723    let over_https = response
724        .final_url
725        .get(..8)
726        .is_some_and(|scheme| scheme.eq_ignore_ascii_case("https://"));
727
728    let graded: Vec<(&str, (HeaderVerdict, Option<String>))> = vec![
729        (
730            "strict-transport-security",
731            grade_hsts(response.header("strict-transport-security"), over_https),
732        ),
733        (
734            "content-security-policy",
735            grade_csp(&csp_policies, csp_report_only),
736        ),
737        (
738            "x-frame-options",
739            grade_frame_options(response.header("x-frame-options"), &csp_policies),
740        ),
741        (
742            "x-content-type-options",
743            grade_content_type_options(response.header("x-content-type-options")),
744        ),
745        (
746            "referrer-policy",
747            grade_referrer_policy(response.header("referrer-policy")),
748        ),
749        (
750            "permissions-policy",
751            grade_presence(
752                response.header("permissions-policy"),
753                "Add Permissions-Policy to disable powerful browser features the site does not use.",
754            ),
755        ),
756        (
757            "cross-origin-opener-policy",
758            grade_enum(
759                response.header("cross-origin-opener-policy"),
760                &[
761                    ("same-origin", HeaderVerdict::Strict),
762                    ("same-origin-allow-popups", HeaderVerdict::Moderate),
763                    ("unsafe-none", HeaderVerdict::Weak),
764                ],
765                "Add Cross-Origin-Opener-Policy: same-origin to isolate the browsing context.",
766            ),
767        ),
768        (
769            "cross-origin-embedder-policy",
770            grade_enum(
771                response.header("cross-origin-embedder-policy"),
772                &[
773                    ("require-corp", HeaderVerdict::Strict),
774                    ("credentialless", HeaderVerdict::Strict),
775                    ("unsafe-none", HeaderVerdict::Weak),
776                ],
777                "Add Cross-Origin-Embedder-Policy: require-corp to block un-opted-in cross-origin loads.",
778            ),
779        ),
780        (
781            "cross-origin-resource-policy",
782            grade_enum(
783                response.header("cross-origin-resource-policy"),
784                &[
785                    ("same-origin", HeaderVerdict::Strict),
786                    ("same-site", HeaderVerdict::Moderate),
787                    ("cross-origin", HeaderVerdict::Weak),
788                ],
789                "Add Cross-Origin-Resource-Policy to control who may embed this origin's resources.",
790            ),
791        ),
792    ];
793
794    // Emit in HEADER_WEIGHTS order so display, notes, and scoring agree.
795    let headers: Vec<HeaderFinding> = HEADER_WEIGHTS
796        .iter()
797        .filter_map(|(name, _)| {
798            let (_, (verdict, note)) = graded.iter().find(|(n, _)| n == name)?;
799            // Show every delivered value (repeated header lines are one
800            // comma-joined field per RFC 9110), not just the first — CSP in
801            // particular is graded across all of them.
802            let values: Vec<&str> = response.header_all(name).collect();
803            let value = (!values.is_empty()).then(|| values.join(", "));
804            Some(HeaderFinding {
805                header: (*name).to_string(),
806                present: value.is_some(),
807                value,
808                verdict: *verdict,
809                note: note.clone(),
810            })
811        })
812        .collect();
813
814    let set_cookies: Vec<String> = response
815        .header_all("set-cookie")
816        .map(|v| v.to_string())
817        .collect();
818    let cookies = grade_cookies(&set_cookies);
819    let disclosures = collect_disclosures(response);
820
821    let score = compute_score(&headers, &cookies, &disclosures);
822    let notes = build_notes(&headers, &cookies, &disclosures);
823
824    HeaderReport {
825        domain,
826        url: response.final_url.clone(),
827        status: response.status,
828        redirects: response.redirects,
829        grade: grade_letter(score).to_string(),
830        score,
831        headers,
832        cookies,
833        disclosures,
834        notes,
835    }
836}
837
838/// Audits the HTTP security headers served by `domain`.
839///
840/// Issues a single SSRF-guarded `GET https://<domain>/`, following redirects
841/// (each one re-validated) and grading the response that finally answers.
842///
843/// # Arguments
844/// * `domain` - Domain to audit (normalized internally)
845/// * `timeout` - Per-hop HTTP timeout
846///
847/// # Returns
848/// * `Ok(HeaderReport)` - the graded audit
849/// * `Err(SeerError::InvalidDomain)` - the domain is not valid
850/// * `Err(SeerError::HttpError)` - the host is SSRF-blocked or unreachable
851///
852/// # Example
853/// ```no_run
854/// # async fn demo() -> seer_core::Result<()> {
855/// use std::time::Duration;
856/// let report = seer_core::headers::audit_headers("example.com", Duration::from_secs(10)).await?;
857/// println!("{} scored {}", report.domain, report.grade);
858/// # Ok(())
859/// # }
860/// ```
861pub async fn audit_headers(domain: &str, timeout: Duration) -> Result<HeaderReport> {
862    // The exact host: `www.example.com` is often a different origin (with
863    // different headers) than the apex, so `www.` is kept.
864    let domain = normalize_host(domain)?;
865    let fetcher = GuardedFetcher::new().with_timeout(timeout);
866    let response = fetcher.get(&format!("https://{domain}/")).await?;
867    Ok(build_report(domain, &response))
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    /// Builds a synthetic response so the grading rules can be tested without
875    /// any network.
876    fn response_with(headers: &[(&str, &str)]) -> FetchedResponse {
877        FetchedResponse {
878            final_url: "https://example.com/".to_string(),
879            status: 200,
880            headers: headers
881                .iter()
882                .map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
883                .collect(),
884            body: String::new(),
885            redirects: 0,
886        }
887    }
888
889    /// Parses CSP header values the way `build_report` does.
890    fn csp(values: &[&str]) -> Vec<CspPolicy> {
891        parse_csp_policies(values.iter().copied())
892    }
893
894    #[test]
895    fn hsts_grading_bands() {
896        assert_eq!(grade_hsts(None, true).0, HeaderVerdict::Absent);
897        assert_eq!(
898            grade_hsts(Some("max-age=31536000; includeSubDomains"), true).0,
899            HeaderVerdict::Strict
900        );
901        // Strong max-age but no includeSubDomains leaves subdomains exposed.
902        assert_eq!(
903            grade_hsts(Some("max-age=31536000"), true).0,
904            HeaderVerdict::Moderate
905        );
906        assert_eq!(
907            grade_hsts(Some("max-age=15552000; includeSubDomains"), true).0,
908            HeaderVerdict::Moderate
909        );
910        assert_eq!(grade_hsts(Some("max-age=300"), true).0, HeaderVerdict::Weak);
911        // max-age=0 actively disables HSTS.
912        assert_eq!(grade_hsts(Some("max-age=0"), true).0, HeaderVerdict::Weak);
913        assert_eq!(
914            grade_hsts(Some("includeSubDomains"), true).0,
915            HeaderVerdict::Weak
916        );
917    }
918
919    #[test]
920    fn csp_unsafe_directives_are_weak() {
921        assert_eq!(
922            grade_csp(&csp(&["default-src 'self' 'unsafe-inline'"]), false).0,
923            HeaderVerdict::Weak
924        );
925        assert_eq!(
926            grade_csp(&csp(&["script-src 'unsafe-eval'"]), false).0,
927            HeaderVerdict::Weak
928        );
929        assert_eq!(
930            grade_csp(&csp(&["default-src 'self'"]), false).0,
931            HeaderVerdict::Strict
932        );
933        // A policy with no script-governing directive restricts nothing.
934        assert_eq!(
935            grade_csp(&csp(&["img-src 'self'"]), false).0,
936            HeaderVerdict::Moderate
937        );
938        assert_eq!(grade_csp(&[], false).0, HeaderVerdict::Absent);
939        // Report-Only alone blocks nothing, but is better than silence.
940        assert_eq!(grade_csp(&[], true).0, HeaderVerdict::Weak);
941    }
942
943    #[test]
944    fn csp_frame_ancestors_substitutes_for_x_frame_options() {
945        // Modern browsers honor frame-ancestors over XFO, so a site using it
946        // must not be marked as missing clickjacking protection.
947        let (verdict, note) =
948            grade_frame_options(None, &csp(&["default-src 'self'; frame-ancestors 'none'"]));
949        assert_eq!(verdict, HeaderVerdict::Present);
950        assert!(note.unwrap_or_default().contains("frame-ancestors"));
951
952        // Without either, it is a real gap.
953        assert_eq!(
954            grade_frame_options(None, &csp(&["default-src 'self'"])).0,
955            HeaderVerdict::Absent
956        );
957        assert_eq!(
958            grade_frame_options(Some("DENY"), &[]).0,
959            HeaderVerdict::Strict
960        );
961        assert_eq!(
962            grade_frame_options(Some("SAMEORIGIN"), &[]).0,
963            HeaderVerdict::Strict
964        );
965        // ALLOW-FROM is obsolete and silently ignored.
966        assert_eq!(
967            grade_frame_options(Some("ALLOW-FROM https://x.com"), &[]).0,
968            HeaderVerdict::Weak
969        );
970    }
971
972    #[test]
973    fn hsts_quoted_max_age_and_insecure_transport() {
974        // RFC 6797 permits a quoted-string value.
975        assert_eq!(
976            grade_hsts(Some("max-age=\"31536000\"; includeSubDomains"), true).0,
977            HeaderVerdict::Strict
978        );
979        // Browsers ignore STS received over plain HTTP.
980        assert_eq!(
981            grade_hsts(Some("max-age=31536000; includeSubDomains"), false).0,
982            HeaderVerdict::Absent
983        );
984        let mut response = response_with(&[(
985            "Strict-Transport-Security",
986            "max-age=31536000; includeSubDomains",
987        )]);
988        response.final_url = "http://example.com/".to_string();
989        let report = build_report("example.com".into(), &response);
990        let hsts = report
991            .headers
992            .iter()
993            .find(|h| h.header == "strict-transport-security")
994            .unwrap();
995        assert_eq!(hsts.verdict, HeaderVerdict::Absent);
996    }
997
998    #[test]
999    fn csp_nonce_hash_and_strict_dynamic_neutralize_unsafe_inline() {
1000        // Google's recommended strict CSP keeps 'unsafe-inline' only as a
1001        // CSP1 fallback; nonce/strict-dynamic make browsers ignore it.
1002        assert_eq!(
1003            grade_csp(
1004                &csp(&["script-src 'nonce-r4nd' 'unsafe-inline' 'strict-dynamic' https:; object-src 'none'; base-uri 'none'"]),
1005                false
1006            )
1007            .0,
1008            HeaderVerdict::Strict
1009        );
1010        assert_eq!(
1011            grade_csp(&csp(&["script-src 'sha256-abc=' 'unsafe-inline'"]), false).0,
1012            HeaderVerdict::Strict
1013        );
1014        // 'unsafe-eval' is not neutralized by a nonce.
1015        assert_eq!(
1016            grade_csp(&csp(&["script-src 'nonce-x' 'unsafe-eval'"]), false).0,
1017            HeaderVerdict::Weak
1018        );
1019        // 'unsafe-inline' that only reaches styles does not reopen script
1020        // injection.
1021        assert_eq!(
1022            grade_csp(
1023                &csp(&["default-src 'self'; style-src 'self' 'unsafe-inline'"]),
1024                false
1025            )
1026            .0,
1027            HeaderVerdict::Strict
1028        );
1029        // script-src overrides a safe default-src.
1030        assert_eq!(
1031            grade_csp(
1032                &csp(&["default-src 'self'; script-src 'self' 'unsafe-inline'"]),
1033                false
1034            )
1035            .0,
1036            HeaderVerdict::Weak
1037        );
1038    }
1039
1040    #[test]
1041    fn csp_is_graded_across_every_delivered_policy() {
1042        // Policy split over two header lines: scripts are still restricted.
1043        let response = response_with(&[
1044            ("Content-Security-Policy", "frame-ancestors 'self'"),
1045            ("Content-Security-Policy", "default-src 'self'"),
1046        ]);
1047        let report = build_report("example.com".into(), &response);
1048        let find = |name: &str| {
1049            report
1050                .headers
1051                .iter()
1052                .find(|h| h.header == name)
1053                .unwrap()
1054                .clone()
1055        };
1056        assert_eq!(
1057            find("content-security-policy").verdict,
1058            HeaderVerdict::Strict
1059        );
1060        // frame-ancestors in the first header still supersedes a missing XFO.
1061        assert_eq!(find("x-frame-options").verdict, HeaderVerdict::Present);
1062        // Both values are shown.
1063        assert_eq!(
1064            find("content-security-policy").value.as_deref(),
1065            Some("frame-ancestors 'self', default-src 'self'")
1066        );
1067        // A comma-separated list in one line is also several policies, and
1068        // one safe policy is enough because every policy must pass.
1069        assert_eq!(
1070            grade_csp(
1071                &csp(&["script-src 'unsafe-inline', script-src 'self'"]),
1072                false
1073            )
1074            .0,
1075            HeaderVerdict::Strict
1076        );
1077    }
1078
1079    #[test]
1080    fn invalid_xfo_is_superseded_by_frame_ancestors() {
1081        // Exactly the migration the ALLOW-FROM advice recommends.
1082        let (verdict, note) = grade_frame_options(
1083            Some("ALLOW-FROM https://x.example"),
1084            &csp(&["frame-ancestors 'self'"]),
1085        );
1086        assert_eq!(verdict, HeaderVerdict::Present);
1087        assert!(note.unwrap_or_default().contains("frame-ancestors"));
1088        // Still Weak without frame-ancestors.
1089        assert_eq!(
1090            grade_frame_options(Some("ALLOW-FROM https://x.example"), &[]).0,
1091            HeaderVerdict::Weak
1092        );
1093    }
1094
1095    #[test]
1096    fn cross_origin_policies_ignore_structured_header_parameters() {
1097        let coep = [
1098            ("require-corp", HeaderVerdict::Strict),
1099            ("unsafe-none", HeaderVerdict::Weak),
1100        ];
1101        assert_eq!(
1102            grade_enum(Some("require-corp; report-to=\"default\""), &coep, "x").0,
1103            HeaderVerdict::Strict
1104        );
1105        let coop = [
1106            ("same-origin", HeaderVerdict::Strict),
1107            ("same-origin-allow-popups", HeaderVerdict::Moderate),
1108        ];
1109        assert_eq!(
1110            grade_enum(
1111                Some("same-origin-allow-popups; report-to=\"gws\""),
1112                &coop,
1113                "x"
1114            )
1115            .0,
1116            HeaderVerdict::Moderate
1117        );
1118    }
1119
1120    #[test]
1121    fn cookie_name_is_not_mistaken_for_an_attribute() {
1122        let findings = grade_cookies(&[
1123            "secure=1; Path=/".to_string(),
1124            "HttpOnly=yes; SameSite=Lax".to_string(),
1125        ]);
1126        assert_eq!(findings[0].name, "secure");
1127        assert!(!findings[0].secure, "a cookie NAMED secure is not Secure");
1128        assert!(!findings[1].http_only);
1129        assert_eq!(findings[1].same_site.as_deref(), Some("Lax"));
1130    }
1131
1132    #[test]
1133    fn referrer_policy_bands() {
1134        assert_eq!(
1135            grade_referrer_policy(Some("strict-origin-when-cross-origin")).0,
1136            HeaderVerdict::Strict
1137        );
1138        assert_eq!(
1139            grade_referrer_policy(Some("no-referrer")).0,
1140            HeaderVerdict::Strict
1141        );
1142        assert_eq!(
1143            grade_referrer_policy(Some("origin")).0,
1144            HeaderVerdict::Moderate
1145        );
1146        assert_eq!(
1147            grade_referrer_policy(Some("unsafe-url")).0,
1148            HeaderVerdict::Weak
1149        );
1150        // A fallback list is graded on the LAST understood token — the one
1151        // the browser actually applies.
1152        assert_eq!(
1153            grade_referrer_policy(Some("no-referrer, strict-origin-when-cross-origin")).0,
1154            HeaderVerdict::Strict
1155        );
1156        assert_eq!(
1157            grade_referrer_policy(Some("no-referrer, unsafe-url")).0,
1158            HeaderVerdict::Weak
1159        );
1160        assert_eq!(
1161            grade_referrer_policy(Some("strict-origin, origin")).0,
1162            HeaderVerdict::Moderate
1163        );
1164        // An unknown trailing token falls back to the last known one.
1165        assert_eq!(
1166            grade_referrer_policy(Some("no-referrer, some-future-policy")).0,
1167            HeaderVerdict::Strict
1168        );
1169        assert_eq!(grade_referrer_policy(None).0, HeaderVerdict::Absent);
1170    }
1171
1172    #[test]
1173    fn content_type_options_only_accepts_nosniff() {
1174        assert_eq!(
1175            grade_content_type_options(Some("nosniff")).0,
1176            HeaderVerdict::Strict
1177        );
1178        assert_eq!(
1179            grade_content_type_options(Some("NOSNIFF")).0,
1180            HeaderVerdict::Strict
1181        );
1182        assert_eq!(
1183            grade_content_type_options(Some("sniff")).0,
1184            HeaderVerdict::Weak
1185        );
1186        assert_eq!(grade_content_type_options(None).0, HeaderVerdict::Absent);
1187    }
1188
1189    #[test]
1190    fn cookie_names_preserve_case() {
1191        // Cookie names are case-sensitive; reporting JSESSIONID as
1192        // "jsessionid" would misname the cookie an operator has to go fix.
1193        let findings = grade_cookies(&[
1194            "JSESSIONID=abc; Secure".to_string(),
1195            "WMF-Last-Access=1; Secure; HttpOnly".to_string(),
1196        ]);
1197        assert_eq!(findings[0].name, "JSESSIONID");
1198        assert_eq!(findings[1].name, "WMF-Last-Access");
1199        // Attribute matching stays case-insensitive.
1200        assert!(findings[1].secure);
1201        assert!(findings[1].http_only);
1202    }
1203
1204    #[test]
1205    fn cookie_flags_are_graded_and_named() {
1206        let findings = grade_cookies(&[
1207            "session=abc; Secure; HttpOnly; SameSite=Strict".to_string(),
1208            "tracker=xyz".to_string(),
1209            "mixed=1; Secure".to_string(),
1210        ]);
1211        assert_eq!(findings[0].name, "session");
1212        assert_eq!(findings[0].verdict, HeaderVerdict::Strict);
1213        assert!(findings[0].issues.is_empty());
1214
1215        // Missing all three protective flags.
1216        assert_eq!(findings[1].name, "tracker");
1217        assert_eq!(findings[1].verdict, HeaderVerdict::Weak);
1218        assert_eq!(findings[1].issues.len(), 3);
1219
1220        // Secure only → missing HttpOnly and SameSite.
1221        assert_eq!(findings[2].verdict, HeaderVerdict::Weak);
1222        assert!(findings[2].secure);
1223        assert!(!findings[2].http_only);
1224    }
1225
1226    #[test]
1227    fn same_site_none_without_secure_is_flagged() {
1228        let findings = grade_cookies(&["c=1; HttpOnly; SameSite=None".to_string()]);
1229        assert!(
1230            findings[0]
1231                .issues
1232                .iter()
1233                .any(|i| i.contains("SameSite=None without Secure")),
1234            "got {:?}",
1235            findings[0].issues
1236        );
1237    }
1238
1239    #[test]
1240    fn version_disclosure_detection() {
1241        assert!(looks_versioned("nginx/1.25.3"));
1242        assert!(looks_versioned("PHP/8.2.1"));
1243        assert!(looks_versioned("Apache/2.4"));
1244        // A bare product name is noise, not a CVE-matchable banner.
1245        assert!(!looks_versioned("cloudflare"));
1246        assert!(!looks_versioned("nginx"));
1247    }
1248
1249    #[test]
1250    fn perfect_headers_score_a_plus() {
1251        let response = response_with(&[
1252            (
1253                "strict-transport-security",
1254                "max-age=63072000; includeSubDomains; preload",
1255            ),
1256            ("content-security-policy", "default-src 'self'"),
1257            ("x-frame-options", "DENY"),
1258            ("x-content-type-options", "nosniff"),
1259            ("referrer-policy", "no-referrer"),
1260            ("permissions-policy", "geolocation=()"),
1261            ("cross-origin-opener-policy", "same-origin"),
1262            ("cross-origin-embedder-policy", "require-corp"),
1263            ("cross-origin-resource-policy", "same-origin"),
1264        ]);
1265        let report = build_report("example.com".to_string(), &response);
1266        assert_eq!(report.score, 100, "notes: {:?}", report.notes);
1267        assert_eq!(report.grade, "A+");
1268        assert!(report.notes.is_empty(), "got {:?}", report.notes);
1269    }
1270
1271    #[test]
1272    fn bare_response_scores_f_and_lists_every_gap() {
1273        let report = build_report("example.com".to_string(), &response_with(&[]));
1274        assert_eq!(report.score, 0);
1275        assert_eq!(report.grade, "F");
1276        // Every weighted header should produce an advisory.
1277        assert_eq!(report.headers.len(), HEADER_WEIGHTS.len());
1278        assert_eq!(report.notes.len(), HEADER_WEIGHTS.len());
1279        assert!(report.headers.iter().all(|h| !h.present));
1280    }
1281
1282    #[test]
1283    fn penalties_apply_but_stay_bounded() {
1284        // Ten flawed cookies and several versioned banners must not drive the
1285        // score below zero or exceed the documented caps.
1286        let cookies = grade_cookies(&(0..10).map(|i| format!("c{i}=1")).collect::<Vec<_>>());
1287        let disclosures = vec![
1288            Disclosure {
1289                header: "server".into(),
1290                value: "nginx/1.0".into(),
1291                versioned: true,
1292            },
1293            Disclosure {
1294                header: "x-powered-by".into(),
1295                value: "PHP/8.0".into(),
1296                versioned: true,
1297            },
1298            Disclosure {
1299                header: "x-generator".into(),
1300                value: "Drupal/9.1".into(),
1301                versioned: true,
1302            },
1303        ];
1304        // A perfect header set (100) minus the capped penalties.
1305        let headers: Vec<HeaderFinding> = HEADER_WEIGHTS
1306            .iter()
1307            .map(|(name, _)| HeaderFinding {
1308                header: (*name).to_string(),
1309                present: true,
1310                value: None,
1311                verdict: HeaderVerdict::Strict,
1312                note: None,
1313            })
1314            .collect();
1315        let score = compute_score(&headers, &cookies, &disclosures);
1316        assert_eq!(score, 100 - COOKIE_PENALTY_CAP - DISCLOSURE_PENALTY_CAP);
1317
1318        // And penalties can never underflow past zero.
1319        let empty: Vec<HeaderFinding> = Vec::new();
1320        assert_eq!(compute_score(&empty, &cookies, &disclosures), 0);
1321    }
1322
1323    #[test]
1324    fn weights_sum_to_one_hundred() {
1325        // The score is presented as a percentage, so the weights must total 100
1326        // or a flawless site could never reach A+.
1327        let total: u32 = HEADER_WEIGHTS.iter().map(|(_, w)| *w).sum();
1328        assert_eq!(total, 100);
1329    }
1330
1331    #[test]
1332    fn grade_bands_are_ordered_and_total() {
1333        assert_eq!(grade_letter(100), "A+");
1334        assert_eq!(grade_letter(95), "A+");
1335        assert_eq!(grade_letter(94), "A");
1336        assert_eq!(grade_letter(85), "A");
1337        assert_eq!(grade_letter(75), "B");
1338        assert_eq!(grade_letter(65), "C");
1339        assert_eq!(grade_letter(50), "D");
1340        assert_eq!(grade_letter(30), "E");
1341        assert_eq!(grade_letter(29), "F");
1342        assert_eq!(grade_letter(0), "F");
1343    }
1344
1345    #[test]
1346    fn report_captures_cookies_and_disclosures_from_response() {
1347        let mut response = response_with(&[
1348            ("server", "nginx/1.25.3"),
1349            ("x-powered-by", "PHP/8.2"),
1350            ("x-content-type-options", "nosniff"),
1351        ]);
1352        // Two Set-Cookie headers, as a real origin would send them.
1353        response.headers.push((
1354            "set-cookie".into(),
1355            "a=1; Secure; HttpOnly; SameSite=Lax".into(),
1356        ));
1357        response.headers.push(("set-cookie".into(), "b=2".into()));
1358
1359        let report = build_report("example.com".to_string(), &response);
1360        assert_eq!(report.cookies.len(), 2);
1361        assert_eq!(report.disclosures.len(), 2);
1362        assert!(report.disclosures.iter().all(|d| d.versioned));
1363        assert!(report.notes.iter().any(|n| n.contains("cookie 'b'")));
1364        assert!(report.notes.iter().any(|n| n.contains("CVE")));
1365    }
1366
1367    #[test]
1368    fn header_values_are_preserved_for_display() {
1369        let response = response_with(&[("x-frame-options", "SAMEORIGIN")]);
1370        let report = build_report("example.com".to_string(), &response);
1371        let xfo = report
1372            .headers
1373            .iter()
1374            .find(|h| h.header == "x-frame-options")
1375            .expect("x-frame-options finding");
1376        assert!(xfo.present);
1377        assert_eq!(xfo.value.as_deref(), Some("SAMEORIGIN"));
1378    }
1379}