#!/usr/bin/env python3
"""cors_prescan.py — deterministic CORS misconfiguration pre-scan.

ARION service artifact. Scans source and config files for the
cross-origin misconfiguration classes that are checkable offline,
line-wise — no server, no browser, no network.

Findings (severity):
  CORS-REFLECT-CRED  HIGH   request Origin is reflected into
                            Access-Control-Allow-Origin while
                            credentials are enabled — any website can
                            read authenticated responses cross-origin
                            (the classic exploitable bug)
  CORS-WILDCARD-CRED HIGH   ACAO "*" combined with
                            Access-Control-Allow-Credentials: true
                            (browsers refuse it — but it shows the
                            intent is wrong and often appears next to
                            reflected-origin fallbacks)
  CORS-NULL-ORIGIN   MEDIUM "null" accepted as an origin — sandboxed
                            iframes, redirects and file:// pages send
                            Origin: null
  CORS-SUBSTRING     MEDIUM origin validated by substring / suffix /
                            prefix string match — bypassable via
                            evil-trusted.com or trusted.com.evil.tld
  CORS-REFLECT       MEDIUM request Origin reflected into ACAO
                            (no explicit credentials seen in file —
                            still worth review)
  CORS-ALLOW-ALL     MEDIUM framework-level allow-every-origin flag
                            (Django CORS_ALLOW_ALL_ORIGINS, Rails
                            origins '*', Spring allowedOrigins("*"),
                            Laravel allowed_origins '*', Phoenix/
                            corsica *, gorilla/handlers "*", etc.)
  CORS-WILDCARD      LOW    Access-Control-Allow-Origin: "*" — fine
                            for truly public data, dangerous on
                            authenticated or user-specific endpoints
  CORS-ANY-METHOD    LOW    Access-Control-Allow-Methods / -Headers "*"
  CORS-NO-VARY       INFO   origin reflected / whitelisted but no
                            "Vary: Origin" in the file — shared caches
                            may serve one origin's CORS grant to
                            another (cache-poisoning assist)

Targets: .js .ts .mjs .cjs .py .rb .go .java .kt .php .cs .rs .ex .exs
  nginx/apache conf (.conf nginx.conf *.nginx .htaccess), .env/.ini/
  .yaml/.json CORS_* settings.

Honest scope: PRE-SCAN, not an audit. It does not evaluate runtime
middleware order, per-route overrides, or deployed proxy layers — a
FLAG means "deserves human review"; a clean report is not proof the
CORS policy is safe.

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

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

TARGET_EXT = (
    ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".py", ".rb", ".go",
    ".java", ".kt", ".php", ".cs", ".rs", ".ex", ".exs", ".conf",
    ".ini", ".env", ".yaml", ".yml", ".json", ".htaccess",
)
TARGET_NAMES = ("nginx.conf", ".htaccess", "cors.php", "web.config")

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

# --- pattern families -------------------------------------------------------

# Raw header with a wildcard value
ACAO_WILDCARD_RE = re.compile(
    r"(?i)(access-control-allow-origin|cors\.allow[-_.]?origin)"
    r"['\"\] ,:=({\s]*(\*|['\"]\*)")

# Credentials enabled
CRED_TRUE_RE = re.compile(
    r"(?i)(access-control-allow-credentials|allow[-_.]?credentials|"
    r"credentials\s*[:=])\s*['\"\] ,:=({]*\s*(true|['\"]true['\"]|1\b|yes\b)")

# Reflecting the request Origin header into ACAO
REFLECT_RE = re.compile(
    r"(?i)access-control-allow-origin[^;\n]{0,200}"
    r"(\$http_origin|_SERVER\[|HTTP_ORIGIN|"
    r"req\.headers|request\.headers|headers\[['\"]?origin|"
    r"headers\.origin|getHeader\s*\(|headers\.get|ctx\.|"
    r"request\.header|context\.request)")

# Framework option that reflects/derives the request origin
# (express `origin: true`, `origin: (o,cb)=>...`, `origin: function`)
REFLECT_OPT_RE = re.compile(
    r"(?i)origin['\"]?\s*:\s*(true\b|\([^)]*\)\s*=>|function\s*\()")

# Framework "allow everything" flags
ALLOW_ALL_PATTERNS = [
    (re.compile(r"(?i)CORS_ALLOW_ALL_ORIGINS\s*=\s*True"), "django"),
    (re.compile(r"(?i)CORS_ORIGIN_ALLOW_ALL\s*=\s*True"), "django-legacy"),
    (re.compile(r"(?i)allowed_origins?['\"]?\s*[:=]=?>?\s*\[?\s*['\"]\*"),
     "allowed_origins=*"),
    (re.compile(r"(?i)allowedOriginPatterns?\s*\(\s*['\"]\*"),
     "spring-pattern-*"),
    (re.compile(r"(?i)allowedOrigins\s*\(\s*['\"]\*"), "spring-*"),
    (re.compile(r"(?i)origins?\s+['\"]\*['\"]"), "rack-cors/*"),
    (re.compile(r"(?i)AllowAnyOrigin\s*\(\s*\)"), "aspnet"),
    (re.compile(r"(?i)AllowedOrigins\s*[:=]\s*(\[\]string\s*\{|\[)"
                r"\s*['\"]\*"), "go-handlers"),
    (re.compile(r"(?i)CORS_ORIGINS?\s*[:=]\s*['\"]?\*"), "env-*"),
    (re.compile(r"(?i)\bcors\s*\(\s*\)"), "express-default-*"),
    (re.compile(r"(?i)CORS\s*\(\s*app\s*\)"), "flask-default-*"),
    (re.compile(r"(?i)Access-Control-Allow-Origin['\"]?\s*[,=]\s*"
                r"['\"]\*"), "raw-*"),
    (re.compile(r"(?i)add_header\s+['\"]?Access-Control-Allow-Origin"
                r"['\"]?\s+['\"]?\*"), "nginx-*"),
    (re.compile(r"(?i)Header\s+(set|always\s+set)\s+"
                r"Access-Control-Allow-Origin\s+\*"), "apache-*"),
    (re.compile(r"(?i)cors_allowed_origins\s*[:=]\s*['\"]\*"), "socketio-*"),
]

# "null" treated as an allowed origin — requires an origin/cors
# keyword on the line plus a quoted "null" in list/assignment context
# (avoids flagging `origin == "null"` checks and JSON Schema
# {"type":"null"} / ("none","null") tuples)
NULL_ORIGIN_RE = re.compile(
    r"(?i)(allowed_?origins?|origins?|cors)[^\n]{0,120}"
    r"[\[,({:=]\s*['\"]null['\"]")

# multi-line allowlist: a line opening `allowed_origins = [` without
# a closing `]` enters list state until `]` appears
ORIGIN_LIST_OPEN_RE = re.compile(
    r"(?i)(allowed_?origins?|origins?|cors)[^\n]*=[^\n]*\[[^\]]*$")
BARE_NULL_RE = re.compile(r"^\s*['\"]null['\"]\s*,?\s*$")

# Substring / suffix / prefix origin checks (bypassable) — only the
# dangerous direction: the trusted string searched INSIDE the origin
# (method called on origin, literal `in` origin, origin as haystack).
# `allowlist.includes(origin)` is the safe direction and is ignored.
SUBSTRING_RE = re.compile(
    r"(?i)origin\s*\.\s*(endswith|startswith|includes|contains|find|"
    r"indexof|match|test|like)\s*\(|"
    r"['\"][^'\"\n]{3,}['\"]\s+in\s+[^\n]*\borigin\b|"
    r"re\.(search|match|findall|fullmatch)\s*\([^)]*,[^)]*origin|"
    r"(instr|strpos|stripos|str_contains|preg_match)\s*\(\s*origin")

# Wildcard methods / headers
ANY_METHOD_RE = re.compile(
    r"(?i)access-control-allow-(methods|headers)['\"\] ,:=({]*\*")

VARY_ORIGIN_RE = re.compile(r"(?i)vary[^\n]*origin")

COMMENT_RE = {
    ".py": re.compile(r"^\s*#"),
    ".rb": re.compile(r"^\s*#"),
    ".conf": re.compile(r"^\s*#"),
    ".ini": re.compile(r"^\s*[#;]"),
    ".env": re.compile(r"^\s*#"),
    ".yaml": re.compile(r"^\s*#"),
    ".yml": re.compile(r"^\s*#"),
    ".htaccess": re.compile(r"^\s*#"),
}
INLINE_COMMENT_RE = re.compile(
    r"(//|/\*|#|<!--|--|;)[^\n]*(cors|origin)[^\n]*$", re.I)


def sev_rank(s):
    return SEV_ORDER.get(s, 9)


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

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


def is_comment(line, ext):
    cre = COMMENT_RE.get(ext)
    if cre and cre.match(line):
        return True
    if not ext or ext in (".js", ".ts", ".go", ".java", ".php", ".cs",
                          ".rs", ".kt", ".ex"):
        stripped = line.strip()
        if stripped.startswith(("//", "/*", "*", "<!--")):
            return True
    return False


def find_targets(root):
    if os.path.isfile(root):
        return [root]
    out = []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        for fn in filenames:
            low = fn.lower()
            if low.endswith(TARGET_EXT) or low in TARGET_NAMES:
                out.append(os.path.join(dirpath, fn))
    return sorted(out)


def scan_file(path, rel):
    findings = []
    try:
        lines = open(path, "r", encoding="utf-8", errors="replace") \
            .read().splitlines()
    except OSError:
        return findings

    ext = os.path.splitext(path)[1].lower()
    wildcard_lines = []    # ACAO * seen
    cred_lines = []        # credentials enabled
    reflect_lines = []     # request-origin reflected
    allow_all = []         # framework allow-all
    in_origin_list = False
    has_vary = False

    for i, line in enumerate(lines, 1):
        if is_comment(line, ext):
            continue
        if in_origin_list:
            if "]" in line:
                in_origin_list = False
            if BARE_NULL_RE.match(line):
                findings.append(Finding(
                    "CORS-NULL-ORIGIN", "MEDIUM", rel, i,
                    "'null' in allowed-origins list — sandboxed "
                    "iframes and redirects send Origin: null"))
            continue
        if ORIGIN_LIST_OPEN_RE.search(line):
            in_origin_list = True
        if ACAO_WILDCARD_RE.search(line):
            wildcard_lines.append(i)
        if CRED_TRUE_RE.search(line):
            cred_lines.append(i)
        if REFLECT_RE.search(line) or REFLECT_OPT_RE.search(line):
            reflect_lines.append(i)
        if NULL_ORIGIN_RE.search(line):
            findings.append(Finding(
                "CORS-NULL-ORIGIN", "MEDIUM", rel, i,
                "'null' accepted as an allowed origin — sandboxed "
                "iframes and redirects send Origin: null"))
        if SUBSTRING_RE.search(line) and "origin" in line.lower():
            findings.append(Finding(
                "CORS-SUBSTRING", "MEDIUM", rel, i,
                "origin checked by substring/suffix match — bypassable "
                "via attacker-controlled domain that contains the "
                "trusted string"))
        if ANY_METHOD_RE.search(line):
            findings.append(Finding(
                "CORS-ANY-METHOD", "LOW", rel, i,
                "wildcard allowed methods/headers"))
        for cre, tag in ALLOW_ALL_PATTERNS:
            if cre.search(line):
                allow_all.append((i, tag))
        if VARY_ORIGIN_RE.search(line):
            has_vary = True

    if reflect_lines and cred_lines:
        findings.append(Finding(
            "CORS-REFLECT-CRED", "HIGH", rel, reflect_lines[0],
            "request Origin reflected into Access-Control-Allow-Origin "
            "while credentials are enabled (cred line %d) — any site "
            "can read authenticated responses cross-origin"
            % cred_lines[0]))
    elif reflect_lines:
        findings.append(Finding(
            "CORS-REFLECT", "MEDIUM", rel, reflect_lines[0],
            "request Origin reflected into Access-Control-Allow-Origin "
            "— verify against an allowlist instead"))

    if wildcard_lines and cred_lines:
        findings.append(Finding(
            "CORS-WILDCARD-CRED", "HIGH", rel, wildcard_lines[0],
            "Access-Control-Allow-Origin: * combined with "
            "Allow-Credentials (cred line %d) — browsers refuse the "
            "combination; the intent is wrong and often sits next to "
            "a reflected-origin fallback" % cred_lines[0]))
    elif wildcard_lines:
        findings.append(Finding(
            "CORS-WILDCARD", "LOW", rel, wildcard_lines[0],
            "Access-Control-Allow-Origin: * — fine for public data, "
            "dangerous on authenticated endpoints"))

    for i, tag in allow_all:
        findings.append(Finding(
            "CORS-ALLOW-ALL", "MEDIUM", rel, i,
            "framework-level allow-every-origin (%s) — every website "
            "can read this service's responses" % tag))

    if (reflect_lines or allow_all) and not has_vary:
        findings.append(Finding(
            "CORS-NO-VARY", "INFO", rel, reflect_lines[0]
            if reflect_lines else allow_all[0][0],
            "origin-dependent ACAO but no 'Vary: Origin' — shared "
            "caches may leak one origin's grant to another"))

    return findings


def render(findings, targets):
    out = ["CORS PRE-SCAN REPORT",
           "=====================",
           "files scanned : %d" % len(targets),
           "findings      : %d" % len(findings), ""]
    by_sev = sorted(findings,
                    key=lambda f: (sev_rank(f.sev), f.file, f.line))
    for f in by_sev:
        out.append("[%-6s] %-22s %s:%d" % (f.sev, f.code, f.file, f.line))
        out.append("         %s" % f.msg)
    if not findings:
        out.append("no findings — NOTE: a clean pre-scan is not proof "
                   "the CORS policy is safe (runtime middleware order, "
                   "per-route overrides and proxy layers are not "
                   "evaluated)")
    out.append("")
    out.append("Scope: deterministic pre-scan only — not an audit.")
    return "\n".join(out)


# --- selftest ---------------------------------------------------------------

SELFTEST = [
    # reflect + credentials -> REFLECT-CRED
    ("x.js", 'app.use((req,res)=>{\n'
     ' res.set("Access-Control-Allow-Origin", req.headers.origin);\n'
     ' res.set("Access-Control-Allow-Credentials", "true");\n});\n',
     {"CORS-REFLECT-CRED", "CORS-NO-VARY"}),
    # wildcard + credentials -> WILDCARD-CRED
    ("x.py", 'response.headers["Access-Control-Allow-Origin"] = "*"\n'
     'response.headers["Access-Control-Allow-Credentials"] = "true"\n',
     {"CORS-WILDCARD-CRED"}),
    # plain wildcard -> WILDCARD only
    ("x.conf", 'add_header Access-Control-Allow-Origin "*";\n',
     {"CORS-WILDCARD", "CORS-ALLOW-ALL"}),
    # express cors() default -> ALLOW-ALL
    ("x.js", "app.use(cors());\n", {"CORS-ALLOW-ALL", "CORS-NO-VARY"}),
    # django allow all
    ("settings.py", "CORS_ALLOW_ALL_ORIGINS = True\n",
     {"CORS-ALLOW-ALL", "CORS-NO-VARY"}),
    # null origin (inline + multi-line list)
    ("x.js", 'const origins = ["https://a.com", "null"];\n',
     {"CORS-NULL-ORIGIN"}),
    ("s.py", 'CORS_ALLOWED_ORIGINS = [\n'
     '    "https://app.example.com",\n'
     '    "null",\n'
     ']\n', {"CORS-NULL-ORIGIN"}),
    # substring check
    ("x.js", 'if (origin.endsWith("trusted.com")) allow();\n',
     {"CORS-SUBSTRING"}),
    ("x.py", 'if "trusted.com" in request.headers.get("origin"):\n'
     '    allow()\n', {"CORS-SUBSTRING"}),
    # nginx reflect
    ("x.conf", 'add_header Access-Control-Allow-Origin $http_origin;\n',
     {"CORS-REFLECT", "CORS-NO-VARY"}),
    # wildcard methods
    ("x.js", 'res.set("Access-Control-Allow-Methods", "*");\n',
     {"CORS-ANY-METHOD"}),
    # clean: allowlist + vary, no findings expected
    ("x.js", 'const ok = ["https://a.com"];\n'
     'if (ok.includes(origin)) res.set("Access-Control-Allow-Origin",'
     ' origin);\nres.set("Vary", "Origin");\n', set()),
    # commented-out line ignored
    ("x.py", '# response.headers["Access-Control-Allow-Origin"]="*"\n',
     set()),
    # rack-cors wildcard
    ("x.rb", "allow do\n  origins '*'\nend\n",
     {"CORS-ALLOW-ALL", "CORS-NO-VARY"}),
    # spring wildcard
    ("x.java", 'registry.addMapping("/**").allowedOrigins("*");\n',
     {"CORS-ALLOW-ALL", "CORS-NO-VARY"}),
]


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)
            open(p, "w").write(src)
            got = {f.code for f in scan_file(p, name)}
            missing = expect - got
            extra = got - expect - {"CORS-NO-VARY", "CORS-WILDCARD",
                                    "CORS-ANY-METHOD"}
            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([f.as_dict() for f in findings], 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())
