15 KiB
Extension Challenges
You have read the code and you understand what it does. Time to make it yours.
These challenges are ordered roughly by difficulty. The early ones are 30-minute changes. The later ones are weekend projects that meaningfully change how the scanner works. None of them have "the right answer" hidden in this folder. Try them, get them wrong, get them right, learn something.
If you get stuck, the source of truth is always the code. Re-read evaluate_header() and scan() and _render_report(). The whole scanner fits on a screen.
Warm-up challenges
1. Add a seventh header to check
Goal: Extend RULES with one more security header. Pick one of:
Cross-Origin-Opener-Policy(COOP): prevents the page from being interacted with by other origins via window.opener. Recommended value:same-origin.Cross-Origin-Embedder-Policy(COEP): controls which cross-origin resources can be loaded. Recommended value:require-corp.Cross-Origin-Resource-Policy(CORP): controls who can embed this resource. Recommended value:same-origin.
Why it is useful. These three headers together (COOP, COEP, CORP) are the modern way to isolate a page from cross-origin attacks like Spectre. Real high-security sites set all three.
Hints.
- Add a
HeaderRule(...)toRULES. Pick a severity. Decide whether you need amust_matchpattern (a plain word like"same-origin"works as a substring check; use a richer regex if "the value has to be exactly one of N options" matters). - The score totals will change. Your existing tests that assert exact scores may break. Either update those tests or write them in terms of percentages.
- Test it against a real site.
https://web.devis a good one to check.
Done when. just test passes, just run -- https://web.dev shows your new header in the table.
2. Add a --json flag for machine-readable output
Goal. Make the scanner emit a JSON blob when the user passes --json, instead of the colored table.
Why useful. CI systems and dashboards need structured output. A grep-friendly table is fine for humans, JSON is fine for everyone else.
Hints.
- Add a
--jsonboolean flag to_build_argument_parser()usingaction="store_true". - In
main(), afterscan()returns, branch onargs.json. If True, use the standard library'sjsonmodule to dump a dict with the report fields. If False, call the existing_render_report(). HeaderFindingis not directly JSON-serializable because it contains a nestedHeaderRule. You can flatten it manually, or usedataclasses.asdict()which converts a frozen dataclass tree into nested dicts.
Done when. just run -- https://example.com --json | jq '.score' prints just the score number.
3. Add a --verbose flag that prints raw response headers
Goal. Optional flag that, when set, prints every header the server returned (including non-security ones) above the table.
Why useful. When debugging "why does the scanner say my HSTS is weak," you want to see the raw value the server sent.
Hints.
- Add the flag the same way as
--json. - The
scan()function only stores findings, not the raw response. You will need to thread the raw headers throughScanReport(add a new field) or havescan()return a tuple. - Print the raw headers in
_render_reportwhen a verbose flag is passed. The renderer signature will need updating.
Done when. just run -- https://example.com --verbose shows all server headers above the findings table.
Intermediate challenges
4. Scan multiple URLs in one run
Goal. Allow the user to pass several URLs and get a report per URL. Bonus: a summary table at the end.
Why useful. When auditing a company's sites, you do not want to run the scanner once per URL. Run it once over the whole list.
Hints.
- Change the
urlargparse argument to acceptnargs="+"(one or more).args.urlbecomes a list of strings. - Loop over the URLs in
main(). Callscan()for each, render each report, collect them. - For the summary, add a small final table with one row per URL: URL, score, grade.
- The exit code becomes interesting. Probably the worst grade across all URLs.
Edge cases.
- One URL fails (network error) but others succeed. Do you still exit with code 2?
- Two URLs return the same final URL (after redirects). Dedupe or show both?
5. Add an --allow-warnings style threshold flag
Goal. Let the user say "I am ok with grade C, only exit non-zero if grade D or below."
Why useful. CI integrations. Different teams have different acceptance bars.
Hints.
- Add
--min-gradetaking a value from{"A", "B", "C", "D", "F"}. Default to"C"(the current behaviour roughly). - In
main(), comparereport.gradeto the threshold and pick exit code accordingly. - Grades have a natural order. You can compare them with a small helper that maps each to an integer.
Done when. just run -- https://example.com --min-grade C exits 0 if the site got C or better, exits 1 otherwise.
6. Cache results to disk
Goal. When the user re-scans the same URL within the last hour, return the cached result instead of hitting the network.
Why useful. When you are iterating on the renderer or scoring, you do not want to spam a real site every test run.
Hints.
- Store cached reports in
~/.cache/http-headers-scanner/. - File name from a hash of the URL (
hashlib.sha256(url.encode()).hexdigest()). - Use
dataclasses.asdict(report)to JSON-ify,json.dump()to write. - Include a timestamp in the cached file. Skip the cache when it is older than an hour.
- Add a
--no-cacheflag to bypass.
Tricky bit. HeaderFinding contains a HeaderRule. When you load from cache, you need to rebuild those rules from the JSON. Or, simpler, only cache the bits you care about and rebuild the report shape from scratch.
7. Detect and warn about mixed HTTP/HTTPS
Goal. If the user passes an http:// URL and the server redirects to https://, mention it prominently in the output.
Why useful. Many sites enforce HTTPS via redirect, but the redirect chain itself is unencrypted on the first hop and gets stripped by attackers (this is what HSTS protects against). Knowing whether a redirect happened is a useful signal.
Hints.
- After
scan(), comparereport.url(what the user typed) toreport.final_url(where they ended up). - If the user typed
http://...and the final URL ishttps://..., print a one-line note: "Note: this URL was upgraded from HTTP to HTTPS via redirect. Without HSTS, the first request is vulnerable to interception." - Better yet, deduct points if HSTS is missing AND the user came in via http.
Advanced challenges
8. Make it async to scan many sites in parallel
Goal. Use httpx.AsyncClient plus asyncio.gather so that scanning 50 URLs takes about the time of one scan, not 50 scans.
Why useful. Real audits cover many hosts. Sequential scanning is the bottleneck.
Hints.
- Add an
async def scan_async(url, ...)alongsidescan(). Useasync with httpx.AsyncClient() as client:andclient.get(...). - In
main(), build anasyncio.gather()over a list ofscan_asynccalls. - Concurrency limit: do not blast 5000 URLs at once. Use
asyncio.Semaphore(20)to cap parallelism. - The pure functions (
evaluate_header,score,grade) do not change. That is the payoff of separating pure logic from I/O.
Watch out for. Some sites rate-limit aggressive scanning. The default User-Agent identifies us; respect any Retry-After headers if they come back.
9. Add a static analyser for the CSP value
Goal. Currently we only check that Content-Security-Policy is present. Build a sub-analyser that looks inside the CSP value and reports specific problems:
- Contains
'unsafe-inline'inscript-src? Major weakness, points off. - Contains a wildcard origin (
*) inscript-src? Same. - Missing
default-src? Worth noting.
Why useful. A CSP that allows 'unsafe-inline' is barely a CSP at all. Real scanners (Mozilla Observatory) do this analysis. Yours can too.
Hints.
- Add a new dataclass
CSPAnalysiswith fields likehas_unsafe_inline: bool,wildcard_origins: list[str], etc. - Add a
parse_csp(value: str) -> CSPAnalysispure function. CSP grammar: directives are semicolon-separated; each directive isname source1 source2 .... - Make the CSP rule's
weakstatus take into account the analysis result. The baseevaluate_headerwill need an escape hatch for rules that have a custom validator.
Tricky bit. CSP is genuinely complex. The official spec is at w3.org/TR/CSP3. Start by parsing only script-src; ignore the rest.
10. Build a continuous monitor
Goal. Run the scanner against a list of URLs every 24 hours and alert when a site's grade drops.
Why useful. Configuration drifts. A site that had grade A six months ago can drop to B because someone disabled HSTS to debug something and forgot to put it back. You want to know.
Hints.
- Need persistent storage. Easiest start: a SQLite database with columns
(url, run_at, score, grade). Standard library hassqlite3so no new dependency. - A separate script reads a URL list (one URL per line, in a file), runs the scanner, writes results.
- Compare today's grade to the most recent previous grade per URL. If today is worse, emit an alert (print to stdout, send an email, push a Slack message, your choice).
- A
cronentry or a systemd timer fires the script once a day.
Watch out for. Networks are flaky. A timeout one day is not necessarily a grade drop. You probably want a "two failures in a row" rule before alerting.
11. Browser-based comparison: scan the same URL with three different User-Agents
Goal. Sites sometimes serve different headers to bots versus real browsers. Add a --compare-uas mode that scans the same URL with the default scanner UA, with a Chrome UA, and with a Googlebot UA, then shows a side-by-side comparison of the headers.
Why useful. A site might serve strict CSP to real users but loose CSP to bots, or vice versa. Knowing this is real evidence of a misconfiguration.
Hints.
- The
scan()function already takes auser_agentargument. Run it three times with three different values. - A real Chrome UA looks like:
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36. - Googlebot's UA is
Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html). - The render layer needs a "three-column comparison" mode. A rich Table with columns "Default", "Chrome", "Googlebot" and one row per header.
Expert challenge
12. Make it a service with a REST API
Goal. Wrap the scanner in a small HTTP server. POST a URL, get back a JSON report.
Why useful. Other services in a security pipeline can call yours. Plus, it is the natural way to make the scanner accessible from a frontend.
Estimated time. A weekend if you have not used FastAPI before, a few hours if you have.
Prerequisites. Some exposure to web frameworks would help. The intermediate siem-dashboard project in this repo is a deeper example of building one.
Architecture sketch.
┌─────────────────────────────────────────────────────┐
│ FastAPI app │
│ │
│ POST /scan { "url": "..." } │
│ └──▶ async scan_async(url) ────▶ ScanReport │
│ ◀── { "url": ..., "grade": ..., findings: ... }│
│ │
│ GET /healthz │
│ └──▶ {"ok": true} │
└─────────────────────────────────────────────────────┘
Steps.
- Add fastapi and uvicorn to dependencies.
uv add fastapi uvicorn[standard]. - Create a new file
server.pythat importsscan_asyncfrom your async-ified scanner. - Define a Pydantic model for the request body (
class ScanRequest(BaseModel): url: HttpUrl). - Define a response model that mirrors
ScanReportfor serialisation.HttpUrlvalidates URLs at the API boundary. - Add the route.
@app.post("/scan", response_model=ScanResponse)thenasync def scan_endpoint(req: ScanRequest). - Add a healthz endpoint so external monitors can confirm the service is alive.
- Add rate limiting. Otherwise people will use your service to scan strangers. The advanced
api-rate-limiterproject in this repo is a good reference.
Production checklist.
- Validation: reject internal IPs (
127.0.0.1,10.0.0.0/8, etc.) to prevent SSRF. - Timeouts: every scan has a hard cap so a slow target cannot tie up workers.
- Logging: every request is logged with
url,grade,duration_ms. - CORS: decide which origins can call your API.
- Deploy: Dockerfile that runs uvicorn, env var for port.
Stretch goal. Build a tiny single-page frontend (vanilla HTML + JS, no framework needed) that lets a user paste a URL and shows the report. Now you have a real product.
Other directions
A few smaller ideas if none of the above grab you:
- Color-blind mode. Replace the green/yellow/red colors with green/yellow/red plus distinct shapes (✓, ⚠, ✗) so the table is readable for color-blind users.
- HAR file import. Instead of scanning a live URL, read headers from a
.harfile (HTTP Archive, what browsers export from dev tools). Lets you scan responses you captured earlier. - Show diff against a known good baseline. Save a "reference report" for a URL. On next scan, show only the changes ("HSTS got weaker," "X-Frame-Options went missing").
- Add a
--strictmode that lowers the bar: deduct points even for minor issues (e.g.max-ageless than six months, missingincludeSubDomains).
What to do when stuck
The strategy that works every single time:
- Reduce. Strip the problem down. Comment out everything except the smallest piece that still misbehaves.
- Print. When
evaluate_headerdoes not return what you expected, print the inputs and the outputs. Do not guess what they look like. Look. - Read the test. If a test is failing, the test is telling you exactly what it expected. Read the assertion. Read the inputs. Compare to the actual output.
- Diff against known good. Use
git diffto see what changed since the tests last passed. The bug is in your changes 99% of the time.
If after all that you still need help, the GitHub Discussions tab on the repository is the right place. Bring:
- What you tried.
- What you expected.
- What actually happened (the full error if any).
- The smallest reproducer that shows the problem.
"It does not work" is not enough information to help with. The above four bullets are.
When you finish a challenge
Make it yours. If you build something interesting, write it up. Push it to your own fork. Open a pull request back to the main repo if your improvement is generally useful. The best way to lock in what you learned is to teach someone else.