640 lines
23 KiB
Python
640 lines
23 KiB
Python
"""
|
||
©AngelaMos | 2026
|
||
http_headers_scanner.py
|
||
|
||
Scan a URL and grade its HTTP security headers A–F
|
||
|
||
When a browser asks a website for a page, the server sends back the
|
||
page itself PLUS a bunch of metadata called "HTTP response headers."
|
||
Some of those headers are security-critical: they tell the browser
|
||
"only talk to me over HTTPS," "do not let other sites embed me in
|
||
an iframe," "ignore guesses about file types," and more
|
||
|
||
If a website forgets these headers, real attacks become easier:
|
||
clickjacking, MIME-sniffing, mixed-content downgrades, XSS that
|
||
otherwise would have been stopped by a good Content-Security-Policy.
|
||
This script connects to a URL, pulls the headers, and tells you which
|
||
ones are missing or weak
|
||
|
||
────────────────────────────────────────────────────────────────────
|
||
The headers we care about
|
||
────────────────────────────────────────────────────────────────────
|
||
Strict-Transport-Security forces HTTPS for return visits
|
||
Content-Security-Policy controls which scripts/styles may load
|
||
X-Content-Type-Options disables MIME-sniffing
|
||
X-Frame-Options controls iframe embedding (clickjacking)
|
||
Referrer-Policy limits Referer leakage
|
||
Permissions-Policy disables browser features the page does
|
||
not need (camera, microphone, etc.)
|
||
|
||
Each rule has a severity (high / medium / low). The score is the
|
||
percentage of weighted points the site earned. The grade comes from
|
||
the score: 90+ A, 80+ B, 70+ C, 60+ D, otherwise F. This mirrors the
|
||
model used by the Mozilla Observatory
|
||
|
||
────────────────────────────────────────────────────────────────────
|
||
What this script does NOT do
|
||
────────────────────────────────────────────────────────────────────
|
||
- It does not crawl the site, only the URL you give it
|
||
- It does not parse complex CSP directives (just checks presence)
|
||
- It does not test for actual XSS or open redirects
|
||
|
||
This is foundational: learn the headers, then graduate to bigger
|
||
tools like Mozilla Observatory or `securityheaders.com`
|
||
|
||
────────────────────────────────────────────────────────────────────
|
||
What this file exposes
|
||
────────────────────────────────────────────────────────────────────
|
||
HeaderRule — one rule (header name, severity, description, ...)
|
||
HeaderFinding — the result of evaluating one rule
|
||
ScanReport — full report (url, status_code, findings, score, grade)
|
||
evaluate_header() — run one rule against a set of headers
|
||
scan() — fetch a URL and run every rule
|
||
main() — CLI entry point used by `headers <url>`
|
||
"""
|
||
|
||
# Standard library: parse command-line flags like `--timeout 5` into
|
||
# a tidy object so we do not have to slice `sys.argv` by hand.
|
||
import argparse
|
||
# Standard library: regular expressions — we use `re.search` to match
|
||
# header values against rule patterns (e.g. `max-age\s*=\s*[1-9]` for
|
||
# HSTS, which must reject `max-age=0`).
|
||
import re
|
||
# Standard library: access to interpreter internals — we use it for
|
||
# stderr writes and to exit the process with a specific status code.
|
||
import sys
|
||
# Standard library: a decorator that turns a class into a small,
|
||
# immutable data record without writing `__init__` boilerplate.
|
||
from dataclasses import dataclass
|
||
# Standard library: a type hint that pins a value to a small fixed
|
||
# set of strings (here: severity levels like "good"/"warn"). Mypy
|
||
# catches typos.
|
||
from typing import Literal
|
||
|
||
# Third-party (httpx): the HTTP client that actually fetches the URL.
|
||
# Modern replacement for `requests` — supports timeouts and HTTP/2.
|
||
import httpx
|
||
# Third-party (rich): the printer that draws colored output to the
|
||
# terminal, with full Unicode and width handling.
|
||
from rich.console import Console
|
||
# Third-party (rich): draws a bordered box around content — we use
|
||
# it for the summary banner at the top of the report.
|
||
from rich.panel import Panel
|
||
# Third-party (rich): builds the colored ASCII table that lists each
|
||
# header finding with its severity.
|
||
from rich.table import Table
|
||
|
||
# =============================================================================
|
||
# Severity type — three valid values
|
||
# =============================================================================
|
||
# Literal["high", "medium", "low"] is a type hint that says "this string
|
||
# can ONLY be one of these three values." Mypy will catch typos like
|
||
# "hgih" at edit time. We chose Literal over an Enum because Carter's
|
||
# style guide prefers Literals for small fixed sets
|
||
|
||
Severity = Literal["high", "medium", "low"]
|
||
Status = Literal["ok", "weak", "missing"]
|
||
|
||
|
||
# =============================================================================
|
||
# HeaderRule — one rule we evaluate against the response
|
||
# =============================================================================
|
||
|
||
|
||
@dataclass(frozen = True, slots = True)
|
||
class HeaderRule:
|
||
"""
|
||
A single security-header check
|
||
|
||
`frozen=True` makes the dataclass immutable — once created, its
|
||
fields cannot change. `slots=True` makes instances lightweight in
|
||
memory. Together these two flags create a clean "value object"
|
||
|
||
Fields
|
||
------
|
||
header
|
||
The HTTP header name to look for (case-insensitive)
|
||
severity
|
||
How important the header is. Drives the point value below
|
||
description
|
||
One sentence explaining what the header does. Shown in output
|
||
recommendation
|
||
Concrete fix the user should apply if the header is missing
|
||
must_match
|
||
Optional regex pattern (case-insensitive) the value MUST match.
|
||
Use a plain word for substring matching (e.g. ``nosniff``), or a
|
||
real regex for stricter checks. Example: ``max-age\\s*=\\s*[1-9]``
|
||
requires ``max-age`` to be a positive integer, which rejects the
|
||
actively-harmful ``max-age=0``. If this is set and the value
|
||
does not match, we report `weak` instead of `ok`
|
||
"""
|
||
header: str
|
||
severity: Severity
|
||
description: str
|
||
recommendation: str
|
||
must_match: str | None = None
|
||
|
||
|
||
# =============================================================================
|
||
# The rules table — single source of truth for what we check
|
||
# =============================================================================
|
||
# Adding a header to this list is the only change needed to extend
|
||
# the scanner. The check logic is generic — it walks this list at
|
||
# runtime and applies each rule the same way
|
||
|
||
RULES: list[HeaderRule] = [
|
||
HeaderRule(
|
||
header = "Strict-Transport-Security",
|
||
severity = "high",
|
||
description = (
|
||
"Tells the browser to ONLY connect over HTTPS for the "
|
||
"next N seconds, defeating SSL-stripping attacks"
|
||
),
|
||
recommendation = (
|
||
"Add: Strict-Transport-Security: "
|
||
"max-age=31536000; includeSubDomains"
|
||
),
|
||
# Require max-age to be a positive integer — `max-age=0`
|
||
# actively disables HSTS, so we must reject it. The regex
|
||
# accepts whitespace around `=` to tolerate `max-age = 60`.
|
||
must_match = r"max-age\s*=\s*[1-9]",
|
||
),
|
||
HeaderRule(
|
||
header = "Content-Security-Policy",
|
||
severity = "high",
|
||
description = (
|
||
"Controls which scripts, styles, frames, and connections "
|
||
"the browser may load — the strongest XSS defense"
|
||
),
|
||
recommendation = (
|
||
"Add a Content-Security-Policy that disallows "
|
||
"'unsafe-inline' and limits sources to trusted origins"
|
||
),
|
||
),
|
||
HeaderRule(
|
||
header = "X-Content-Type-Options",
|
||
severity = "medium",
|
||
description = (
|
||
"Stops browsers from second-guessing the Content-Type "
|
||
"and treating a .txt file as HTML — defeats MIME-sniffing"
|
||
),
|
||
recommendation = "Add: X-Content-Type-Options: nosniff",
|
||
# Value must literally be `nosniff`; anything else is broken.
|
||
# `re.search("nosniff", ...)` is a substring match here — no
|
||
# special regex characters in the pattern.
|
||
must_match = "nosniff",
|
||
),
|
||
HeaderRule(
|
||
header = "X-Frame-Options",
|
||
severity = "medium",
|
||
description = (
|
||
"Prevents another site from embedding this page in an "
|
||
"iframe, defeating clickjacking attacks"
|
||
),
|
||
recommendation = (
|
||
"Add: X-Frame-Options: DENY (or use "
|
||
"Content-Security-Policy: frame-ancestors 'none')"
|
||
),
|
||
),
|
||
HeaderRule(
|
||
header = "Referrer-Policy",
|
||
severity = "low",
|
||
description = (
|
||
"Limits how much of the current URL leaks to other sites "
|
||
"when the user clicks an outbound link"
|
||
),
|
||
recommendation = (
|
||
"Add: Referrer-Policy: strict-origin-when-cross-origin"
|
||
),
|
||
),
|
||
HeaderRule(
|
||
header = "Permissions-Policy",
|
||
severity = "low",
|
||
description = (
|
||
"Disables browser features the page does not use "
|
||
"(camera, microphone, geolocation, payments, etc.)"
|
||
),
|
||
recommendation = (
|
||
"Add: Permissions-Policy: "
|
||
"camera=(), microphone=(), geolocation=()"
|
||
),
|
||
),
|
||
]
|
||
|
||
|
||
# =============================================================================
|
||
# Severity → points. Drives the final score
|
||
# =============================================================================
|
||
# Each present-and-correct header earns its full points; weak presence
|
||
# earns half points; missing earns zero. Total achievable = sum of
|
||
# all rule points. The score is (earned / total) * 100, rounded
|
||
|
||
SEVERITY_POINTS: dict[Severity,
|
||
int] = {
|
||
"high": 30,
|
||
"medium": 15,
|
||
"low": 5,
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# HeaderFinding — the result of evaluating one rule
|
||
# =============================================================================
|
||
|
||
|
||
@dataclass(frozen = True, slots = True)
|
||
class HeaderFinding:
|
||
"""
|
||
Outcome of running one HeaderRule against the response headers
|
||
|
||
Fields
|
||
------
|
||
rule
|
||
The rule we evaluated. Carrying it inside the finding means
|
||
the renderer never has to look up the rule again
|
||
status
|
||
"ok" — header is present and (if applicable) the value
|
||
matches must_match
|
||
"weak" — header is present but the value is wrong
|
||
"missing" — header is not in the response at all
|
||
actual_value
|
||
Whatever the server actually sent for this header, or None
|
||
when the header was missing entirely
|
||
note
|
||
Short human-readable explanation. Shown in the table next
|
||
to the status column
|
||
"""
|
||
rule: HeaderRule
|
||
status: Status
|
||
actual_value: str | None
|
||
note: str
|
||
|
||
|
||
# =============================================================================
|
||
# ScanReport — the full result returned by scan()
|
||
# =============================================================================
|
||
|
||
|
||
@dataclass(frozen = True, slots = True)
|
||
class ScanReport:
|
||
"""
|
||
A full scan result for one URL
|
||
|
||
The `score` and `grade` properties are computed on demand from
|
||
the findings, so they always reflect whatever the rules table
|
||
looked like at scan time
|
||
"""
|
||
url: str
|
||
final_url: str
|
||
status_code: int
|
||
findings: list[HeaderFinding]
|
||
|
||
@property
|
||
def score(self) -> int:
|
||
"""
|
||
Return a 0–100 score reflecting the weighted findings
|
||
|
||
Formula
|
||
-------
|
||
earned = full points for every "ok"
|
||
+ half points for every "weak"
|
||
+ zero for every "missing"
|
||
score = round(earned / total * 100)
|
||
"""
|
||
total = sum(SEVERITY_POINTS[r.severity] for r in RULES)
|
||
# Guard against an empty rules table — would only matter if
|
||
# someone deletes RULES while testing. Keeps the code total
|
||
if total == 0:
|
||
return 0
|
||
|
||
earned = 0.0
|
||
for finding in self.findings:
|
||
full = SEVERITY_POINTS[finding.rule.severity]
|
||
if finding.status == "ok":
|
||
earned += full
|
||
elif finding.status == "weak":
|
||
earned += full / 2
|
||
# "missing" earns 0 — no else branch needed
|
||
|
||
# Round-half-up via int(x + 0.5) avoids Python's banker's
|
||
# rounding, which would map round(0.5) -> 0 and round(2.5) -> 2
|
||
# — surprising for a score that should always round up at the
|
||
# .5 boundary
|
||
return int((earned / total) * 100 + 0.5)
|
||
|
||
@property
|
||
def grade(self) -> str:
|
||
"""
|
||
Map the score to a letter grade A–F
|
||
"""
|
||
score = self.score
|
||
if score >= 90:
|
||
return "A"
|
||
if score >= 80:
|
||
return "B"
|
||
if score >= 70:
|
||
return "C"
|
||
if score >= 60:
|
||
return "D"
|
||
return "F"
|
||
|
||
|
||
# =============================================================================
|
||
# Header evaluation — pure function, no I/O
|
||
# =============================================================================
|
||
# Splitting this out from scan() makes it trivially testable: pass in
|
||
# a rule and a dict of headers, get back a finding. No network needed
|
||
|
||
|
||
def evaluate_header(
|
||
rule: HeaderRule,
|
||
response_headers: dict[str,
|
||
str],
|
||
) -> HeaderFinding:
|
||
"""
|
||
Apply a single HeaderRule to a set of response headers
|
||
|
||
HTTP header names are case-insensitive per RFC 7230 — `HSTS` and
|
||
`hsts` and `Hsts` are the same header. We normalize both sides
|
||
to lowercase before comparing
|
||
"""
|
||
target = rule.header.lower()
|
||
|
||
# Walk the response headers manually instead of building a
|
||
# case-insensitive dict. The input is always a plain dict here —
|
||
# scan() converts httpx's Headers object before calling us, so
|
||
# tests can pass any dict[str, str] without ceremony
|
||
actual_value: str | None = None
|
||
for name, value in response_headers.items():
|
||
if name.lower() == target:
|
||
actual_value = value
|
||
break
|
||
|
||
if actual_value is None:
|
||
return HeaderFinding(
|
||
rule = rule,
|
||
status = "missing",
|
||
actual_value = None,
|
||
note = f"Header `{rule.header}` is not set",
|
||
)
|
||
|
||
# If the rule has no must_match check, presence is enough
|
||
if rule.must_match is None:
|
||
return HeaderFinding(
|
||
rule = rule,
|
||
status = "ok",
|
||
actual_value = actual_value,
|
||
note = "Present",
|
||
)
|
||
|
||
# Otherwise verify the value matches the required pattern.
|
||
# re.search finds the pattern anywhere in the string — for a plain
|
||
# word like `nosniff` that behaves as a substring check; for a
|
||
# real regex like `max-age\s*=\s*[1-9]` it enforces a richer
|
||
# condition (positive integer after `max-age=`)
|
||
if re.search(rule.must_match, actual_value, re.IGNORECASE):
|
||
return HeaderFinding(
|
||
rule = rule,
|
||
status = "ok",
|
||
actual_value = actual_value,
|
||
note = f"Present and matches `{rule.must_match}`",
|
||
)
|
||
|
||
return HeaderFinding(
|
||
rule = rule,
|
||
status = "weak",
|
||
actual_value = actual_value,
|
||
note = (
|
||
f"Present but does not match `{rule.must_match}` "
|
||
f"(got `{actual_value}`)"
|
||
),
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# scan() — fetch the URL and apply every rule
|
||
# =============================================================================
|
||
|
||
|
||
# A polite, identifiable User-Agent. Some servers block requests with
|
||
# the default httpx UA or no UA at all
|
||
DEFAULT_USER_AGENT: str = (
|
||
"http-headers-scanner/1.0 "
|
||
"(+https://github.com/CarterPerez-dev/Cybersecurity-Projects)"
|
||
)
|
||
|
||
|
||
def scan(
|
||
url: str,
|
||
*,
|
||
timeout: float = 10.0,
|
||
user_agent: str = DEFAULT_USER_AGENT,
|
||
) -> ScanReport:
|
||
"""
|
||
Fetch `url` once and grade its response headers
|
||
|
||
Parameters
|
||
----------
|
||
url
|
||
Full URL including the scheme. Bare hostnames like
|
||
"example.com" are NOT supported because we cannot guess
|
||
whether the user wanted http or https
|
||
timeout
|
||
Seconds before we give up on a slow server. Default 10
|
||
user_agent
|
||
Sent as the User-Agent header. Some sites serve different
|
||
responses to bots; the default identifies us honestly
|
||
|
||
Returns
|
||
-------
|
||
ScanReport
|
||
Containing the findings, status code, and final URL after
|
||
any redirects
|
||
|
||
Raises
|
||
------
|
||
httpx.RequestError
|
||
On DNS failure, connection refusal, timeout, etc. The CLI
|
||
catches these to print a clean error message
|
||
"""
|
||
# follow_redirects=True means http://example.com → https://example.com
|
||
# is followed automatically. We grade the FINAL URL, not the first
|
||
# one, because that is the one users actually see
|
||
response = httpx.get(
|
||
url,
|
||
timeout = timeout,
|
||
follow_redirects = True,
|
||
headers = {"User-Agent": user_agent},
|
||
)
|
||
|
||
# httpx Headers object behaves like a dict for our purposes.
|
||
# dict(response.headers) gives us a regular dict[str, str]
|
||
response_headers = dict(response.headers)
|
||
|
||
# Run every rule against the response. List comprehension is
|
||
# cleaner than a for-loop with .append() here
|
||
findings = [evaluate_header(rule, response_headers) for rule in RULES]
|
||
|
||
return ScanReport(
|
||
url = url,
|
||
final_url = str(response.url),
|
||
status_code = response.status_code,
|
||
findings = findings,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# CLI rendering — keeps display logic out of the data layer
|
||
# =============================================================================
|
||
|
||
|
||
# How each status / severity should be colored in the terminal
|
||
STATUS_COLORS: dict[Status,
|
||
str] = {
|
||
"ok": "green",
|
||
"weak": "yellow",
|
||
"missing": "red",
|
||
}
|
||
|
||
GRADE_COLORS: dict[str,
|
||
str] = {
|
||
"A": "bright_green",
|
||
"B": "green",
|
||
"C": "yellow",
|
||
"D": "red",
|
||
"F": "bright_red",
|
||
}
|
||
|
||
|
||
def _render_report(report: ScanReport, console: Console) -> None:
|
||
"""
|
||
Print the scan report as a rich table plus a grade panel
|
||
"""
|
||
# The header table — one row per rule
|
||
table = Table(
|
||
title = (
|
||
f"Headers for {report.final_url} "
|
||
f"(HTTP {report.status_code})"
|
||
),
|
||
title_style = "bold cyan",
|
||
show_lines = False,
|
||
)
|
||
table.add_column("header", style = "bold white", no_wrap = True)
|
||
table.add_column("status", no_wrap = True)
|
||
table.add_column("severity", no_wrap = True)
|
||
table.add_column("note", style = "dim")
|
||
|
||
for finding in report.findings:
|
||
status_color = STATUS_COLORS[finding.status]
|
||
table.add_row(
|
||
finding.rule.header,
|
||
f"[{status_color}]{finding.status}[/{status_color}]",
|
||
finding.rule.severity,
|
||
finding.note,
|
||
)
|
||
console.print(table)
|
||
|
||
# Browsers IGNORE HSTS received over plain HTTP per RFC 6797 §8.1
|
||
# — if the final response was served over http://, any HSTS grade
|
||
# above is misleading. Warn so the user does not walk away with a
|
||
# false sense of security
|
||
if report.final_url.startswith("http://"):
|
||
console.print(
|
||
"[yellow]Note:[/yellow] this response was served over plain "
|
||
"HTTP. Browsers IGNORE HSTS over HTTP, so any HSTS grade "
|
||
"above is misleading until the site enforces HTTPS"
|
||
)
|
||
|
||
# The grade panel — big, color-coded, eye-catching
|
||
grade_color = GRADE_COLORS[report.grade]
|
||
panel = Panel(
|
||
f"[bold {grade_color}]Grade: {report.grade}[/bold {grade_color}]\n"
|
||
f"Score: {report.score} / 100",
|
||
title = "Result",
|
||
border_style = grade_color,
|
||
)
|
||
console.print(panel)
|
||
|
||
# Print recommendations for any non-ok findings, so the user has
|
||
# an action list — what to add or fix
|
||
actionable = [f for f in report.findings if f.status != "ok"]
|
||
if actionable:
|
||
console.print("\n[bold]Recommendations:[/bold]")
|
||
for finding in actionable:
|
||
console.print(
|
||
f" • [yellow]{finding.rule.header}[/yellow] "
|
||
f"— {finding.rule.recommendation}"
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# argparse plumbing — broken out so tests can call it directly
|
||
# =============================================================================
|
||
|
||
|
||
def _build_argument_parser() -> argparse.ArgumentParser:
|
||
"""
|
||
Construct the argparse parser used by main()
|
||
"""
|
||
parser = argparse.ArgumentParser(
|
||
prog = "headers",
|
||
description = (
|
||
"Scan a URL for HTTP security headers and grade the result A–F."
|
||
),
|
||
)
|
||
parser.add_argument(
|
||
"url",
|
||
help = "Full URL to scan (must include http:// or https://).",
|
||
)
|
||
parser.add_argument(
|
||
"--timeout",
|
||
type = float,
|
||
default = 10.0,
|
||
help =
|
||
"Seconds to wait before giving up on the request (default: 10).",
|
||
)
|
||
return parser
|
||
|
||
|
||
# =============================================================================
|
||
# main() — exit codes mean something
|
||
# =============================================================================
|
||
# 0 → grade A or B (green light for CI)
|
||
# 1 → grade C or D (warn but do not fail by default)
|
||
# 2 → grade F or network error (fail loudly)
|
||
|
||
|
||
def main() -> int:
|
||
"""
|
||
CLI entry point — return an exit code reflecting the scan result
|
||
"""
|
||
parser = _build_argument_parser()
|
||
args = parser.parse_args()
|
||
console = Console()
|
||
|
||
# Catch network errors here so the user sees a clean message
|
||
# instead of a raw traceback. We let httpx's own message bubble
|
||
# through after our prefix — the underlying error usually has
|
||
# useful detail (DNS failure, connection refused, etc.)
|
||
try:
|
||
report = scan(args.url, timeout = args.timeout)
|
||
except httpx.RequestError as exc:
|
||
console.print(
|
||
f"[red]Request failed:[/red] {type(exc).__name__}: {exc}"
|
||
)
|
||
return 2
|
||
|
||
_render_report(report, console)
|
||
|
||
if report.grade in ("A", "B"):
|
||
return 0
|
||
if report.grade in ("C", "D"):
|
||
return 1
|
||
return 2
|
||
|
||
|
||
# Standard "if invoked directly as a script" guard — lets the file be
|
||
# imported by tests without firing main()
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|