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