http-sec-audit
Audit a website's HTTP security headers and cookie flags — CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, COOP/COEP, version-leaking banners, and Secure/HttpOnly/SameSite cookies. Use when the user asks to "check my site's security header
Install
npx skills add https://github.com/NovaCode37/claude-security-skills/tree/main/skills/http-sec-audit
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install novacode37-claude-security-skills@llmmart
git clone https://github.com/NovaCode37/claude-security-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole novacode37/claude-security-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
HTTP Security Header Audit
Checks a site's response headers against modern web-security best practices and
returns prioritized findings with concrete fixes. The analysis core is pure and
offline-testable; live scanning uses only Python's stdlib urllib.
When to use this skill
- "Audit the security headers on https://example.com."
- "Is my CSP / HSTS / cookie config correct?"
- "Why is this site flagged for missing headers?"
What it checks
- Content-Security-Policy — presence,
unsafe-inline, wildcards. - Strict-Transport-Security — presence and
max-agelength. - X-Content-Type-Options: nosniff, X-Frame-Options /
frame-ancestors. - Referrer-Policy, Permissions-Policy.
- Information disclosure —
Server/X-Powered-Byversion banners. - Cookies —
Secure,HttpOnly,SameSite(incl.SameSite=NonewithoutSecure).
How to run it
# Live scan
python skills/http-sec-audit/audit.py https://example.com
# JSON output
python skills/http-sec-audit/audit.py https://example.com --json
# Offline: audit a saved raw header block (no network)
python skills/http-sec-audit/audit.py --headers-file response_headers.txt
# Only fail CI on high/critical (a missing Permissions-Policy is LOW and
# shows up on almost every site)
python skills/http-sec-audit/audit.py https://example.com --min-severity high
# Also check cross-origin isolation (COOP/COEP/CORP) and Cache-Control.
# Off by default: most sites have good reasons not to set these, and a
# reported finding fails the run.
python skills/http-sec-audit/audit.py https://example.com --advisory
Exit codes: 0 clean · 1 findings reported · 2 fetch/usage error.
Every reported finding fails the build; raise --min-severity to filter
advisory findings out of both the report and the exit code.
Recommended workflow for Claude
- Run the audit (live, or offline against captured headers).
- Group findings by severity and present each with its one-line fix.
- Offer ready-to-paste header snippets for the user's stack (nginx, Apache, Express, etc.) for the missing headers.
- Only scan sites the user owns or is authorized to test.
Files (claude-security-skills)
-
tests
-
test_audit.py 6.6 KB
import json import os import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import audit SECURE_HEADERS = { "Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'", "Strict-Transport-Security": "max-age=31536000; includeSubDomains", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "strict-origin-when-cross-origin", "Permissions-Policy": "geolocation=()", "Server": "nginx", } SECURE_COOKIE = ["session=abc; Secure; HttpOnly; SameSite=Lax"] def ids(headers, cookies=None, is_https=True): return {f.id for f in audit.audit_headers(headers, cookies, is_https)} def test_secure_site_is_clean(): found = audit.audit_headers(SECURE_HEADERS, SECURE_COOKIE) assert found == [], [f.to_dict() for f in found] def test_missing_csp_flagged(): assert "csp-missing" in ids({}) def test_missing_hsts_flagged_on_https(): assert "hsts-missing" in ids({}, is_https=True) def test_hsts_not_required_on_http(): assert "hsts-missing" not in ids({}, is_https=False) def test_csp_unsafe_inline_flagged(): h = dict(SECURE_HEADERS) h["Content-Security-Policy"] = "default-src 'self' 'unsafe-inline'" assert "csp-unsafe-inline" in ids(h, SECURE_COOKIE) def test_short_hsts_flagged(): h = dict(SECURE_HEADERS) h["Strict-Transport-Security"] = "max-age=3600" assert "hsts-short" in ids(h, SECURE_COOKIE) def test_nosniff_missing_flagged(): h = dict(SECURE_HEADERS) del h["X-Content-Type-Options"] assert "xcto-missing" in ids(h, SECURE_COOKIE) def test_frame_ancestors_satisfies_clickjacking(): h = dict(SECURE_HEADERS) del h["X-Frame-Options"] assert "xfo-missing" not in ids(h, SECURE_COOKIE) def test_version_banner_flagged(): h = dict(SECURE_HEADERS) h["Server"] = "nginx/1.18.0" assert "info-disclosure" in ids(h, SECURE_COOKIE) def test_cookie_missing_flags(): found = ids(SECURE_HEADERS, ["session=abc"]) assert "cookie-no-secure" in found assert "cookie-no-httponly" in found assert "cookie-no-samesite" in found def test_samesite_none_without_secure(): found = ids(SECURE_HEADERS, ["s=1; HttpOnly; SameSite=None"]) assert "cookie-samesite-none-insecure" in found def test_case_insensitive_headers(): h = {k.lower(): v for k, v in SECURE_HEADERS.items()} assert audit.audit_headers(h, SECURE_COOKIE) == [] def test_parse_raw_headers(): raw = ("HTTP/1.1 200 OK\r\n" "Content-Type: text/html\r\n" "Set-Cookie: a=1; HttpOnly\r\n" "Set-Cookie: b=2\r\n") headers, cookies = audit.parse_raw_headers(raw) assert headers["Content-Type"] == "text/html" assert len(cookies) == 2 def test_findings_sorted_by_severity(): found = audit.audit_headers({}, ["s=1"]) ranks = [audit.SEV_RANK[f.severity] for f in found] assert ranks == sorted(ranks) def test_cli_headers_file(tmp_path, capsys): f = tmp_path / "h.txt" f.write_text("HTTP/1.1 200 OK\nContent-Type: text/html\n") rc = audit.main(["--headers-file", str(f), "--json"]) data = json.loads(capsys.readouterr().out) assert any(d["id"] == "csp-missing" for d in data) assert rc == 1 LOW_ONLY_HEADERS = ( "HTTP/1.1 200 OK\n" "Content-Security-Policy: default-src 'self'; frame-ancestors 'none'\n" "Strict-Transport-Security: max-age=31536000; includeSubDomains\n" "X-Content-Type-Options: nosniff\n" "Referrer-Policy: strict-origin-when-cross-origin\n" ) def test_cli_low_only_findings_exit_one(tmp_path, capsys): """LOW findings are still findings, so the build fails (issue #46).""" f = tmp_path / "h.txt" f.write_text(LOW_ONLY_HEADERS) rc = audit.main(["--headers-file", str(f), "--json"]) data = json.loads(capsys.readouterr().out) assert [d["severity"] for d in data] == ["low"] assert rc == 1 def test_cli_min_severity_filters_report_and_exit(tmp_path, capsys): f = tmp_path / "h.txt" f.write_text(LOW_ONLY_HEADERS) rc = audit.main(["--headers-file", str(f), "--min-severity", "medium", "--json"]) assert json.loads(capsys.readouterr().out) == [] assert rc == 0 def test_filter_by_severity(): findings = audit.audit_headers({}, []) assert audit.filter_by_severity(findings, "info") == findings assert all(f.severity in ("critical", "high") for f in audit.filter_by_severity(findings, "high")) # --- cross-origin isolation and Cache-Control (issues #36, #37) ------------- ADVISORY_IDS = {"coop-missing", "coep-missing", "corp-missing", "cache-control-missing"} ISOLATED_HEADERS = dict( SECURE_HEADERS, **{ "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp", "Cross-Origin-Resource-Policy": "same-origin", "Cache-Control": "no-store", }, ) def test_advisory_checks_are_off_by_default(): """A reported finding fails the run, so these stay opt-in (issue #46).""" assert not (ids(SECURE_HEADERS, SECURE_COOKIE) & ADVISORY_IDS) def test_advisory_flags_missing_cross_origin_headers(): found = ids(SECURE_HEADERS, SECURE_COOKIE) assert not (found & ADVISORY_IDS) with_advisory = { f.id for f in audit.audit_headers(SECURE_HEADERS, SECURE_COOKIE, advisory=True) } assert ADVISORY_IDS <= with_advisory def test_advisory_clean_when_headers_present(): found = audit.audit_headers(ISOLATED_HEADERS, SECURE_COOKIE, advisory=True) assert found == [], [f.to_dict() for f in found] def test_advisory_findings_are_info_severity(): found = audit.audit_headers(SECURE_HEADERS, SECURE_COOKIE, advisory=True) assert {f.severity for f in found} == {"info"} def test_cli_missing_only_advisory_headers_exits_zero(tmp_path, capsys): """Without the flag, a site that only lacks COOP/COEP/CORP is clean.""" f = tmp_path / "h.txt" f.write_text( "HTTP/1.1 200 OK\n" "Content-Security-Policy: default-src 'self'; frame-ancestors 'none'\n" "Strict-Transport-Security: max-age=31536000; includeSubDomains\n" "X-Content-Type-Options: nosniff\n" "Referrer-Policy: strict-origin-when-cross-origin\n" "Permissions-Policy: camera=(), microphone=()\n" ) rc = audit.main(["--headers-file", str(f), "--json"]) assert json.loads(capsys.readouterr().out) == [] assert rc == 0 rc = audit.main(["--headers-file", str(f), "--advisory", "--json"]) data = json.loads(capsys.readouterr().out) assert {d["id"] for d in data} == ADVISORY_IDS assert rc == 1
-
-
audit.py 10.7 KB
from __future__ import annotations import argparse import json import sys from dataclasses import dataclass, asdict SEV_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} @dataclass class Finding: id: str severity: str header: str message: str recommendation: str def to_dict(self) -> dict: return asdict(self) def _lower_keys(headers: dict) -> dict: return {str(k).lower(): str(v) for k, v in headers.items()} def audit_headers(headers: dict, cookies: list[str] | None = None, is_https: bool = True, advisory: bool = False) -> list[Finding]: h = _lower_keys(headers) cookies = cookies or [] findings: list[Finding] = [] def miss(id_, sev, header, msg, rec): findings.append(Finding(id_, sev, header, msg, rec)) if "content-security-policy" not in h: miss("csp-missing", "high", "Content-Security-Policy", "No Content-Security-Policy — primary defense against XSS is absent.", "Add a restrictive CSP, e.g. default-src 'self'.") else: csp = h["content-security-policy"] if "unsafe-inline" in csp: miss("csp-unsafe-inline", "medium", "Content-Security-Policy", "CSP allows 'unsafe-inline', weakening XSS protection.", "Remove 'unsafe-inline'; use nonces or hashes.") if "*" in csp.split(): miss("csp-wildcard", "low", "Content-Security-Policy", "CSP contains a wildcard source.", "Restrict sources to explicit origins.") if is_https: if "strict-transport-security" not in h: miss("hsts-missing", "high", "Strict-Transport-Security", "No HSTS — connections can be downgraded to HTTP.", "Add Strict-Transport-Security: max-age=31536000; " "includeSubDomains.") else: hsts = h["strict-transport-security"] maxage = _parse_max_age(hsts) if maxage is not None and maxage < 15552000: miss("hsts-short", "low", "Strict-Transport-Security", f"HSTS max-age is short ({maxage}s).", "Use max-age >= 31536000 (1 year).") if h.get("x-content-type-options", "").lower() != "nosniff": miss("xcto-missing", "medium", "X-Content-Type-Options", "Missing 'nosniff' — browser may MIME-sniff responses.", "Set X-Content-Type-Options: nosniff.") has_fa = ("content-security-policy" in h and "frame-ancestors" in h["content-security-policy"]) if "x-frame-options" not in h and not has_fa: miss("xfo-missing", "medium", "X-Frame-Options", "No clickjacking protection (X-Frame-Options / frame-ancestors).", "Set X-Frame-Options: DENY or CSP frame-ancestors 'none'.") if "referrer-policy" not in h: miss("referrer-missing", "low", "Referrer-Policy", "No Referrer-Policy — full URLs may leak to other origins.", "Set Referrer-Policy: strict-origin-when-cross-origin.") if "permissions-policy" not in h and "feature-policy" not in h: miss("permissions-missing", "low", "Permissions-Policy", "No Permissions-Policy — powerful browser features unrestricted.", "Set a Permissions-Policy limiting camera, microphone, geolocation, etc.") if advisory: for header, id_, purpose in ( ("cross-origin-opener-policy", "coop-missing", "isolates this page from cross-origin windows"), ("cross-origin-embedder-policy", "coep-missing", "controls which cross-origin resources may be embedded"), ("cross-origin-resource-policy", "corp-missing", "controls which origins may embed this resource")): if header not in h: title = "-".join(w.capitalize() for w in header.split("-")) miss(id_, "info", title, f"No {title} — {purpose}.", f"Set {title} if this response needs cross-origin " "isolation. Plenty of sites have good reasons not to.") if "cache-control" not in h: miss("cache-control-missing", "info", "Cache-Control", "No Cache-Control — caching is left to heuristics, which is " "a problem only if the response carries private data.", "Send Cache-Control: no-store on responses with sensitive " "data. Static public pages do not need it.") for banner in ("server", "x-powered-by", "x-aspnet-version"): if banner in h and any(ch.isdigit() for ch in h[banner]): miss("info-disclosure", "low", banner.title(), f"'{banner}: {h[banner]}' reveals software/version.", "Remove or genericize version banners.") for raw in cookies: findings.extend(_audit_cookie(raw, is_https)) findings.sort(key=lambda f: SEV_RANK.get(f.severity, 9)) return findings def filter_by_severity(findings: list[Finding], min_severity: str = "info") -> list[Finding]: threshold = SEV_RANK.get(min_severity, SEV_RANK["info"]) return [f for f in findings if SEV_RANK.get(f.severity, 3) <= threshold] def _parse_max_age(hsts: str): for part in hsts.split(";"): part = part.strip().lower() if part.startswith("max-age="): try: return int(part.split("=", 1)[1]) except ValueError: return None return None def _audit_cookie(raw: str, is_https: bool) -> list[Finding]: name = raw.split("=", 1)[0].strip() low = raw.lower() out: list[Finding] = [] if is_https and "secure" not in low: out.append(Finding("cookie-no-secure", "medium", "Set-Cookie", f"Cookie '{name}' lacks the Secure flag.", "Add Secure so the cookie is HTTPS-only.")) if "httponly" not in low: out.append(Finding("cookie-no-httponly", "medium", "Set-Cookie", f"Cookie '{name}' lacks HttpOnly — readable by JS.", "Add HttpOnly to mitigate XSS cookie theft.")) if "samesite" not in low: out.append(Finding("cookie-no-samesite", "low", "Set-Cookie", f"Cookie '{name}' has no SameSite attribute.", "Add SameSite=Lax or Strict to mitigate CSRF.")) elif "samesite=none" in low and "secure" not in low: out.append(Finding("cookie-samesite-none-insecure", "medium", "Set-Cookie", f"Cookie '{name}' is SameSite=None without Secure.", "SameSite=None requires the Secure flag.")) return out def fetch_headers(url: str, timeout: float = 10.0): import urllib.request import urllib.error req = urllib.request.Request(url, method="GET", headers={"User-Agent": "http-sec-audit/1.0"}) try: with urllib.request.urlopen(req, timeout=timeout) as resp: headers = dict(resp.headers.items()) cookies = resp.headers.get_all("Set-Cookie") or [] final_url = resp.geturl() except urllib.error.HTTPError as exc: headers = dict(exc.headers.items()) if exc.headers else {} cookies = exc.headers.get_all("Set-Cookie") if exc.headers else [] final_url = url return headers, list(cookies), final_url.startswith("https://") def parse_raw_headers(text: str): headers: dict = {} cookies: list[str] = [] for line in text.splitlines(): if not line.strip() or line.startswith("HTTP/"): continue if ":" not in line: continue key, val = line.split(":", 1) key, val = key.strip(), val.strip() if key.lower() == "set-cookie": cookies.append(val) else: headers[key] = val return headers, cookies def render(findings: list[Finding], target: str) -> str: if not findings: return f"[http-sec-audit] {target}: no issues found. [OK]" out = [f"[http-sec-audit] {target}: {len(findings)} finding(s):\n"] for f in findings: out.append(f" [{f.severity.upper():<8}] {f.id} ({f.header})") out.append(f" {f.message}") out.append(f" fix: {f.recommendation}") counts: dict[str, int] = {} for f in findings: counts[f.severity] = counts.get(f.severity, 0) + 1 out.append("\nSummary: " + ", ".join( f"{k}={counts[k]}" for k in sorted(counts, key=lambda s: SEV_RANK[s]))) return "\n".join(out) def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser( prog="http-sec-audit", description="Audit HTTP security headers and cookie flags.") p.add_argument("url", nargs="?", help="URL to scan (https://...)") p.add_argument("--headers-file", help="offline: file with a raw HTTP response header block") p.add_argument("--json", action="store_true", help="emit JSON") p.add_argument("--min-severity", default="info", choices=list(SEV_RANK), help="report findings at or above this severity " "(default: info, i.e. report everything)") p.add_argument("--advisory", action="store_true", help="also check cross-origin isolation (COOP/COEP/CORP) " "and Cache-Control. Off by default: these are " "advisory, and a reported finding fails the run") args = p.parse_args(argv) if args.headers_file: try: with open(args.headers_file, "r", encoding="utf-8", errors="ignore") as fh: headers, cookies = parse_raw_headers(fh.read()) except OSError as exc: print(f"error: {exc}", file=sys.stderr) return 2 target = args.headers_file is_https = True elif args.url: try: headers, cookies, is_https = fetch_headers(args.url) except Exception as exc: print(f"error: could not fetch {args.url}: {exc}", file=sys.stderr) return 2 target = args.url else: p.error("provide a URL or --headers-file") return 2 findings = filter_by_severity( audit_headers(headers, cookies, is_https=is_https, advisory=args.advisory), args.min_severity) if args.json: print(json.dumps([f.to_dict() for f in findings], indent=2)) else: print(render(findings, target)) return 1 if findings else 0 if __name__ == "__main__": try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass raise SystemExit(main()) -
SKILL.md 2.6 KB
--- name: http-sec-audit description: >- Audit a website's HTTP security headers and cookie flags — CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, COOP/COEP, version-leaking banners, and Secure/HttpOnly/SameSite cookies. Use when the user asks to "check my site's security headers", "audit HTTP headers", "is my CSP/HSTS configured right", or "scan a URL for header misconfigs". license: MIT --- # HTTP Security Header Audit Checks a site's response headers against modern web-security best practices and returns prioritized findings with concrete fixes. The analysis core is pure and offline-testable; live scanning uses only Python's stdlib `urllib`. ## When to use this skill - "Audit the security headers on https://example.com." - "Is my CSP / HSTS / cookie config correct?" - "Why is this site flagged for missing headers?" ## What it checks - **Content-Security-Policy** — presence, `unsafe-inline`, wildcards. - **Strict-Transport-Security** — presence and `max-age` length. - **X-Content-Type-Options: nosniff**, **X-Frame-Options** / `frame-ancestors`. - **Referrer-Policy**, **Permissions-Policy**. - **Information disclosure** — `Server` / `X-Powered-By` version banners. - **Cookies** — `Secure`, `HttpOnly`, `SameSite` (incl. `SameSite=None` without `Secure`). ## How to run it ```bash # Live scan python skills/http-sec-audit/audit.py https://example.com # JSON output python skills/http-sec-audit/audit.py https://example.com --json # Offline: audit a saved raw header block (no network) python skills/http-sec-audit/audit.py --headers-file response_headers.txt # Only fail CI on high/critical (a missing Permissions-Policy is LOW and # shows up on almost every site) python skills/http-sec-audit/audit.py https://example.com --min-severity high # Also check cross-origin isolation (COOP/COEP/CORP) and Cache-Control. # Off by default: most sites have good reasons not to set these, and a # reported finding fails the run. python skills/http-sec-audit/audit.py https://example.com --advisory ``` **Exit codes:** `0` clean · `1` findings reported · `2` fetch/usage error. Every reported finding fails the build; raise `--min-severity` to filter advisory findings out of both the report and the exit code. ## Recommended workflow for Claude 1. Run the audit (live, or offline against captured headers). 2. Group findings by severity and present each with its one-line fix. 3. Offer ready-to-paste header snippets for the user's stack (nginx, Apache, Express, etc.) for the missing headers. 4. Only scan sites the user owns or is authorized to test.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.