1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "kebab-case")]
46pub enum HeaderVerdict {
47 Absent,
49 Weak,
51 Moderate,
53 Strict,
55 Present,
57}
58
59impl HeaderVerdict {
60 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#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct HeaderFinding {
75 pub header: String,
77 pub present: bool,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub value: Option<String>,
81 pub verdict: HeaderVerdict,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub note: Option<String>,
85}
86
87#[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 #[serde(skip_serializing_if = "Vec::is_empty")]
98 pub issues: Vec<String>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct Disclosure {
104 pub header: String,
105 pub value: String,
106 pub versioned: bool,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct HeaderReport {
114 pub domain: String,
115 pub url: String,
117 pub status: u16,
118 pub redirects: usize,
120 pub grade: String,
122 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 pub notes: Vec<String>,
131}
132
133pub const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(10);
136
137const 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
156const HSTS_STRONG_MAX_AGE: u64 = 31_536_000;
159const HSTS_MODERATE_MAX_AGE: u64 = 15_552_000;
162
163const COOKIE_PENALTY: u32 = 4;
165const COOKIE_PENALTY_CAP: u32 = 12;
166const DISCLOSURE_PENALTY: u32 = 3;
168const DISCLOSURE_PENALTY_CAP: u32 = 6;
169
170fn 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
191fn 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 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 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
401fn 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
409fn 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
428fn grade_cookies(cookies: &[String]) -> Vec<CookieFinding> {
430 cookies
431 .iter()
432 .map(|raw| {
433 let attrs = parse_attributes(raw);
434 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
483fn looks_versioned(value: &str) -> bool {
486 let bytes = value.as_bytes();
487 bytes.windows(2).any(|w| {
488 (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
495const 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
522fn 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
551fn 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
564fn build_notes(
566 headers: &[HeaderFinding],
567 cookies: &[CookieFinding],
568 disclosures: &[Disclosure],
569) -> Vec<String> {
570 let mut notes = Vec::new();
571
572 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
600fn 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 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
711pub 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 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 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 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 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 assert_eq!(grade_csp(None, true).0, HeaderVerdict::Weak);
804 }
805
806 #[test]
807 fn csp_frame_ancestors_substitutes_for_x_frame_options() {
808 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 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 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 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 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 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 assert_eq!(findings[1].name, "tracker");
906 assert_eq!(findings[1].verdict, HeaderVerdict::Weak);
907 assert_eq!(findings[1].issues.len(), 3);
908
909 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 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 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 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 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 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 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 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}