An HTTP security header analysis engine, plus a command-line tool over it.
It began as a fork of shcheck and outgrew it; the fork's tool was deleted and
only the analyser survived, until hst was written against the analyser rather
than tangled into it.
The engine is still library-only in the sense that matters: it never fetches
anything. Every network call lives in cli/live.py, import http_security_test pulls in no fetching code of ours, and a test pins that
nothing outside cli/ names cli. Do not relax that direction — it is what
lets the analyser be embedded in a Burp extension, a CI job or a notebook
without dragging a tool along.
GPL-3.0-or-later. Every source file carries the notice.
findings.py Finding, identity(), FINDING_SEVERITY, SEVERITIES, severity(), order_findings()
catalog.py MESSAGES + describe(): every sentence the package can produce
references.py header and taxonomy URLs -- the only module whose exported data is a URL table
message.py Request, Response, the header mapping model: parse_headers, parse_raw_headers, mapping, lookups
exchange.py Exchange, Connection: one HTTP exchange, and the facts neither message carries
csp.py Content-Security-Policy: split_policies() -> parse_csp() -> combiner (largest)
hsts.py Strict-Transport-Security + the ONLY third-party dependency
isolation.py COOP / COEP / CORP / CORS
policies.py Permissions-Policy + Feature-Policy
legacy.py the obsolete headers
cookies.py Set-Cookie: the parser, the name tables, the analysis
response.py tables, registry, cross-header rules, analyze, inventory, orphans
reporting.py report(): findings + inventories as plain data, ready for JSON
__init__.py public API -- the core only, see its own docstring
adaptors.py live library objects (requests, httpx, urllib3, ...) -> Request/Response
cli/ the `hst` tool -- the only code here that opens a socket
__init__.py main(argv) -> int: parse, dispatch, exit codes
meta.py TOOL_NAME, tool_version(), USER_AGENT, LEVELS
options.py the entire argparse contract, one file to read the CLI
commands.py do_scan(), do_explain(): glue, one function per verb
outcome.py Hop, Failure, Run: what the CLI's own fetch produced, apart
from the message facts that now live in exchange.py
scope.py host patterns -- where a redirect may wander
live.py the live source: urllib, redirects, TLS, proxy
run.py run_document(): results -> the JSON run envelope
text.py render(): the human-readable report
writers.py format table, -o resolution, the writers
cli/ is flat for the same reason the analyser is. formats/ -- Burp XML,
HAR, SAZ, WCAT -- is a fourth layer, between adaptors and cli, and today it
is a recorded decision rather than a directory: zero parsers exist, and this
project's rule is that a directory earns its place at three implementations.
The first one lands in formats/, not in cli/ beside live.py as an earlier
version of this paragraph said. cli/live.py itself is the one deliberate
exception to the layering below: it stays in cli/ rather than becoming a
peer layer, because the opening paragraph's claim -- "it never fetches
anything" -- has to stay unconditional. Promoting live out of cli/ would
soften that to "unless you import the part that does," and the sentence a Burp
extension author reads while auditing what they are embedding matters more
than the symmetry.
Dependencies run one way, lowest layer first, and there are no cycles. Four layers, each free to import its own layer and anything below it, never above:
core findings, catalog, message, references, csp, hsts, isolation,
policies, legacy, cookies, response, reporting, exchange
-> stdlib only
adaptors live library objects -> core types -> imports core
formats Burp XML, HAR, SAZ, WCAT -> NOT BUILT YET
cli argv, orchestration, writers, live.py -> imports all above
cli.meta holds LEVELS = tuple(reversed(SEVERITIES)) rather than restating
the severity vocabulary, which is why options needs no import of the renderer
just to spell --min-level's choices — and why adding a level upstream cannot
silently desynchronise the two.
Nothing outside cli/ may import cli, and nothing in core may import
adaptors -- both are the same rule at different layers, and
test_imports_only_ever_run_downhill (tests/test_cli_structure.py) is what
enforces it. It classifies every import in every module by which layer it
names -- relative (from .cli import x) and absolute
(import http_security_test.cli) alike -- and fails on any import pointing
uphill, which is a strictly stronger claim than the single forbidden edge the
test used to name: it also catches a formats/ parser reaching past
adaptors into cli, the day one exists.
test_importing_the_library_does_not_import_the_adaptors_or_the_cli, in the
same file, checks the operational half of the claim: importing the package in
a fresh subprocess must not pull http_security_test.cli or
http_security_test.adaptors into sys.modules. Together the two are what
keep the claim in the opening paragraph true.
catalog.py is a leaf on purpose and no analyser may import it. The
analysers emit (header, code, data, level) and hold no prose; only reporting.py and
a consumer calling describe() turn that into a sentence. A test reads the
syntax of every Finding() call to keep it that way -- prose in a comment or a
docstring is ordinary English and fine, a sentence passed as data is not.
Two naming rules, both learned the hard way in one session: a module and an
exported callable must not share a name (from .message import ... sets
http_security_test.message to the submodule and silently clobbers a function
of that name -- which is why the renderer is describe() and not message()),
and catalog.py is not called messages.py because one character from
message.py is not a distinction. The rule bit again in cli/: the fetch
module is live.py exporting fetch() rather than fetch.py, and the envelope
module exports run_document() rather than run().
The boundary that matters: a family module answers "what is wrong with this
header" — a value in, findings out, no knowledge of siblings. response.py
answers "what is wrong with this response" — which headers should have been
there, what the ones present mean together, what a sibling has made moot. Cross-
header rules cannot be split by family; they belong in response.py.
message.py is the HTTP message model, not the response model. A request
carries headers the same way, so a future request.py shares it.
X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
Clear-Site-Data and Integrity-Policy live in response.py because they have
no family. That is the resting point, not a half-measure — a module each would
be overkill.
Reporting-Endpoints and Report-To live there for the opposite reason: they
are a family, and every other member of it is a header analysed elsewhere.
Five headers name a reporting group — CSP, COOP, COEP, Integrity-Policy and
Document-Isolation-Policy when it lands — while only these two define one, so
every question about reporting is a cross-header question by construction.
These were expensive to arrive at. Do not quietly reverse them.
- Findings are facts; ratings are policy — but published. A finding is
(header, code, data, level), wherelevelisNonefor the common case of a code whose rating never varies. The ratings are SARIF levels (error/warning/note) so a consumer can adopt, remap, or ignore them, and the wording is split off the same way: a template belongs to the rule and the values belong to the result, which is SARIF'smessageStrings+argumentsin all but name.datais the contract; the sentence is a convenience. A rating is a code's default, not its only possible level: a finding may carry its own, which is SARIF'sresult.leveloverridingrule.defaultConfiguration.level-- one defect can be worth more on one cookie than on another, and that is a rating rather than a different fact.ESCALATABLEnames the codes that do this andlevel_of()resolves a finding's own level or its code's default. - Inventories are facts, findings are judgments. Nothing is withheld from an
inventory because of what it contains. HSTS appears in a
missinginventory on a plaintext target; the finding is whatsecure=Falsesuppresses. - Severity rule.
error= the header does not deliver the protection its presence implies (browsers ignore it, or it permits the very thing it exists to stop).warning= it protects, but a hardening directive is missing.note= a fact with no defect. - A false positive on a correct configuration is the worst outcome. This
project exists because the tool it forked called
default-src 'self'unsafe by substring match. Two Criticals found in review were the same bug in new clothes (require-corp; report-to="…"read as invalid; the nonce +'unsafe-inline'idiom flagged as XSS). Assume the next one is too. This is not an amateur failure mode: PortSwigger shipped the same bug and then archived it —documentation/BChecksarchived/Content-Security-Policy.bcheckdecides everything by substring containment and carries a comment recording a false positive it had already had to patch out. See that entry in the reference section. - Only an effective header earns a suppression. An
X-Frame-Optionsbrowsers ignore protects nothing, and neither doesframe-ancestors *. - What a non-enforcing header permits decides nothing. Report-only content
is never analyzed; Feature-Policy's content is ignored once Permissions-Policy
is present;
coep-missingis excused unless COOP asks for isolation. - Code naming.
<prefix>-deprecatedmeans "present, legacy, no defect", and there is no longer an exception:xfo-deprecatedwas renamedxfo-allow-fromonce the schema made codes an external contract, because it is ratederrorand a-deprecatedsuffix understated a value no browser honours. A code names the defect, not the age of the thing. The table and the code name are independent: membership ofDEPRECATED_HEADERSsays "do not reach for this", the suffix says what is wrong with it.xdpc-nonstandardis in that table without a-deprecatedcode because X-DNS-Prefetch-Control was never standardised in the first place, and calling that deprecated would be false.
Breaking one of these silently is easy; each has a test, and each has been mutation-verified.
- Every emittable code has a rating, and every rated code is emittable. The
corpus in
tests/is what makes that checkable — a new code needs a case there or the completeness tests pass vacuously. - A code belongs to exactly one header. Exception:
duplicate-headers, which is about the response; it is explicitly exempted in that test. - Findings are deduped by
identity()—(header, code, data)— never bycodealone and no longer by(header, code)either. The pair was right only while a finding carried prose: twoX-Frame-Optionsvalues that are both invalid are two facts, and two cookies each missing the same attribute are two more --test_two_cookies_missing_the_same_thing_are_two_findings(tests/test_cookies.py) is what exercises it. A repeated header with identical values still reports once, because the data is identical too. - Every emittable code has a message template and every template's code is
emittable — the same bijection the severities have, checked the same way.
Separately, every finding the corpus can produce is rendered, because a
template naming
{sources}beside data carryingdirectivesis invisible until someone asks for the sentence. - Every combinable CSP code can name the directive its claim is about, via
_claim_directives()— from the finding's owndata, or fromCSP_CODE_DIRECTIVEwhere it carries none. A claim the combiner cannot locate cannot be intersected. The bijection caughtcsp-plain-schemethe first time it ran: it names its directives underschemes, as{directive, scheme}pairs, soCSP_CLAIM_PAIRSdeclares that spelling rather than the resolver sniffing the data's shape. - Output order is deterministic: the tables are tuples, not sets. A set literal
here reorders output per process. Sets are fine inside a function —
_references()andtaxonomy()both build one and returnsorted(), and the CSP combiner tests membership in one it never iterates. The way to check a writer for this is cross-process: the same document rendered under twoPYTHONHASHSEEDvalues must give identical bytes. Verified for thejsonwriter on 2026-09-08 — five seeds, one md5.
present maps a lowercased name to a list of values, and mapping() is what
builds it -- from the (name, value) pairs a Request or Response carries --
so a header sent once is still a one-item list and the ordinary caller never has
to special-case it.
Build it with parse_headers(pairs) or parse_raw_headers(raw) — never with a
dict comprehension. Verified stdlib behaviour:
| access | value |
|---|---|
getheaders() |
both pairs, duplicates intact |
Message["name"] |
the first |
{k: v for k, v in pairs} |
the last |
CaseInsensitiveDict[name] |
comma-joined |
The fourth row is requests' own .headers (urllib3's HTTPHeaderDict
underneath), and it does not merely lose a duplicate the way the first three
do -- it corrupts what is left. Two Set-Cookie values, only one of them
carrying an Expires date, comma-join into one string whose own commas are
now indistinguishable from the join: re-splitting on , yields three
pieces for two cookies, because the date's internal comma looks exactly
like a second join point. adaptors.from_requests() exists to route around
this by reading .raw.headers instead, never to parse through it.
Repeated headers are not a corner case. Several CSPs are enforced
conjunctively, and "several" includes a single header line: the value is a
serialized-policy-list, so split_policies() runs before anything parses a
policy. A coverage gap fires only if no policy closes it, a weakness only if
no policy blocks it, and a syntax defect if any policy has it — that split
is CSP_SYNTAX_CODES. Getting this wrong inverts the verdict.
"No policy blocks it" is not "every policy reports it", and the difference is the whole of the combiner: a policy silent about a directive permits it. The two CSP entries under Deliberately decided carry the reasoning and the measurements.
REPEATABLE_HEADERS (CSP, CSP-Report-Only, Set-Cookie) may legally repeat;
anything else repeated raises duplicate-headers. _sole_value() returns None
for a header repeated with values that disagree — no specification says which
wins, so no suppression can be earned from it.
report(exchange, message=True) is the whole analysis of one exchange as plain
data — strings, numbers, lists, dicts, no encoder needed. raw and request_raw
are no longer separate parameters: those blobs come straight from
exchange.response.raw and exchange.request.raw, as the raw / fidelity
bullets below explain:
{
"response": {
"findings": [
{"header": "Clear-Site-Data", "code": "csd-unquoted", "level": "error",
"data": {"members": ["cookies"]}, "consequences": ["cache-exposure"],
"message": "present but cookies is not…"}
],
"inventory": {
"security": {}, "missing": [], "deprecated": {}, "information": {},
"caching": {}, "cookies": []
},
"references": {"headers": ["Clear-Site-Data"],
"taxonomy": ["CAPEC-204", "CWE-525"]},
"raw": "<base64>",
"fidelity": "capture"
},
"request": {"raw": "<base64>", "fidelity": "capture"}
}Decisions inside that shape, each of which had an alternative:
- A list of findings, not
{severity: [code]}. The old tool's schema grouped codes under a severity, which is lossless only while a code identifies one header and cannot repeat. Neither holds:duplicate-headersnames several headers today, and per-cookie findings repeat within one header --test_two_cookies_missing_the_same_thing_are_two_findings(tests/test_cookies.py) is what exercises it. - Nested by message, not flat. Not only for the parked
request.py:Cache-Controlis both a request and a response header, so a bareheaderfield could not say which one a finding meant once requests are analysed.requestgrowsfindingsandinventorywhen that lands and nothing else moves. A finding about the exchange goes underresponse— origin reflection is a defect in what the response did, with the forgedOriginindata. Do not add a top-levelfindingsfor those; it would be empty in every ordinary report and force consumers to merge two lists forever. - No URL key. A response does not know where it came from. A tool that fetched several wraps as many of these as it fetched — that is a fact about the run, not about any response.
levelis denormalised. It is derivable fromcode, and written out anyway, because the common reader wants to sort by severity without also carryingFINDING_SEVERITY.datais always present,{}included, so a consumer never tests for the key.messagecan be dropped entirely withmessage=False.inventory()takes nosecure. Principle 2: nothing is withheld from an inventory. HSTS is missing on a plaintext response and the inventory says so.cookiesis the sixth inventory key, and the only one that is a list of parsed objects rather than a header-name mapping.Set-Cookiemay legally repeat and each value is an unrelated cookie, so a mapping keyed by name would drop one of two cookies sharing a name. It is derived independently offindings-- principle 2 again, in the other direction from the false positive: a correctly configured cookie still earns its row.securityandmissingare two halves of one question, with four exceptions.REPORTING_HEADERS(Report-To,Reporting-Endpoints),CORS_HEADERS(the six response-sideAccess-Control-*),PRESENT_ONLY_HEADERS(Clear-Site-Data,Integrity-Policy) and the fourREPORT_ONLY_HEADERSkeys are inventoried undersecuritywhen present and are never reported absent, because a response that configures no reporting, shares nothing across origins, is not a logout endpoint, or is not trialling a policy is the ordinary state of the web rather than a gap. This is why none of them is inSECURITY_HEADERS: that tuple is read three times — forsecurity, formissing, and by_report_missing()— and only the first is wanted here. Adding a header there to get it inventoried mints an<initials>-missingcode that fires on nearly every site; the bijection tests catch it, but the design should not need saving by them.Integrity-Policyis the sharp case: it is unambiguously a security header and its absence really does mean subresources load unverified, but demanding it would fire on nearly the whole web, so it is inventoried and not demanded. Mutation-checked — moving it intoSECURITY_HEADERSfails 53 tests. The CORS table was added on 2026-08-21 and closed a plain oversight:Access-Control-Allow-Originhad findings but appeared in no inventory at all, so a response sharing itself with credentials to an arbitrary origin showed five empty tables. The other two tables closed the same oversight on 2026-08-24, and worse: a correctly configuredIntegrity-Policyraised no finding either, so the response sent it and the report showed no trace of it anywhere — a reader could not tell it from a response that never sent the header.Content-Typeis in no inventory table, deliberately, and is the only header a finding can name that none carries. It is analysed, for the charset parameter alone, soinformationandcachingcannot take it — both mean never analysed. And it is not a security header: OWASP's 250 000-domain corpus tracks 17 names andcontent-typeis not among them, which is an independent check rather than an opinion. A seventh inventory key for it was designed on 2026-08-24 and deferred, because one member is ceremony by this project's own three-implementations rule. The trigger: a second analysed-but-not-security header.Varyis the nearest parked candidate. Until then the gap is one header's value, recorded here so it reads as a decision rather than the oversight it looks like.- Absent beats empty for passthrough. No blob, no
rawkey; nothing known about the request, norequestkey. The rule that reconciles this withdataalways being{}: content this package derived is always present, content it was merely given is present only if it was given. fidelitynever travels withoutraw, butrawcan travel withoutfidelity. Three values:captureis the bytes exactly as they crossed the wire;reconstructedis a reassembly from a parsed model — every HTTP/2 exchange counts, since h2 has no start line for anything to have captured, so whatever rendered one had to invent it;redactedis a reconstruction known to have lost content. It is taken verbatim fromexchange.request.raw/exchange.response.rawand never validated against that vocabulary — this package has no way to confirm what bytes really crossed a wire it never touched, so checking the string would catch a typo while remaining unable to catch a lie. The biconditional a reader expects does not hold: a statement with no blob says nothing, sofidelityis absent wheneverrawis, but the reverse is not required — a caller may hand over bytes (Response.from_parts(raw=b"..."), say) without saying what kind they are, andrawis then present withfidelityabsent. Absent beats empty either way: both fields are content the caller gave, not content this package derived, so each is present only if it was given.consequencesis per finding,[]included, the same rule asdata. A hint about potential risk, never a claim it is reachable — every entry's wording says so — so a consumer filtering by risk never has to test for the key, and a finding with nothing to say has said so explicitly rather than by omission.referencesis one key per response, not scattered throughfindings[]and the inventories. Scattering puts the same MDN entry in six places and forces every consumer to merge six lists forever — the argument that already killed a top-levelfindingskey. It carries identifiers, not URLs: a header name and a CWE id do not rot, a link does, and both resolve to a stable URL by pattern throughreferences.header_url()andtaxonomy_url(). Storing five links per header was the first design and is exactly the curated-data-ages problem that keepsreferences.pydown to three curated exceptions. It is fed by findings only — a header earns its place because something was said about it,-missingcodes included — so a flawless response gets two empty lists, which reads as "nothing to read" rather than as an omission, and one result cut out of a multi-target run stays self-contained.headersis canonicalised throughCODE_HEADERrather than read off the finding, because aduplicate-headersfinding alone carries a lowercased name.taxonomyis deduped and sorted by scheme then numeric id, not lexically, orCWE-1021sorts beforeCWE-79.
The raw blobs are optional and the analyser never fetches anything, so they
are whatever the caller hands over — cli/live.py is what supplies them for a
live scan, and it does so only under --raw. Two things decided about them:
- The library does the base64, from bytes or text, text encoded latin-1 to
match what
parse_raw_headers()decodes with. One encoding decision in one place — the alternative was accepting a ready-encoded string, which leaves nothing enforcing whether it is base64 of UTF-8 or of latin-1, and that bug stays invisible until a non-ASCIIServerbanner turns up. Base64 at all because header values are latin-1:Server: café-serveris not valid UTF-8 and a JSON string cannot carry it losslessly. - A blob makes a report reproducible.
rawis exactly whatparse_raw_headers()accepts, so an archived report can be re-analysed by a later version and the findings diffed. That is the real argument for carrying it, more than provenance. - They carry credentials. A raw response head normally includes
Set-Cookiewith a live session token; a raw request includesCookieandAuthorization. Nothing here can police it, and reports get pasted into tickets and dashboards. Passing only the header block, redacting, or passing nothing is the caller's call — the docstring makes sure it is a call and not an accident.
The three find_* functions are gone; inventory() replaces them and adds the
missing list, which every consumer previously rebuilt by hand. Deriving those
inventories from findings does not work and the temptation is real: the 91
information headers and the 5 caching headers emit no findings at all by design,
and missing deliberately differs from the -missing findings wherever a
suppression applies.
hst, added 2026-08-21. docs/designs/2026-08-21-cli-contract.md is the
contract and the full reasoning; this is the part worth carrying in your head.
hst scan https://example.com # the 95% case
hst scan -oA evidence example.com # nmap-style: terminal AND files
hst scan -j example.com | jq . # machine output on stdout
hst explain csp-unsafe-inline # what a code meansTwo console scripts, hst and http-security-test, both at
http_security_test.cli:main. Standard library only — pip install http-security-test gives a working tool with no dependencies, and there is
deliberately no [cli] extra, because an extra that installs nothing
misrepresents the package.
Verb-first, and the bare form is deliberately a usage error. hst example.com exits 2 with did you mean: hst scan example.com. A flat,
curl-shaped CLI was considered and rejected: every file-input format on the
roadmap has a flag set disjoint from scan's — no -k, no -H, but filters a
single fetch has no use for — and retrofitting verbs onto a flat tool breaks
every invocation anyone has written down.
nmap's output model, and it is not only ergonomics. The terminal always
shows the run; -o FORMAT:PATH (repeatable) and -oA PREFIX write files. With
several simultaneous outputs the pipeline is forced to materialise one
plain-data run document and render it N times, rather than letting a renderer
walk live objects — the same discipline reporting.py imposes on the analyser,
and the reason a SARIF writer will be a pure function over data that already
exists. Corollaries: colour is a terminal property and never reaches a file,
and --min-level filters the terminal only so evidence files stay complete.
Exit codes are frozen. 0 ran clean; 1 findings at or above --fail-on;
2 usage error (argparse's own, which we do not fight); 3 a target could not be
reached. Findings never make the exit nonzero by default — a pentester with
set -e must not have a loop die because a site is missing CSP, and CI opts in
with --fail-on. 3 beats 1, because an incomplete answer is the more
serious fact: a gate reporting "clean" after failing to reach half its targets
is worse than useless.
Failure kinds, not retryability. dns, refused, timeout, reset,
tls, protocol, other, from one exception-to-tag mapping. "Retryable" is a
prediction; the kind is a fact, and a calling tool predicts for itself. Note
classify() returns None for an HTTPError: a WAF's 403 is a response whose
headers are worth analysing, not a failure. Two live-tested details that will
otherwise be "tidied" wrong — urllib wraps only what h.request() raises, so
BadStatusLine and RemoteDisconnected arrive bare while connect-phase
errors arrive inside URLError; and RemoteDisconnected subclasses both
ConnectionResetError and BadStatusLine, so the reset branch catches it and a
separate branch would be dead code.
{"schema": 1,
"tool": {"name": "http-security-test", "version": "0.1.0"},
"run": {"started": "...", "finished": "..."},
"results": [{"outcome": "ok", "target": "example.com",
"source": {"kind": "live", "url": "...", "status": 200,
"reason": "OK", "hops": [...]},
"report": { ...verbatim from report()... }},
{"outcome": "failed", "target": "...",
"failure": {"kind": "dns", "message": "..."}}]}report holds what report() returned, unmutated. Run facts live in the
sibling source key. That is what lets "a response does not know where it came
from" stay true instead of being worked around, and it keeps the two
contracts separable: schema versions the wrapper, tool.version versions the
contents. The throwaway scan.py used to splice result["url"] = final into
the document and label it "not part of the schema"; that hack is gone.
source.kind is the discriminator, and it is where the next input format
slots in. A HAR result reads {"kind": "har", "file": ..., "entry": 12, ...}
in the same slot. This is the cheap form of extensibility: the polymorphism
lives in the document, not in the call graph, which is why there is no source
registry and should not be one until a second source exists.
The three questions CLAUDE.md used to park under "The schema is not finished" are answered, and answered without touching the library:
- A version field:
schema, an integer, owned by the envelope. A version is a property of a serialised artifact;report()returns a dict, and the CLI is what writes files that outlive the process. - Run metadata: yes, tool name and version, because reproducibility is the
stated reason
rawexists. Without it, a finding that vanished between two archived reports is ambiguous — the site was fixed, or a rule changed. - How several results travel: a list, never a URL-keyed map. Keying by URL was already rejected and is independently wrong here, since one target can yield several results and the same URL can be scanned twice in one run.
Deliberately absent: the command line, because it carries -H 'Authorization: …' and proxy credentials, and redacting means guessing at
secrets. The precise provenance record already exists — --raw gives the actual
request head, with its credential warning attached. One documented footgun beats
two, one of them fuzzy.
--follow {none,host,subdomain,any} was designed and then deleted: every value
of it turned out to be a host pattern. What ships is -n/--no-redirect plus
--scope PATTERN (repeatable), defaulting to {H, *.H} for each target host
and printed to stderr before the first request so the guard is auditable.
*.example.com does not match the apex and does match at any depth — decided on
composability, because keeping them disjoint is what gives "subdomains but not
the apex" a spelling at all.
A derived "sibling" rule was proposed and killed by measurement. From
example.co.uk a label-counting rule derives co.uk and would admit
evil.co.uk. Chromium's PSL (net/base/registry_controlled_domains/
effective_tld_names.dat, read 2026-08-21): 6934 ICANN rules, 5470 of them
multi-label, 3367 two-label suffixes across 195 ccTLDs — a second level is the
norm for country TLDs, not an exception, and .in carries 5g.in/6g.in, so
the pattern is still growing. Three approximations were tried and all fail: a
two-label floor misses .co.uk; refusing a two-label parent under a two-letter
TLD then breaks .de, .io, .fr and most of Europe; and a shared-suffix test
cannot distinguish www.example.co.uk -> shop.example.co.uk from
example.co.uk -> evil.co.uk. Sibling scoping is PSL-or-nothing, and
vendoring a PSL is the csp-evaluator objection again (curated data that ages,
failing open when stale).
The precedent is wider than one flag. Any "same site" notion this tool grows
— CORS origin checks, cookie Domain analysis, __Host- reasoning — hits the
same wall. And nothing here is called same-site: that term has a precise
PSL-backed meaning in RFC 6265bis and Fetch, and attaching it to a looser rule
would be exactly the borrowed-term sloppiness this document warns about
elsewhere.
Given one -o argument: if it contains a colon and the text before the first
colon is a known format name, that is the format and the rest is the path;
otherwise the whole argument is a path and its extension decides; otherwise a
usage error naming both fixes. Invariant: no single-letter format name,
ever — that is what makes -o C:\out.json unambiguous on Windows, and a
future -o c:report.csv would break it silently.
An "obvious" third branch — error on a format-shaped head that names nothing —
was written, shipped and reverted in one session. It rejects note:doc.json, an
ordinary relative filename, and no syntactic rule can separate that from
nope:out.json; the two are structurally identical. So a mistyped format
prefix with a valid extension writes a file of that literal name, which is
principle 4 applied to argv: refusing a legitimate input is worse than accepting
an odd one, and the odd one is loud anyway.
-
Verify, don't assert. Several confident claims this project ran on turned out false under test — including mine and the human's. Check stdlib behaviour with a constructed response; check a "this is unused" hunch by grepping call sites; check browser support against MDN BCD first (
caniusealone cannot answer most header questions — see the reference section), and check what sites really send against OWASP's 250 000-domain corpus instead of recalling a figure. One rationale here was already wrong on exactly this point and stood for weeks because nothing on disk could contradict it; now something can. -
Mutation-test new guards. Break the code, confirm the test fails, restore. A test that passes both ways is worse than none. This has paid for itself twice; most recently a guard returning
Noneinstead of""survived every mutation because the only caller tested truthiness, which is dead code pretending to be a decision. -
The wording is pinned too.
tests/rendered_messages.txtholds every distinct sentence the package can produce, and a companion test asserts it covers every rated code so it cannot pass vacuously.catalog.pyis prose nothing else reads, so an accidental edit there changes what a consumer sees while every other test stays green. Regenerate deliberately and read the diff:UPDATE_MESSAGE_SNAPSHOT=1 python -m pytest tests/ -k snapshot
-
Refactors get behavioural equivalence checks.
git show HEAD:<file>into/tmp, run a corpus through old and new, diff the(header, code)sets. Both module splits were verified this way at 335 and 168 cases, zero mismatches. -
Scripted edits fail silently, twice bitten.
str.replacewith an indentation-prefixed pattern also matches deeper indentation (a 4-space tuple entry matched a 12-space constructor argument and corrupted aFinding). And an anchor edited earlier in the session no-ops without complaint. Assert the anchor exists and is unique before replacing. -
A section number copied out of browser source needs re-checking. The URL in Chromium's
net/cookies/cookie_constants.h:391citesdraft-ietf-httpbis-rfc6265bis-13; the current revision is -22, and the section numbering moved. Browser comments cite the draft they were written against, so read the number as a date stamp rather than a reference — and go to the draft text (orw3c/webref's extract of it) for the number itself. This generalises past cookies: any living-standard citation in an engine checkout is as old as the line it sits on. -
urllib.parseis core;urllib.requestis not.exchange.pyimportsurllib.parseforscheme()andhost()— string parsing, no socket — and that belongs incoresame as any other stdlib import;urllib.requestis what opens one, and onlycli/live.pymay import it. Worth a sentence of its own because the module name alone, seen out of context, reads like exactly the breach the opening paragraph promises never happens.
The first two are not preferences. They are data-loss rules, and both have been broken by an agent that had read the section and filed it under taste.
-
Never destroy uncommitted work; never write to git. The human owns the git workflow entirely. Two families of command, and the second is the trap:
- Writing git state:
add,commit,stash,push,merge,rebase,tag,branch,worktree add. An agent rangit stashonce and flattened the human's staged changes. - Discarding working-tree content:
checkout -- <path>,restore,reset --hard,clean. These change no git state at all — no ref moves, no index entry changes, nothing enters the object database — which is exactly why the older wording, "anything that changes git state", did not stop an agent runninggit checkout -- <file>inside a mutation-test harness on 2026-08-21 and reverting its own uncommitted work in two files. Protect the work, not the state: git is only one route to losing it.
Assume other agents are editing this tree right now. Sessions run in parallel against one working tree — on the day above,
ListAgentsshowed two other live sessions on this repo andgit worktree listshowed a single checkout, so the file the harness reverted could as easily have been someone else's. There is no way to tell whose an uncommitted edit is from inside a session, and no way to ask before the command lands. The recovery odds are not symmetric either:git stashat least leaves something ingit stash list, whilegit checkout -- <path>over never-staged changes is unrecoverable, because that content never entered the object database and there is no blob to recover.Reading git is fine, and the practices above require it:
git show,git log,git diff,git statusandgit archivewrite nothing, and the behavioural-equivalence check depends on them.To restore a file broken on purpose — mutation testing asks for exactly this, so the need is real and the doc used to leave it unanswered — back the file up outside the repo and copy it back:
cp module.py "$SCRATCH/" # ...mutate, run the suite... cp "$SCRATCH/module.py" module.py
Reaching for git to undo your own edit is the mistake. The backup is one line and it cannot reach anyone else's work. A
PreToolUsehook in.claude/settings.jsonblocks the discarding commands outright, because a rule that lives only in prose is read by exactly the agents that were going to follow it anyway. - Writing git state:
-
Never maintain
/home/crapula/ref. The human has their own tooling for that whole tree — no syncing, fetching, updating, pruning or cleanup, and no state-changing git command in any checkout under it. Read it; that is all. The reference section repeats this where the sync sources are recorded. -
They run
ruff format. Keepruff checkclean; do not reformat. -
Ask, with options and a recommendation, on policy and design calls (severity, schema, scope). They engage closely and will push back with evidence when a premise is wrong — that has repeatedly been the right call.
- A CSP header value is a policy list, and folding is legal. Fixed
2026-09-08;
hstused to read one header line as one policy and was the only implementation of four that did. CSP3 defines the value asserialized-policy-list(1#serialized-policy), and RFC 9110 §5.3 makes folding a list-valued field into one comma-separated line explicitly semantics-preserving. All three engines split before parsing, on the live response path, each with a comment saying why: ChromiumParseContentSecurityPolicies()walkingSplitAndTrim(value, ",")(reached fromPopulateParsedHeaders,parsed_headers.cc:50) on top ofHttpResponseHeaders::AddHeaderalready splitting coalescing headers; FirefoxCSP_AppendCSPFromHeader()(nsCSPUtils.cpp:585, fromDocument::InitCSP); WebKitdidReceiveHeader()(ContentSecurityPolicy.cpp:250). Chromium's own unit testSubsumesBasedOnCSPSourcesOnlyputs"script-src http://*.one.com, script-src https://two.com"in a singleContent-Security-Policyheader, runsAddContentSecurityPolicyFromHeadersinto astd::vector, and asserts subsumption against the result — decisive, because a repeated directive within one policy is ignored after the first, so the case means nothing unless the comma made two policies.split_policies()is the split, exported besideparse_csp()so an embedder cannot repeat the bug, andparse_csp()takes ONE policy. Four details:- Empty elements are dropped — RFC 9110 §5.6.1.2 makes ignoring them a recipient MUST, and an empty policy constrains nothing anyway.
- A header naming no policy at all (
CSP:,CSP: ,) is analysed as one empty policy. Zero policies would report nothing, andcsp-missingcannot cover for it because the header IS present — a real site sends this, and it was a regression caught only by the corpus diff, not by any unit test. - A comma inside a
report-urisplits the policy, and the resultingcsp-unknown-directiveis CORRECT. It looks like a false positive and is not: all three engines split on every comma at this layer, so the URL really does break the policy in a browser. RFC 9110 §5.6.1 predicts it — a field carrying a URI-reference "ought to be defined with delimiters around that element", and CSP's grammar has none. Set-Cookiecannot be folded and must never be, which is the same question answered the other way: RFC 9110 §5.3 names it the exception ("Since it cannot be combined into a single field value"), Chromium lists it inIsNonCoalescingHeader(), andpath-valueadmits a comma (RFC 6265 §4.1.1), so rewriting dates would not make folding safe either. Prevalence: 65 domains of 43 476 sending CSP in OWASP's corpus (0.15 %), including Pinterest across ~15 ccTLDs andforms.gle, which earned anerror-ratedcsp-invalid-keywordon a correct policy before the fix.
- Several policies combine by claim, never by code, and silence permits.
Rewritten 2026-09-10. The unit is
(code, directive): two policies permissive about different directives agree about nothing, and intersecting on the code alone reported "a wildcard is effective" forscript-src *besideimg-src *when the sibling pinned both to'self'. And a policy that does not constrain a directive permits it — the older rule read absence-of-finding as "this policy blocks it", so a policy silent about script suppressed every script weakness beside it._sources()returningNoneis exactly the silence test.- There is no table of per-directive default values, and asking for one is
the wrong question. CSP has no such thing: the only fallback is
default-srcfor fetch directives (CSP_FALLBACKS, applied by_sources()), and an absent directive with no fallback restricts nothing. What the combiner needed was the third state, not a value. - The coverage codes needed no special case and did not get one. For
csp-no-frame-ancestors, "does not constrain frame-ancestors" is "reports the gap", so the one rule collapses to "every policy reports it", which is what a gap always meant. Syntax defects stay any-policy. - The old rule failed worst where it was most likely to fire: suppression
needed every policy to report, so the less a policy said the more it
silenced. A bare
upgrade-insecure-requestssecond header — constraining nothing, usually added by a CDN — hid every script and style weakness in the real policy beside it. Of the 267 multi-policy domains whose findings changed, 229 (86 %) carry a policy with no fetch directive at all and 72 carry exactly that one header. Treat "all sources must agree" as a smell: it privileges the source that knows nothing. Multi-policy responses are 754 of 43 476 CSP-sending domains (1.73 %) — 691 by repeated header lines and 65 by folding, two doing both — so the conjunctive rules are not a corner case. Single-policy responses take thelen(policies) == 1short-circuit and were provably untouched: zero comma-free values changed across all 27 222 distinct CSP values in the corpus.
- There is no table of per-directive default values, and asking for one is
the wrong question. CSP has no such thing: the only fallback is
- The whole reporting family is rated
note,ip-endpoints-undefinedincluded, which was re-rated down fromwarningon 2026-08-17. A reporting failure costs the operator information and nothing else: no browser protection is withheld by it, and there is no path through it for an attacker to reach the site or its users. What makes it worth reporting at all is that the operator plainly intended the reports to arrive — which is anote's job exactly, a fact with no defect in protection. Apply this to any reporting code added later rather than rating it on how broken the configuration looks. - A defect in an endpoint's definition belongs to the header that defined
it. One endpoint URL nothing can be delivered to is one fact however many
policies name that group, so it is a finding on
Reporting-EndpointsorReport-Toand the referencing policies stay quiet. Consequently_reporting_endpoint_names()is syntactic: a policy naming a group that something did define has done nothing wrong, and only a group nobody names at all earns<prefix>-report-to-undefined. The reverse split — folding deliverability into the group lookup — was implemented first and produces the same defect up to four times with one fix between them. Report-Tois deprecated but still honoured, and reading onlyReporting-Endpointsis a false positive. BCD saysdeprecated: true, standard_track: false(Chrome 70 / Firefox 149 / Safari never), and both engines nonetheless parse it and act on it: Chromium wires it up atnet/reporting/reporting_service.cc:250, Firefox atdom/reporting/ReportingHeader.cpp:211. A response defining its groups only this way is configured correctly — the norm, in fact, in cloaked.pl's 2021 survey, whereReport-Toappears 50 times andReporting-Endpointsnot once. The general lesson, which is the reason this entry is here: BCD'sdeprecatedflag means "do not reach for this", never "browsers ignore it". That is a different trap from thefeatures.ccone below, and it bites in the opposite direction — there a value is parsed but not honoured, here a header is disowned but still honoured.- Endpoint existence is out of scope; endpoint syntax is not. Whether
anything answers at a reporting URL needs a request, so it waits for the
active checks. Whether the header parses at all, whether a browser reads it
on this response, and whether each URL is one reports can be delivered to are
all answerable from the response alone, and all three are checked. Note the
parse question is real rather than pedantic: structured field dictionary keys
are lower-case by grammar (RFC 9651
key = ( lcalpha / "*" ) *( lcalpha / DIGIT / "_" / "-" / "." / "*" )), and both engines drop the entire header when the dictionary will not parse — FirefoxSFV::ParseDict(...); if (!dict.IsValid()) return 0;, ChromiumParseDictionaryreturningnullopt— so one capital letter costs every group the header meant to define. - The engines disagree about loopback reporting endpoints, and the spec sides
with Firefox. Reporting API step 5.3 (
w3c/webrefed/algorithms/reporting-1.json) says "If endpoint url's origin is not potentially trustworthy, then continue"; Firefox implements exactly that, Chromium requiresSchemeIsCryptographic()and so rejectshttp://localhost._delivers()therefore fires only on what both reject. If Chromium relaxes toward the spec the predicate can widen; the reverse is not on the cards. - Origin reflection cannot be detected from one exchange, and this is the
fact the passive CORS work turns on. Settled 2026-08-21. A server whose
allowlist is exactly
{https://app.example.com}, answering a request from that origin, emits byte-for-byte what a reflect-anything server emits. Nothing in a response distinguishes a correct allowlist from a broken one, so any check that calls an echoed origin "reflection" is principle 4 in new clothes. What closes the gap is one bit the caller holds and the response does not: whether the request'sOriginwas one the caller forged.burp/Additional_CORS_Checksworks exactly this way —CorsHelper.kt:62sends nine forged origins (arbitrary,http://downgrade,null, prefix, suffix, subdomain, substring, underscore, unescaped-dot) andCorsIssue.kt:84decides severity purely on did my origin come back × wasACAC: trueset. When request analysis lands, take the bit as an argument; do not try to infer it fromhost, because partner APIs and CDNs legitimately allowlist unrelated origins. - Not analyzed:
Vary: Origin. Decided 2026-08-21, against MDN's advice and against humble, which flags it — so this is a deliberate divergence from peers rather than an omission. The cache-poisoning story does not survive being worked through: a shared cache handing origin A'sACAO: https://a.exampleto origin B only makes B's CORS check fail, and the reverse direction fails too, so it is closed in both. The exploitable version needs the response content to differ per origin, which no header can say — the same wall the parked cache/cookie item hit. Note also thatVaryappears nowhere in Fetch: this is an RFC 9111 §4.1 cache-key question, not a browser-CORS one, so a finding here would be about correctness, not protection. Re-propose only with a concrete attack that does not need origin-varying content. - Not analyzed:
Access-Control-Allow-Private-Network— no MDN BCD entry, so by the same reasoning as the CSPEE ruling its prevalence cannot be measured and its support cannot be checked. Reopen if BCD gains one. ACAH: *not coveringAuthorizationis an interop bug, not a security one. Fetch makesAuthorizationa CORS non-wildcard request-header name, but BCD'sAccess-Control-Allow-Headers.authorization_not_covered_by_wildcardis Firefox 115 / Chromefalse/ Safarifalse— only Firefox implements it. A finding here would say "your request fails in one engine", which is not what this package rates. Left alone deliberately.- Not analyzed:
Document-Policy(no closed value set, no delegation axis, no concrete configuration points of its own);X-Content-Security-Policy/X-WebKit-CSPcontents (no browser reads them, so content decides nothing). - Not analyzed: CSP Embedded Enforcement, i.e.
Allow-CSP-From(response) andSec-Required-CSP(request). Decided 2026-08-17 with the spec source in front of us atw3c/webappsec-cspeeand the implementation confirmed — Chromium'sParseAllowCSPFromHeader()(services/network/public/cpp/content_security_policy/content_security_policy.cc:1404) takes*or a single origin and rejects anything else, andcontent/browser/renderer_host/cspee_histogram_browsertest.ccexists, so this is live in Chrome. It is nonetheless out of scope, for reasons that are about the value set, not obscurity:Allow-CSP-From: *is not a defect. CSPEE only lets an embedder tighten — the embedded document must return a policy that subsumes the required one or the frame is blocked — so a wildcard grants an embedder no power to weaken anything. Rating it would be principle 4 all over again.- That leaves exactly one judgeable case, a value that is neither
*nor a valid origin. One narrowerrorcode, on a header absent from both MDN BCD and OWASP's 17 tracked names, so its prevalence cannot be measured from the corpus at all. Not worth a code today. - Reopen it if BCD gains an entry, or if a second engine ships it — the spec checkout is on disk, so the recheck is cheap. Do not re-survey the repo.
- Not embedded: csp-evaluator's 171-entry JSONP/Angular bypass lists — high catch rate, but curated data that ages, and the module has no upkeep burden today. The data-free checks from it were ported.
noopener-allow-popupsis accepted as a valid COOP value. MDN BCD: Chrome 131, Safari 18.4, Firefoxfalse(andwebview_androidfalse). The "~84 %" this used to cite came off caniuse.com, which renders BCD; the versions above are the on-disk fact and the ones to recheck. Confirmed in Chromium source 2026-08-17, which matters because the parser gates the value on a feature flag —cross_origin_opener_policy_parser.cc:66accepts it only whenfeatures::kCoopNoopenerAllowPopupsis enabled, and that flag isFEATURE_ENABLED_BY_DEFAULTatfeatures.cc:89. So BCD's "Chrome 131" is right and the guard is a launched-feature remnant. Worth knowing as a pattern: in Chromium a value being parsed is not the same as it being honoured, andfeatures.ccis where the difference lives.HSTS_MIN_MAX_AGE = 15552000(180 days), chosen over the 6-month figure because it is what sites actually send. Now measured rather than recalled: of the 55 349Strict-Transport-Securityvalues carrying amax-agein OWASP's 250 000-domain corpus (subprojects/data/data.db, see the reference section), 86.1 % are ≥ 180 days, so this threshold contradicts 13.9 % of live deployments where a 1-year threshold would contradict 26.4 %. 1 105 sendmax-age=0. Re-run the query there before moving the constant.infowas renamednotethroughout, including theme keys, to match SARIF.- From the OWASP cheat sheet, and settled — do not re-propose from it:
X-Robots-Tagis inventory at most, never a finding (not browser-enforced, and whether you wantnoindexdepends on content nobody here sees); the secure-downloadContent-Dispositionadvice needs to know the resource is user-supplied, which headers cannot say; and the FLoC section (interest-cohort=()) is stale — Google cancelled FLoC in 2022. Content-Typeis analysed for exactly one thing, the charset parameter ontext/html, ratednote. Real-world impact is negligible now (the injection needed UTF-7, and<meta charset>satisfies it invisibly), but tools still flag it, so the fact is reported without the noise a warning would make. Absence of the header is still not reported -- that has not changed -- but no longer because the status is invisible:analyze()readsexchange.response.statusnow, the same visibility that closed the--all-hopsgap. A 204 or 304 still carries no representation, so that survives as the shape the rule would take ifContent-Typeever joinedSECURITY_HEADERS-- what is gone is the claim that the gap could not be closed. This is a deliberate choice now, not a constraint, and revisiting it is the human's call.X-DNS-Prefetch-Controlis inventoried, never a gap. It is inDEPRECATED_HEADERSand emits onenote,xdpc-nonstandard. No finding can do better: MDN BCD has it as Chrome 1 and Firefox 2 with no partial or pref caveat on either, Safari never, andonasks for the default everywhere it works — in Firefox bothnetwork.dns.disablePrefetchandnetwork.dns.disablePrefetchFromHTTPSdefault tofalse. Sooffis a real measure in two engines,onis a no-op, and neither is a defect. BCD also recordsstandard_track: false, deprecated: false, which is exactly whatxdpc-nonstandardnames and why it carries no-deprecatedsuffix. Note that OWASP'sheaders_add.jsonrecommends sending it — the note qualifies that, it does not contradict it. Rationale corrected 2026-08-16, conclusion unchanged. This used to read "only Chrome acts on the header at all", from OWASP's hand-testing in their issue #201, plus "the local caniuse checkout has no feature entry for it". BCD contradicts the first and supersedes the second. Nothing in the code moved, because the verdict never depended on how many engines honour it — only ononbeing the default in all of them. If you find issue #201 again, this is the paragraph that already accounts for it.Set-Cookie2is inDEPRECATED_HEADERSalthough it is a state-management header rather than a security one. Added 2026-09-10, onenote,sc2-deprecated, value unparsed. That it is dead was not the hard part — RFC 6265 §1 deprecates it by name in moving RFC 2965 to Historic, its IANA registration readsobsoleted(known-http-header-db), and no engine parses one: zero occurrences in Firefoxnetwerk/cookie/and Chromiumnet/cookies/, while WebKit knows the name only in order to withhold it (HTTPParsers.cpp:917returns false for it fromisCrossOriginSafeHeader, besideSet-Cookie). The judgement was the placement, becauseSet-Cookieitself is not inSECURITY_HEADERS— it earns its own inventory key — so the table's "obsolete security headers" wording had to stretch. It stretches on two counts, both the human's: a cookie carries the attributes half the cookie analysis is about, and a response still sending this one is evidence nobody has read that configuration since 2011, which is the parked hygiene axis in miniature. The content decides nothing (principle 6), so the value is not parsed and the spec's own example earns the same note as a garbage string. The BCD absence here cuts the opposite way from the CSPEE andAccess-Control-Allow-Private-Networkrulings and must not be read as contradicting them: there, no BCD entry meant support and prevalence were unmeasurable, so no code; here three independent sources positively assert death. Ask which of the two you have before reusing either.references.pygained its fourth_SPECentry for the same reason — neither pattern source documents the header — which is the permanence test that table's comment names, not licence granted by a short table.blocked-destinations=(style)is treated as blocking nothing. Chrome and Safari do not implement it and Firefox only behindsecurity.integrity_policy.stylesheet.enabled(MDN BCD, matching OWASP's hand-testing). This is browser-support data with a shelf life: recheck it before trustingIP_BLOCKING_DESTINATIONS, and a style-only policy stops being inert the day an engine ships it. Rechecking is one file — theblocked-destinations_stylenode ofdocumentation/browser-compat-data/http/headers/Integrity-Policy.json. Verified still true 2026-08-16: Chromefalse, Safarifalse, Firefox 142 pref-gated, againstblocked-destinations_scriptat Chrome 138 / Firefox 145 / Safari 26. Chromium source agrees, and more strongly than BCD can (2026-08-17):integrity_policy_parser.cc:36acceptsblocked-destinations=scriptandsources=inlineand pushes every other token ontoparsing_errorsas "not supported". That is a closed value set in code, not an absence of support inferred from a table, soIP_BLOCKING_DESTINATIONSis on firm ground until thatifgrows a branch.- Integrity-Policy's
endpointsis unreliable in Firefox — it enforces the blocking from 145 but ignores the directive and logs violations to the console instead. MDN BCD carries this aspartial_implementation: trueon Firefox with the note "Reportingendpointsare ignored (violations are logged to console)"; the "~81.6 % global support" figure came off caniuse.com, which renders BCD and is not the same as the caniuse checkout. Deliberately not a code: it would fire on every correct policy that asks for reports.ip-endpoints-undefinedis the finding worth having. - The consequence vocabulary is the slug; a taxonomy id is an attribute of
it, never the vocabulary itself. Measured against CWE 4.20 (969
weaknesses) and CAPEC 2.1 on 2026-08-23: there is no weakness at all for MIME
sniffing — zero hits across "MIME", "Content-Type" and "sniff" — none for
XS-Leaks, and nothing written for permission delegation either, since every
permission CWE is scoped to filesystem or OS permissions and CWE-732 fits
permission-abuseonly because its Class-level wording is resource-agnostic while its examples are not. Had CWE ids been the vocabulary itself, a quarter of the eight slugs would have had no id, or a forced one. Do not re-propose CWE ids as the consequence names for this reason. - A Pillar-level CWE is not a usable fallback for a gap. CWE-693
Protection Mechanism Failure and CWE-284 Improper Access Control were
both tried for the slug that initially lacked a better id, and both fail the
same test: a broader id is honest only if it would still be chosen when a
narrower one already existed. CWE-693's Pillar-level wording — "does not use
or incorrectly uses a protection mechanism that provides sufficient defense
against directed attacks" — is true of every finding this package emits,
xssandclickjackingincluded, so pinning it to the one slug that lacked something better would stop it naming a class of harm and start meaning "unclassified".permission-abusetook CWE-732 Incorrect Permission Assignment for Critical Resource instead, which is not true of the other seven slugs. - Pick a CAPEC id by its CWE cross-reference, not by its name. Keyword
matching against CAPEC's pattern names was wrong three times out of about a
dozen during this design — CAPEC-468 Generic Cross-Browser Cross-Domain
Theft reads like
cors-data-theftand is CSS-injection data theft instead. The join that catches it is CAPEC's ownexternal_references, filtered tosource_name == "cwe", asking which patterns cross-reference the CWE already chosen for the slug — that is how CAPEC-103 was confirmed forclickjackingand how CAPEC-468 was eliminated. - CWE's cookie coverage is unusually rich — 1004 (
HttpOnly), 1275 (SameSite), 614 (Secure), 315, 539, 565, 784 — which maps almost one-to-one onto theSet-Cookieprefix rulescookies.pynow implements. None of the cookie codes carry aCODE_TAXONOMYentry yet; reach for this list first when they do, rather than starting the CWE search from zero.
- Reporting groups named by a report-only policy —
csp-ro-,coop-ro-,coep-ro-andip-ro-equivalents of the-report-to-undefinedcodes. Deliberately not shipped, and the reason is not principle 6: an author running a policy in report-only mode knows it is not enforcing and is precisely the person who wants to hear that its plumbing is broken. It is that in most responses this is noise, and the one audience it serves is someone deliberately trialling a policy — which is a switch on a tool, not a default of the analysis engine. The CLI now exists and reserves the switch as--include-report-only, documented and unimplemented; that is the hook, and the codes are still the work. - The cache/cookie cross-header quirk — the cookie-parser blocker is gone;
what remains is the
cachingtable's own contract. RFC 9111 §7.3: "the Set-Cookie response header field does not inhibit caching; a cacheable response with a Set-Cookie header field can be (and often is) used to satisfy subsequent requests to caches." SoCache-Control: publicors-maxagebeside aSet-Cookielets a shared cache hand one visitor's cookie to the next. Tempting to write as a two-header rule today — don't. No header says a cookie is a session cookie. On alang=enthis is not a finding at all, and shipping it standalone means guessing.HttpOnly,SameSite, and the__Host-/__Secure-prefixes are the signals that make it worth reporting, andcookies.pysupplies every one of them now. What still blocks this is the sibling item below:inventory()'scachingtable promises no judgement of cache-control values at all, so there is nothing yet to test a session cookie's exposure against. Pragma: no-cachewith nothing enforcing it — same parcel, same reason: it is the other half of "does the analyzer judge cache values at all", andinventory()'scachingtable currently promises it does not. One code,pragma-ineffective,error, cross-header, inresponse.py. It fires only when noCache-Controlprevents storage. Not aDEPRECATED_HEADERSentry even though RFC 9111 §5.4 does say "this specification deprecates Pragma": that tuple means obsolete security headers whose absence is desired, and apragma-deprecatednote would fire on OWASP's own recommended pairing ofCache-Control: no-store, max-age=0withPragma: no-cache. What makes the narrow case judgeable at all is that intent is visible — a missingCache-Controlsays nothing about what the author wanted, a presentPragma: no-cachesays exactly what they wanted, and §5.4 says they did not get it: "the meaning ofPragma: no-cachein responses was never specified". Neither of these two can be sized from OWASP's corpus, and the check is not worth repeating: it collects 17 header names andset-cookieandpragmaare not among them, so how often the pairings actually occur is unmeasurable from disk. What is there is 101 728 domains' worth of rawcache-controlvalues — enough to write and sanity-check the "noCache-Controlprevents storage" condition against real directives rather than invented ones.- Active checks — Origin reflection is the highest-value CORS test and needs a
second request with a forged
Origin. Tool work, not analysis. The CLI reserves--probe LISTfor it, onscanrather than as its own verb, because a probe run must produce the same document shape as a passive one — a separate verb would invite a second shape and then every consumer needs two parsers forever. An explicit list rather than a bare boolean, because a tool that fires unusual traffic at someone's server should make you name what you are firing. - Inverted "interesting headers" — report anything not on a boring list,
rather than only known-interesting names. The human wants to compile their own
list, behind its own switch; the CLI reserves
--unknown-headersfor it. - A hygiene axis, sibling to
consequencesrather than a ninth slug. A finding can say something about the operator rather than about an attacker — a header no browser has read since 2018 that nobody removed, aReport-Towhose JSON does not parse, an RFC 1918 address left in a production CSP — and CWE's own prose has a precedent for that inference: CWE-477 Use of Obsolete Function describes itself as suggesting "the code has not been actively reviewed or maintained." It survived the expected CWE-693 objection: framed narrowly as the operator wrote something that does not do what they evidently intended, it discriminates well — false for the deliberate opt-ins (acao-wildcard,corp-cross-origin, whose messages say as much), false for competent-but-constrained configurations (csp-unsafe-inlineis usually a legacy application, not neglect), true forrt-invalid,hpkp-deprecatedandcsp-ip-source. What disqualifies it as a ninth slug is the axis, not the discrimination: every consequence answers what could an attacker achieve, this answers what does this tell me about the operator, and mixing a capability with an inference in one list would force every consumer triaging by risk to filter one back out forever. It waits because the strong evidence is not in the headers analysed here —Server: Apache/2.2.15andX-Powered-By: PHP/5.3are, and those belong to the parked inverted "interesting headers" switch above, where humble's 1 287-namefingerprint.txtandburp/burp-suite-software-version-checks' 114-regexmatch-rules.tab, already surveyed for that switch, are the material to build it from. Land the two together. request.py— request parsing/analysis, sharingmessage.py.The schema is not finished— ANSWERED 2026-08-21 by the CLI's run envelope, and answered without touching this library. A version field, run metadata, and how several results travel together are all properties of the serialised artifact, andreport()returns a dict — so they belong to the thing that writes files. See "The run envelope" under The CLI. One consequence to know: a consumer callingreport()directly and dumping it to JSON has built its own artifact and owns its versioning; this package does not stamp one into the dict.--all-hops, no longer blocked on the analyser. The CLI can analyse every hop of a redirect chain and the envelope is specified for it; it stays reserved rather than shipped (see Status), but the analyser-side reason it used to be blocked is gone -- more completely than the commit that first closed it left it. The blocker was structural: with noREPRESENTATION_HEADERSsuppression at all, a bare 301 produced eight findings — the correcthsts-missingplus seven false positives,csp-missing,coop-missing,corp-missing,pp-missing,rp-missing,xcto-missingandxfo-missing— on a response that carries no representation for any of them to protect, because the oldanalyze_allnever saw a status line. The commit that introducedREPRESENTATION_HEADERSsuppressed six of those seven and missedpp-missing, even thoughPermissions-Policywas already inSECURITY_HEADERSat the time: it governs which features the document a response creates may use, and a 3xx creates no document, so the reasoning was identical to the other six and simply was not applied to it.coep-missingis the eighth and was not part of that oversight in the same way —_suppress_redundant's sibling rule already excuses it whenever COOP does not ask for isolation, which a bare redirect never does, so it was never actually observable on a bare 301; it only surfaced, and only needed suppressing, on a redirect that also carriedCross-Origin-Opener-Policy: same-origin. Both are now inREPRESENTATION_HEADERSfor the same document-creation reasoning as the original six, soanalyze()— which now readsexchange.response.statusdirectly via_report_missing()'s third argument — no longer misfires on any of the eight. Note HSTS was always correctly absent from that tuple: on the https legs of a chain a redirect is precisely where HSTS matters, so per-hop analysis is genuinely valuable and it is only the representation-scoped headers that misfired. Shipping the flag itself is CLI orchestration work — walkingRun.hops, building oneExchangeper hop, deciding how N reports nest in the envelope — not analyser work, which is why it is still reserved.- Three finding families this task's types newly make checkable, none
designed or implemented yet:
- Protocol hygiene.
Exchange.connectioncarries the host, IP, port and scheme actually connected to, deliberately apart from theHost:header a request carries (exchange.py's own docstring explains why:Host:can be forged, and testing that on purpose is a thing security tools do). A finding comparing the two — aHost:that disagrees withconnection.host— was unwritable before this task, because nothing carried the fact it would be checked against. - Verb/preflight. Every CORS rule so far reads the response only,
because nothing carried the request's method.
Request.methodnow does, which is what a preflight check needs: compare anOPTIONSrequest'sAccess-Control-Request-Methodagainst the response'sAccess-Control-Allow-Methodsand say whether the verb actually used would have been allowed. - TRACE / XST. Cross-site tracing needs the same new fact from the other
side: whether the request method was
TRACEand whether the response reflected it back.Request.methodmakes the premise checkable; nothing reads it yet.
- Protocol hygiene.
- File input for the CLI —
readverb, Burp XML, HAR, SAZ, WCAT. The seam is designed and the payload shape fixed (exchange.py'sExchange), deliberately with no registry: every one of those formats is a multi-exchange container carrying both request and response, so a source yields an iterable and one live target yields a tuple of one. Getting the payload right was the commitment; the registry is a few lines whenever the second source lands, informats/— see Layout.
For reading and analysis only, do not copy. They live at
/home/crapula/ref/<category>/<repo>, the subdirectory naming the kind of
material: documentation, security, burp, cookie_security, w3c,
web_browsers, web_servers, web_app_servers, operating_systems. All
git checkouts except web_browsers/lynx2.9.3, which is an unpacked tarball, so
git log / git show are available for history on the rest.
Read, and nothing else. The human maintains this tree with their own tooling
and an agent does no maintenance on it — ever. That means no writes, and also
no rsync, no fetching or updating a checkout, no pruning, no cleanup of files
that look stray, and no git command that changes state anywhere under
/home/crapula/ref. Sizes, staleness and what is or is not mirrored are not
problems to fix here; if something needed is missing, say so and stop. Where a
sync source is documented below it is recorded so the fact is checkable, not
as a task to run.
This section is a whitelist, not a sample. What is named below was surveyed
once and verified rather than assumed, and is the whole of what this project
cares about; the closing subsection says what to do with everything else. Sizes
matter, and they grow with every fetch, so re-measure rather than trust these:
web_browsers/chromium is 70 GB (64 GB of it .git, so git log is cheap
and a tree-wide grep is not), web_browsers/WebKit is 19 GB,
web_browsers/firefox is 11 GB, operating_systems/nt5src is 9.1 GB,
w3c/webref is 1.4 GB (1.1 GB of it .git; the data is ed/, 186 MB)
and burp/http-request-smuggler is 154 MB — so scope every grep to a
subdirectory.
documentation/www-project-secure-headers — the OWASP Secure Headers
Project, upstream of most of this package's header set and of several settled
rulings above.
-
mainsite/01_headers.mdandmainsite/03_best_practices.md— the header list and the recommended values.03_best_practices.mdis the source of truth; the two JSONs below are generated from its tables by CI. -
ci/headers_add.json— 13 headers with OWASP's recommended values. -
ci/headers_remove.json— 87 information-leakage header names OWASP says to strip (X-Powered-By,X-AspNet-Version, the Envoy/Datadog/B3 tracing set, …). This is the closest thing on disk to prior art for the parked inverted "interesting headers" switch. -
mainsite/02_browser_support.md— one caniuse URL per header. Note how many aremdn-*URLs; see the caniuse caveat below before trusting the checkout to answer them. -
mainsite/04_technical_resources.md— the other tools in this space. -
mainsite/07_statistics.md— the published prevalence charts, PNG images only, so useless to read programmatically. Use the database instead: -
subprojects/data/data.db— the real-world corpus, and the most valuable single file in/home/crapula/ref. 79 MB of SQLite, fetched by hand from the project's GitHub Release assets (CI generates it, local generation does not work). One table and it holds raw values, not just counts:CREATE TABLE stats (id integer PRIMARY KEY, domain text, http_header_name text, http_header_value text);
684 485 rows over 250 000 domains — the Majestic top-1M prefix, with
input.csvbeside it as the domain list. Three things to know before quoting a number from it:- Only the 17 headers OSHP tracks appear (
cache-control,x-frame-options,x-content-type-options,referrer-policy,strict-transport-security,content-security-policy, the threecross-origin-*,permissions-policy,x-xss-protection,x-permitted-cross-domain-policies,content-security-policy-report-only,x-dns-prefetch-control,expect-ct,public-key-pins,clear-site-data). It is not a corpus for the "interesting headers" work — noServer, noX-Powered-By, noSet-Cookie. - A domain with no security header at all still gets one row, with
http_header_name IS NULL. That is 110 982 of the 250 000; the other 139 018 have at least one. Filterhttp_header_name IS NOT NULLor every ratio comes out wrong. - The path is
subprojects/data/, one level above thesubprojects/statistics/data/thatscripts/*.pyreads as../data. Irrelevant for querying it directly; it means the repo's own scripts will not find it.
This is the first thing on disk that can settle a threshold empirically. Worked example — the
HSTS_MIN_MAX_AGEquestion, over the 55 349 HSTS values carrying amax-age: 86.1 % are ≥ 180 days, so the current threshold contradicts 13.9 % of live deployments, while a 1-year threshold would contradict 26.4 % (and 1 105 sendmax-age=0). Prefer this over recalling a figure from Shodan or a blog. - Only the 17 headers OSHP tracks appear (
-
subprojects/validator/tests_suite.yml— a Venom suite asserting OSHP conformance against a live site. Useful as an independent opinion to diff verdicts against. -
The repo carries its own
CLAUDE.md, including strict GenAI rules. Those govern contributing there, not reading it from here.
documentation/browser-compat-data and documentation/caniuse —
browser support, in two halves that answer different questions. Use them
together; using one alone is how a wrong support claim gets made.
- MDN BCD (
browser-compat-data) says whether.http/headers/holds one JSON per header, 160 of them, named exactly as the header is (Integrity-Policy.json,Cross-Origin-Opener-Policy.json,Set-Cookie.json,X-DNS-Prefetch-Control.json, …). Sub-keys carry per-directive and per-value support, which is the granularity this package reasons at and which caniuse does not have. A support entry can sayversion_added: false,partial_implementation: truewith anotesstring, orflags: [{type: preference, name: …}]for pref-gated — all three distinctions matter here."mirror"means "same as the parent engine's browser", not "unknown". - caniuse says how much.
data.json/features-json/carry only caniuse's own 554 features and nomdn-*entries — anything caniuse.com shows asmdn-http_headers_*is BCD rendered on their site, not data in this checkout. What is here natively:contentsecuritypolicy,contentsecuritypolicy2,stricttransportsecurity,x-frame-options,referrer-policy,permissions-policy,feature-policy,document-policy,upgradeinsecurerequests,cors,same-site-cookie-attribute,subresource-integrity, withusage_perc_y/usage_perc_aper feature (x-frame-optionsis 0.28 %y+ 96.41 %a, the whole deprecation story in two numbers). - To recompute a global support percentage for a header BCD covers but
caniuse does not — the "~81.6 %" sort of figure — join BCD's
version_addedagainstcaniuse/region-usage-json/alt-ww.json, which is worldwide usage share per browser per version (itstotalis 96.44, not 100, so normalise or say "of tracked traffic").
Three rulings in "Deliberately decided" were re-verified against BCD and all
three hold exactly as written — Integrity-Policy is
partial_implementation in Firefox 145 with the note "Reporting endpoints
are ignored (violations are logged to console)";
blocked-destinations_style is false in Chrome and Safari and Firefox 142
behind security.integrity_policy.stylesheet.enabled; COOP
noopener-allow-popups is Chrome 131 / Safari 18.4 / Firefox false. Recheck
them there, not from memory.
documentation/rfc-library — the RFC/BCP/STD/FYI/IEN archive as plain text,
laid out rfc/NNNNN-NNNNN/rfcNNNN.txt, with indexes/rfc-index.txt to search
by title. Complete for what it covers: 9 810 RFC texts, every published RFC that
has one, verified against the index. The ones this package reasons from:
9110 (HTTP semantics), 9111 (caching — §5.4 on Pragma, §7.3 on
Set-Cookie and shared caches, both quoted in the parked items), 6797
(HSTS), 6265 (cookies), 7034 (X-Frame-Options).
It mirrors the published series only — no Internet-Drafts, by design. A
scope decision of the mirror, not a defective checkout: scripts/update.sh
rsyncs rfcs-text-only and rfcs-pdf-all from rsync://ftp.rfc-editor.org,
and that server offers eight modules, none of which carries drafts (asked
directly, 2026-08-17; its prerelease/ is approved-but-unpublished RFCs, a
different thing). The consequence that matters here: the Set-Cookie prefix
rules are in no RFC at all, so 6265 is not the authority for the prefixes —
do not cite it for one.
Internet-Drafts, when they are here, are under mirror/ — human-maintained,
gitignored in that repo alongside pdf/ and dist/, and not an agent's to
create, sync or tidy (see the rule at the top of this section; untracked files
there are expected, not a mess). Read whatever is in it, and if the draft you
need is absent, say so rather than fetching it. Provenance, recorded once so the
claim is checkable: drafts come from the IETF, rsync://rsync.ietf.org, module
internet-drafts (currently active, 9 356 files / 795 MB, or 3 366 files /
199 MB text-only) or id-archive (active and expired, every revision). Not
from the RFC Editor, and no scrape of datatracker.ietf.org is involved — it
renders the same documents.
Two standing facts about draft coverage, which are about analysis, not upkeep:
- Only the cookie prefixes need a draft at all.
draft-ietf-httpbis-rfc6265bisanddraft-ietf-httpbis-layered-cookies— every other header here resolves to a published RFC already on disk. - No IETF mirror closes the real spec gap. Counting
specifications[]links inknown-http-header-dbacross the ~20 headers this package analyses, the IETF links are all published RFC pages, while 9 point at WHATWG (html.spec.whatwg.org,fetch.spec.whatwg.org) and 8 at W3C (w3c.github.io,w3.org) — CSP, Permissions-Policy, COOP/COEP, Clear-Site-Data,nosniff. Those are living standards in neither IETF module, so for them the browser sources remain the on-disk authority.
web_browsers/chromium, web_browsers/firefox and
web_browsers/WebKit — ground truth for "does a browser actually honour
this", which principle 5 and every deprecation ruling turn on. All three
engines are now readable, so a support claim no longer has to rest on BCD
alone; BCD says whether it shipped, the source says what it actually does
with the value, and the second question is the one this package reasons about.
- Chromium: nearly everything is under
services/network/public/cpp/—content_security_policy/(csp_source_list.ccis the one to read),cross_origin_opener_policy_parser.cc,cross_origin_embedder_policy_parser.cc,cross_origin_resource_policy.cc,integrity_policy_parser.cc,x_frame_options_parser.cc, andparsed_headers.ccas the index of what the network service parses at all. Cookies arenet/cookies/cookie_util.cc(the prefix rules) andcanonical_cookie.cc; HSTS isnet/http/transport_security_state.cc.net/http/http_util.cc'sIsNonCoalescingHeader()answers "may this header be folded with commas" for any header, not just CSP — nine names (date,expires,last-modified,location,retry-after,set-cookie,www-authenticate,proxy-authenticate,strict-transport-security), and everything absent from it gets comma-split inAddHeader()before any parser sees it. The HSTS entry carries its own reason: "STS specifies that UAs must not process any STS headers after the first one." Feature flags gate a lot of this —services/network/public/cpp/features.ccsays whether a parsed value is actually live, andbase::FEATURE_ENABLED_BY_DEFAULTthere is what turns "parsed" into "honoured". - Two Chromium files are data, not code, and both are assets in their own
right:
services/network/public/cpp/permissions_policy/permissions_policy_features.json5is the authoritative registry of the 210 Permissions-Policy features Chromium knows, each with its wire name (permissions_policy_name: "ch-ua") and itsfeature_default— the closed value setpolicies.pyotherwise has to hand-maintain. Andnet/http/transport_security_state_static.jsonis 10.5 MB of the HSTS preload list itself, which is whatsecurity/hstspreload's packedhstspreload.binis generated from, so a preload claim can now be checked against the source rather than the binary. - Firefox:
dom/security/nsCSPParser.cpp,dom/security/nsCSPUtils.cpp,security/manager/ssl/nsSiteSecurityService.cpp(HSTS),dom/security/featurepolicy/,dom/security/IntegrityPolicy.cpp,netwerk/cookie/CookiePrefixes.cpp(102 lines, and the best-commented account of the prefix rules on disk — it cites the RFC section per prefix), andmodules/libpref/init/StaticPrefList.yamlfor what is behind a pref. - WebKit:
Source/WebCore/page/csp/ContentSecurityPolicy*.cpp,Source/WebCore/loader/CrossOriginOpenerPolicy.cpp,Source/WebCore/loader/CrossOriginEmbedderPolicy.h. WebKit cannot answer cookie questions. The only prefix logic in the tree isSource/WebCore/platform/network/curl/CookieJarDB.cpp:492, the curl backend used by the non-Apple ports; Safari goes through CFNetwork, which is not open source and not in this checkout. Take Safari cookie behaviour from BCD. - Three browser-support claims in "Deliberately decided" were re-verified
against these checkouts and all hold:
security.integrity_policy.stylesheet.enabledis real (StaticPrefList.yaml:19276, besidesecurity.integrity_policy.enabledat 19271);noopener-allow-popupsis parsed in WebKit (CrossOriginOpenerPolicy.cpp:237), parsed in Chromium (cross_origin_opener_policy_parser.cc:66) behind a flag that isFEATURE_ENABLED_BY_DEFAULT(features.cc:89), and appears nowhere in Firefox; and Chromium'sintegrity_policy_parser.cc:36acceptsblocked-destinations=scriptand nothing else, pushing anything else ontoparsing_errors. This is the cheap way to recheck them when the shelf life runs out.
security/csp-evaluator — the source of the ported CSP checks.
checks/security_checks.ts, checks/strictcsp_checks.ts and
checks/parser_checks.ts are the checks themselves;
allowlist_bypasses/{jsonp,angular,flash}.ts is the curated data deliberately
not embedded here. Do not take its multi-policy model, because it has
none: CspEvaluator takes a single Csp, CspParser does
unparsedCsp.split(';'), and the string , appears in no splitting call
anywhere in the package — so it reads a folded policy list as one mangled
policy, the bug fixed here on 2026-09-08. Read it for the checks, not for how
policies combine.
security/hstspreload — the source of the declared [preload] extra, so
the behaviour of the only third-party dependency is readable. One public
function, in_hsts_preload(host); the list is a packed hstspreload.bin
rebuilt monthly from Chromium's transport_security_state_static.json — which
is now on disk too, at web_browsers/chromium/net/http/, so the packed list and
its source can be compared.
security/humble — rfc-st/humble, the closest peer to this package on disk
and the one to diff verdicts against. humble.py is 5 648 lines, but the part
worth reading is the data beside it, because it is a catalogue in the same
sense catalog.py is:
additional/insecure.txt— 158 defect names over 93 distinct headers, each writtenHeader: Defect(Access-Control-Allow-Origin: Unsafe Values,Cache-Control: No Valid Directives). This package has 119 codes over far fewer headers, so that file is a ready-made gap list. Read it for candidates, not as a specification, and rate anything taken from it by principle 3.additional/missing.txt— the 14 headers humble reports as absent, against this package'smissinginventory.additional/security.txt— the 62 names it treats as security-relevant at all: its answer to the scoping question theLayoutsection answers here.additional/fingerprint.txt— 1 287 vendor/product banner headers, each annotated with what it identifies ($WSEP (IBM WebSphere Application Server),Akamai-Cache-Status (Akamai Edge)). Fifteen times the size of OWASP's 87-nameheaders_remove.json, and the best prior art on disk for the parked inverted "interesting headers" switch.l10n/details.txt(and its_estwin) — its prose catalogue, the analogue ofcatalog.py, for how another tool words the same findings.
MIT-licensed, which changes nothing here: the read-only rule above is about scope, not licence.
security/cryptolyzer — the closest architectural cousin on disk, and
easy to miss because its README sells it as a TLS/SSH/DNSSEC analyzer. Surveyed
2026-08-21. docs/features.rst:250-281 lists an HTTP header feature set
overlapping this package's almost exactly — CSP and CSP-Report-Only, HSTS, XFO,
XCTO, X-XSS-Protection, Referrer-Policy, Expect-CT, Set-Cookie, NEL, Server,
Content-Type, and the five caching headers. Two things make it worth reading:
- It draws the inventory/judgement line where principle 2 draws it, and it
drew it independently.
cryptolyzer/httpx/headers.pyis 55 lines and its whole job is'Check which response headers are sent by the server(s)'— an inventory, no findings. Itshttpx/content.pyis the other half and needs the response body (it walks tags for SRI attributes and mixed content), which is the same wall this package's out-of-scope rulings keep hitting. - The parse layer is a separate repository,
security/cryptoparser, which has its own entry below. Note thatsubmodules/cryptoparserinside this checkout is an empty gitlink and always will be —pull.shdoes not do submodules, which is why the sibling clone exists. Read the sibling; the empty directory is not a problem to solve.
security/cryptoparser — cryptolyzer's parse layer, cloned separately
2026-08-21 so pull.sh maintains it. cryptoparser/httpx/header.py is
2 028 lines and the most complete typed model of HTTP header values on
disk: attrs classes, one per header and one per directive, each with
_parse() and compose() so every header round-trips. The nearest thing to a
rival design for message.py plus the family modules, and the closest
available answer to "what would this package look like if the value model were
the product rather than the findings".
- Coverage is 22 headers, and lopsided against this package's. Age,
Cache-Control, Content-Type, CSP, CSP-Report-Only, Date, ETag, Expect-CT,
Expect-Staple, Expires, Last-Modified, NEL, Pragma, Public-Key-Pinning,
Server, Set-Cookie, Referrer-Policy, HSTS, X-Content-Security-Policy, XCTO,
XFO, X-XSS-Protection. Wider on caching and on the obsolete set, narrower
on everything modern — no COOP, COEP or CORP, no Permissions-Policy, no
Clear-Site-Data, no Integrity-Policy, no reporting headers, no
Access-Control-*. Read it for the modelling, not for coverage. - Do not copy its header-name table. One entry is simply wrong:
:1644registerscode='public-key-pinning'/normalized_name='Public-Key-Pinning', and the stringPublic-Key-Pins— the name RFC 7469 actually defines, and the one OWASP's corpus tracks — appears zero times in the file, so that parser can never match the header on the wire. Worth knowing for its own sake and as a warning: a round-trip_parse()/compose()test cannot catch this, because both directions read the same wrong constant. That is the "passes both ways" failure the working practices above describe, sitting in production code. Set-Cookieis parsed, the prefixes are not.HttpHeaderFieldValueSetCookie(:1399) carriesname,value,expires,max_age,domain,path,secure,http_only,same_site, split across two layers: the value class takesname=valueand delegates the rest toHttpHeaderFieldValueSetCookieParams, a semicolon-separated field set. ItsSameSiteis aStringEnumCaseInsensitiveParsable, matching the case-insensitivity the parked item verified. But__Host-,__Secure-,__Http-,__Host-Http-andPartitionedappear zero times in the file — so this is a parse model to borrow from and not a source for the four prefix rules, which stay this package's own work offCookiePrefixes.cppandcookie_util.cc.- Its XFO value set is
{DENY, SAMEORIGIN}case-insensitive, withALLOW-FROMabsent from the file entirely. An independent parser that simply does not admit the value, which is corroboration for ratingxfo-allow-fromanerrorrather than a deprecation note. - CSP is modelled as a closed grammar, not strings. Nonce, hash, scheme,
host and keyword are five distinct parsable source types
(
ContentSecurityPolicySourceHash,…Nonce,…Scheme,…Host,…Keyword), with the keyword set closed at nine ('none','report-sample','self','strict-dynamic','unsafe-allow-redirects','unsafe-eval','unsafe-hashes','unsafe-inline','wasm-unsafe-eval'). A source-list type that cannot represent an unparsed string is the structural answer to principle 4's substring bug — worth reading before the next CSP change, whether or not the model is adopted. test/httpx/test_header.pyis 1 143 lines of real header values with their expected parses. The only per-header parse corpus on disk that is not this package's owntests/, so it is a free source of awkward inputs to checkmessage.pyand the family parsers against.
documentation/BChecks — PortSwigger's official BCheck library, a
declarative scan-check DSL (metadata: / define: / given response then).
Surveyed 2026-08-21. Its value here is mostly evidentiary:
archived/Content-Security-Policy.bcheckis principle 4's failure mode shipped by a major vendor and then archived by them. Every decision is substring containment against the whole header block —" *" in {to_lower(latest.response.headers)}," 'unsafe-inline'"flagged with no nonce orstrict-dynamicawareness — andreport-uriandblock-all-mixed-contentare listed among its "insecure values". It even carries the epitaph: "the deprecatedreferrervalue was removed from insecure_value due to causing false positives from the Referrer-Policy header". Cite this when someone proposes a substring check.other/Cookie-SameSite-Disabled.bcheck,other/tokens/cookie-cached-on-disk.bcheckandother/corsCredentialedRequestsMisconfiguration.bcheckare small independent opinions to diff the cookie analyser and the CORS rulings against.
security/shcheck and security/shcheck-fork — this package's own
provenance, now checkable instead of recalled. security/shcheck is santoru's
upstream at v1.7; security/shcheck-fork is MarioVilas/shcheck, the fork this
package grew out of and whose tool was deleted. git log in either settles what
the original did or what the fork changed — including the default-src 'self'
substring bug that principle 4 is named after.
documentation/known-http-header-db — 271 headers in one JSON
(src/db.json pretty-printed, dist/db.json the same content minified),
aggregated from MDN, the IANA http-fields registry and Wikipedia. Per header:
type (request/response), description, syntax, directives[] with a
description each, specifications[] with RFC links, and status (permanent,
…). The fastest way to answer "is this a real header, what does it take, which
RFC" for a name nobody here has met. Do not quote its
browserCompatibility — it is a
scraped MDN rendering, carrying "Yes" and "?" where versions belong;
documentation/browser-compat-data is the machine-readable original and the
only one of the two that records flags, partial implementations and
version_added: false.
documentation/http.dev — not a git repository, and the first thing to
know about it. It is an unpacked HTML scrape of http.dev, like
web_browsers/lynx2.9.3 is an unpacked tarball, so pull.sh reports it
skipped (not a git repository) and any git command run inside it walks up
and answers about the git-mirror repo at the root of /home/crapula/ref —
which looks alarmingly like a mirror pointing at the wrong origin, and is not.
Surveyed 2026-08-21. 506 files, no extensions: 138 status-code pages and 365
non-numeric ones, mostly one per header name with a few topic pages
(caching, authentication). Two uses and one trap:
- The broadest header-name list on disk — 365 against
known-http-header-db's 271 — which makes it the best candidate corpus for the parked inverted "interesting headers" switch. It is HTML, so strip tags before using it as data. - Readable prose per header when orientation is wanted on something nobody here has met.
- Never cite it for browser support. Its "Baseline: Widely available" banners are rendered from web-features/webstatus.dev, so it is BCD at second hand — the same trap this section already records for caniuse.com, one step further removed.
w3c/webref — mechanically extracted content of 752 crawled web specs,
and the answer to the spec gap this section otherwise records as unclosable:
the WHATWG and W3C living standards are in no IETF mirror, and this is them, in
JSON. Generated by Reffy, regenerated upstream every 6 hours. Surveyed
2026-08-17.
ed/dfns/is a registry of headers defined by living standards. Filtering ontype == "http-header"yields 77 headers across 19 specs, each with itslinkingText, its exact anchorhref, and theheadingit sits under — CSP and CSP-Report-Only fromCSP3, COOP/COEP and their report-only twins, XFO andOrigin-Agent-Clusterfromhtml, the eightAccess-Control-*,X-Content-Type-Optionsand CORP fromfetch,Clear-Site-Data,Permissions-Policy, the fourSec-Fetch-*, and the Document-Policy family. The type filter is not complete and must not be used alone: authors who wrote a plain<dfn>gettype: "dfn", which is howReferrer-Policy(dfns/referrer-policy.json) and everyIntegrity-Policyterm (dfns/sri-2.json) escape it. GreplinkingTextas well.ed/algorithms/is the normative text, 58 MB of it: each algorithm as a name, anhref, and a nestedsteps[]tree whosehtmlcarries the actual numbered prose with every cross-reference resolved to an absolute URL. This is the closest thing on disk to reading the spec, and it is greppable.- Worked example, and it moved a ruling:
algorithms/sri-2.jsonholds processing an integrity policy, which confirmsblocked-destinationsis the closed set{script, style}at spec level — matching Chromium's parser — and adds a fact the browser source does not state as plainly, that an absentsourceskey meansinline("Ifdictionary["sources"]does not exist or if its value containsinline, appendinline"). A policy with an explicitly emptysourcestherefore blocks nothing. - RFCs are crawled too but yield less.
rfc6797(HSTS) andrfc7034(XFO) getheadings/andids/only — plain RFC HTML has no Bikeshed markup, so there are no dfns and no algorithms. For those,rfc-libraryremains the source. - This checkout is the
mainbranch — raw extracts, explicitly carrying no validity guarantee. Thecuratedbranch is the patched one; the difference bites on IDL and CSS, which this project does not read. Extraction artefacts do exist in what it does read:html's XFO dfn is filed under aspeculative-loading.htmlhref because heading attribution drifts across HTML's multipage split. Trust thelinkingText, sanity-check thehref.
w3c/browser-specs — the 804-entry curated spec list that decides what
webref crawls, and independently useful as a spec liveness oracle:
standing is good (735) / discontinued (61) / pending (8), with
obsoletedBy, formerNames, organization and both nightly and release URLs
per entry. Facts read off it 2026-08-17, each of which bears on a ruling here:
document-policy is in good standing, so "not analyzed" is a scope call and
not a dead-spec call; feature-policy is absent entirely — not
discontinued, not present, the list simply does not carry it, which is a
stronger statement about it than DEPRECATED_HEADERS makes; there is no
integrity-policy entry because Integrity-Policy is defined inside sri-2;
partitioned-cookies is discontinued; and rfc7230–rfc7235 are marked
obsoleted by rfc9110/rfc9111/rfc9112, which is a cheap way to check an RFC
citation has not gone stale.
w3c/web-features — the WebDX Baseline catalogue, 1 189 features as
features/<id>.yml (hand-written: name, description, spec, caniuse,
compat_features as BCD keys) beside features/<id>.yml.dist (generated:
baseline: high|low|false, baseline_low_date, baseline_high_date, and the
minimum version per browser). It is a rollup of BCD, not a rival to it —
use it for the one-line "is this widely available" answer (hsts is
baseline: high since 2018-01-29, csp since 2019-02-02) and go to BCD the
moment the question is per-directive or per-value.
- Its real asset here is the
discouraged:key:reason,alternatives, an optionalremoval_date, andaccording_to— a URL to the decision itself. That is the citationDEPRECATED_HEADERSentries otherwise have to be argued from prose. Of the 50 discouraged features exactly one is a header:feature-policy, "superseded by permissions policy", cited tow3c/webappsec-permissions-policyPR #379. - Coverage is partial and the gaps are ours. It references 115 headers, but
there is no feature for
X-Frame-Options,X-Content-Type-Options, COOP,X-XSS-Protection,Expect-CTorPublic-Key-Pins— it curates what web developers are told to use, so obsolete and defensive-only headers fall outside it. Do not read an absence here as an absence of support.
Set-Cookieanalysis. Jetty'sjetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/HttpCookieStore.javaimplements the__Host-/__Secure-prefix rules, withHttpCookieStoreTest.javabeside it; Tomcat'sjava/org/apache/tomcat/util/http/Rfc6265CookieProcessor.javaplusSameSiteCookies.javaandtest/…/TestCookieProcessorGeneration.javaare the serialization ground truth;werkzeug/tests/test_http.pyhas prefix tests in Python. Read these three when the cookie parser lands, not before.Set-Cookieanalysis, the analyser side.security/securityheaders(koenbuyens) is the only PythonSet-Cookiechecker on disk and is decomposed the way this package is —checkers/<header>/besidemodels/<header>/, withcheckers/setcookie/holdingnotsecure.py,nothttponly.pyandrequiressecurity.py, andmodels/setcookie/parsingSameSiteas a directive. Read it for the decomposition, and readrequiressecurity.pyas a warning: it lowercases the cookie name and then testsstartswith('__Secure')/startswith('__Host'), so both prefix branches are unreachable, and it falls back to guessing from'session' in name/'csrf' in name— the guess the parked cache/cookie item refuses to make. Two failure modes to avoid in one 15-line file. Note why the first one is a bug, because this was recorded wrongly here once: lowercasing the name is not itself the error — browsers match these prefixes case-insensitively (seecookies.py'sstrip_prefix(), andnetwerk/cookie/CookiePrefixes.cppbehind it) — the error is comparing the lowered name against a mixed-case literal, which can never match. Lower both sides.burp/burp-samesite-reporteris 326 lines of Java that classifies each cookie asSameSitemissing /None/ other and carries its reasoning in the issue prose.cookie_security/cookie-security-analyzer(added to the survey 2026-08-21) is the one peer with an explicit severity ladder for cookies: a Chrome MV3 extension rating each oneSAFE/LOW RISK/MEDIUM RISK/HIGH RISKinsrc/popup.tsfromsecure,httpOnly,sameSiteand third-party status —pageIsSecure && !secure,sameSiteunspecified, andsameSite === "None" && !secureare its three rules. Read it for the ladder, and note what it cannot do: it readschrome.cookies, notSet-Cookie, so the prefixes are invisible to it and it implements none of the four. That blind spot is the argument for parsing the wire header rather than a cookie store, and it is why this repo is worth naming despite sitting in a directory the "Everything else" section otherwise writes off. For the parse side rather than the rules,security/cryptoparser'sHttpHeaderFieldValueSetCookieis the typed Python model to compare against — see its entry above, including the warning that it implements none of the four prefixes.documentation/Open-Cookie-Database— 2 264 cookies as CSV and JSON, keyed by name, each with platform, category (Functional / Personalization / Analytics / Marketing / Security), retention period and aWildcard matchflag (_gac_1234matches wildcard_ga). This is the nearest thing on disk to the oracle the parked cache/cookie quirk says does not exist — with two caveats that probably sink it: the categories are privacy purposes, not "is this a session token", and it is curated data that ages, which is the objection that kept csp-evaluator's bypass lists out.burp/CookieMonstervendors it and implements the wildcard rule, if the semantics are ever needed.- Prior art on defaults. Tomcat's
java/org/apache/catalina/filters/HttpHeaderSecurityFilter.java(HSTS max-age, XFO, XCTO defaults) andCorsFilter.java; Jetty'sCrossOriginFilter.javain eachjetty-ee*/…/servlets/. - Boring-list prior art, for the inverted "interesting headers" switch:
wpscan's
app/models/headers.rbhas a hand-curated 27-entryknown_headerslist feedingapp/finders/interesting_findings/headers.rb— a working implementation of exactly that design.burp/HeadersAnalyzercarries a 91-entryBoringHeaders.txt, the same design again and the largest hand- curated boring list here; humble's 1 287-entryfingerprint.txtabove is the largest interesting one.lighttpd1.4/src/http_header.his a third, written from the server's side. security/testssl.sh—run_security_headers()(~line 3580) is the baseline to beat, not a source of checks: it enumerates headers and rates presence only, and the comment near line 3641 says so outright ("I am not testing for the correctness or anything stupid yet, e.g.X-Frame-Options: allowall"). That sentence is this package's reason to exist.burp/Headersandburp/HeaderGuardian-Burpsuite-Pro-Extensionare the same baseline in miniature and in Python: hand-curated presence lists with an on/off flag per entry (security_headers.txt10 entries,cookie_flags.txt7,dangerous_headers.txt2,potentially_dangerous_headers.txt3), plus a user-editablethresholds.txtgiving the count at which each list flags a host. Both are small enough to read in a sitting and neither judges a value.security/badssl.com—domains/upgrade/{hsts,preloaded-hsts,upgrade}.confdefine live, publicly reachable hosts with known-good HSTS, preload andupgrade-insecure-requestsvalues. The only end-to-end fixture source on disk, for whenever a fetcher exists.- HPACK / QPACK static tables —
nginx/src/http/v2/ngx_http_v2_table.candsrc/http/v3/ngx_http_v3_table.c,lighttpd1.4/src/ls-hpack/lshpack.c,libmicrohttpd2/src/mhd2/h2/hpack/. They enumerate canonical lowercase header names, which is the wire-level justification formessage.pylowercasing rather than a convenience. security/Security-Headers-Validator— one module per header underheaders/, and the only tool on disk carryingpragma.pyandcache_control.py, so it is the one independent opinion available on both parked cache items before writingpragma-ineffective. For the parse side,security/cryptoparsermodels both — aCache-Controlresponse class withmax-age,s-maxage,no-cache,no-store,must-revalidate,proxy-revalidate,public,privateandno-transformas typed directives, andPragmaas a header in its own right — which is the closed directive set the "noCache-Controlprevents storage" condition has to be written against.- CSP allowlist-bypass corpora, still not embedded.
burp/csp-auditorhascsp-auditor-core/src/main/resources/resources/data/csp_host_user_content.txtandcsp_host_vulnerable_js.txt— a second curated list beside csp-evaluator's and split along the same two axes, loaded bymodel/WeakCdnHost.java.burp/CSP-Bypasshas a readable standalonecsp_parser.pybut acsp_known_bypasses.pyholding exactly one domain, so it is no substitute for either. The Not embedded ruling covers all of them; nothing here changes the upkeep argument. burp/burp-javascript-security-extension— the only peer on disk that ties CSP to subresource integrity, and it is instructive for being wrong twice over.src/main/java/burp/BurpExtender.java:135isif (!response.contains("Content-Security-Policy: require-sri-for script;"))— an exact-substring test, trailing semicolon included, so it fires on every correct policy that omits the;, capitalises differently, or orders its directives another way. That is principle 4's failure mode inverted: the shcheck bug was a match too loose, this is a match too tight, and both produce a finding on a correct configuration. Second, the directive it asks for is dead — MDN BCD carries norequire-sri-forkey at all underhttp/headers/Content-Security-Policy.json(verified 2026-08-21), the same absence that decided the CSPEE andAccess-Control-Allow-Private-Networkrulings.Integrity-Policyis the live mechanism for this and is already analysed here. Do not port this check.burp/Additional_CORS_Checks— Kotlin, and the prior art for the parked active origin-reflection check: it re-issues a request with a forgedOriginand reports arbitrary-origin andnull-origin reflection (doc/*.pngshows what it claims). Tool-side work, exactly as that item says.burp/t0xodiles-cors-check— the second implementation of that same check, found 2026-08-21, and the reason the CORS ruling can be called corroborated rather than reasoned: it decides on exactly the same bit (ACAOequal to the origin it sent), from a different author, in a codebase last touched 2026-05-31 whereAdditional_CORS_Checksstopped in 2022. It adds one axis the other lacks —src/main/kotlin/TrustedDomainValidationBypassCheck.ktcarries 24 hand-written allowlist-regex bypass patterns, 23 of them distinct — one is listed twice and two more are commented out. They are the trusted domain and the attacker domain joined by each of eighteen punctuation characters (_,-,,,;,!,',(,),*,&,+,=,~,$,{,}, a backtick and a double quote) or by a bare dot, plus the concatenationstrusted.comweb-attacker.com,web-attacker.com.trusted.com,anythingtrusted.comandstrusted.com— each tried as bothhttp://andhttps://.TrustedDomainCheck.ktbootstraps from a domain it has observed to be trusted, then enumerates subdomains of it. Take the pattern list from here when the active checks land; take the severity rule (reflection ×ACAC: true) fromAdditional_CORS_Checks.burp/additional-scanner-checks— small, from 2018, and worth an entry only because it corroborates two decisions this package otherwise made alone. Its BApp description lists "Multiple occurrences of the checked headers" as a check in its own right, for HSTS and X-XSS-Protection —duplicate-headersby another name. AndBurp-MissingScannerChecks.py:316reaches theidentity()conclusion independently: "it is assumed that multipleX-Content-Type-Options: nosniffheaders can't cause confusion at browser side because they all have the same meaning", which is this package's "a repeated header with identical values still reports once" in someone else's words. It also demonstrates the failure_sole_value()exists to avoid —:120carries# TODO: multiple max-age directives cause confusion!on its HSTS regex, unresolved.- The Burp extension-development kit, if a Burp front-end for this library
is ever wanted:
documentation/burp-extensions-montoya-apiis the current API (documentation/burp-extender-apiis the same thing deprecated, by upstream's own README), withdocumentation/burp-extensions-montoya-api-examples— whosecustomscanchecks/is the relevant one — plusdocumentation/extension-template-projectas a ready-made Gradle skeleton andburp/example-scanner-checksas the three-language version. Packaging material, not analysis material; noted so it is not rediscovered as though it were.
/home/crapula/ref holds far more than the whitelist above: 153 repositories
and unpacked trees across ten categories, of which 35 GB is operating-system
source, a dozen are application servers, seven are cookie tools and 80 are
Burp extensions — and only the dozen or so named above touch a header value
or a cookie flag. Nothing outside this section is relevant unless the human
says so. Do not survey it, do not grep it speculatively, and do not re-derive
that it is uninteresting — that was done once, and the cost of doing it again is
the reason this paragraph exists.
How far the negative reaches, because the tree grows. Everything present on 2026-08-21 was surveyed, superseding an earlier pass that had seen only 83 repositories — which is why this paragraph used to say "two dozen Burp extensions" and was wrong by 55 of them. So the blanket negative is good for what was on disk that day and claims nothing about anything added since. It went stale silently once and will again; a dated survey is the honest form.
To tell what is new, compare directory mtimes against the survey date: the
clones surveyed then are stamped 2026-08-12 to 2026-08-21, so anything later is
an addition. Treat it as a hint rather than proof — a directory is restamped
whenever a top-level entry appears or disappears, so an upstream that added a
file will look new. Do not look for a manifest. A research.tsv may or may
not be sitting at the root of /home/crapula/ref; it was written once to
exercise export.sh, it is not maintained and is expected to be deleted, and
reading it as an inventory would under-report the tree by 70 repositories. When
the mtime is ambiguous, ask rather than re-survey 153 repositories.
CAPEC and CWE, since the taxonomy work above cites specific versions of
both and a blanket negative here would otherwise say neither is available.
CAPEC 2.1 is on disk and in the whitelist above, at
documentation/cti/capec/2.1/stix-capec.json. CWE is not in
/home/crapula/ref at all — no reliable upstream repository of it was found —
so CWE 4.20 lives as a gitignored scratch download in this project's own tree,
tmp/cwec_v4.20.xml, which this section's read-only rule does not govern
because it was never part of the reference tree.
Only the negatives that would otherwise look promising are kept:
operating_systems/*— nothing about HTTP security headers.busybox/networking/httpd.candWindows-Server-2003/inetsrv/iis(IIS 6) predate every header analysed here; at most they explain where a fewServer:andX-Powered-Bybanners originate.- The remaining servers —
httpd,caddy,heliod,Zope,daphne,gunicorn,hypercorn,twisted,uvicorn,uwsgi,glassfish*— pass configured headers through and originate no analysis logic. One exception:hiawatha/src/send.cemits HSTS itself rather than by configuration. - The remaining
burp/andcookie_security/repos — JWT tooling, WAF fingerprinting and cookie decryption, HAR/Nessus/sitemap import, request smuggling, nuclei and semgrep bridges, GDPR consent scanners, 403/429 and WAF bypass helpers, GraphQL and OpenAPI tooling, AI assistants, and PortSwigger's own example extensions. Burp plumbing and privacy-compliance tools; none analyses a header value or a cookie flag. Four that look like exceptions and are not, checked 2026-08-21 so they are not re-opened:burp/identity-crisisdiffs responses across requestUser-Agentvalues;burp/BurpSuite_CookieReflectionandburp/sri-checkboth need the response body (a cookie echoed into it, anintegrityattribute on a tag);burp/http-terminatoris request smuggling, already out of scope. Two data files were weighed for the parked inverted "interesting headers" switch and lost to humble's 1 287-namefingerprint.txt:burp/waf-detect's 64-rowresources/WafFingerprints.csv(which does have aHEADER_ONLYcolumn and header-anchored regexes such as^Server: AkamaiGHostand^Set-Cookie: ak_bmsc=) and the 114-regexmatch-rules.tabshared byburp/software-version-reporterandburp/burp-suite-software-version-checks— the latter is the only one that extracts a version out of a value rather than just naming the product, so revisit it if the switch ever wants that. Andcookie_security/sentry-watchdog'sknown_cookies.jsonis not a cookie database: it is the cookies observed on Sentry's own marketing sites, keyedname/domain. security/nmap-nse-vulnerability-scripts— named only because the name promises far more than it holds. It is NCC's three unrelated NSE scripts (Lexmark, PJL, SMTP), not the nmap NSE corpus, sohttp-security-headers.nseis not here and this is not the independent opinion it looks like. Same forsecurity/retire.js(JS library CVEs),security/IIS-ShortName-Scanner,security/cloud_ip_ranges,security/nsdp-discoverandvulnerable/research-labs— the last is three PortSwigger research labs (token signing, PDF rendering, control-character injection), none header-related, though thevulnerable/category is where an end-to-end fixture would live if one ever appears, besidesecurity/badssl.com.documentation/xss-cheatsheet-dataanddocumentation/url-cheatsheet-data— PortSwigger's cheat-sheet corpora as JSON. Payload data for injection and SSRF testing; nothing about response headers.
Analyser: 119 codes (48 error / 26 warning / 45 note), each with a rating, a
message template, a declared header and a consequence tuple — 73 codes carry at
least one consequence slug, 46 carry (). Every rendered sentence is pinned by
a snapshot. CODE_HEADER closes the parked code-to-header table:
test_the_declared_header_is_the_header_the_finding_carries is what makes it
stronger than the test it replaced, which could only prove the corpus was
self-consistent — this one proves the package agrees with it.
Ten consequence slugs live in catalog.CONSEQUENCES, a ten-entry
CODE_TAXONOMY overlays specific published ids onto a handful of codes, and
references.py resolves all 42 headers a finding names, plus the security,
deprecated and caching inventories — 31 of them via MDN, 4 via a permanent
spec URL, 7 via http.dev. That is deliberately narrower than "or an inventory
can name": information alone names 91 headers and none of them resolve.
Layering: four layers, lowest first — core (13 modules, cookies.py
newly among them), adaptors (one module, adaptors.py, converting a dozen
live HTTP libraries' response objects into Request/Response), formats
(recorded, not built — see Layout), cli (10 modules, cli/outcome.py
replacing cli/exchange.py). __init__.py exports the core only.
test_imports_only_ever_run_downhill and
test_importing_the_library_does_not_import_the_adaptors_or_the_cli
(tests/test_cli_structure.py) pin both halves of the claim.
CLI: hst ships the scan and explain verbs over 10 modules in cli/,
standard library only. Reserved and documented but not implemented: the read
verb and its file parsers, --probe, --all-hops, --include-report-only,
--unknown-headers, --retry, scope exclusions, -d/--data, --ignore-cookie,
and the sarif and ndjson output formats — the last two are named in
writers.RESERVED so a user gets "not implemented yet" rather than "invalid
choice", which is the whole of their implementation. Consequences and taxonomy
references took no
new flag: identifiers are cheap enough to emit unconditionally, and parking
the long-form descriptions to land with the SARIF writer's fullDescription
field removed the only thing a verbosity switch would have gated — do not
reserve one now.
Tests: 916 passing across 536 test functions, 122 of them CLI. ruff check
clean. No test touches the network, with one deliberate exception: the redirect-
limit test binds a loopback http.server on an ephemeral port, because urllib's
own redirect bookkeeping cannot be tested any other way.
pyproject.toml declares two console scripts (hst and http-security-test,
both http_security_test.cli:main) and hstspreload as the optional
[preload] extra. No runtime dependencies. README.md landed in 01121ce, a
five-line placeholder that says as much ("work in progress, stay tuned") —
still no real one.
The CLI was built 2026-08-21 against
docs/designs/2026-08-21-cli-contract.md; docs/superpowers/plans/
2026-08-21-cli-tool.md is the implementation plan, corrected in five places
during the build where it disagreed with the spec or with reality.