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