406 lines
15 KiB
Python
406 lines
15 KiB
Python
"""
|
|
©AngelaMos | 2026
|
|
test_http_headers_scanner.py
|
|
|
|
Tests for http_headers_scanner — covers rule evaluation, the score
|
|
calculation, grade thresholds, and a mocked end-to-end scan
|
|
|
|
────────────────────────────────────────────────────────────────────
|
|
What "tests" are and why we write them
|
|
────────────────────────────────────────────────────────────────────
|
|
A test is a tiny Python function that calls our real code with a
|
|
known input and then ASSERTS that the result is what we expected.
|
|
If the assertion fails, pytest prints a red FAIL message — which
|
|
means we changed something and broke a behavior we cared about
|
|
|
|
Tests are insurance. The first time you write the code, the test
|
|
just confirms it works. But six months later when you refactor or
|
|
add a new feature, the existing tests catch any accidental breakage
|
|
|
|
────────────────────────────────────────────────────────────────────
|
|
Why we mock the network with respx
|
|
────────────────────────────────────────────────────────────────────
|
|
A test that hits a real website is FRAGILE. The site might be down,
|
|
slow, redesigned, or behind a captcha. None of that has anything to
|
|
do with whether OUR code is correct
|
|
|
|
`respx` is a library that intercepts httpx calls and returns a
|
|
canned response we control. So when we test scan("https://test"),
|
|
respx hands back EXACTLY the headers we tell it to — letting us
|
|
verify the scanning logic without touching the network
|
|
|
|
────────────────────────────────────────────────────────────────────
|
|
Coverage strategy
|
|
────────────────────────────────────────────────────────────────────
|
|
We exercise each branch of the code at least once
|
|
|
|
- evaluate_header: ok / weak / missing / case-insensitive lookup
|
|
- ScanReport.score: all-ok, all-missing, mixed
|
|
- ScanReport.grade: each band (A, B, C, D, F)
|
|
- scan(): full pipeline against a mocked response, including a redirect
|
|
|
|
That is enough confidence — adding ten variations of "another header"
|
|
would not catch any new bugs
|
|
"""
|
|
|
|
# Third-party (httpx): we need its `Response` type to construct fake
|
|
# responses inside our mocked routes.
|
|
import httpx
|
|
# Third-party: the test runner. We also use its `@pytest.mark.parametrize`
|
|
# decorator to expand one test function into many test cases.
|
|
import pytest
|
|
# Third-party (respx): intercepts httpx calls and returns fakes we
|
|
# define, so tests do not actually hit the real internet.
|
|
import respx
|
|
|
|
# Local: our own module. We pull in the public pieces under test —
|
|
# the rules table, dataclasses, and the two entry functions.
|
|
from http_headers_scanner import (
|
|
RULES,
|
|
HeaderFinding,
|
|
HeaderRule,
|
|
ScanReport,
|
|
Status,
|
|
evaluate_header,
|
|
scan,
|
|
)
|
|
|
|
# =============================================================================
|
|
# Fixtures — small helpers used by multiple tests
|
|
# =============================================================================
|
|
# A pytest "fixture" is a setup function pytest runs before a test
|
|
# that needs it. Tests ask for fixtures by listing them as parameters
|
|
|
|
|
|
@pytest.fixture
|
|
def hsts_rule() -> HeaderRule:
|
|
"""
|
|
A representative HeaderRule that requires a positive max-age value
|
|
"""
|
|
# We construct one inline rather than reaching into RULES so this
|
|
# test is robust to future additions / reorderings of the table.
|
|
# The regex matches `max-age` followed by `=` and a digit 1-9 —
|
|
# which rejects `max-age=0` (HSTS deliberately disabled)
|
|
return HeaderRule(
|
|
header = "Strict-Transport-Security",
|
|
severity = "high",
|
|
description = "Forces HTTPS",
|
|
recommendation = "Add: Strict-Transport-Security: max-age=31536000",
|
|
must_match = r"max-age\s*=\s*[1-9]",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def referrer_rule() -> HeaderRule:
|
|
"""
|
|
A rule with NO must_contain — presence alone earns full points
|
|
"""
|
|
return HeaderRule(
|
|
header = "Referrer-Policy",
|
|
severity = "low",
|
|
description = "Limits Referer leakage",
|
|
recommendation =
|
|
"Add: Referrer-Policy: strict-origin-when-cross-origin",
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# evaluate_header — the pure function at the heart of the scanner
|
|
# =============================================================================
|
|
# These tests do not touch the network at all — we hand-build the
|
|
# headers dict and check the finding
|
|
|
|
|
|
def test_evaluate_header_present_with_required_substring(
|
|
hsts_rule: HeaderRule,
|
|
) -> None:
|
|
"""
|
|
Header is present AND contains must_contain → status = ok
|
|
"""
|
|
headers = {"Strict-Transport-Security": "max-age=31536000"}
|
|
finding = evaluate_header(hsts_rule, headers)
|
|
assert finding.status == "ok"
|
|
assert finding.actual_value == "max-age=31536000"
|
|
|
|
|
|
def test_evaluate_header_present_without_required_substring(
|
|
hsts_rule: HeaderRule,
|
|
) -> None:
|
|
"""
|
|
Header is present but does NOT match must_match → status = weak
|
|
|
|
A real-world example: someone sets the header to an empty string
|
|
or only `includeSubDomains` without `max-age=`. The header exists
|
|
but is functionally useless
|
|
"""
|
|
headers = {"Strict-Transport-Security": "includeSubDomains"}
|
|
finding = evaluate_header(hsts_rule, headers)
|
|
assert finding.status == "weak"
|
|
|
|
|
|
def test_evaluate_header_missing(hsts_rule: HeaderRule) -> None:
|
|
"""
|
|
Header is not in the response at all → status = missing
|
|
"""
|
|
# Empty dict — no headers at all
|
|
headers: dict[str, str] = {}
|
|
finding = evaluate_header(hsts_rule, headers)
|
|
assert finding.status == "missing"
|
|
# And actual_value should be None when the header was absent
|
|
assert finding.actual_value is None
|
|
|
|
|
|
def test_evaluate_header_hsts_max_age_zero_is_weak(
|
|
hsts_rule: HeaderRule,
|
|
) -> None:
|
|
"""
|
|
`max-age=0` actively DISABLES HSTS for return visits — the header
|
|
is present but does the opposite of what we want, so it must be
|
|
flagged as weak rather than ok
|
|
|
|
This pins the behavior fixed in the audit — substring-based
|
|
matching graded this case as ok and left users thinking HSTS was
|
|
on when it was deliberately turned off
|
|
"""
|
|
headers = {"Strict-Transport-Security": "max-age=0; includeSubDomains"}
|
|
finding = evaluate_header(hsts_rule, headers)
|
|
assert finding.status == "weak"
|
|
assert finding.actual_value == "max-age=0; includeSubDomains"
|
|
|
|
|
|
def test_evaluate_header_case_insensitive_lookup(
|
|
referrer_rule: HeaderRule,
|
|
) -> None:
|
|
"""
|
|
HTTP header names are case-insensitive per RFC 7230 — `Referrer-Policy`
|
|
and `referrer-policy` and `REFERRER-POLICY` mean the same thing
|
|
"""
|
|
# The server returned the header with lowercase letters, but the
|
|
# rule asks for "Referrer-Policy" with the canonical case.
|
|
# Without case-insensitive lookup, this test would fail
|
|
headers = {"referrer-policy": "no-referrer"}
|
|
finding = evaluate_header(referrer_rule, headers)
|
|
assert finding.status == "ok"
|
|
|
|
|
|
def test_evaluate_header_no_must_match_treats_presence_as_ok(
|
|
referrer_rule: HeaderRule,
|
|
) -> None:
|
|
"""
|
|
A rule with must_match=None passes whenever the header exists
|
|
"""
|
|
headers = {"Referrer-Policy": "anything-here-works"}
|
|
finding = evaluate_header(referrer_rule, headers)
|
|
assert finding.status == "ok"
|
|
|
|
|
|
# =============================================================================
|
|
# ScanReport.score and .grade — the math behind the report
|
|
# =============================================================================
|
|
# Score is computed from the findings on the fly, not stored. So we
|
|
# can build a synthetic ScanReport with whatever findings we want and
|
|
# assert exactly what the score should come out to
|
|
|
|
|
|
def _make_report(statuses: list[Status]) -> ScanReport:
|
|
"""
|
|
Build a fake ScanReport pairing each rule with the given status
|
|
|
|
Helper so each test does not have to construct findings by hand.
|
|
The first item in `statuses` pairs with the first rule, etc.
|
|
Pad the list with "missing" if it is shorter than RULES.
|
|
|
|
The parameter is typed as `list[Status]` so mypy enforces the
|
|
Literal contract at every call site — no runtime check or
|
|
type-ignore escape hatch needed
|
|
"""
|
|
findings: list[HeaderFinding] = []
|
|
for index, rule in enumerate(RULES):
|
|
# When the caller passed fewer statuses than rules, treat the
|
|
# rest as missing. Common when a test only cares about the
|
|
# first few rules
|
|
status: Status = (
|
|
statuses[index] if index < len(statuses) else "missing"
|
|
)
|
|
findings.append(
|
|
HeaderFinding(
|
|
rule = rule,
|
|
status = status,
|
|
actual_value = None,
|
|
note = "synthetic",
|
|
)
|
|
)
|
|
return ScanReport(
|
|
url = "https://example.com",
|
|
final_url = "https://example.com",
|
|
status_code = 200,
|
|
findings = findings,
|
|
)
|
|
|
|
|
|
def test_score_all_ok_is_100() -> None:
|
|
"""
|
|
Every rule passing should yield a perfect score
|
|
"""
|
|
statuses: list[Status] = ["ok"] * len(RULES)
|
|
report = _make_report(statuses)
|
|
assert report.score == 100
|
|
# And the grade follows the score
|
|
assert report.grade == "A"
|
|
|
|
|
|
def test_score_all_missing_is_zero() -> None:
|
|
"""
|
|
Nothing present, nothing earned. Score = 0, grade = F
|
|
"""
|
|
statuses: list[Status] = ["missing"] * len(RULES)
|
|
report = _make_report(statuses)
|
|
assert report.score == 0
|
|
assert report.grade == "F"
|
|
|
|
|
|
def test_grade_threshold_a_at_90_percent() -> None:
|
|
"""
|
|
Passing both highs and both mediums (90/100 = 90%) lands exactly
|
|
on the A boundary
|
|
|
|
The current rules table totals 100 points (30 + 30 + 15 + 15 + 5 + 5)
|
|
Two `high` rules = 60 / 100 = 60%, which is grade D
|
|
Both highs + both mediums = 90 / 100 = 90%, which is grade A
|
|
"""
|
|
statuses_by_severity: dict[str, Status] = {
|
|
"high": "ok",
|
|
"medium": "ok",
|
|
"low": "missing",
|
|
}
|
|
statuses: list[Status] = [
|
|
statuses_by_severity[r.severity] for r in RULES
|
|
]
|
|
report = _make_report(statuses)
|
|
assert report.score == 90
|
|
assert report.grade == "A"
|
|
|
|
|
|
def test_grade_threshold_b_at_83_percent() -> None:
|
|
"""
|
|
Both highs ok, one medium ok and the other weak (60 + 15 + 7.5 = 82.5
|
|
→ rounds up to 83) drops below 90 and lands in the B band
|
|
"""
|
|
# Two highs ok, mediums split between ok and weak, lows missing
|
|
statuses: list[Status] = []
|
|
medium_seen = 0
|
|
for rule in RULES:
|
|
if rule.severity == "high":
|
|
statuses.append("ok")
|
|
elif rule.severity == "medium":
|
|
statuses.append("ok" if medium_seen == 0 else "weak")
|
|
medium_seen += 1
|
|
else:
|
|
statuses.append("missing")
|
|
report = _make_report(statuses)
|
|
assert 80 <= report.score < 90
|
|
assert report.grade == "B"
|
|
|
|
|
|
# =============================================================================
|
|
# scan() — full pipeline against a mocked response
|
|
# =============================================================================
|
|
# `@respx.mock` intercepts every httpx request inside the test and
|
|
# returns whatever we set up in the body. The real network is never
|
|
# touched
|
|
|
|
|
|
@respx.mock
|
|
def test_scan_mocks_a_clean_response_and_grades_it_correctly() -> None:
|
|
"""
|
|
A response with every recommended header set should score 100
|
|
"""
|
|
# We respond to GET https://safe.example.com/ with a 200 and the
|
|
# full set of security headers. respx.get(...).mock(return_value=...)
|
|
# registers the mock; the next httpx.get inside this test fires it
|
|
respx.get("https://safe.example.com/").mock(
|
|
return_value = httpx.Response(
|
|
status_code = 200,
|
|
headers = {
|
|
"Strict-Transport-Security":
|
|
"max-age=31536000; includeSubDomains",
|
|
"Content-Security-Policy": "default-src 'self'",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"X-Frame-Options": "DENY",
|
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
"Permissions-Policy": "camera=(), microphone=()",
|
|
},
|
|
)
|
|
)
|
|
|
|
report = scan("https://safe.example.com/")
|
|
|
|
assert report.status_code == 200
|
|
assert report.score == 100
|
|
assert report.grade == "A"
|
|
# Every finding should be `ok`
|
|
assert all(f.status == "ok" for f in report.findings)
|
|
|
|
|
|
@respx.mock
|
|
def test_scan_flags_missing_and_weak_headers() -> None:
|
|
"""
|
|
A response missing CSP and with a weak X-Content-Type-Options
|
|
should produce findings of mixed status
|
|
"""
|
|
respx.get("https://weak.example.com/").mock(
|
|
return_value = httpx.Response(
|
|
status_code = 200,
|
|
headers = {
|
|
"Strict-Transport-Security": "max-age=600",
|
|
# X-Content-Type-Options is present but value is wrong:
|
|
# the rule requires `nosniff`, this says something else.
|
|
# We pick a value that genuinely does NOT contain the
|
|
# substring `nosniff` — `"snifftest"` would actually be
|
|
# treated as ok because it embeds the word
|
|
"X-Content-Type-Options": "off",
|
|
# Note: Content-Security-Policy is NOT included
|
|
},
|
|
)
|
|
)
|
|
|
|
report = scan("https://weak.example.com/")
|
|
|
|
findings_by_header = {f.rule.header: f for f in report.findings}
|
|
assert findings_by_header["Content-Security-Policy"
|
|
].status == "missing"
|
|
assert findings_by_header["X-Content-Type-Options"].status == "weak"
|
|
assert findings_by_header["Strict-Transport-Security"].status == "ok"
|
|
|
|
# Score should be less than 100 since CSP is missing and XCTO is weak
|
|
assert report.score < 100
|
|
|
|
|
|
@respx.mock
|
|
def test_scan_records_final_url_after_redirect() -> None:
|
|
"""
|
|
When http://x.example.com/ → https://x.example.com/, the report
|
|
must remember the final URL — that is what the user actually
|
|
landed on
|
|
"""
|
|
# First request: 301 to the https version
|
|
respx.get("http://redirect.example.com/").mock(
|
|
return_value = httpx.Response(
|
|
status_code = 301,
|
|
headers = {"Location": "https://redirect.example.com/"},
|
|
)
|
|
)
|
|
# Final request: 200 with one header set
|
|
respx.get("https://redirect.example.com/").mock(
|
|
return_value = httpx.Response(
|
|
status_code = 200,
|
|
headers = {"X-Frame-Options": "DENY"},
|
|
)
|
|
)
|
|
|
|
report = scan("http://redirect.example.com/")
|
|
|
|
assert report.url == "http://redirect.example.com/"
|
|
assert report.final_url == "https://redirect.example.com/"
|
|
assert report.status_code == 200
|