Cybersecurity-Projects/PROJECTS/foundations/http-headers-scanner/test_http_headers_scanner.py

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