Skip to main content

seer_core/
headers.rs

1//! HTTP security-header and cookie audit.
2//!
3//! Seer already inspects a domain's transport layer ([`crate::ssl`],
4//! [`crate::caa`]), its DNS/email layer ([`crate::posture`], [`crate::dns`]),
5//! and its registration layer ([`crate::whois`], [`crate::rdap`]). This module
6//! covers the remaining one: what the origin actually sends back over HTTP.
7//!
8//! A single SSRF-guarded GET (see [`crate::http`]) is issued against
9//! `https://<domain>`, and the response is graded on three axes:
10//!
11//! - **Security headers** — HSTS, CSP, X-Frame-Options, X-Content-Type-Options,
12//!   Referrer-Policy, Permissions-Policy, and the cross-origin isolation trio
13//!   (COOP/COEP/CORP). Each gets a [`HeaderVerdict`] plus an advisory note
14//!   explaining *why* it scored what it did.
15//! - **Cookies** — every `Set-Cookie` is checked for `Secure`, `HttpOnly`, and
16//!   `SameSite`, the flags whose absence turns a session cookie into an XSS or
17//!   CSRF primitive.
18//! - **Disclosure** — `Server`/`X-Powered-By`-style banners that hand an
19//!   attacker a version number to match against a CVE list.
20//!
21//! The verdict scale mirrors [`crate::posture::PostureVerdict`] so the two
22//! security reports read the same way. Scoring is a weighted sum over the
23//! headers plus bounded penalties for cookie and disclosure findings, mapped to
24//! a letter grade.
25//!
26//! Deliberately **one request, no probing**: this reports what the origin
27//! volunteers to any ordinary visitor. It sends no crafted payloads, tries no
28//! paths, and never authenticates — the same non-intrusive stance as
29//! [`crate::status`]. All grading is pure and unit-tested; only the fetch is
30//! async.
31
32use std::time::Duration;
33
34use serde::{Deserialize, Serialize};
35
36use crate::error::Result;
37use crate::http::{FetchedResponse, GuardedFetcher};
38use crate::validation::normalize_domain;
39
40/// A coarse enforcement verdict for one header or cookie.
41///
42/// Mirrors [`crate::posture::PostureVerdict`] so `seer headers` and
43/// `seer posture` grade on the same scale.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "kebab-case")]
46pub enum HeaderVerdict {
47    /// Not sent at all.
48    Absent,
49    /// Sent, but configured so permissively it offers little protection.
50    Weak,
51    /// Sent with partial protection.
52    Moderate,
53    /// Sent with full protection.
54    Strict,
55    /// Sent; the mechanism has no weak/strict axis (presence is the signal).
56    Present,
57}
58
59impl HeaderVerdict {
60    /// Fraction of a header's weight this verdict earns, in percent. Integer
61    /// math keeps scoring exactly reproducible across platforms.
62    fn score_pct(self) -> u32 {
63        match self {
64            HeaderVerdict::Strict | HeaderVerdict::Present => 100,
65            HeaderVerdict::Moderate => 60,
66            HeaderVerdict::Weak => 30,
67            HeaderVerdict::Absent => 0,
68        }
69    }
70}
71
72/// The audit result for one security header.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct HeaderFinding {
75    /// Lowercase header name, e.g. `strict-transport-security`.
76    pub header: String,
77    pub present: bool,
78    /// The raw value as sent (untrusted — sanitize before display).
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub value: Option<String>,
81    pub verdict: HeaderVerdict,
82    /// Why this verdict, and what would improve it.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub note: Option<String>,
85}
86
87/// The audit result for one `Set-Cookie`.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CookieFinding {
90    pub name: String,
91    pub secure: bool,
92    pub http_only: bool,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub same_site: Option<String>,
95    pub verdict: HeaderVerdict,
96    /// The specific flags that are missing or unsafe.
97    #[serde(skip_serializing_if = "Vec::is_empty")]
98    pub issues: Vec<String>,
99}
100
101/// A header that discloses software or version information.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct Disclosure {
104    pub header: String,
105    pub value: String,
106    /// True when the value carries something that looks like a version number,
107    /// which is what makes a banner CVE-matchable rather than merely noisy.
108    pub versioned: bool,
109}
110
111/// The full HTTP security-header audit for a domain.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct HeaderReport {
114    pub domain: String,
115    /// The URL that finally answered, after redirects.
116    pub url: String,
117    pub status: u16,
118    /// Number of redirect hops followed to reach `url`.
119    pub redirects: usize,
120    /// Letter grade derived from `score` (A+ through F).
121    pub grade: String,
122    /// 0–100 weighted score.
123    pub score: u32,
124    pub headers: Vec<HeaderFinding>,
125    #[serde(skip_serializing_if = "Vec::is_empty")]
126    pub cookies: Vec<CookieFinding>,
127    #[serde(skip_serializing_if = "Vec::is_empty")]
128    pub disclosures: Vec<Disclosure>,
129    /// Ranked advisory findings, worst first.
130    pub notes: Vec<String>,
131}
132
133/// Default per-audit HTTP timeout, for callers with no configured value of
134/// their own (the Python bindings). The CLI passes `config.http_timeout()`.
135pub const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(10);
136
137/// Per-header weights, summing to 100. Ordering here is also the display order.
138///
139/// The weighting reflects blast radius: HSTS and CSP each defend against a
140/// whole class of attack (transport downgrade, injected script), framing and
141/// MIME-sniffing controls defend against one well-understood attack each, and
142/// the cross-origin isolation trio is a smaller, more situational hardening
143/// step that most sites legitimately do not need.
144const HEADER_WEIGHTS: &[(&str, u32)] = &[
145    ("strict-transport-security", 20),
146    ("content-security-policy", 20),
147    ("x-frame-options", 15),
148    ("x-content-type-options", 10),
149    ("referrer-policy", 10),
150    ("permissions-policy", 10),
151    ("cross-origin-opener-policy", 5),
152    ("cross-origin-embedder-policy", 5),
153    ("cross-origin-resource-policy", 5),
154];
155
156/// HSTS `max-age` at or above which the policy is considered fully enforcing
157/// (1 year — the floor the browser preload lists require).
158const HSTS_STRONG_MAX_AGE: u64 = 31_536_000;
159/// HSTS `max-age` below which the window is too short to be meaningful
160/// (6 months).
161const HSTS_MODERATE_MAX_AGE: u64 = 15_552_000;
162
163/// Penalty per cookie missing a protective flag, and the cap on that penalty.
164const COOKIE_PENALTY: u32 = 4;
165const COOKIE_PENALTY_CAP: u32 = 12;
166/// Penalty per version-disclosing banner, and the cap on that penalty.
167const DISCLOSURE_PENALTY: u32 = 3;
168const DISCLOSURE_PENALTY_CAP: u32 = 6;
169
170// --- Pure grading ------------------------------------------------------
171
172/// Splits a `k=v; k; k=v` header (HSTS, Set-Cookie, CSP directive) into
173/// lowercased-key / raw-value pairs. Valueless attributes map to an empty
174/// string, so `HttpOnly` and `Secure` are found by key alone.
175fn parse_attributes(value: &str) -> Vec<(String, String)> {
176    value
177        .split(';')
178        .filter_map(|part| {
179            let part = part.trim();
180            if part.is_empty() {
181                return None;
182            }
183            Some(match part.split_once('=') {
184                Some((k, v)) => (k.trim().to_ascii_lowercase(), v.trim().to_string()),
185                None => (part.to_ascii_lowercase(), String::new()),
186            })
187        })
188        .collect()
189}
190
191/// Looks up an attribute by lowercase key.
192fn attr<'a>(attrs: &'a [(String, String)], key: &str) -> Option<&'a str> {
193    attrs
194        .iter()
195        .find(|(k, _)| k == key)
196        .map(|(_, v)| v.as_str())
197}
198
199fn grade_hsts(value: Option<&str>) -> (HeaderVerdict, Option<String>) {
200    let Some(value) = value else {
201        return (
202            HeaderVerdict::Absent,
203            Some("Add Strict-Transport-Security to stop protocol-downgrade and cookie-stripping attacks.".into()),
204        );
205    };
206    let attrs = parse_attributes(value);
207    let max_age: Option<u64> = attr(&attrs, "max-age").and_then(|v| v.trim().parse().ok());
208    let include_subdomains = attr(&attrs, "includesubdomains").is_some();
209
210    match max_age {
211        None | Some(0) => (
212            HeaderVerdict::Weak,
213            Some("max-age is missing or zero, which disables HSTS entirely.".into()),
214        ),
215        Some(age) if age >= HSTS_STRONG_MAX_AGE && include_subdomains => {
216            (HeaderVerdict::Strict, None)
217        }
218        Some(age) if age >= HSTS_STRONG_MAX_AGE => (
219            HeaderVerdict::Moderate,
220            Some("max-age is strong but includeSubDomains is missing — subdomains stay downgradable.".into()),
221        ),
222        Some(age) if age >= HSTS_MODERATE_MAX_AGE => (
223            HeaderVerdict::Moderate,
224            Some(format!(
225                "max-age={age} is below the 31536000 (1 year) that preload requires."
226            )),
227        ),
228        Some(age) => (
229            HeaderVerdict::Weak,
230            Some(format!(
231                "max-age={age} is a short window; use at least 15552000 (6 months)."
232            )),
233        ),
234    }
235}
236
237fn grade_csp(value: Option<&str>, report_only: bool) -> (HeaderVerdict, Option<String>) {
238    let Some(value) = value else {
239        if report_only {
240            return (
241                HeaderVerdict::Weak,
242                Some("Only Content-Security-Policy-Report-Only is set — it reports violations but blocks nothing.".into()),
243            );
244        }
245        return (
246            HeaderVerdict::Absent,
247            Some(
248                "Add Content-Security-Policy — it is the primary defense against injected script."
249                    .into(),
250            ),
251        );
252    };
253
254    let lower = value.to_ascii_lowercase();
255    let unsafe_inline = lower.contains("'unsafe-inline'");
256    let unsafe_eval = lower.contains("'unsafe-eval'");
257    let has_baseline = lower.contains("default-src") || lower.contains("script-src");
258
259    if unsafe_inline || unsafe_eval {
260        let mut which = Vec::new();
261        if unsafe_inline {
262            which.push("'unsafe-inline'");
263        }
264        if unsafe_eval {
265            which.push("'unsafe-eval'");
266        }
267        return (
268            HeaderVerdict::Weak,
269            Some(format!(
270                "Policy allows {} — this reopens the injection hole CSP exists to close.",
271                which.join(" and ")
272            )),
273        );
274    }
275
276    if !has_baseline {
277        return (
278            HeaderVerdict::Moderate,
279            Some("Policy sets neither default-src nor script-src, so script loading is unrestricted.".into()),
280        );
281    }
282
283    (HeaderVerdict::Strict, None)
284}
285
286fn grade_frame_options(value: Option<&str>, csp: Option<&str>) -> (HeaderVerdict, Option<String>) {
287    // CSP frame-ancestors supersedes X-Frame-Options in every current browser;
288    // a site that sets it is protected even with no XFO header at all.
289    let csp_frame_ancestors = csp
290        .map(|c| c.to_ascii_lowercase().contains("frame-ancestors"))
291        .unwrap_or(false);
292
293    let Some(value) = value else {
294        if csp_frame_ancestors {
295            return (
296                HeaderVerdict::Present,
297                Some(
298                    "No X-Frame-Options, but CSP frame-ancestors is set, which supersedes it."
299                        .into(),
300                ),
301            );
302        }
303        return (
304            HeaderVerdict::Absent,
305            Some(
306                "Add X-Frame-Options (or CSP frame-ancestors) to prevent clickjacking via framing."
307                    .into(),
308            ),
309        );
310    };
311
312    match value.trim().to_ascii_uppercase().as_str() {
313        "DENY" | "SAMEORIGIN" => (HeaderVerdict::Strict, None),
314        v if v.starts_with("ALLOW-FROM") => (
315            HeaderVerdict::Weak,
316            Some(
317                "ALLOW-FROM is obsolete and ignored by modern browsers; use CSP frame-ancestors."
318                    .into(),
319            ),
320        ),
321        other => (
322            HeaderVerdict::Weak,
323            Some(format!(
324                "Unrecognized value '{other}' — use DENY or SAMEORIGIN."
325            )),
326        ),
327    }
328}
329
330fn grade_content_type_options(value: Option<&str>) -> (HeaderVerdict, Option<String>) {
331    match value.map(|v| v.trim().to_ascii_lowercase()) {
332        None => (
333            HeaderVerdict::Absent,
334            Some(
335                "Add X-Content-Type-Options: nosniff to stop MIME-sniffing of user-supplied files."
336                    .into(),
337            ),
338        ),
339        Some(ref v) if v == "nosniff" => (HeaderVerdict::Strict, None),
340        Some(other) => (
341            HeaderVerdict::Weak,
342            Some(format!(
343                "Value '{other}' is not recognized; the only valid value is nosniff."
344            )),
345        ),
346    }
347}
348
349fn grade_referrer_policy(value: Option<&str>) -> (HeaderVerdict, Option<String>) {
350    let Some(value) = value else {
351        return (
352            HeaderVerdict::Absent,
353            Some(
354                "Add Referrer-Policy so URLs (and any secrets in them) do not leak cross-origin."
355                    .into(),
356            ),
357        );
358    };
359    // A list picks the last token the browser understands; grading the
360    // strongest present token is a close, simpler approximation.
361    let tokens: Vec<String> = value
362        .split(',')
363        .map(|t| t.trim().to_ascii_lowercase())
364        .collect();
365
366    let strict = [
367        "no-referrer",
368        "same-origin",
369        "strict-origin",
370        "strict-origin-when-cross-origin",
371    ];
372    let moderate = [
373        "origin",
374        "origin-when-cross-origin",
375        "no-referrer-when-downgrade",
376    ];
377
378    if tokens.iter().any(|t| strict.contains(&t.as_str())) {
379        (HeaderVerdict::Strict, None)
380    } else if tokens.iter().any(|t| moderate.contains(&t.as_str())) {
381        (
382            HeaderVerdict::Moderate,
383            Some("Policy still sends the origin cross-site; strict-origin-when-cross-origin is tighter.".into()),
384        )
385    } else if tokens.iter().any(|t| t == "unsafe-url") {
386        (
387            HeaderVerdict::Weak,
388            Some(
389                "unsafe-url sends the full URL to every destination, including over plain HTTP."
390                    .into(),
391            ),
392        )
393    } else {
394        (
395            HeaderVerdict::Weak,
396            Some(format!("Unrecognized policy '{}'.", value.trim())),
397        )
398    }
399}
400
401/// Grades a header whose presence is the whole signal (Permissions-Policy).
402fn grade_presence(value: Option<&str>, advice: &str) -> (HeaderVerdict, Option<String>) {
403    match value {
404        Some(v) if !v.trim().is_empty() => (HeaderVerdict::Present, None),
405        _ => (HeaderVerdict::Absent, Some(advice.to_string())),
406    }
407}
408
409/// Grades a header against an ordered `(value, verdict)` table.
410fn grade_enum(
411    value: Option<&str>,
412    table: &[(&str, HeaderVerdict)],
413    absent_advice: &str,
414) -> (HeaderVerdict, Option<String>) {
415    let Some(value) = value else {
416        return (HeaderVerdict::Absent, Some(absent_advice.to_string()));
417    };
418    let v = value.trim().to_ascii_lowercase();
419    match table.iter().find(|(k, _)| *k == v) {
420        Some((_, verdict)) => (*verdict, None),
421        None => (
422            HeaderVerdict::Weak,
423            Some(format!("Unrecognized value '{}'.", value.trim())),
424        ),
425    }
426}
427
428/// Grades every `Set-Cookie` on the response.
429fn grade_cookies(cookies: &[String]) -> Vec<CookieFinding> {
430    cookies
431        .iter()
432        .map(|raw| {
433            let attrs = parse_attributes(raw);
434            // The first pair is `name=value`; everything after is attributes.
435            // Take the name from the raw header rather than the parsed attrs:
436            // cookie names are case-sensitive, and `parse_attributes`
437            // lowercases keys so attribute lookups can be case-insensitive.
438            let name = raw
439                .split(';')
440                .next()
441                .map(|first| first.split_once('=').map_or(first, |(k, _)| k).trim())
442                .filter(|n| !n.is_empty())
443                .unwrap_or("(unnamed)")
444                .to_string();
445
446            let secure = attr(&attrs, "secure").is_some();
447            let http_only = attr(&attrs, "httponly").is_some();
448            let same_site = attr(&attrs, "samesite").map(|v| v.to_string());
449
450            let mut issues = Vec::new();
451            if !secure {
452                issues.push("missing Secure (may be sent over plain HTTP)".to_string());
453            }
454            if !http_only {
455                issues.push("missing HttpOnly (readable by JavaScript/XSS)".to_string());
456            }
457            match same_site.as_deref().map(|s| s.to_ascii_lowercase()) {
458                None => issues.push("missing SameSite (CSRF exposure)".to_string()),
459                Some(ref s) if s == "none" && !secure => {
460                    issues.push("SameSite=None without Secure is rejected by browsers".to_string());
461                }
462                _ => {}
463            }
464
465            let verdict = match issues.len() {
466                0 => HeaderVerdict::Strict,
467                1 => HeaderVerdict::Moderate,
468                _ => HeaderVerdict::Weak,
469            };
470
471            CookieFinding {
472                name,
473                secure,
474                http_only,
475                same_site,
476                verdict,
477                issues,
478            }
479        })
480        .collect()
481}
482
483/// True when a banner carries something version-shaped (`nginx/1.25.3`,
484/// `PHP/8.2`), which is what makes it CVE-matchable.
485fn looks_versioned(value: &str) -> bool {
486    let bytes = value.as_bytes();
487    bytes.windows(2).any(|w| {
488        // A digit adjacent to '/' or '.' is the shape of a version token.
489        (w[0] == b'/' && w[1].is_ascii_digit())
490            || (w[0].is_ascii_digit() && w[1] == b'.')
491            || (w[0] == b'.' && w[1].is_ascii_digit())
492    })
493}
494
495/// Headers that routinely leak software identity.
496const DISCLOSURE_HEADERS: &[&str] = &[
497    "server",
498    "x-powered-by",
499    "x-aspnet-version",
500    "x-aspnetmvc-version",
501    "x-generator",
502    "x-drupal-cache",
503];
504
505fn collect_disclosures(response: &FetchedResponse) -> Vec<Disclosure> {
506    DISCLOSURE_HEADERS
507        .iter()
508        .filter_map(|name| {
509            let value = response.header(name)?;
510            if value.trim().is_empty() {
511                return None;
512            }
513            Some(Disclosure {
514                header: (*name).to_string(),
515                value: value.to_string(),
516                versioned: looks_versioned(value),
517            })
518        })
519        .collect()
520}
521
522/// Computes the 0–100 score from the graded headers plus bounded penalties.
523fn compute_score(
524    headers: &[HeaderFinding],
525    cookies: &[CookieFinding],
526    disclosures: &[Disclosure],
527) -> u32 {
528    let earned: u32 = headers
529        .iter()
530        .map(|f| {
531            let weight = HEADER_WEIGHTS
532                .iter()
533                .find(|(name, _)| *name == f.header)
534                .map(|(_, w)| *w)
535                .unwrap_or(0);
536            weight * f.verdict.score_pct() / 100
537        })
538        .sum();
539
540    let cookie_penalty = (cookies.iter().filter(|c| !c.issues.is_empty()).count() as u32
541        * COOKIE_PENALTY)
542        .min(COOKIE_PENALTY_CAP);
543
544    let disclosure_penalty = (disclosures.iter().filter(|d| d.versioned).count() as u32
545        * DISCLOSURE_PENALTY)
546        .min(DISCLOSURE_PENALTY_CAP);
547
548    earned.saturating_sub(cookie_penalty + disclosure_penalty)
549}
550
551/// Maps a score to a letter grade.
552fn grade_letter(score: u32) -> &'static str {
553    match score {
554        95..=u32::MAX => "A+",
555        85..=94 => "A",
556        75..=84 => "B",
557        65..=74 => "C",
558        50..=64 => "D",
559        30..=49 => "E",
560        _ => "F",
561    }
562}
563
564/// Builds the ranked advisory list from the graded findings.
565fn build_notes(
566    headers: &[HeaderFinding],
567    cookies: &[CookieFinding],
568    disclosures: &[Disclosure],
569) -> Vec<String> {
570    let mut notes = Vec::new();
571
572    // Missing headers first, in weight order (HEADER_WEIGHTS order), so the
573    // highest-impact gap is the first thing an operator reads.
574    for finding in headers {
575        if let Some(note) = &finding.note {
576            notes.push(format!("{}: {}", finding.header, note));
577        }
578    }
579
580    for cookie in cookies {
581        if !cookie.issues.is_empty() {
582            notes.push(format!(
583                "cookie '{}': {}",
584                cookie.name,
585                cookie.issues.join("; ")
586            ));
587        }
588    }
589
590    for d in disclosures.iter().filter(|d| d.versioned) {
591        notes.push(format!(
592            "{} discloses a version ('{}') — an attacker can match it against known CVEs.",
593            d.header, d.value
594        ));
595    }
596
597    notes
598}
599
600/// Grades an already-fetched response. Pure, so the whole ruleset is
601/// unit-testable without any network.
602fn build_report(domain: String, response: &FetchedResponse) -> HeaderReport {
603    let csp = response.header("content-security-policy");
604    let csp_report_only = response
605        .header("content-security-policy-report-only")
606        .is_some();
607
608    let graded: Vec<(&str, (HeaderVerdict, Option<String>))> = vec![
609        (
610            "strict-transport-security",
611            grade_hsts(response.header("strict-transport-security")),
612        ),
613        ("content-security-policy", grade_csp(csp, csp_report_only)),
614        (
615            "x-frame-options",
616            grade_frame_options(response.header("x-frame-options"), csp),
617        ),
618        (
619            "x-content-type-options",
620            grade_content_type_options(response.header("x-content-type-options")),
621        ),
622        (
623            "referrer-policy",
624            grade_referrer_policy(response.header("referrer-policy")),
625        ),
626        (
627            "permissions-policy",
628            grade_presence(
629                response.header("permissions-policy"),
630                "Add Permissions-Policy to disable powerful browser features the site does not use.",
631            ),
632        ),
633        (
634            "cross-origin-opener-policy",
635            grade_enum(
636                response.header("cross-origin-opener-policy"),
637                &[
638                    ("same-origin", HeaderVerdict::Strict),
639                    ("same-origin-allow-popups", HeaderVerdict::Moderate),
640                    ("unsafe-none", HeaderVerdict::Weak),
641                ],
642                "Add Cross-Origin-Opener-Policy: same-origin to isolate the browsing context.",
643            ),
644        ),
645        (
646            "cross-origin-embedder-policy",
647            grade_enum(
648                response.header("cross-origin-embedder-policy"),
649                &[
650                    ("require-corp", HeaderVerdict::Strict),
651                    ("credentialless", HeaderVerdict::Strict),
652                    ("unsafe-none", HeaderVerdict::Weak),
653                ],
654                "Add Cross-Origin-Embedder-Policy: require-corp to block un-opted-in cross-origin loads.",
655            ),
656        ),
657        (
658            "cross-origin-resource-policy",
659            grade_enum(
660                response.header("cross-origin-resource-policy"),
661                &[
662                    ("same-origin", HeaderVerdict::Strict),
663                    ("same-site", HeaderVerdict::Moderate),
664                    ("cross-origin", HeaderVerdict::Weak),
665                ],
666                "Add Cross-Origin-Resource-Policy to control who may embed this origin's resources.",
667            ),
668        ),
669    ];
670
671    // Emit in HEADER_WEIGHTS order so display, notes, and scoring agree.
672    let headers: Vec<HeaderFinding> = HEADER_WEIGHTS
673        .iter()
674        .filter_map(|(name, _)| {
675            let (_, (verdict, note)) = graded.iter().find(|(n, _)| n == name)?;
676            let value = response.header(name).map(|v| v.to_string());
677            Some(HeaderFinding {
678                header: (*name).to_string(),
679                present: value.is_some(),
680                value,
681                verdict: *verdict,
682                note: note.clone(),
683            })
684        })
685        .collect();
686
687    let set_cookies: Vec<String> = response
688        .header_all("set-cookie")
689        .map(|v| v.to_string())
690        .collect();
691    let cookies = grade_cookies(&set_cookies);
692    let disclosures = collect_disclosures(response);
693
694    let score = compute_score(&headers, &cookies, &disclosures);
695    let notes = build_notes(&headers, &cookies, &disclosures);
696
697    HeaderReport {
698        domain,
699        url: response.final_url.clone(),
700        status: response.status,
701        redirects: response.redirects,
702        grade: grade_letter(score).to_string(),
703        score,
704        headers,
705        cookies,
706        disclosures,
707        notes,
708    }
709}
710
711/// Audits the HTTP security headers served by `domain`.
712///
713/// Issues a single SSRF-guarded `GET https://<domain>/`, following redirects
714/// (each one re-validated) and grading the response that finally answers.
715///
716/// # Arguments
717/// * `domain` - Domain to audit (normalized internally)
718/// * `timeout` - Per-hop HTTP timeout
719///
720/// # Returns
721/// * `Ok(HeaderReport)` - the graded audit
722/// * `Err(SeerError::InvalidDomain)` - the domain is not valid
723/// * `Err(SeerError::HttpError)` - the host is SSRF-blocked or unreachable
724///
725/// # Example
726/// ```no_run
727/// # async fn demo() -> seer_core::Result<()> {
728/// use std::time::Duration;
729/// let report = seer_core::headers::audit_headers("example.com", Duration::from_secs(10)).await?;
730/// println!("{} scored {}", report.domain, report.grade);
731/// # Ok(())
732/// # }
733/// ```
734pub async fn audit_headers(domain: &str, timeout: Duration) -> Result<HeaderReport> {
735    let domain = normalize_domain(domain)?;
736    let fetcher = GuardedFetcher::new().with_timeout(timeout);
737    let response = fetcher.get(&format!("https://{domain}/")).await?;
738    Ok(build_report(domain, &response))
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744
745    /// Builds a synthetic response so the grading rules can be tested without
746    /// any network.
747    fn response_with(headers: &[(&str, &str)]) -> FetchedResponse {
748        FetchedResponse {
749            final_url: "https://example.com/".to_string(),
750            status: 200,
751            headers: headers
752                .iter()
753                .map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
754                .collect(),
755            body: String::new(),
756            redirects: 0,
757        }
758    }
759
760    #[test]
761    fn hsts_grading_bands() {
762        assert_eq!(grade_hsts(None).0, HeaderVerdict::Absent);
763        assert_eq!(
764            grade_hsts(Some("max-age=31536000; includeSubDomains")).0,
765            HeaderVerdict::Strict
766        );
767        // Strong max-age but no includeSubDomains leaves subdomains exposed.
768        assert_eq!(
769            grade_hsts(Some("max-age=31536000")).0,
770            HeaderVerdict::Moderate
771        );
772        assert_eq!(
773            grade_hsts(Some("max-age=15552000; includeSubDomains")).0,
774            HeaderVerdict::Moderate
775        );
776        assert_eq!(grade_hsts(Some("max-age=300")).0, HeaderVerdict::Weak);
777        // max-age=0 actively disables HSTS.
778        assert_eq!(grade_hsts(Some("max-age=0")).0, HeaderVerdict::Weak);
779        assert_eq!(grade_hsts(Some("includeSubDomains")).0, HeaderVerdict::Weak);
780    }
781
782    #[test]
783    fn csp_unsafe_directives_are_weak() {
784        assert_eq!(
785            grade_csp(Some("default-src 'self' 'unsafe-inline'"), false).0,
786            HeaderVerdict::Weak
787        );
788        assert_eq!(
789            grade_csp(Some("script-src 'unsafe-eval'"), false).0,
790            HeaderVerdict::Weak
791        );
792        assert_eq!(
793            grade_csp(Some("default-src 'self'"), false).0,
794            HeaderVerdict::Strict
795        );
796        // A policy with no script-governing directive restricts nothing.
797        assert_eq!(
798            grade_csp(Some("img-src 'self'"), false).0,
799            HeaderVerdict::Moderate
800        );
801        assert_eq!(grade_csp(None, false).0, HeaderVerdict::Absent);
802        // Report-Only alone blocks nothing, but is better than silence.
803        assert_eq!(grade_csp(None, true).0, HeaderVerdict::Weak);
804    }
805
806    #[test]
807    fn csp_frame_ancestors_substitutes_for_x_frame_options() {
808        // Modern browsers honor frame-ancestors over XFO, so a site using it
809        // must not be marked as missing clickjacking protection.
810        let (verdict, note) =
811            grade_frame_options(None, Some("default-src 'self'; frame-ancestors 'none'"));
812        assert_eq!(verdict, HeaderVerdict::Present);
813        assert!(note.unwrap_or_default().contains("frame-ancestors"));
814
815        // Without either, it is a real gap.
816        assert_eq!(
817            grade_frame_options(None, Some("default-src 'self'")).0,
818            HeaderVerdict::Absent
819        );
820        assert_eq!(
821            grade_frame_options(Some("DENY"), None).0,
822            HeaderVerdict::Strict
823        );
824        assert_eq!(
825            grade_frame_options(Some("SAMEORIGIN"), None).0,
826            HeaderVerdict::Strict
827        );
828        // ALLOW-FROM is obsolete and silently ignored.
829        assert_eq!(
830            grade_frame_options(Some("ALLOW-FROM https://x.com"), None).0,
831            HeaderVerdict::Weak
832        );
833    }
834
835    #[test]
836    fn referrer_policy_bands() {
837        assert_eq!(
838            grade_referrer_policy(Some("strict-origin-when-cross-origin")).0,
839            HeaderVerdict::Strict
840        );
841        assert_eq!(
842            grade_referrer_policy(Some("no-referrer")).0,
843            HeaderVerdict::Strict
844        );
845        assert_eq!(
846            grade_referrer_policy(Some("origin")).0,
847            HeaderVerdict::Moderate
848        );
849        assert_eq!(
850            grade_referrer_policy(Some("unsafe-url")).0,
851            HeaderVerdict::Weak
852        );
853        // A fallback list is graded on its strongest understood token.
854        assert_eq!(
855            grade_referrer_policy(Some("no-referrer, strict-origin-when-cross-origin")).0,
856            HeaderVerdict::Strict
857        );
858        assert_eq!(grade_referrer_policy(None).0, HeaderVerdict::Absent);
859    }
860
861    #[test]
862    fn content_type_options_only_accepts_nosniff() {
863        assert_eq!(
864            grade_content_type_options(Some("nosniff")).0,
865            HeaderVerdict::Strict
866        );
867        assert_eq!(
868            grade_content_type_options(Some("NOSNIFF")).0,
869            HeaderVerdict::Strict
870        );
871        assert_eq!(
872            grade_content_type_options(Some("sniff")).0,
873            HeaderVerdict::Weak
874        );
875        assert_eq!(grade_content_type_options(None).0, HeaderVerdict::Absent);
876    }
877
878    #[test]
879    fn cookie_names_preserve_case() {
880        // Cookie names are case-sensitive; reporting JSESSIONID as
881        // "jsessionid" would misname the cookie an operator has to go fix.
882        let findings = grade_cookies(&[
883            "JSESSIONID=abc; Secure".to_string(),
884            "WMF-Last-Access=1; Secure; HttpOnly".to_string(),
885        ]);
886        assert_eq!(findings[0].name, "JSESSIONID");
887        assert_eq!(findings[1].name, "WMF-Last-Access");
888        // Attribute matching stays case-insensitive.
889        assert!(findings[1].secure);
890        assert!(findings[1].http_only);
891    }
892
893    #[test]
894    fn cookie_flags_are_graded_and_named() {
895        let findings = grade_cookies(&[
896            "session=abc; Secure; HttpOnly; SameSite=Strict".to_string(),
897            "tracker=xyz".to_string(),
898            "mixed=1; Secure".to_string(),
899        ]);
900        assert_eq!(findings[0].name, "session");
901        assert_eq!(findings[0].verdict, HeaderVerdict::Strict);
902        assert!(findings[0].issues.is_empty());
903
904        // Missing all three protective flags.
905        assert_eq!(findings[1].name, "tracker");
906        assert_eq!(findings[1].verdict, HeaderVerdict::Weak);
907        assert_eq!(findings[1].issues.len(), 3);
908
909        // Secure only → missing HttpOnly and SameSite.
910        assert_eq!(findings[2].verdict, HeaderVerdict::Weak);
911        assert!(findings[2].secure);
912        assert!(!findings[2].http_only);
913    }
914
915    #[test]
916    fn same_site_none_without_secure_is_flagged() {
917        let findings = grade_cookies(&["c=1; HttpOnly; SameSite=None".to_string()]);
918        assert!(
919            findings[0]
920                .issues
921                .iter()
922                .any(|i| i.contains("SameSite=None without Secure")),
923            "got {:?}",
924            findings[0].issues
925        );
926    }
927
928    #[test]
929    fn version_disclosure_detection() {
930        assert!(looks_versioned("nginx/1.25.3"));
931        assert!(looks_versioned("PHP/8.2.1"));
932        assert!(looks_versioned("Apache/2.4"));
933        // A bare product name is noise, not a CVE-matchable banner.
934        assert!(!looks_versioned("cloudflare"));
935        assert!(!looks_versioned("nginx"));
936    }
937
938    #[test]
939    fn perfect_headers_score_a_plus() {
940        let response = response_with(&[
941            (
942                "strict-transport-security",
943                "max-age=63072000; includeSubDomains; preload",
944            ),
945            ("content-security-policy", "default-src 'self'"),
946            ("x-frame-options", "DENY"),
947            ("x-content-type-options", "nosniff"),
948            ("referrer-policy", "no-referrer"),
949            ("permissions-policy", "geolocation=()"),
950            ("cross-origin-opener-policy", "same-origin"),
951            ("cross-origin-embedder-policy", "require-corp"),
952            ("cross-origin-resource-policy", "same-origin"),
953        ]);
954        let report = build_report("example.com".to_string(), &response);
955        assert_eq!(report.score, 100, "notes: {:?}", report.notes);
956        assert_eq!(report.grade, "A+");
957        assert!(report.notes.is_empty(), "got {:?}", report.notes);
958    }
959
960    #[test]
961    fn bare_response_scores_f_and_lists_every_gap() {
962        let report = build_report("example.com".to_string(), &response_with(&[]));
963        assert_eq!(report.score, 0);
964        assert_eq!(report.grade, "F");
965        // Every weighted header should produce an advisory.
966        assert_eq!(report.headers.len(), HEADER_WEIGHTS.len());
967        assert_eq!(report.notes.len(), HEADER_WEIGHTS.len());
968        assert!(report.headers.iter().all(|h| !h.present));
969    }
970
971    #[test]
972    fn penalties_apply_but_stay_bounded() {
973        // Ten flawed cookies and several versioned banners must not drive the
974        // score below zero or exceed the documented caps.
975        let cookies = grade_cookies(&(0..10).map(|i| format!("c{i}=1")).collect::<Vec<_>>());
976        let disclosures = vec![
977            Disclosure {
978                header: "server".into(),
979                value: "nginx/1.0".into(),
980                versioned: true,
981            },
982            Disclosure {
983                header: "x-powered-by".into(),
984                value: "PHP/8.0".into(),
985                versioned: true,
986            },
987            Disclosure {
988                header: "x-generator".into(),
989                value: "Drupal/9.1".into(),
990                versioned: true,
991            },
992        ];
993        // A perfect header set (100) minus the capped penalties.
994        let headers: Vec<HeaderFinding> = HEADER_WEIGHTS
995            .iter()
996            .map(|(name, _)| HeaderFinding {
997                header: (*name).to_string(),
998                present: true,
999                value: None,
1000                verdict: HeaderVerdict::Strict,
1001                note: None,
1002            })
1003            .collect();
1004        let score = compute_score(&headers, &cookies, &disclosures);
1005        assert_eq!(score, 100 - COOKIE_PENALTY_CAP - DISCLOSURE_PENALTY_CAP);
1006
1007        // And penalties can never underflow past zero.
1008        let empty: Vec<HeaderFinding> = Vec::new();
1009        assert_eq!(compute_score(&empty, &cookies, &disclosures), 0);
1010    }
1011
1012    #[test]
1013    fn weights_sum_to_one_hundred() {
1014        // The score is presented as a percentage, so the weights must total 100
1015        // or a flawless site could never reach A+.
1016        let total: u32 = HEADER_WEIGHTS.iter().map(|(_, w)| *w).sum();
1017        assert_eq!(total, 100);
1018    }
1019
1020    #[test]
1021    fn grade_bands_are_ordered_and_total() {
1022        assert_eq!(grade_letter(100), "A+");
1023        assert_eq!(grade_letter(95), "A+");
1024        assert_eq!(grade_letter(94), "A");
1025        assert_eq!(grade_letter(85), "A");
1026        assert_eq!(grade_letter(75), "B");
1027        assert_eq!(grade_letter(65), "C");
1028        assert_eq!(grade_letter(50), "D");
1029        assert_eq!(grade_letter(30), "E");
1030        assert_eq!(grade_letter(29), "F");
1031        assert_eq!(grade_letter(0), "F");
1032    }
1033
1034    #[test]
1035    fn report_captures_cookies_and_disclosures_from_response() {
1036        let mut response = response_with(&[
1037            ("server", "nginx/1.25.3"),
1038            ("x-powered-by", "PHP/8.2"),
1039            ("x-content-type-options", "nosniff"),
1040        ]);
1041        // Two Set-Cookie headers, as a real origin would send them.
1042        response.headers.push((
1043            "set-cookie".into(),
1044            "a=1; Secure; HttpOnly; SameSite=Lax".into(),
1045        ));
1046        response.headers.push(("set-cookie".into(), "b=2".into()));
1047
1048        let report = build_report("example.com".to_string(), &response);
1049        assert_eq!(report.cookies.len(), 2);
1050        assert_eq!(report.disclosures.len(), 2);
1051        assert!(report.disclosures.iter().all(|d| d.versioned));
1052        assert!(report.notes.iter().any(|n| n.contains("cookie 'b'")));
1053        assert!(report.notes.iter().any(|n| n.contains("CVE")));
1054    }
1055
1056    #[test]
1057    fn header_values_are_preserved_for_display() {
1058        let response = response_with(&[("x-frame-options", "SAMEORIGIN")]);
1059        let report = build_report("example.com".to_string(), &response);
1060        let xfo = report
1061            .headers
1062            .iter()
1063            .find(|h| h.header == "x-frame-options")
1064            .expect("x-frame-options finding");
1065        assert!(xfo.present);
1066        assert_eq!(xfo.value.as_deref(), Some("SAMEORIGIN"));
1067    }
1068}