#!/usr/bin/env python3
"""webconf_prescan.py — deterministic nginx + Apache HTTPD config
misconfiguration pre-scan (CWE-16 family: improper configuration).

ARION service artifact. Scans web-server configuration files —
nginx.conf + included server/location blocks, httpd.conf /
apache2.conf, .vhost files, conf.d / sites-enabled snippets, and
.htaccess — for the directive shapes that repeatedly produce real
exposure. Checkable offline, line- and block-aware, no execution:

  * nginx alias/location trailing-slash parity — `location /i {
    alias /data/i/; }` serves /i../secret outside the alias root;
    alias inside a regex location — HIGH
  * nginx `satisfy any` mixed with auth/allow/deny in the same
    block — the classic auth-bypass shape — HIGH
  * Apache `ProxyRequests On` — open forward proxy — HIGH
  * Apache `Dav On` / nginx `dav_methods` with PUT/DELETE/COPY/
    MOVE/MKCOL/PROPFIND and no auth/allow/internal guard in the
    enclosing blocks — HIGH
  * Apache `Require all granted` / legacy `Allow from all` inside
    `<Directory />` or a filesystem-root block — HIGH
  * Apache ExecCGI / `AddHandler|SetHandler cgi-script` under an
    upload/tmp/files/media/userdir path — HIGH
  * unguarded status/handler exposure (stub_status, SetHandler
    server-status/server-info/balancer-manager), `Options
    Indexes`/`Options All`, `AllowOverride All`, `Options
    Includes` (SSI exec), TraceEnable On, autoindex on — MEDIUM
  * weak TLS directives (ssl_protocols TLSv1/TLSv1.1/SSLv3,
    SSLProtocol with `all` and no -TLSv1, EXPORT/NULL/RC4/DES/MD5
    cipher tokens) — MEDIUM
  * nginx header-smuggling switches (underscores_in_headers on,
    ignore_invalid_headers off), variable proxy_pass upstreams
    ($var — unvalidated/SSRF-shaped), add_header/return/rewrite
    reflecting $http_/$arg_/$cookie_ values, client-supplied
    identity headers forwarded (proxy_set_header X-Real-IP
    $http_…), wildcard Access-Control-Allow-Origin in config —
    MEDIUM
  * .htaccess PHP-engine flags (php_flag engine on, AddHandler/
    AddType/SetHandler x-httpd-php) — upload-dir RCE shape —
    MEDIUM
  * `client_max_body_size` >= 50m, `merge_slashes off`, Options
    MultiViews, ServerTokens Full, ServerSignature On, UserDir
    enabled, LogLevel debug, internal-pivot ProxyPass/RewriteRule
    [P] — LOW
  * `if (` inside an nginx location — INFO (the "if is evil"
    class)

Guard model: exposure findings (DAV, status handlers, autoindex,
Indexes, satisfy-any, Require/Allow) are evaluated against the
WHOLE text of their enclosing blocks (location/server or
<Directory>/<Location>/<VirtualHost> chains). auth_basic /
AuthType / AuthName / Require|require valid-user / deny all /
allow <private-net> / internal; / satisfy all in any enclosing
block downgrades or silences the finding. Test/fixture paths
downgrade one tier (INFO drops off). Comments (# outside quotes)
are stripped.

Honest scope: PRE-SCAN, not an audit. Directive- and block-level;
include-file indirection (include /etc/nginx/x.conf) is followed
only insofar as the files exist under the scanned tree. A FLAG
means "this directive shape deserves human review"; a clean
report is not proof the vhost is safe — effective config depends
on merge order across all included files.

Usage:
  webconf_prescan.py <file-or-dir> [--json]
  webconf_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}

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

CONF_BASENAMES = {"nginx.conf", "httpd.conf", "apache2.conf",
                  "apache.conf", ".htaccess", "nginx.conf.default",
                  "httpd.conf.default"}
CONF_EXT = {".conf", ".vhost", ".htaccess"}
CONF_PATH_HINT = ("nginx", "apache", "httpd", "conf.d",
                  "sites-enabled", "sites-available", "vhosts",
                  "conf-enabled", "mods-enabled", "extra")

TEST_PATH_RE = re.compile(
    r"(?i)(^|/)(test|tests|spec|specs|fixtures?|mocks?|__tests__|"
    r"__mocks__|testdata|e2e|stubs?)(/|$)|(^|/)test_|_test\.|"
    r"\.test\.|\.spec\.")

# -------------------------------------------------------------- sniffing

SNIFF_NGX = re.compile(
    r"^\s*(listen|server_name|location|upstream|proxy_pass|"
    r"fastcgi_pass|access_log|ssl_certificate|error_page|"
    r"server_tokens|worker_processes)\b")
SNIFF_AP = re.compile(
    r"^\s*(<(VirtualHost|Directory|Location|Files|FilesMatch|"
    r"LocationMatch|DirectoryMatch|IfModule|Limit|LimitExcept|"
    r"Proxy|IfVersion)\b|"
    r"(ServerTokens|ServerSignature|AllowOverride|DocumentRoot|"
    r"RewriteEngine|SSLEngine|DirectoryIndex|Require|Options|"
    r"AddHandler|SetHandler|ProxyPass|TraceEnable|Dav|UserDir|"
    r"LogLevel|DirectoryIndex)\b)")


def is_candidate(path):
    base = os.path.basename(path)
    low = base.lower()
    if low in CONF_BASENAMES:
        return True
    ext = os.path.splitext(low)[1]
    if ext in CONF_EXT:
        return True
    norm = path.replace("\\", "/").lower()
    if any(h in norm for h in CONF_PATH_HINT) and \
            ext in ("", ".conf", ".conf.in", ".tmpl", ".tpl",
                    ".sample", ".example"):
        return True
    return False


def sniff_flavor(code_lines):
    ngx = sum(1 for l in code_lines if SNIFF_NGX.search(l))
    ap = sum(1 for l in code_lines if SNIFF_AP.search(l))
    return ngx, ap


# -------------------------------------------------------------- text utils

def strip_comments(raw_lines):
    """Cut trailing '#' outside quotes. Config files: '#' inside a
    quoted arg is rare; quoted strings preserved."""
    out = []
    for raw in raw_lines:
        res, q = [], None
        for ch in raw:
            if q:
                res.append(ch)
                if ch == q:
                    q = None
                continue
            if ch in "\"'":
                q = ch
                res.append(ch)
                continue
            if ch == "#":
                break
            res.append(ch)
        out.append("".join(res))
    return out


class Frame:
    __slots__ = ("kind", "arg", "start", "end", "parent")

    def __init__(self, kind, arg, start, parent):
        self.kind, self.arg, self.start = kind, arg, start
        self.end = None
        self.parent = parent


def parse_frames(code_lines):
    """Return list of block frames (nginx {} blocks + Apache <X> sections)
    with start/end line indices."""
    frames, stack = [], []
    for i, line in enumerate(code_lines):
        s = line.strip()
        # apache close
        if re.match(r"^</\w+\s*>", s):
            for j in range(len(stack) - 1, -1, -1):
                if stack[j].kind.startswith("ap:"):
                    stack[j].end = i
                    frames.append(stack.pop(j))
                    break
            continue
        # apache open
        m = re.match(r"^<(\w+)\s*([^>]*)>", s)
        if m and not s.startswith("</"):
            fr = Frame("ap:" + m.group(1).lower(),
                       m.group(2).strip(), i,
                       stack[-1] if stack else None)
            stack.append(fr)
            # single-line section `<X ..> .. </X>` — rare; treat braces below
            continue
        # nginx-style open: `name args {` (allow `location ~ /x {`)
        m = re.match(r"^([\w@-]+)\s+([^{};]*)\{\s*$", s)
        if m or s.endswith("{"):
            kind = m.group(1).lower() if m else "block"
            arg = m.group(2).strip() if m else s[:-1].strip()
            stack.append(Frame("ngx:" + kind, arg, i,
                               stack[-1] if stack else None))
            continue
        # one or more closes on a line
        if "}" in s:
            closes = s.count("}")
            while closes and stack:
                fr = stack.pop()
                fr.end = i
                frames.append(fr)
                closes -= 1
    for fr in stack:
        fr.end = len(code_lines) - 1
        frames.append(fr)
    return frames


def enclosing(frames, i):
    return [f for f in frames if f.start < i <= (f.end or i)]


def block_text(code_lines, fr):
    return "\n".join(code_lines[fr.start:(fr.end or fr.start) + 1])


# -------------------------------------------------------------- vocabulary

# auth/containment vocabulary — presence in an enclosing block or any
# ancestor means the exposure was considered
GUARD_RE = re.compile(
    r"(?i)(auth_basic|AuthType|AuthName|\bRequire\s+valid-user|"
    r"\brequire\s+valid-user|\bRequire\s+user|\brequire\s+user|"
    r"\bdeny\s+all|\bDeny\s+from\s+all|"
    r"\ballow\s+(127\.|::1|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|"
    r"3[01])\.)|\bAllow\s+from\s+(127\.|localhost|10\.|192\.168\.|"
    r"172\.(1[6-9]|2[0-9]|3[01])\.)|"
    r"\binternal\s*;|\bsatisfy\s+all|\bSatisfy\s+All|"
    r"auth_request|SSLRequireSSL)")

PRIV_NET = r"(localhost|127\.|::1|0\.0\.0\.0|10\.|192\.168\.|" \
           r"172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.|unix:)"

WEAK_TLS_TOKENS = re.compile(
    r"(?i)\b(SSLv2|SSLv3|TLSv1\.0|TLSv1\.1|TLSv1(?!\.\d))\b")
WEAK_CIPHER = re.compile(
    r"(?i)(EXPORT|NULL|aNULL|eNULL|RC4|RC2|\bDES\b|DES40|MD5|PSK|"
    r"SRP|EXP-|LOW\b)")

# upload-ish path hints — ExecCGI/AddHandler/Indexes here is worse
WRITABLE_PATH = re.compile(
    r"(?i)(upload|uploads|tmp|temp|files|filestore|media|avatars?|"
    r"attachments|userfiles|userdir|public_html|incoming|drop|"
    r"cache|runtime|writable|data/|/data\b)")

# static-asset scope — a wildcard CORS inside a FilesMatch/location
# limited to images/fonts/static files is deliberate
STATIC_EXT_RE = re.compile(
    r"(?i)(avifs?|bmp|cur|gif|ico|jpe?g|jxl|a?png|svgz?|webp|"
    r"eot|otf|tt[cf]|woff2?|css|map|txt|xml|pdf|mp[34]|webm|json)")
EXEC_EXT_RE = re.compile(
    r"(?i)(php\d?|phtml|pl|cgi|py|rb|aspx?|jsp|exe|sh|htaccess)")


def static_scoped(frames_):
    for f in frames_:
        if f.kind in ("ap:filesmatch", "ap:files", "ap:locationmatch",
                      "ap:location", "ngx:location"):
            a = f.arg
            if STATIC_EXT_RE.search(a) and not EXEC_EXT_RE.search(a):
                return True
    return False


# -------------------------------------------------------------- scanning

def scan_file(path, raw_lines, relpath=None):
    code = strip_comments(raw_lines)
    base = os.path.basename(path)
    known = base in CONF_BASENAMES
    ngx_n, ap_n = sniff_flavor(code)
    if not known and ngx_n + ap_n == 0:
        return [], 0
    do_ngx = ngx_n > 0 or known
    do_ap = ap_n > 0 or known or base.endswith(".htaccess")
    rel = relpath if relpath is not None else path

    frames = parse_frames(code)
    test_path = bool(TEST_PATH_RE.search(path.replace("\\", "/")))
    findings = []

    def enclosing_text(i):
        """Text of blocks enclosing line i, EXCLUDING sibling
        subtrees — a guard inside one location must not leak into
        the evaluation of a different location via shared parents."""
        enc = enclosing(frames, i)
        excl = [(f.start, f.end or f.start) for f in frames
                if (f.end or f.start) < i or f.start > i]
        out = []
        for f in enc:
            for j in range(f.start, (f.end or f.start) + 1):
                if any(a <= j <= b for a, b in excl):
                    continue
                out.append(code[j])
        return "\n".join(out)

    def guarded(i):
        return bool(GUARD_RE.search(enclosing_text(i)))

    def emit(i, sev, code_, msg):
        order = list(SEV_ORDER)
        idx = order.index(sev) + (1 if test_path else 0)
        if idx >= len(order):
            return
        findings.append({"file": path, "line": i + 1,
                         "severity": order[idx], "code": code_,
                         "msg": msg})

    def nearest_location(i):
        loc = None
        for f in enclosing(frames, i):
            if f.kind == "ngx:location":
                loc = f
        return loc

    def loc_parts(arg):
        """split `location` arg into (modifier, path)."""
        arg = arg.strip()
        m = re.match(r"^(~\*|~|\^~|=)\s*(\S+)", arg)
        if m:
            return m.group(1), m.group(2)
        m = re.match(r"^(\S+)", arg)
        return ("", m.group(1) if m else "")

    def directory_rootish(frames_):
        for f in frames_:
            if f.kind in ("ap:directory", "ap:location",
                          "ap:directorymatch", "ap:files"):
                p = f.arg.strip().strip('"\'')
                if p in ("/", "/*") or \
                        re.fullmatch(r"[A-Za-z]:[\\/]?\*?", p):
                    return True
        return False

    def writable_ctx(i, fpath):
        enc = enclosing_text(i)
        for f in enclosing(frames, i):
            if f.kind.startswith("ap:") and \
                    WRITABLE_PATH.search(f.arg):
                return True
            if f.kind == "ngx:location" and \
                    WRITABLE_PATH.search(loc_parts(f.arg)[1]):
                return True
        return bool(WRITABLE_PATH.search(
            os.path.dirname(rel.replace("\\", "/"))))

    for i, line in enumerate(code):
        s = line.strip()
        if not s or s.startswith("#"):
            continue

        # ============================ nginx ============================
        if do_ngx:
            # alias / location slash-parity traversal
            m = re.match(r"^alias\s+(\S+?);?\s*$", s)
            if m:
                target = m.group(1).strip('"\'')
                loc = nearest_location(i)
                if loc is not None:
                    mod, lp = loc_parts(loc.arg)
                    if ".." in target:
                        emit(i, "HIGH", "NG-ALIAS-TRAV",
                             "alias path contains '..' — escapes the "
                             "alias root entirely")
                    elif mod in ("~", "~*"):
                        emit(i, "HIGH", "NG-ALIAS-TRAV",
                             "alias inside a regex location — the "
                             "matched suffix is attacker-shaped; "
                             "classic path-traversal shape")
                    elif mod == "=":
                        emit(i, "MEDIUM", "NG-ALIAS-TRAV",
                             "alias inside an exact-match location — "
                             "unusual; verify the mapping")
                    elif lp and (lp.endswith("/") !=
                                 target.endswith("/")):
                        emit(i, "HIGH", "NG-ALIAS-TRAV",
                             "alias/location trailing-slash parity "
                             "break — '/loc../x' requests resolve "
                             "outside the alias root (CVE-class "
                             "misconfig)")

            if re.match(r"^autoindex\s+on\b", s):
                emit(i, "LOW" if guarded(i) else "MEDIUM",
                     "NG-AUTOINDEX",
                     "autoindex on — directory listing exposed"
                     + (" (auth/internal guard present)" if guarded(i)
                        else ""))

            if re.match(r"^satisfy\s+any\b", s):
                ctx = enclosing_text(i)
                if re.search(r"(?i)(auth_basic|allow|deny|AuthType|"
                             r"Require|require)", ctx):
                    emit(i, "HIGH", "NG-SATISFY-ANY",
                         "satisfy any inside a block that also "
                         "carries auth/allow/deny vocabulary — one "
                         "satisfied condition bypasses the rest")
                else:
                    emit(i, "MEDIUM", "NG-SATISFY-ANY",
                         "satisfy any — verify no sibling block "
                         "relies on ANDed conditions")

            if re.match(r"^ssi\s+on\b", s):
                emit(i, "MEDIUM", "NG-SSI",
                     "ssi on — server-side includes enabled; SSI "
                     "directives in user-reachable content execute "
                     "#exec/#include")

            m = re.match(r"^dav_(ext_)?methods\s+([^;]+)", s)
            if m:
                toks = m.group(2).upper()
                writes = [t for t in
                          ("PUT", "DELETE", "MKCOL", "COPY", "MOVE",
                           "PROPFIND") if re.search(r"\b" + t, toks)]
                if writes:
                    emit(i, "MEDIUM" if guarded(i) else "HIGH",
                         "NG-DAV",
                         "dav_methods %s — HTTP write methods "
                         "enabled%s" % (
                             "/".join(writes),
                             "" if not guarded(i) else
                             " (auth/internal guard present)"))

            if re.match(r"^stub_status\b", s):
                emit(i, "LOW" if guarded(i) else "MEDIUM",
                     "NG-STATUS",
                     "stub_status exposed — connection/request "
                     "metrics leak"
                     + (" (guarded)" if guarded(i) else ""))

            if re.match(r"^proxy_pass\s+[^;]*\$", s):
                emit(i, "MEDIUM", "NG-PROXY-VAR",
                     "proxy_pass with a variable upstream — the "
                     "target is attacker/request-shaped; SSRF-shaped "
                     "unless validated upstream")

            if re.match(r"^(add_header|more_set_headers)\s+[^;]*"
                        r"\$(http_|arg_|cookie_|query_string)", s):
                emit(i, "MEDIUM", "NG-HEADER-REFLECT",
                     "response header set from a client-controlled "
                     "$http_/$arg_/$cookie_ variable — header "
                     "reflection / splitting surface")

            if re.match(r"^(return\s+\d{3}|rewrite)\s+[^;]*"
                        r"\$(arg_|http_|cookie_)", s):
                emit(i, "MEDIUM", "NG-REDIRECT-VAR",
                     "return/rewrite target built from a "
                     "client-controlled variable — open-redirect "
                     "shape")

            if re.match(r"^proxy_set_header\s+"
                        r"(X-Real-IP|X-Forwarded-For|X-Forwarded-Host|"
                        r"Forwarded|X-Forwarded-Proto)\s+"
                        r"\$http_", s):
                emit(i, "MEDIUM", "NG-HDR-SPOOF",
                     "identity header (X-Real-IP/X-Forwarded-*) "
                     "set from the client's OWN header — IP/host "
                     "spoofing reaches the upstream")

            if re.match(r"^underscores_in_headers\s+on\b", s) or \
                    re.match(r"^ignore_invalid_headers\s+off\b", s):
                emit(i, "MEDIUM", "NG-HDR-SMUGGLE",
                     "underscores_in_headers on / "
                     "ignore_invalid_headers off — header-name "
                     "ambiguity is a request-smuggling surface")

            m = re.match(r"^ssl_protocols\s+([^;]+)", s)
            if m and WEAK_TLS_TOKENS.search(m.group(1)):
                emit(i, "MEDIUM", "NG-SSL-WEAK",
                     "ssl_protocols enables TLSv1/TLSv1.1/SSLv3 — "
                     "deprecated transport")

            m = re.match(r"^ssl_ciphers\s+([^;]+)", s)
            if m and WEAK_CIPHER.search(m.group(1)):
                emit(i, "MEDIUM", "NG-SSL-WEAK",
                     "ssl_ciphers lists weak tokens "
                     "(EXPORT/NULL/RC4/DES/MD5)")

            m = re.match(r"^add_header\s+Access-Control-Allow-Origin"
                         r"\s+[\"']?\*", s)
            if m:
                scoped = static_scoped(enclosing(frames, i))
                emit(i, "LOW" if scoped else "MEDIUM",
                     "NG-CORS-WILD",
                     "Access-Control-Allow-Origin: * set in config%s"
                     % (" — scoped to static assets" if scoped else
                        " — verify this block never serves "
                        "credentialed/session content"))

            m = re.match(r"^client_max_body_size\s+(\d+)\s*"
                         r"([kKmMgG]?)", s)
            if m:
                mult = {"": 1, "k": 1 << 10, "m": 1 << 20,
                        "g": 1 << 30}[m.group(2).lower()]
                if int(m.group(1)) * mult >= 50 * (1 << 20):
                    emit(i, "LOW", "NG-BODY-SIZE",
                         "client_max_body_size >= 50m — large upload "
                         "DoS surface")

            if re.match(r"^merge_slashes\s+off\b", s):
                emit(i, "LOW", "NG-MERGE-SLASHES",
                     "merge_slashes off — // sequences not "
                     "normalized; path-matching gaps vs upstream")

            if re.match(r"^if\s*\(", s) and nearest_location(i):
                emit(i, "INFO", "NG-IF-LOC",
                     "if (…) inside a location — 'if is evil' in "
                     "nginx; rewrite/return inside if is the only "
                     "safe pattern")

        # ============================ apache ===========================
        if do_ap:
            # Options token classes
            m = re.match(r"^Options\s+(.+?);?\s*$", s, re.I)
            if m:
                toks = m.group(1).split()
                plus = {t.lstrip("+-") for t in toks
                        if not t.startswith("-")}
                minus = {t.lstrip("+-") for t in toks
                         if t.startswith("-")}
                eff = plus - minus
                if "All" in eff:
                    emit(i, "MEDIUM", "AP-OPTIONS-ALL",
                         "Options All grants Indexes + ExecCGI + "
                         "Includes + MultiViews together")
                else:
                    if "Indexes" in eff:
                        emit(i, "LOW" if guarded(i) else "MEDIUM",
                             "AP-INDEXES",
                             "Options Indexes — directory listing "
                             "enabled%s" % (
                                 " (guarded)" if guarded(i) else ""))
                    if "ExecCGI" in eff or "CGI" in eff:
                        emit(i,
                             "HIGH" if writable_ctx(i, path)
                             else "MEDIUM",
                             "AP-CGI",
                             "Options ExecCGI — script execution "
                             "in this directory%s" % (
                                 " under an upload/writable-looking "
                                 "path" if writable_ctx(i, path)
                                 else ""))
                    if "Includes" in eff and \
                            "IncludesNOEXEC" not in eff:
                        emit(i, "MEDIUM", "AP-SSI",
                             "Options Includes — SSI enabled with "
                             "#exec allowed")
                    if "MultiViews" in eff:
                        emit(i, "LOW", "AP-MULTIVIEWS",
                             "Options MultiViews — content "
                             "negotiation turns /x into /x.php; "
                             "file-name confusion surface")

            m = re.match(r"^AllowOverride\s+(\S+)", s, re.I)
            if m and m.group(1).lower() in ("all",):
                enc = enclosing(frames, i)
                emit(i, "HIGH" if directory_rootish(enc)
                     else "MEDIUM", "AP-OVERRIDE",
                     "AllowOverride All — .htaccess can re-enable "
                     "handlers/options the server config did not "
                     "intend")

            if re.match(r"^Require\s+all\s+granted\b", s, re.I) or \
                    re.match(r"^Allow\s+from\s+all\b", s, re.I):
                enc = enclosing(frames, i)
                emit(i, "HIGH" if directory_rootish(enc)
                     else "LOW", "AP-REQUIRE-ALL",
                     "world-readable grant ('Require all granted'/"
                     "'Allow from all')%s" % (
                         " on a filesystem-root block — whole disk "
                         "servable" if directory_rootish(enc)
                         else " — standard on a public docroot; "
                              "review if the block holds private "
                              "content"))

            if re.match(r"^ProxyRequests\s+On\b", s, re.I):
                emit(i, "HIGH", "AP-OPEN-PROXY",
                     "ProxyRequests On — forward proxy open to "
                     "abuse (scan/relay for anyone)")

            if re.match(r"^SetHandler\s+(server-status|server-info|"
                        r"balancer-manager|ldap-status|"
                        r"proxy-status)", s, re.I):
                emit(i, "LOW" if guarded(i) else "MEDIUM",
                     "AP-STATUS",
                     "status/handler endpoint exposed — leaks "
                     "config, vhosts, child processes%s" % (
                         " (guarded)" if guarded(i) else ""))

            if re.match(r"^TraceEnable\s+On\b", s, re.I):
                emit(i, "MEDIUM", "AP-TRACE",
                     "TraceEnable On — TRACE echoes request headers "
                     "(XST class)")

            m = re.match(r"^AddHandler\s+cgi-script|"
                         r"^SetHandler\s+cgi-script", s, re.I)
            if m:
                emit(i, "HIGH" if writable_ctx(i, path)
                     else "MEDIUM", "AP-CGI",
                     "cgi-script handler bound — any file here "
                     "executes%s" % (
                         " under an upload/writable-looking path"
                         if writable_ctx(i, path) else ""))

            if re.match(r"^Dav\s+On\b", s, re.I):
                emit(i, "MEDIUM" if guarded(i) else "HIGH",
                     "AP-DAV",
                     "WebDAV enabled — write methods over HTTP%s" %
                     (" (auth guard present)" if guarded(i) else ""))

            m = re.match(r"^SSLProtocol\s+(.+)$", s, re.I)
            if m:
                spec = m.group(1)
                weak = WEAK_TLS_TOKENS.search(spec)
                all_on = re.search(r"(?<!-)\ball\b|\+all\b", spec)
                neg_tls = re.search(r"-TLSv1(?![.\d])", spec) and \
                    re.search(r"-TLSv1\.1", spec)
                neg_ssl3 = re.search(r"-SSLv3\b", spec)
                if weak or (all_on and not (neg_tls and neg_ssl3)):
                    emit(i, "MEDIUM", "AP-SSL-WEAK",
                         "SSLProtocol permits SSLv3/TLSv1/TLSv1.1 "
                         "(or 'all' without explicit -TLSv1/-SSLv3)")

            m = re.match(r"^SSLCipherSuite\s+(.+)$", s, re.I)
            if m and WEAK_CIPHER.search(m.group(1)):
                emit(i, "MEDIUM", "AP-SSL-WEAK",
                     "SSLCipherSuite lists weak tokens "
                     "(EXPORT/NULL/RC4/DES/MD5)")

            if re.match(r"^ServerTokens\s+Full\b", s, re.I):
                emit(i, "LOW", "AP-TOKENS",
                     "ServerTokens Full — exact version/module "
                     "banner to clients")
            if re.match(r"^ServerSignature\s+On\b", s, re.I):
                emit(i, "LOW", "AP-TOKENS",
                     "ServerSignature On — version footer on "
                     "generated pages")

            if re.match(r"^UserDir\s+(enabled\b(?!.*disabled)|"
                        r"public_html|web\b)", s, re.I):
                emit(i, "LOW", "AP-USERDIR",
                     "UserDir enabled — per-user ~dirs servable; "
                     "legacy enumeration/PHP-risk surface")

            m = re.match(r"^LogLevel\s+(\w+)", s, re.I)
            if m and m.group(1).lower() in ("debug", "trace1",
                                            "trace2", "trace3",
                                            "trace4", "trace5",
                                            "trace6", "trace7",
                                            "trace8"):
                emit(i, "LOW", "AP-LOGLEVEL",
                     "LogLevel debug/trace — request data (auth "
                     "headers, cookies) lands in logs")

            if re.match(r"^Header\s+(always\s+)?set\s+"
                        r"Access-Control-Allow-Origin\s+[\"']?\*",
                        s, re.I):
                scoped = static_scoped(enclosing(frames, i))
                emit(i, "LOW" if scoped else "MEDIUM",
                     "AP-CORS-WILD",
                     "Access-Control-Allow-Origin: * set in config%s"
                     % (" — scoped to static assets" if scoped else
                        " — verify the vhost never serves "
                        "credentialed content"))

            m = re.match(r"^ProxyPass(Reverse)?\s+\S+\s+"
                         r"(https?://%s|unix:)" % PRIV_NET, s, re.I)
            if m:
                emit(i, "LOW", "AP-PROXY-INTERNAL",
                     "ProxyPass to an internal/loopback target — "
                     "normal fronting pattern; note it reaches an "
                     "internal service from outside")

            m = re.match(r"^RewriteRule\s+\S+\s+https?://%s\S*\s*"
                         r"\[[^\]]*P" % PRIV_NET, s, re.I)
            if m:
                emit(i, "LOW", "AP-PROXY-INTERNAL",
                     "RewriteRule [P] proxies to an internal target")

            if base == ".htaccess" or base.endswith(".htaccess"):
                if re.match(r"^php_(admin_)?flag\s+engine\s+on\b",
                            s, re.I) or \
                        re.match(r"^php_(admin_)?flag\s+"
                                 r"display_errors\s+on\b", s, re.I):
                    emit(i, "MEDIUM", "AP-HTACCESS-PHP",
                         "php_flag engine/display_errors on in "
                         ".htaccess — upload-dir PHP-exec / debug-"
                         "leak shape")
                if re.match(r"^(AddHandler|AddType|SetHandler)\s+"
                            r"\S*(php|cgi)", s, re.I):
                    emit(i, "MEDIUM", "AP-HTACCESS-PHP",
                         "PHP/CGI handler bound in .htaccess — "
                         "file-upload-to-exec shape")

    return findings, 1


def scan(target):
    findings, scanned = [], 0
    if os.path.isfile(target):
        paths = [target]
    else:
        paths = []
        for root, dirs, files in os.walk(target):
            dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
            for fn in sorted(files):
                p = os.path.join(root, fn)
                if is_candidate(p):
                    paths.append(p)
    for p in sorted(paths):
        try:
            with open(p, "r", encoding="utf-8",
                      errors="replace") as fh:
                raw = fh.readlines()
        except (OSError, UnicodeError):
            continue
        rel = os.path.relpath(p, target) \
            if not os.path.isfile(target) else os.path.basename(p)
        f, hit = scan_file(p, raw, rel)
        scanned += hit
        findings.extend(f)
    findings.sort(key=lambda f: (SEV_ORDER.get(f["severity"], 9),
                                 f["file"], f["line"]))
    return findings, scanned


def report_md(findings, scanned, target):
    counts = {s: 0 for s in SEV_ORDER}
    for f in findings:
        counts[f["severity"]] += 1
    out = ["# Web-server config misconfiguration pre-scan",
           "",
           f"- target: `{target}`",
           f"- config files scanned: {scanned}",
           f"- findings: HIGH {counts['HIGH']} · MEDIUM "
           f"{counts['MEDIUM']} · LOW {counts['LOW']} · INFO "
           f"{counts['INFO']}",
           "",
           "_Deterministic pre-scan — not an audit. Directive- and "
           "block-level; include-file indirection is followed only "
           "inside the scanned tree. A clean report is not proof "
           "the vhost is safe._",
           ""]
    if not findings:
        out.append("No findings.")
        return "\n".join(out)
    cur = None
    for f in findings:
        if f["severity"] != cur:
            cur = f["severity"]
            out += ["", f"## {cur}", ""]
        out.append(f"- `{f['file']}:{f['line']}` **{f['code']}** — "
                   f"{f['msg']}")
    return "\n".join(out)


SELFTEST = [
    # ---- NG-ALIAS-TRAV -------------------------------------------
    ("alias-slash-mismatch", "nginx.conf",
     ["server {",
      "  location /i {",
      "    alias /data/welse/i/;",
      "  }",
      "}"],
     {"NG-ALIAS-TRAV:HIGH"}),
    ("alias-parity-ok", "nginx.conf",
     ["server {",
      "  location /i/ {",
      "    alias /data/welse/i/;",
      "  }",
      "}"],
     set()),
    ("alias-regex-loc", "nginx.conf",
     ["server {",
      "  location ~ /img/(.*) {",
      "    alias /data/img/;",
      "  }",
      "}"],
     {"NG-ALIAS-TRAV:HIGH"}),
    ("alias-dotdot", "nginx.conf",
     ["server {",
      "  location /x/ {",
      "    alias /data/../etc/;",
      "  }",
      "}"],
     {"NG-ALIAS-TRAV:HIGH"}),
    ("alias-exact-loc", "nginx.conf",
     ["server {",
      "  location = /x {",
      "    alias /data/x;",
      "  }",
      "}"],
     {"NG-ALIAS-TRAV:MEDIUM"}),
    # ---- NG-SATISFY-ANY ------------------------------------------
    ("satisfy-any-auth", "nginx.conf",
     ["server {",
      "  location /adm {",
      "    satisfy any;",
      "    allow 10.0.0.0/8;",
      "    auth_basic 'adm';",
      "  }",
      "}"],
     {"NG-SATISFY-ANY:HIGH"}),
    ("satisfy-any-alone", "nginx.conf",
     ["server {",
      "  location /x {",
      "    satisfy any;",
      "  }",
      "}"],
     {"NG-SATISFY-ANY:MEDIUM"}),
    # ---- NG-AUTOINDEX / STATUS / DAV ------------------------------
    ("autoindex-on", "nginx.conf",
     ["server {",
      "  location /files/ {",
      "    autoindex on;",
      "  }",
      "}"],
     {"NG-AUTOINDEX:MEDIUM"}),
    ("autoindex-guarded", "nginx.conf",
     ["server {",
      "  location /files/ {",
      "    auth_basic 'x';",
      "    autoindex on;",
      "  }",
      "}"],
     {"NG-AUTOINDEX:LOW"}),
    ("stub-status-open", "nginx.conf",
     ["server {",
      "  location /status {",
      "    stub_status;",
      "  }",
      "}"],
     {"NG-STATUS:MEDIUM"}),
    ("stub-status-internal", "nginx.conf",
     ["server {",
      "  location /status {",
      "    stub_status;",
      "    internal;",
      "  }",
      "}"],
     {"NG-STATUS:LOW"}),
    ("dav-put-open", "nginx.conf",
     ["server {",
      "  location /dav/ {",
      "    dav_methods PUT DELETE MKCOL;",
      "  }",
      "}"],
     {"NG-DAV:HIGH"}),
    ("dav-getonly-ok", "nginx.conf",
     ["server {",
      "  location /dav/ {",
      "    dav_methods GET;",
      "  }",
      "}"],
     set()),
    # ---- NG headers / proxy ---------------------------------------
    ("proxy-var", "nginx.conf",
     ["server {",
      "  location / {",
      "    proxy_pass http://$host$request_uri;",
      "  }",
      "}"],
     {"NG-PROXY-VAR:MEDIUM"}),
    ("hdr-reflect", "nginx.conf",
     ["server {",
      "  add_header X-Echo $http_user_agent;",
      "}"],
     {"NG-HEADER-REFLECT:MEDIUM"}),
    ("redirect-var", "nginx.conf",
     ["server {",
      "  location /go {",
      "    return 302 $arg_next;",
      "  }",
      "}"],
     {"NG-REDIRECT-VAR:MEDIUM"}),
    ("hdr-spoof", "nginx.conf",
     ["server {",
      "  location / {",
      "    proxy_set_header X-Real-IP $http_x_real_ip;",
      "  }",
      "}"],
     {"NG-HDR-SPOOF:MEDIUM"}),
    ("underscores-on", "nginx.conf",
     ["server {",
      "  underscores_in_headers on;",
      "}"],
     {"NG-HDR-SMUGGLE:MEDIUM"}),
    ("ssl-weak", "nginx.conf",
     ["server {",
      "  ssl_protocols TLSv1 TLSv1.1 TLSv1.2;",
      "}"],
     {"NG-SSL-WEAK:MEDIUM"}),
    ("ssl-ok", "nginx.conf",
     ["server {",
      "  ssl_protocols TLSv1.2 TLSv1.3;",
      "}"],
     set()),
    ("cipher-weak", "nginx.conf",
     ["server {",
      "  ssl_ciphers 'RC4:MD5:DES';",
      "}"],
     {"NG-SSL-WEAK:MEDIUM"}),
    ("cors-wild", "nginx.conf",
     ["server {",
      "  add_header Access-Control-Allow-Origin *;",
      "}"],
     {"NG-CORS-WILD:MEDIUM"}),
    ("body-200m", "nginx.conf",
     ["server {",
      "  client_max_body_size 200m;",
      "}"],
     {"NG-BODY-SIZE:LOW"}),
    ("body-8m-ok", "nginx.conf",
     ["server {",
      "  client_max_body_size 8m;",
      "}"],
     set()),
    ("merge-slashes-off", "nginx.conf",
     ["server {",
      "  merge_slashes off;",
      "}"],
     {"NG-MERGE-SLASHES:LOW"}),
    ("if-in-loc", "nginx.conf",
     ["server {",
      "  location /x {",
      "    if ($request_method = POST) { return 405; }",
      "  }",
      "}"],
     {"NG-IF-LOC:INFO"}),
    ("ssi-on", "nginx.conf",
     ["server {",
      "  ssi on;",
      "}"],
     {"NG-SSI:MEDIUM"}),
    # ---- APACHE ----------------------------------------------------
    ("options-indexes", "httpd.conf",
     ["<Directory /var/www/files>",
      "  Options Indexes",
      "</Directory>"],
     {"AP-INDEXES:MEDIUM"}),
    ("options-minus-ok", "httpd.conf",
     ["<Directory /var/www>",
      "  Options -Indexes +FollowSymLinks",
      "</Directory>"],
     set()),
    ("options-all", "httpd.conf",
     ["<Directory /var/www>",
      "  Options All",
      "</Directory>"],
     {"AP-OPTIONS-ALL:MEDIUM"}),
    ("options-execcgi-upload", "httpd.conf",
     ["<Directory /var/www/uploads>",
      "  Options +ExecCGI",
      "</Directory>"],
     {"AP-CGI:HIGH"}),
    ("options-execcgi-else", "httpd.conf",
     ["<Directory /var/www/cgi-bin>",
      "  Options +ExecCGI",
      "</Directory>"],
     {"AP-CGI:MEDIUM"}),
    ("options-includes", "httpd.conf",
     ["<Directory /var/www>",
      "  Options +Includes",
      "</Directory>"],
     {"AP-SSI:MEDIUM"}),
    ("options-includes-noexec-ok", "httpd.conf",
     ["<Directory /var/www>",
      "  Options +IncludesNOEXEC",
      "</Directory>"],
     set()),
    ("options-multiviews", "httpd.conf",
     ["<Directory /var/www>",
      "  Options MultiViews",
      "</Directory>"],
     {"AP-MULTIVIEWS:LOW"}),
    ("override-all", "httpd.conf",
     ["<Directory /var/www>",
      "  AllowOverride All",
      "</Directory>"],
     {"AP-OVERRIDE:MEDIUM"}),
    ("override-all-root", "httpd.conf",
     ["<Directory />",
      "  AllowOverride All",
      "</Directory>"],
     {"AP-OVERRIDE:HIGH"}),
    ("require-all-granted", "httpd.conf",
     ["<Directory /var/www/pub>",
      "  Require all granted",
      "</Directory>"],
     {"AP-REQUIRE-ALL:LOW"}),
    ("require-root", "httpd.conf",
     ["<Directory />",
      "  Require all granted",
      "</Directory>"],
     {"AP-REQUIRE-ALL:HIGH"}),
    ("allow-from-all-legacy", "httpd.conf",
     ["<Directory />",
      "  Order allow,deny",
      "  Allow from all",
      "</Directory>"],
     {"AP-REQUIRE-ALL:HIGH"}),
    ("proxyrequests-on", "httpd.conf",
     ["ProxyRequests On"],
     {"AP-OPEN-PROXY:HIGH"}),
    ("status-handler-open", "httpd.conf",
     ["<Location /server-status>",
      "  SetHandler server-status",
      "</Location>"],
     {"AP-STATUS:MEDIUM"}),
    ("status-handler-guarded", "httpd.conf",
     ["<Location /server-status>",
      "  SetHandler server-status",
      "  Require ip 127.0.0.1",
      "  Deny from all",
      "</Location>"],
     {"AP-STATUS:LOW"}),
    ("traceenable-on", "httpd.conf",
     ["TraceEnable On"],
     {"AP-TRACE:MEDIUM"}),
    ("dav-on-open", "httpd.conf",
     ["<Directory /var/www/dav>",
      "  Dav On",
      "</Directory>"],
     {"AP-DAV:HIGH"}),
    ("dav-on-guarded", "httpd.conf",
     ["<Directory /var/www/dav>",
      "  Dav On",
      "  Require valid-user",
      "</Directory>"],
     {"AP-DAV:MEDIUM"}),
    ("sslproto-weak", "httpd.conf",
     ["SSLProtocol all -SSLv3 -TLSv1.3"],
     {"AP-SSL-WEAK:MEDIUM"}),
    ("sslproto-ok", "httpd.conf",
     ["SSLProtocol -all +TLSv1.2 +TLSv1.3"],
     set()),
    ("cipher-weak-ap", "httpd.conf",
     ["SSLCipherSuite RC4:DES:EXPORT"],
     {"AP-SSL-WEAK:MEDIUM"}),
    ("servertokens-full", "httpd.conf",
     ["ServerTokens Full"],
     {"AP-TOKENS:LOW"}),
    ("serversig-on", "httpd.conf",
     ["ServerSignature On"],
     {"AP-TOKENS:LOW"}),
    ("userdir-on", "httpd.conf",
     ["UserDir public_html"],
     {"AP-USERDIR:LOW"}),
    ("userdir-disabled-ok", "httpd.conf",
     ["UserDir disabled"],
     set()),
    ("loglevel-debug", "httpd.conf",
     ["LogLevel debug"],
     {"AP-LOGLEVEL:LOW"}),
    ("addhandler-cgi-upload", "httpd.conf",
     ["<Directory /var/www/uploads>",
      "  AddHandler cgi-script .pl",
      "</Directory>"],
     {"AP-CGI:HIGH"}),
    ("header-cors-wild", "httpd.conf",
     ["Header set Access-Control-Allow-Origin *"],
     {"AP-CORS-WILD:MEDIUM"}),
    ("header-cors-scoped", ".htaccess",
     ["<FilesMatch \"\\.(woff2?|png)$\">",
      "  Header set Access-Control-Allow-Origin \"*\"",
      "</FilesMatch>"],
     {"AP-CORS-WILD:LOW"}),
    ("proxypass-internal", "httpd.conf",
     ["ProxyPass /app/ http://127.0.0.1:8080/"],
     {"AP-PROXY-INTERNAL:LOW"}),
    # ---- .htaccess -------------------------------------------------
    ("htaccess-phpflag", ".htaccess",
     ["php_flag engine on"],
     {"AP-HTACCESS-PHP:MEDIUM"}),
    ("htaccess-addhandler", ".htaccess",
     ["AddHandler application/x-httpd-php .jpg"],
     {"AP-HTACCESS-PHP:MEDIUM"}),
    ("htaccess-options-indexes", ".htaccess",
     ["Options +Indexes"],
     {"AP-INDEXES:MEDIUM"}),
    # ---- misc ------------------------------------------------------
    ("test-downgrade", "test_nginx.conf",
     ["server {",
      "  location /i {",
      "    alias /data/i/;",
      "  }",
      "}"],
     {"NG-ALIAS-TRAV:MEDIUM"}),
    ("comment-ok", "nginx.conf",
     ["# alias /data/i/;",
      "server {",
      "  listen 80;",
      "  server_name x;",
      "}"],
     set()),
    ("multiline-alias", "nginx.conf",
     ["server {",
      "  location /static/ {",
      "    alias",
      "      /data/static/;",
      "  }",
      "}"],
     set()),
]


def selftest():
    import tempfile
    fails = []
    for name, fname, lines, expect in SELFTEST:
        src = "\n".join(lines) + "\n"
        d = tempfile.mkdtemp()
        path = os.path.join(d, fname)
        try:
            with open(path, "w") as fh:
                fh.write(src)
            findings, _ = scan(path)
            got = {f"{f['code']}:{f['severity']}" for f in findings}
            if got != expect:
                fails.append((name, sorted(expect), sorted(got)))
        finally:
            os.unlink(path)
            os.rmdir(d)
    if fails:
        print(f"SELFTEST FAIL ({len(fails)})")
        for n, e, g in fails:
            print(f"  {n}: expected {e} got {g}")
        return 1
    print(f"SELFTEST PASS ({len(SELFTEST)} vectors)")
    return 0


def main(argv):
    if "--selftest" in argv:
        return selftest()
    args = [a for a in argv[1:] if not a.startswith("--")]
    if not args:
        print(__doc__)
        return 2
    target = args[0]
    if not os.path.exists(target):
        print(f"error: {target} not found", file=sys.stderr)
        return 2
    findings, scanned = scan(target)
    if "--json" in argv:
        print(json.dumps({"target": target,
                          "files_scanned": scanned,
                          "findings": findings}, indent=2))
    else:
        print(report_md(findings, scanned, target))
    return 1 if any(SEV_ORDER[f["severity"]] < 2
                    for f in findings) else 0


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