#!/usr/bin/env python3
"""oauth_prescan.py — deterministic OAuth2/OIDC flow-misconfiguration
pre-scan.

ARION service artifact. Scans source files for the signatures of
broken OAuth flows — authorization URLs hand-built without a `state`
parameter (login CSRF), callbacks that consume `code` but never look
at `state`, `redirect_uri` values sourced from the request or
whitelist-compared with substring/startswith (open-redirect-into-
token-theft), the deprecated implicit response types, auth-code URLs
with no PKCE anywhere in the file, the password grant, client_secret
shipped as a literal, bearer tokens read from the query string, and
oauth endpoints/redirect URIs pinned to plaintext http.
Line-wise, fully offline, no inference.

Findings (base severity):
  OAUTH-STATE-MISSING    MEDIUM  an authorize-URL build (string or
                                 params object carrying client_id +
                                 redirect_uri/response_type) whose
                                 statement never sets `state` — the
                                 callback then has nothing to bind to
                                 the user's browser (login CSRF).
                                 Library calls that manage state
                                 internally (authorize_redirect,
                                 get_authorization_url, generateAuthUrl,
                                 WebServerFlow, buildAuthorizationUri)
                                 stay silent; `state` anywhere in the
                                 file silences this tier
  OAUTH-STATE-UNCHECKED  HIGH    a callback handler reads `code`
                                 from the request (req.query.code,
                                 request.args['code'], $_GET['code'],
                                 c.QueryParam, params[:code],
                                 RequestParam) and `state` never
                                 appears anywhere in the file — the
                                 code is exchanged against an
                                 attacker-started flow
  OAUTH-REDIRECT-REQ     HIGH    redirect_uri is built or compared
                                 from request input — req.query,
                                 request.args/GET, $_GET/$_REQUEST,
                                 params[:], QueryParam — the
                                 provider will send the code wherever
                                 the caller points
  OAUTH-REDIRECT-LOOSE   MEDIUM  a redirect_uri allowlist check that
                                 uses startswith / substring /
                                 regex-search instead of an exact
                                 match — allowed.com.evil.com passes
  OAUTH-IMPLICIT-FLOW    MEDIUM  response_type=token / id_token in
                                 an authorize URL — the implicit
                                 grant puts access tokens in the URL
                                 fragment; deprecated by auth-code
                                 + PKCE
  OAUTH-PKCE-MISSING     LOW     an auth-code authorize URL is built
                                 (response_type=code) and no
                                 code_challenge / code_verifier /
                                 pkce appears anywhere in the file —
                                 confidential server clients may not
                                 need it; public clients do
  OAUTH-GRANT-PASSWORD   MEDIUM  grant_type=password /
                                 ResourceOwnerPasswordCredentials /
                                 passwordGrant — the ROPC grant is
                                 deprecated and trains users to hand
                                 passwords to the client
  OAUTH-SECRET-LITERAL   HIGH    client_secret / consumer_secret /
                                 app_secret assigned a literal that
                                 is weak (<16 chars or default-word)
                                 — else MEDIUM; env/config/vault
                                 sources never flag
  OAUTH-TOKEN-QUERY      LOW     access_token / id_token /
                                 refresh_token read from the URL
                                 query on the server side — tokens
                                 land in access logs, history and
                                 referer headers
  OAUTH-INSECURE-HTTP    MEDIUM  http:// literal as redirect_uri or
                                 authorize/token endpoint —
                                 credentials and codes cross the
                                 wire in cleartext; localhost and
                                 127/::1 are silent

Context rules:
  * comment lines and block-comment bodies are stripped first
  * import/require lines never flag
  * test/spec/fixture paths downgrade one tier
  * `state` vocabulary anywhere in a file marks it state-aware —
    generation may live in a shared helper
  * library-managed authorize calls (authorize_redirect,
    authorization_url, generateAuthUrl, WebServerFlow,
    buildAuthorizationUri, passport authenticate) are never
    STATE-MISSING findings

Scope: deterministic pre-scan for human review — flags the line
shapes where the OAuth handshake is missing a binding, a check, or
a transport guarantee; it cannot see provider-side app
registration, reverse-proxy TLS, or middleware — clean output !=
a correct flow.

  oauth_prescan.py <file-or-dir> [--json]
  oauth_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",
)

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)

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

# an authorize-URL string literal / template carrying client_id
AUTHZ_URL = re.compile(
    r"['\"`][^'\"`]{0,400}?"
    r"(?:/oauth2?/v?\d*/?(?:authorize|auth)\b|"
    r"authorize\?|"
    r"login/oauth/authorize|"
    r"/o/oauth2/auth|"
    r"dialog/oauth)"
    r"[^'\"`]*?"
    r"(?:client_id|response_type)",
    re.I)

# params-object style builders: URLSearchParams({client_id: ...}) etc
PARAMS_BUILD = re.compile(
    r"(?:URLSearchParams|params\s*[:=]|authParams|query\s*[:=])"
    r"\s*[\(\{][^)]{0,400}?client_id", re.I | re.S)

# library calls that manage state internally — never flag
LIB_MANAGED = re.compile(
    r"authorize_redirect\s*\(|authorization_url\s*\(|"
    r"get_authorization_url\s*\(|generateAuthUrl\s*\(|"
    r"WebServerFlow|buildAuthorizationUri\s*\(|"
    r"createAuthorizationURL\s*\(|getAuthorizationUrl\s*\(|"
    r"passport\.authenticate\s*\(|auth0|openid-client|"
    r"makeAuthUrl|get_auth_url\s*\(", re.I)

STATE_WORD = re.compile(r"\bstate\b")

# callback code-readers — server side
CODE_READ = re.compile(
    r"req\.query\.code\b|req\.query\s*\[\s*['\"]code['\"]|"
    r"request\.(?:args|GET|values)\s*(?:\.get\(|\[)\s*['\"]code['\"]|"
    r"\$_(?:GET|REQUEST)\s*\[\s*['\"]code['\"]\s*\]|"
    r"c\.QueryParam\(\s*['\"]code['\"]|"
    r"r\.URL\.Query\(\)\.Get\(\s*['\"]code['\"]|"
    r"params\s*\[\s*:?['\"]?code['\"]?\s*\]|"
    r"@RequestParam[^)]*['\"]?code|RequestParam\(\s*['\"]code|"
    r"context\.query\.code\b|ctx\.query\.code\b|"
    r"searchParams\.get\(\s*['\"]code['\"]|"
    r"getQuery[^)]*['\"]code|query\.code\b")

# redirect_uri sourced from request input
REDIRECT_REQ = re.compile(
    r"redirect_uri[^;\n]{0,80}?(?:req\.query|req\.body|req\.param|"
    r"request\.(?:args|GET|POST|values|form)|\$_GET|\$_REQUEST|\$_POST|"
    r"params\s*\[|QueryParam|body\.|input\s*\()|"
    r"(?:req\.query|request\.(?:args|GET)|\$_GET|params\s*\[|"
    r"QueryParam|c\.Query)[^;\n]{0,80}?redirect_uri", re.I)

# loose whitelist compare on redirect/callback URIs — only the
# receiver form (uri.startsWith(...) / uri.includes(...) /
# uri.indexOf(...) / uri.match(...)) plus regex engines whose
# subject is the URI. ALLOWED.includes(redirect_uri) is exact
# membership — silent.
REDIRECT_LOOSE = re.compile(
    r"(?:redirect_uri|callback_url|return_url|continue_url|"
    r"redirectUrl|redirect_uri\b|\$redirect)"
    r"\s*\.\s*(?:startsWith|startswith|includes|contains|indexOf|"
    r"match|test)\s*\(|"
    r"re\.(?:search|match|fullmatch)\s*\([^;\n]{0,80}redirect|"
    r"preg_match\s*\([^;\n]{0,80}redirect|"
    r"str(?:pos|str|contains)\s*\(\s*\$?(?:redirect|return_url)", re.I)

IMPLICIT_RX = re.compile(
    r"response_type['\"]?\s*[:=]\s*['\"]"
    r"(?:token|id_token|id_token[ +]token|token[ +]id_token)['\"]|"
    r"response_type=token|response_type=id_token", re.I)

CODE_FLOW_RX = re.compile(
    r"response_type['\"]?\s*[:=]\s*['\"]code['\"]|response_type=code",
    re.I)

PKCE_WORD = re.compile(r"code_challenge|code_verifier|\bpkce\b", re.I)

GRANT_PASSWORD = re.compile(
    r"grant_type['\"]?\s*[:=]\s*['\"]password['\"]|"
    r"grant_type=password|"
    r"ResourceOwnerPassword|passwordGrant|password_grant|"
    r"grant_type['\"]?\s*[:=]\s*['\"]http://oauth.net/grant_type/"
    r"password", re.I)

SECRET_ASSIGN = re.compile(
    r"(?:client_secret|consumer_secret|app_secret|oauth_secret)"
    r"['\"]?\s*[:=]\s*['\"]([^'\"\n]{1,200})['\"]", re.I)

ENV_SRC = re.compile(
    r"process\.env|os\.environ|getenv\s*\(|ENV\s*\[|ENV\.fetch|"
    r"config\.|settings\.|System\.getenv|os\.getenv|vault|kms|"
    r"SecretManager|secretsmanager|credentials\.", re.I)

WEAK_WORDS = re.compile(
    r"^(secret|password|passwd|changeme|change_me|test|testing|key|"
    r"my_?secret|client_?secret|supersecret|topsecret|development|"
    r"dev|default|sh+|[0-9]+|abc123|letmein|qwerty|iloveyou|admin|"
    r"root|token|auth|hmac|foobar|your[_-]?(?:secret|token|key)"
    r"[_-]?.*|thesecret|sample|example|dummy|placeholder)$", re.I)

TOKEN_QUERY = re.compile(
    r"req\.query\.(?:access_token|id_token|refresh_token)\b|"
    r"req\.query\s*\[\s*['\"](?:access_token|id_token|refresh_token)|"
    r"request\.(?:args|GET|values)\s*(?:\.get\(|\[)\s*['\"]"
    r"(?:access_token|id_token|refresh_token)|"
    r"\$_(?:GET|REQUEST)\s*\[\s*['\"](?:access_token|id_token|"
    r"refresh_token)|"
    r"c\.QueryParam\(\s*['\"](?:access_token|id_token)|"
    r"r\.URL\.Query\(\)\.Get\(\s*['\"](?:access_token|id_token)|"
    r"params\s*\[\s*:?['\"]?(?:access_token|id_token|refresh_token)")

HTTP_LITERAL = re.compile(r"['\"`]http://([^'\"`\s]*)")

OAUTH_HTTP_CTX = re.compile(
    r"redirect_uri|redirect_url|callback|authorize|authorization|"
    r"token_endpoint|oauth|openid|issuer|auth_endpoint", re.I)

LOCAL_HOST = re.compile(
    r"^(localhost|127\.|::1|0\.0\.0\.0|\[::1\]|host\.docker)|"
    r"\.(test|example|invalid|localhost)([:/]|$)|"
    r"example\.(com|net|org)|\.local[:/]")

# 'code' reads that are NOT oauth callbacks — device-flow user codes,
# invites, OTP/2FA, email-confirmation, referral/promo codes carry no
# state by design
NONCALLBACK_CODE = re.compile(
    r"device|user_code|invite|referral|promo|coupon|voucher|"
    r"\botp\b|2fa|\btotp\b|mfa|confirm|verif|redeem|gift|"
    r"access_?code|invite_?code|pairing|short_?code", re.I)

PER_CODE_CAP = 40
STATEMENT_MAX = 600   # chars to treat as one URL-build statement


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 weak_secret(s):
    if len(s) < 16:
        return True
    return bool(WEAK_WORDS.match(s.strip()))


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)
    file_has_state = bool(STATE_WORD.search(text))
    file_has_pkce = bool(PKCE_WORD.search(text))
    file_has_lib = bool(LIB_MANAGED.search(text))
    findings = []
    counts = {}

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

    def line_at(pos):
        # binary search line number for absolute offset
        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 IMPLICIT_RX.search(ln):
            emit("MEDIUM", "OAUTH-IMPLICIT-FLOW", i,
                 "implicit response_type (token / id_token) — access "
                 "tokens land in the URL fragment; use auth-code + PKCE")

        for m in GRANT_PASSWORD.finditer(ln):
            emit("MEDIUM", "OAUTH-GRANT-PASSWORD", i,
                 "password grant '%s' — ROPC is deprecated and hands "
                 "user credentials to the client"
                 % m.group(0).strip()[:60])

        for m in SECRET_ASSIGN.finditer(ln):
            lit = m.group(1)
            if ENV_SRC.search(ln):
                continue
            if weak_secret(lit):
                emit("HIGH", "OAUTH-SECRET-LITERAL", i,
                     "client/app secret is an inline literal "
                     "(weak/<16 chars) — ships in source and bundles")
            else:
                emit("MEDIUM", "OAUTH-SECRET-LITERAL", i,
                     "client/app secret is an inline literal — "
                     "ships in source, images and bundle maps")

        if TOKEN_QUERY.search(ln):
            emit("LOW", "OAUTH-TOKEN-QUERY", i,
                 "bearer token read from the URL query — tokens land "
                 "in access logs, history and referer headers")

        for m in HTTP_LITERAL.finditer(ln):
            host = m.group(1)
            if not OAUTH_HTTP_CTX.search(ln):
                continue
            if LOCAL_HOST.search(host):
                continue
            emit("MEDIUM", "OAUTH-INSECURE-HTTP", i,
                 "http:// in an oauth context (endpoint/redirect) — "
                 "codes and tokens cross the wire in cleartext")

        if REDIRECT_REQ.search(ln):
            emit("HIGH", "OAUTH-REDIRECT-REQ", i,
                 "redirect_uri built from request input — the provider "
                 "sends the authorization code wherever pointed")

        if REDIRECT_LOOSE.search(ln):
            emit("MEDIUM", "OAUTH-REDIRECT-LOOSE", i,
                 "redirect_uri compared with startswith/substring "
                 "instead of exact match — allowed.com.evil.com passes")

        # callback consumes code but file never mentions state;
        # device/invite/OTP code reads in the surrounding window are
        # not oauth callbacks and carry no state by design
        if CODE_READ.search(ln) and not file_has_state:
            ctx = "\n".join(lines[max(0, i - 6):i + 5])
            if not NONCALLBACK_CODE.search(ctx):
                emit("HIGH", "OAUTH-STATE-UNCHECKED", i,
                     "callback reads 'code' but 'state' appears "
                     "nowhere in this file — nothing binds the flow "
                     "to the browser that started it (login CSRF)")

    # ---- file-level findings ------------------------------------------
    # authorize-URL built without state (unless library-managed or
    # state vocabulary exists elsewhere in the file)
    if not file_has_state and not file_has_lib:
        for rx in (AUTHZ_URL, PARAMS_BUILD):
            for m in rx.finditer(text):
                seg = text[m.start():m.start() + STATEMENT_MAX]
                if "state" not in seg:
                    emit("MEDIUM", "OAUTH-STATE-MISSING",
                         line_at(m.start()),
                         "authorize URL built without a 'state' "
                         "parameter — login CSRF; state binds the "
                         "callback to the initiating browser")
                    break   # one per regex per file is plenty

        # auth-code flow with no PKCE vocabulary anywhere
        if CODE_FLOW_RX.search(text):
            emit("LOW", "OAUTH-PKCE-MISSING",
                 line_at(CODE_FLOW_RX.search(text).start()),
                 "auth-code flow built with no code_challenge / "
                 "pkce anywhere in file — public clients require "
                 "PKCE, confidential clients should still use it")
    else:
        # state-aware file can still run code flow without PKCE
        if CODE_FLOW_RX.search(text) and not file_has_pkce:
            emit("LOW", "OAUTH-PKCE-MISSING",
                 line_at(CODE_FLOW_RX.search(text).start()),
                 "auth-code flow built with no code_challenge / "
                 "pkce anywhere in file — public clients require "
                 "PKCE, confidential clients should still use it")

    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):
                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 = ["# oauth_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 != a correct flow")
    return "\n".join(rows)


# --- selftest -------------------------------------------------------------
SELFTEST = [
    # --- state ---
    ("a.js",
     "const u = 'https://github.com/login/oauth/authorize?client_id='"
     " + CID + '&redirect_uri=' + encodeURIComponent(CB);\n",
     {"OAUTH-STATE-MISSING"}),
    ("a.js",
     "const u = 'https://github.com/login/oauth/authorize?client_id='"
     " + CID + '&state=' + csrf();\n",
     set()),
    ("a.py",
     "return oauth.authorize_redirect(request, redirect_uri=cb)\n",
     set()),                                        # library-managed
    ("cb.js",
     "app.get('/cb', (req, res) => { const c = req.query.code; "
     "exchange(c); });\n",
     {"OAUTH-STATE-UNCHECKED"}),
    ("cb.js",
     "app.get('/cb', (req,res)=>{ if(req.query.state!==stash) "
     "return 403; exchange(req.query.code); });\n",
     set()),
    ("cb.py",
     "code = request.args.get('code')\n"
     "tok = trade(code)\n",
     {"OAUTH-STATE-UNCHECKED"}),
    # --- redirect_uri validation ---
    ("a.js",
     "const ru = req.query.redirect_uri;\n"
     "res.redirect('https://idp/authorize?client_id=x&state=1"
     "&redirect_uri=' + ru);\n",
     {"OAUTH-REDIRECT-REQ"}),
    ("a.js",
     "if (!allowed.find(u => redirect_uri.startsWith(u))) reject();\n",
     {"OAUTH-REDIRECT-LOOSE"}),
    ("a.js",
     "if (!ALLOWED.includes(redirect_uri)) reject();\n",
     set()),                                        # exact match
    ("a.js",
     "if (redirect_uri !== 'https://app.example.com/cb') reject();\n",
     set()),
    # --- grant types ---
    ("a.js",
     "const url = 'https://idp/authorize?response_type=token"
     "&client_id=x';\n",
     {"OAUTH-IMPLICIT-FLOW", "OAUTH-STATE-MISSING"}),
    ("a.js",
     "const url = 'https://idp/authorize?response_type=id_token'\n",
     {"OAUTH-IMPLICIT-FLOW", "OAUTH-STATE-MISSING"}),
    ("a.js",
     "const url = 'https://idp/authorize?response_type=code"
     "&client_id=x&state=' + s;\n",
     {"OAUTH-PKCE-MISSING"}),
    ("a.js",
     "const url = 'https://idp/authorize?response_type=code"
     "&client_id=x&state=' + s + '&code_challenge=' + cc;\n",
     set()),
    ("a.py",
     "data = {'grant_type': 'password', 'username': u}\n",
     {"OAUTH-GRANT-PASSWORD"}),
    ("a.java",
     "form.put(\"username\", u); form.put(\"password\", p);\n",
     set()),                                        # plain credentials
    ("a.java",
     "var g = new ResourceOwnerPasswordCredentialsGrant(u,p);\n",
     {"OAUTH-GRANT-PASSWORD"}),
    # --- secrets ---
    ("a.py",
     "client_secret = 'abc'\n",
     {"OAUTH-SECRET-LITERAL"}),
    ("a.py",
     "client_secret = 'kX9$mQ2!vR8#pL4&nT7@wZ6*bH1'\n",
     {"OAUTH-SECRET-LITERAL"}),                     # MEDIUM literal
    ("a.py",
     "client_secret = os.environ['CLIENT_SECRET']\n",
     set()),
    ("a.js",
     "const cs = { client_secret: process.env.OAUTH_SECRET };\n",
     set()),
    # --- tokens in query ---
    ("a.js",
     "const at = req.query.access_token;\n",
     {"OAUTH-TOKEN-QUERY"}),
    ("a.py",
     "at = request.args.get('access_token')\n",
     {"OAUTH-TOKEN-QUERY"}),
    # --- http ---
    ("a.js",
     "const CB = 'http://app.acme-corp.io/cb'; "
     "const u = authorize_url({redirect_uri: CB});\n",
     {"OAUTH-INSECURE-HTTP"}),
    ("a.js",
     "const CB = 'http://board.test/api/cb';\n",
     set()),                                        # fixture TLD
    ("a.js",
     "const CB = 'http://localhost:3000/cb';\n",
     set()),
    ("a.py",
     "token_endpoint = 'http://idp.internal/oauth/token'\n",
     {"OAUTH-INSECURE-HTTP"}),
    # --- interplay / FP guards ---
    ("test/cb.js",
     "const c = req.query.code;\n",
     {"OAUTH-STATE-UNCHECKED"}),                    # downgraded
    ("a.js",
     "// const c = req.query.code;\n",
     set()),
    ("a.js",
     "/* const u = 'x/authorize?client_id=y'; */\n",
     set()),
    ("a.js",
     "import { useAuth0 } from '@auth0/auth0-react';\n",
     set()),
    ("a.js",
     "const authUrl = provider.getAuthorizationUrl({scope: 'openid'});"
     "\n",
     set()),                                        # library-managed
    ("cb.php",
     "$code = $_GET['code'];\nexchange($code);\n",
     {"OAUTH-STATE-UNCHECKED"}),
    ("device.js",
     "app.get('/device', (req,res)=>{ const code = "
     "req.query.code; findDeviceCode(code); });\n",
     set()),                                        # device-flow code
    ("invite.py",
     "code = request.args.get('code')\n"
     "apply_invite_code(code)\n",
     set()),                                        # invite code
    ("cb.php",
     "if ($_GET['state'] !== $_SESSION['st']) die();\n"
     "$code = $_GET['code'];\n",
     set()),
    ("a.go",
     "u := \"https://idp.example.com/oauth2/authorize?client_id=\" + "
     "cid + \"&redirect_uri=\" + ru\n",
     {"OAUTH-STATE-MISSING"}),
    ("a.js",
     "const p = new URLSearchParams({client_id: CID, redirect_uri: CB,"
     " response_type: 'code'});\n",
     {"OAUTH-STATE-MISSING", "OAUTH-PKCE-MISSING"}),
    ("a.js",
     "const p = new URLSearchParams({client_id: CID, state: st,"
     " redirect_uri: CB, response_type: 'code', code_challenge: c});\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), 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())
