#!/usr/bin/env python3
"""headers_prescan.py — deterministic HTTP security-header / response-
hardening misconfiguration pre-scan.

ARION service artifact. Scans source + server-config files for the
line shapes where response-header protection is explicitly weakened
or removed — frame-protection opt-outs (clickjacking), security
headers unset or hidden, CSPs that carry unsafe-inline/unsafe-eval/
wildcard sources, HSTS disabled, X-Content-Type-Options switched
off, X-Powered-By exposed, referrer-policy unsafe-url, wildcard
Permissions-Policy grants of powerful features, and public cache
directives on files that carry auth/session vocabulary.
Line-wise, fully offline, no inference.

Findings (base severity):
  HDR-FRAME-OPT-OUT   HIGH    framing protection explicitly turned
                              off — X-Frame-Options ALLOWALL /
                              ALLOW-FROM, CSP frame-ancestors *,
                              Spring frameOptions().disable(),
                              helmet frameguard:false, Django
                              X_FRAME_OPTIONS='ALLOWALL' — the page
                              may be framed by any origin
                              (clickjacking / UI redress)
  HDR-DEFAULTS-OFF    MEDIUM  Spring headers().defaultsDisabled() —
                              the whole secure-header default set is
                              switched off
  HDR-STRIP           HIGH    a hardening header removed/passed-
                              through-hidden — res.removeHeader,
                              headers.delete/remove, PHP
                              header_remove, delete resp.headers[..],
                              Apache Header [always] unset, nginx
                              more_clear_headers / proxy_hide_header
                              on X-Frame-Options, Content-Security-
                              Policy, Strict-Transport-Security,
                              X-Content-Type-Options, Referrer-Policy,
                              Permissions-Policy, Cross-Origin-*
  HDR-CSP-UNSAFE      MEDIUM  CSP directives carrying 'unsafe-inline'
                              or 'unsafe-eval' in script scope
                              (script-src / default-src / *-elem /
                              *-attr / camelCase helmet directives);
                              LOW when confined to style-src or a
                              non-script directive
  HDR-CSP-WILDCARD    MEDIUM  a wildcard or data:/blob: host source
                              in a script-bearing directive
                              (script-src/default-src/object-src/
                              connect-src) — any origin or data: URL
                              becomes a valid script/connect source
  HDR-HSTS-OFF        MEDIUM  HSTS explicitly disabled — max-age=0,
                              helmet hsts:false, Spring
                              httpStrictTransportSecurity().disable()
                              LOW: Django SECURE_HSTS_SECONDS=0 and
                              Rails force_ssl=false (explicit but
                              matching the framework default)
  HDR-NOSNIFF-OFF     MEDIUM  X-Content-Type-Options set to a
                              non-nosniff value / SECURE_CONTENT_TYPE
                              _NOSNIFF=False / noSniff:false /
                              contentTypeOptions().disable()
  HDR-POWEREDBY       LOW     X-Powered-By explicitly set or kept —
                              setHeader/headers[..] assigns it,
                              poweredBy:true / x-powered-by:true,
                              nginx server_tokens on, Apache
                              ServerTokens Full — fingerprints the
                              stack for targeted exploits
  HDR-REFERRER-UNSAFE LOW     Referrer-Policy unsafe-url — full URL
                              (path + query) leaks cross-origin
  HDR-PERM-WILD       LOW     Permissions-Policy granting a powerful
                              feature to '*' — camera, microphone,
                              geolocation, usb, serial, bluetooth,
                              payment, fullscreen, midi,
                              magnetometer, gyroscope, accelerometer,
                              display-capture, publickey-credentials
  HDR-PUBLIC-CACHE    LOW     Cache-Control public on a file that
                              also carries auth/session/token
                              vocabulary — shared caches may store a
                              per-user response

Context rules:
  * comment lines and block-comment bodies are stripped first
  * import/require lines never flag
  * test/spec/fixture paths downgrade one tier
  * only explicit weakening/removal is flagged — a file that simply
    never mentions headers is silent (a reverse proxy or CDN may set
    them; absence is not evidence)
  * good values stay silent: deny/sameorigin frame options,
    'self'/'none' CSP sources, nosniff, max-age>0, disable/remove/
    hide on X-Powered-By
  * X-XSS-Protection: 0 is NOT flagged — disabling the legacy auditor
    is the current recommendation

Scope: deterministic pre-scan for human review — flags the line
shapes where header hardening is explicitly reduced; it cannot see
reverse-proxy/CDN rules, framework defaults applied at runtime, or
middleware in other files — clean output != hardened responses.

  headers_prescan.py <file-or-dir> [--json]
  headers_prescan.py --selftest
Stdlib only. Exit 0 = no HIGH/MEDIUM findings, 1 = findings, 2 = usage.
"""
import json
import os
import re
import sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}

TARGET_EXT = (
    ".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
    ".php", ".rb", ".go", ".java", ".kt", ".cs", ".groovy",
    ".rs", ".scala", ".dart", ".vue", ".svelte", ".conf",
    ".html", ".htm",
)

TARGET_NAMES = {".htaccess", "nginx.conf", "apache.conf"}

SKIP_DIRS = {
    "node_modules", ".git", "vendor", "dist", "build", "target",
    "__pycache__", ".venv", "venv", "coverage", ".next", "out",
}

IMPORT_LINE = re.compile(
    r"^\s*(?:from\s+[\w.]+\s+import\b|import\s+[\w.{*]|"
    r"(?:const|let|var)\s+[\w${},\s]*=\s*require\s*\(|"
    r"\buse\s+[\w:]+|require\s*\(['\"][\w./-]+['\"]\s*\)?\s*;?\s*$|"
    r"#include|package\s+[\w.]+;?\s*$|using\s+[\w.]+;)")

TEST_PATH = re.compile(
    r"(^|/)(test|tests|spec|specs|fixtures?|mocks?|__tests__|__mocks__|"
    r"e2e|cypress|playwright)(/|$)|(_test|_spec|\.test|\.spec)\.",
    re.I)

# --- per-line vocabulary ---------------------------------------------------

# explicit frame-protection opt-out (non-CSP forms)
FRAME_OFF = re.compile(
    r"X-Frame-Options['\"\]]{0,2}\s*(?:=>|[:,= ])\s*['\"]?\s*"
    r"ALLOW(?:ALL|-FROM)\b|"
    r"X_FRAME_OPTIONS\s*=\s*['\"]ALLOW(?:ALL|-FROM)?|"
    r"frameOptions\s*\(\s*\)\s*\.(?:disable|permitAll)\s*\(|"
    r"frame[Gg]uard\s*:\s*false|"
    r"xFrameOptions\s*:\s*false", re.I)

# blanket default-header disable
DEFAULTS_OFF = re.compile(
    r"headers\s*\(\s*\)\s*\.defaultsDisabled\s*\(|"
    r"\.defaultsDisabled\s*\(\s*\)", re.I)

# explicit removal / hiding of a hardening header
HDR_NAMES = (
    r"x-frame-options|content-security-policy|strict-transport-security|"
    r"x-content-type-options|referrer-policy|permissions-policy|"
    r"cross-origin-\w+-policy|x-xss-protection")

HDR_STRIP = re.compile(
    r"(?:removeHeader|remove_header|header_remove|headers\.delete|"
    r"headers\.remove|headers\.unset|more_clear_headers|"
    r"proxy_hide_header|Header\s+(?:always\s+)?unset|del\s)"
    r"[^;\n]{0,90}?(?:" + HDR_NAMES + r")|"
    r"delete\s+[\w.\[\]'\"]*headers[^;\n]{0,60}?(?:" + HDR_NAMES + r")",
    re.I)

# non-CSP HSTS/nosniff/power/referrer forms
HSTS_ZERO = re.compile(
    r"strict-transport-security[^;\n]{0,120}?max-age\s*=\s*0\b|"
    r"httpStrictTransportSecurity\s*\(\s*\)\s*\.disable\s*\(|"
    r"StrictTransportSecurity[^;\n]{0,60}\bdisable\b", re.I)

HSTS_LOW = re.compile(
    r"SECURE_HSTS_SECONDS\s*=\s*0\b|"
    r"force_ssl\s*=\s*false", re.I)

NOSNIFF_SET = re.compile(
    r"x-content-type-options['\"\]]?\s*[:,= ]\s*['\"]?([A-Za-z-]+)|"
    r"SECURE_CONTENT_TYPE_NOSNIFF\s*=\s*False|"
    r"noSniff\s*:\s*false|xContentTypeOptions\s*:\s*false|"
    r"contentTypeOptions\s*\(\s*\)\s*\.disable\s*\(", re.I)

POWEREDBY_SET = re.compile(
    r"x-powered-by['\"\]]{0,2}\s*[:,= ]\s*['\"]?[\w]|"
    r"poweredBy\s*:\s*true|x-powered-by\s*:\s*true|"
    r"server_tokens\s+on\b|ServerTokens\s+Full", re.I)

POWEREDBY_GOOD = re.compile(
    r"removeHeader|remove_header|header_remove|disable\s*\(|"
    r"app\.disable|\.disable\(|hidePoweredBy|unset|delete|"
    r"server_tokens\s+off|ServerTokens\s+(?:Prod|Minimal|Minor|Major)",
    re.I)

REFERRER_BAD = re.compile(
    r"referrer-policy[^;\n]{0,60}?unsafe-url|"
    r"referrerPolicy[^;\n}]{0,80}?unsafe-url|"
    r"SECURE_REFERRER_POLICY\s*=\s*['\"]unsafe-url", re.I)

PERM_WILD = re.compile(
    r"(?:camera|microphone|geolocation|usb|serial|bluetooth|payment|"
    r"fullscreen|midi|magnetometer|gyroscope|accelerometer|"
    r"display-capture|publickey-credentials|interest-cohort)"
    r"\s*=\s*\(?\s*\*\s*\)?", re.I)

CACHE_PUBLIC = re.compile(
    r"cache-control['\"\]]{0,2}\s*[:,= ]\s*['\"]?[^;\n'\"]{0,80}"
    r"\bpublic\b", re.I)

CACHE_SAFE = re.compile(r"no-store|no-cache|private", re.I)

AUTH_VOCAB = re.compile(
    r"\blogin|session|token|password|auth|credential|account\b", re.I)

# helmet({ ... hsts: false ... }) — 400-char window
HELMET_HSTS = re.compile(
    r"helmet\s*\([^)]{0,400}?hsts\s*:\s*false", re.I | re.S)

HELMET_GENERIC_OFF = re.compile(
    r"helmet\s*\([^)]{0,400}?(?:contentSecurityPolicy|frameguard|"
    r"noSniff|referrerPolicy|crossOriginResourcePolicy)"
    r"\s*:\s*false", re.I | re.S)

# --- CSP handling -----------------------------------------------------------

# any CSP directive name — marks a file region worth evaluating
CSP_ANY = re.compile(
    r"default-src|script-src(?:-elem|-attr)?|style-src(?:-elem|-attr)?|"
    r"connect-src|img-src|font-src|object-src|media-src|frame-src|"
    r"frame-ancestors|base-uri|form-action|worker-src|manifest-src|"
    r"child-src|prefetch-src|"
    r"defaultSrc|scriptSrc(?:Elem|Attr)?|styleSrc(?:Elem|Attr)?|"
    r"connectSrc|imgSrc|fontSrc|objectSrc|mediaSrc|frameSrc|"
    r"frameAncestors|baseUri|formAction|workerSrc|manifestSrc|"
    r"childSrc|prefetchSrc|content-security-policy|"
    r"contentSecurityPolicy", re.I)

# hyphenated directive: `script-src 'self' 'unsafe-inline';`
# values may carry quotes — stop at ; or newline
CSP_DIR_HYPHEN = re.compile(
    r"(default-src|script-src(?:-elem|-attr)?|style-src(?:-elem|-attr)?|"
    r"connect-src|img-src|font-src|object-src|media-src|frame-src|"
    r"frame-ancestors|base-uri|form-action|worker-src|manifest-src|"
    r"child-src|prefetch-src)\s+([^;\n]{0,200})", re.I)

# camelCase directive (helmet/spring object forms): scriptSrc: [...]
CSP_DIR_CAMEL = re.compile(
    r"(defaultSrc|scriptSrc(?:Elem|Attr)?|styleSrc(?:Elem|Attr)?|"
    r"connectSrc|imgSrc|fontSrc|objectSrc|mediaSrc|frameSrc|"
    r"frameAncestors|baseUri|formAction|workerSrc|manifestSrc|"
    r"childSrc|prefetchSrc)\s*:\s*[\[\('\"]?([^;\]\}\n]{0,200})",
    re.I)

SCRIPT_DIRS = {
    "default-src", "script-src", "script-src-elem", "script-src-attr",
    "defaultsrc", "scriptsrc", "scriptsrcelem", "scriptsrcattr",
}
STYLE_DIRS = {
    "style-src", "style-src-elem", "style-src-attr",
    "stylesrc", "stylesrcelem", "stylesrcattr",
}
WILD_DIRS = SCRIPT_DIRS | {
    "object-src", "connect-src", "objectsrc", "connectsrc",
}
FRAME_DIRS = {"frame-ancestors", "frameancestors"}

PER_CODE_CAP = 40
CSP_WIN = 700   # chars of context to evaluate around a CSP marker


class Finding:
    def __init__(self, sev, code, where, line, msg):
        self.sev, self.code, self.where = sev, code, where
        self.line, self.msg = line, msg

    def as_dict(self):
        return {"severity": self.sev, "code": self.code,
                "file": self.where, "line": self.line, "msg": self.msg}


# --- helpers --------------------------------------------------------------
def strip_comments(lines):
    """Remove full-line comments and /* */ block bodies. Keeps line count."""
    out, in_block = [], False
    for ln in lines:
        s = ln
        if in_block:
            if "*/" in s:
                s = s.split("*/", 1)[1]
                in_block = False
            else:
                out.append("")
                continue
        while "/*" in s:
            if "*/" in s.split("/*", 1)[1]:
                s = s.replace(s[s.index("/*"):s.index("*/") + 2], " ", 1)
            else:
                s = s.split("/*", 1)[0]
                in_block = True
        st = s.strip()
        if st.startswith(("//", "#", "*", "<!--")):
            out.append("")
            continue
        out.append(s)
    return out


def downgrade(sev):
    return "MEDIUM" if sev == "HIGH" else "LOW"


def eval_csp_segment(seg, base, emit_at):
    """Evaluate one CSP-ish text segment for unsafe sources.

    emit_at(sev, code, pos, msg) records a finding; pos is an offset
    inside seg. Returns True if the segment looked like a CSP at all.
    """
    dirs = []
    for m in CSP_DIR_HYPHEN.finditer(seg):
        dirs.append((m.group(1).lower(), m.group(2), m.start()))
    for m in CSP_DIR_CAMEL.finditer(seg):
        dirs.append((m.group(1).lower(), m.group(2), m.start()))
    if not dirs:
        # bare unsafe token with no directive association still counts
        # as CSP material when the marker line named the header
        if "unsafe-inline" in seg or "unsafe-eval" in seg:
            emit_at("MEDIUM", "HDR-CSP-UNSAFE", 0,
                    "CSP carries 'unsafe-inline'/'unsafe-eval' — "
                    "inline script or eval() becomes a valid source")
            return True
        return False
    saw_script_scope = any(d in SCRIPT_DIRS for d, _, _ in dirs)
    for dname, vals, pos in dirs:
        toks = vals.split()
        has_unsafe_inline = "unsafe-inline" in vals
        has_unsafe_eval = "unsafe-eval" in vals
        has_star = any(t.strip("\"'[](),") == "*" for t in toks)
        has_data = any(t.strip("\"'[],") in ("data:", "blob:")
                       for t in toks)
        if dname in FRAME_DIRS:
            if has_star or "https:" in vals:
                emit_at("HIGH", "HDR-FRAME-OPT-OUT", pos,
                        "CSP frame-ancestors '%s' — any origin may "
                        "frame this page (clickjacking)"
                        % vals.strip()[:40])
            continue
        if dname in SCRIPT_DIRS and (has_unsafe_inline or
                                   has_unsafe_eval):
            emit_at("MEDIUM", "HDR-CSP-UNSAFE", pos,
                    "%s carries %s — script source the CSP was meant "
                    "to exclude"
                    % (dname, "'unsafe-eval'" if has_unsafe_eval
                       else "'unsafe-inline'"))
        elif has_unsafe_eval and saw_script_scope:
            emit_at("MEDIUM", "HDR-CSP-UNSAFE", pos,
                    "%s carries 'unsafe-eval' in a CSP that also "
                    "defines script sources" % dname)
        elif (has_unsafe_inline or has_unsafe_eval) and \
                dname in STYLE_DIRS:
            emit_at("LOW", "HDR-CSP-UNSAFE", pos,
                    "%s carries an unsafe-* source — style-level "
                    "exposure only" % dname)
        elif has_unsafe_inline or has_unsafe_eval:
            emit_at("LOW", "HDR-CSP-UNSAFE", pos,
                    "%s carries an unsafe-* source" % dname)
        if dname in WILD_DIRS and (has_star or
                                   (has_data and dname in SCRIPT_DIRS)):
            emit_at("MEDIUM", "HDR-CSP-WILDCARD", pos,
                    "%s allows %s — any origin/data URL becomes a "
                    "valid source"
                    % (dname, "'*'" if has_star else "data:/blob:"))
    return True


def scan_file(path, rel):
    try:
        raw = open(path, encoding="utf-8", errors="replace").read()
    except OSError:
        return []
    is_test = bool(TEST_PATH.search(rel))
    lines = strip_comments(raw.splitlines())
    text = "\n".join(lines)
    findings = []
    counts = {}

    offsets = []
    _pos = 0
    for _ln in lines:
        offsets.append(_pos)
        _pos += len(_ln) + 1

    def line_at(pos):
        lo, hi = 0, len(offsets) - 1
        while lo < hi:
            mid = (lo + hi + 1) // 2
            if offsets[mid] <= pos:
                lo = mid
            else:
                hi = mid - 1
        return lo + 1

    def emit(sev, code, ln, msg):
        if is_test:
            sev = downgrade(sev)
        if counts.get(code, 0) >= PER_CODE_CAP:
            return
        counts[code] = counts.get(code, 0) + 1
        findings.append(Finding(sev, code, rel, ln, msg))

    # ---- per-line findings -------------------------------------------
    for i, ln in enumerate(lines, 1):
        if not ln.strip() or IMPORT_LINE.match(ln):
            continue

        if FRAME_OFF.search(ln):
            emit("HIGH", "HDR-FRAME-OPT-OUT", i,
                 "frame protection explicitly off — the page may be "
                 "framed by any origin (clickjacking/UI redress)")

        if DEFAULTS_OFF.search(ln):
            emit("MEDIUM", "HDR-DEFAULTS-OFF", i,
                 "secure-header default set disabled wholesale — every "
                 "hardening header must now be re-added by hand")

        if HDR_STRIP.search(ln):
            emit("HIGH", "HDR-STRIP", i,
                 "hardening header explicitly removed/hidden — "
                 "upstream protection never reaches the client")

        if HSTS_ZERO.search(ln):
            emit("MEDIUM", "HDR-HSTS-OFF", i,
                 "HSTS explicitly disabled (max-age=0 / disable()) — "
                 "browsers stop pinning TLS")

        if HSTS_LOW.search(ln):
            emit("LOW", "HDR-HSTS-OFF", i,
                 "transport-security switch matches the insecure "
                 "default — TLS/HSTS is not being enabled at this "
                 "layer")

        m = NOSNIFF_SET.search(ln)
        if m:
            val = (m.group(1) or "").lower()
            if not val or val != "nosniff":
                emit("MEDIUM", "HDR-NOSNIFF-OFF", i,
                     "X-Content-Type-Options disabled/non-nosniff — "
                     "browsers may sniff uploads as script")

        if POWEREDBY_SET.search(ln) and not POWEREDBY_GOOD.search(ln):
            emit("LOW", "HDR-POWEREDBY", i,
                 "X-Powered-By/ServerTokens exposes the stack — "
                 "fingerprints the platform for targeted exploits")

        if REFERRER_BAD.search(ln):
            emit("LOW", "HDR-REFERRER-UNSAFE", i,
                 "Referrer-Policy unsafe-url — the full URL incl. "
                 "query leaks cross-origin in Referer")

        for m in PERM_WILD.finditer(ln):
            emit("LOW", "HDR-PERM-WILD", i,
                 "Permissions-Policy grants '%s' to every origin"
                 % m.group(0).strip()[:50])

        if CACHE_PUBLIC.search(ln) and not CACHE_SAFE.search(ln):
            if AUTH_VOCAB.search(text):
                emit("LOW", "HDR-PUBLIC-CACHE", i,
                     "Cache-Control public in a file carrying "
                     "auth/session vocabulary — shared caches may "
                     "store per-user responses")

    # ---- window findings ---------------------------------------------
    for m in HELMET_HSTS.finditer(text):
        emit("MEDIUM", "HDR-HSTS-OFF", line_at(m.start()),
             "helmet configured with hsts:false — Strict-Transport-"
             "Security not sent")

    for m in HELMET_GENERIC_OFF.finditer(text):
        seg = m.group(0)
        if "contentSecurityPolicy" in seg:
            emit("MEDIUM", "HDR-DEFAULTS-OFF", line_at(m.start()),
                 "helmet configured with contentSecurityPolicy:false "
                 "— no CSP is sent")
        if "referrerPolicy" in seg:
            emit("LOW", "HDR-REFERRER-UNSAFE", line_at(m.start()),
                 "helmet configured with referrerPolicy:false — no "
                 "Referrer-Policy is sent")

    # CSP evaluation — one window per distinct region
    last_end = -1
    for m in CSP_ANY.finditer(text):
        seg_start = m.start()
        if seg_start < last_end:
            continue
        seg = text[seg_start:seg_start + CSP_WIN]
        last_end = seg_start + CSP_WIN

        def emit_at(sev, code, pos, msg):
            emit(sev, code, line_at(seg_start + pos), msg)

        eval_csp_segment(seg, 0, emit_at)

    return findings


def find_targets(root):
    if os.path.isfile(root):
        return [root]
    out = []
    for dp, dns, fns in os.walk(root):
        dns[:] = [d for d in dns if d not in SKIP_DIRS]
        for fn in fns:
            if fn.endswith(TARGET_EXT) or fn in TARGET_NAMES:
                out.append(os.path.join(dp, fn))
    return sorted(out)


def render(findings, targets):
    findings.sort(key=lambda f: (SEV_ORDER[f.sev], f.where, f.line))
    rows = ["# headers_prescan report", "",
            "files scanned: %d — findings: %d" % (len(targets),
                                                  len(findings)), ""]
    cur = None
    for f in findings:
        if f.sev != cur:
            cur = f.sev
            rows.append("## %s" % cur)
        rows.append("- `%s` %s:%d — %s" % (f.code, f.where, f.line,
                                           f.msg))
    if not findings:
        rows.append("no HIGH/MEDIUM/LOW signatures found — this is a "
                    "pre-scan; clean output != hardened responses")
    return "\n".join(rows)


# --- selftest -------------------------------------------------------------
SELFTEST = [
    # --- frame opt-out ---
    ("a.js",
     "res.setHeader('X-Frame-Options', 'ALLOWALL');\n",
     {"HDR-FRAME-OPT-OUT"}),
    ("a.js",
     "res.setHeader('X-Frame-Options', 'DENY');\n",
     set()),
    ("a.py",
     "X_FRAME_OPTIONS = 'ALLOWALL'\n",
     {"HDR-FRAME-OPT-OUT"}),
    ("a.py",
     "X_FRAME_OPTIONS = 'SAMEORIGIN'\n",
     set()),
    ("a.java",
     "http.headers().frameOptions().disable();\n",
     {"HDR-FRAME-OPT-OUT"}),
    ("a.java",
     "http.headers().frameOptions().sameOrigin();\n",
     set()),
    ("a.js",
     "app.use(helmet({ frameguard: false }));\n",
     {"HDR-FRAME-OPT-OUT"}),
    ("a.rb",
     "config.action_dispatch.default_headers = "
     "{ 'X-Frame-Options' => 'ALLOWALL' }\n",
     {"HDR-FRAME-OPT-OUT"}),
    # --- defaults off ---
    ("a.java",
     "http.headers().defaultsDisabled()\n"
     "  .contentTypeOptions();\n",
     {"HDR-DEFAULTS-OFF"}),
    # --- strip ---
    ("a.js",
     "res.removeHeader('content-security-policy');\n",
     {"HDR-STRIP"}),
    ("a.conf",
     "more_clear_headers 'x-frame-options';\n",
     {"HDR-STRIP"}),
    ("a.conf",
     "proxy_hide_header X-Frame-Options;\n",
     {"HDR-STRIP"}),
    ("a.conf",
     "Header always unset \"Strict-Transport-Security\"\n",
     {"HDR-STRIP"}),
    ("a.php",
     "header_remove('X-Frame-Options');\n",
     {"HDR-STRIP"}),
    ("a.py",
     "del resp.headers['x-content-type-options']\n",
     {"HDR-STRIP"}),
    ("a.js",
     "res.removeHeader('x-powered-by');\n",
     set()),                                        # good removal
    # --- CSP ---
    ("a.js",
     "res.setHeader('Content-Security-Policy', \"default-src 'self'\");\n",
     set()),
    ("a.js",
     "res.setHeader('Content-Security-Policy', "
     "\"script-src 'self' 'unsafe-inline'\");\n",
     {"HDR-CSP-UNSAFE"}),
    ("a.js",
     "res.setHeader('Content-Security-Policy', "
     "\"script-src 'self' 'unsafe-eval'\");\n",
     {"HDR-CSP-UNSAFE"}),
    ("a.js",
     "res.setHeader('Content-Security-Policy', "
     "\"style-src 'self' 'unsafe-inline'; script-src 'self'\");\n",
     {"HDR-CSP-UNSAFE"}),                           # LOW only via style? -> script-src present means saw_script; unsafe in style → LOW
    ("a.js",
     "res.setHeader('Content-Security-Policy', \"script-src *\");\n",
     {"HDR-CSP-WILDCARD"}),
    ("a.js",
     "res.setHeader('Content-Security-Policy', "
     "\"default-src 'self'; frame-ancestors *\");\n",
     {"HDR-FRAME-OPT-OUT"}),
    ("a.js",
     "res.setHeader('Content-Security-Policy', "
     "\"default-src 'self'; frame-ancestors 'self'\");\n",
     set()),
    ("a.js",
     "const csp = \"default-src 'self'; script-src data: https:\";\n",
     {"HDR-CSP-WILDCARD"}),
    ("a.html",
     "<meta http-equiv=\"Content-Security-Policy\" "
     "content=\"script-src 'unsafe-eval' 'self'\">\n",
     {"HDR-CSP-UNSAFE"}),
    ("a.js",
     "helmet({ contentSecurityPolicy: { directives: { scriptSrc: "
     "[\"'self'\", \"'unsafe-inline'\"] } } });\n",
     {"HDR-CSP-UNSAFE"}),
    # --- HSTS ---
    ("a.conf",
     "add_header Strict-Transport-Security \"max-age=0\";\n",
     {"HDR-HSTS-OFF"}),
    ("a.conf",
     "add_header Strict-Transport-Security \"max-age=63072000; "
     "includeSubDomains\";\n",
     set()),
    ("a.js",
     "app.use(helmet({ hsts: false }));\n",
     {"HDR-HSTS-OFF"}),
    ("a.java",
     "http.headers().httpStrictTransportSecurity().disable();\n",
     {"HDR-HSTS-OFF"}),
    ("a.py",
     "SECURE_HSTS_SECONDS = 0\n",
     {"HDR-HSTS-OFF"}),                             # LOW tier
    ("a.rb",
     "config.force_ssl = false\n",
     {"HDR-HSTS-OFF"}),                             # LOW tier
    ("a.rb",
     "config.force_ssl = true\n",
     set()),
    # --- nosniff ---
    ("a.js",
     "res.setHeader('X-Content-Type-Options', 'sniff');\n",
     {"HDR-NOSNIFF-OFF"}),
    ("a.js",
     "res.setHeader('X-Content-Type-Options', 'nosniff');\n",
     set()),
    ("a.py",
     "SECURE_CONTENT_TYPE_NOSNIFF = False\n",
     {"HDR-NOSNIFF-OFF"}),
    ("a.js",
     "app.use(helmet({ noSniff: false }));\n",
     {"HDR-NOSNIFF-OFF"}),
    # --- powered-by ---
    ("a.js",
     "res.setHeader('X-Powered-By', 'Express');\n",
     {"HDR-POWEREDBY"}),
    ("a.js",
     "app.disable('x-powered-by');\n",
     set()),
    ("a.js",
     "res.removeHeader('X-Powered-By');\n",
     set()),
    ("a.conf",
     "server_tokens on;\n",
     {"HDR-POWEREDBY"}),
    ("a.conf",
     "server_tokens off;\n",
     set()),
    # --- referrer ---
    ("a.js",
     "res.setHeader('Referrer-Policy', 'unsafe-url');\n",
     {"HDR-REFERRER-UNSAFE"}),
    ("a.js",
     "res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');\n",
     set()),
    # --- permissions policy ---
    ("a.js",
     "res.setHeader('Permissions-Policy', 'camera=*, microphone=()');\n",
     {"HDR-PERM-WILD"}),
    ("a.js",
     "res.setHeader('Permissions-Policy', 'camera=(self), "
     "geolocation=()');\n",
     set()),
    # --- public cache on auth file ---
    ("sess.py",
     "def get_session(u): return u\n"
     "resp['Cache-Control'] = 'public, max-age=3600'\n",
     {"HDR-PUBLIC-CACHE"}),
    ("page.py",
     "resp['Cache-Control'] = 'public, max-age=3600'\n",
     set()),                                        # no auth vocab
    ("sess.py",
     "token = check()\n"
     "resp['Cache-Control'] = 'private, no-store'\n",
     set()),
    # --- interplay / FP guards ---
    ("a.js",
     "// res.setHeader('X-Frame-Options', 'ALLOWALL');\n",
     set()),
    ("a.js",
     "/* res.setHeader('X-Frame-Options', 'ALLOWALL'); */\n",
     set()),
    ("a.js",
     "import helmet from 'helmet';\n",
     set()),
    ("a.js",
     "res.setHeader('X-XSS-Protection', '0');\n",
     set()),                                        # recommended now
    ("test/a.js",
     "res.setHeader('X-Frame-Options', 'ALLOWALL');\n",
     {"HDR-FRAME-OPT-OUT"}),                        # downgraded MED
    ("a.js",
     "res.setHeader('Content-Security-Policy', "
     "\"default-src 'none'; frame-ancestors 'none'; "
     "base-uri 'none'\");\n",
     set()),
    ("a.js",
     "const note = 'frame protection and clickjacking matters';\n",
     set()),                                        # prose
    ("a.py",
     "response['X-Frame-Options'] = 'DENY'\n"
     "response['Content-Security-Policy'] = \"frame-ancestors 'self'\"\n",
     set()),
]


def run_selftest():
    import tempfile
    fails = []
    for i, (name, src, expect) in enumerate(SELFTEST):
        with tempfile.TemporaryDirectory() as td:
            p = os.path.join(td, name)
            os.makedirs(os.path.dirname(p) or td, exist_ok=True)
            open(p, "w").write(src)
            got = {f.code for f in scan_file(p, name)}
            missing = expect - got
            extra = got - expect
            if missing or extra:
                fails.append(("v%d %s" % (i, name),
                              "missing=%s extra=%s" % (missing, extra)))
    if fails:
        for f_ in fails:
            print("SELFTEST FAIL", f_)
        return 1
    print("SELFTEST PASS (%d vectors)" % len(SELFTEST))
    return 0


def main():
    args = [a for a in sys.argv[1:] if a != "--json"]
    want_json = "--json" in sys.argv
    if "--selftest" in sys.argv:
        return run_selftest()
    if not args:
        print(__doc__)
        return 2
    root = args[0]
    targets = find_targets(root)
    findings = []
    for p in targets:
        rel = os.path.relpath(p, root) if os.path.isdir(root) \
            else os.path.basename(p)
        findings += scan_file(p, rel)
    if want_json:
        print(json.dumps({"findings": [f.as_dict() for f in findings],
                          "scanned": len(targets)}, indent=1))
    else:
        print(render(findings, targets))
    return 1 if any(f.sev in ("HIGH", "MEDIUM") for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
