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

ARION service artifact. Scans source files for the signatures of
broken JSON Web Token handling — tokens decoded but never verified
(jsonwebtoken's jwt.decode, ParseUnverified, verify=False), the
'none' algorithm accepted, symmetric/asymmetric algorithm lists
mixed in one verify call (RS256->HS256 confusion), sign/verify
keyed by inline literal secrets, tokens minted with no expiry,
tokens read from the URL query string or kept in localStorage,
and the unverified kid header used to resolve the key file.
Line-wise, fully offline, no inference.

Findings (base severity):
  JWT-UNVERIFIED-READ   HIGH    a token decode that cannot or does
                                not verify the signature is used as
                                truth — jsonwebtoken jwt.decode(t)
                                (never verifies, by design), Go
                                ParseUnverified, PyJWT options
                                {verify_signature: False} /
                                verify=False, ruby JWT.decode(t,
                                nil, false); MEDIUM when the same
                                file also runs a real verify call
                                (decode may be header inspection),
                                and for explicitly-named unverified
                                readers (jwt-decode's jwtDecode,
                                get_unverified_claims)
  JWT-ALG-NONE          HIGH    the 'none' algorithm appears in an
                                accepted-algorithms position —
                                algorithms: ['none'], alg='none',
                                Algorithm.none — unsigned tokens
                                are then valid
  JWT-ALG-CONFUSION     HIGH    a single allowed-algorithms list
                                mixes asymmetric (RS*/ES*/PS*) and
                                symmetric (HS*) schemes — the
                                classic pubkey-as-HMAC-secret
                                downgrade path
  JWT-ALG-UNPINNED      MEDIUM  a verify call carries no algorithm
                                allow-list at all — jsonwebtoken
                                then accepts ANY alg the token
                                header names, jjwt defaults accept
                                the token's own alg claim
  JWT-INLINE-SECRET     HIGH    sign/verify keyed on a literal that
                                is short (<16 chars) or matches the
                                default-secret vocabulary (secret,
                                password, changeme, jwt_secret,
                                mysecret, test, key, 1234…);
                                MEDIUM for any other inline literal
                                key — literals ship in source,
                                images and bundle maps
  JWT-NO-EXPIRY         MEDIUM  a sign/encode call whose argument
                                window contains no expiry vocab
                                (exp, expiresIn, expires_in,
                                setExpiration, ttl, maxAge) —
                                tokens without exp are bearer
                                credentials that never die
  JWT-TOKEN-IN-URL      LOW     a token read from the query string —
                                req.query.token, $_GET['token'],
                                request.args.get('token') — URLs
                                land in access logs, browser
                                history and referer headers
  JWT-TOKEN-LOCALSTORAGE LOW    a token written to or read from
                                localStorage/sessionStorage —
                                reachable by any XSS in the origin
  JWT-KID-UNTRUSTED     HIGH    the kid value is pulled from the
                                unverified token header and used
                                inside a file read/path join —
                                kid path traversal resolves an
                                attacker-chosen file as the key

Context rules:
  * comment lines and block-comment bodies are stripped first
  * import/require lines never flag
  * test/spec/fixture paths downgrade one tier
  * jwt.decode calls whose argument window carries a key +
    algorithms list (PyJWT/jjwt verified shape) are silent
  * env/config lookups (process.env, os.environ, ENV[,
    getenv(, config., settings., System.getenv) in the key
    position are never literal-secret findings
  * a file that also calls a real verify API downgrades bare
    decode findings — reading the header before verify is a
    legitimate pattern (kid lookup)

Scope: deterministic pre-scan for human review — flags the line
shapes where token verification is absent, weakened, or keyed on
shipped secrets; it cannot see middleware applied elsewhere,
key-rotation policy, or runtime alg pinning — clean output !=
correct token handling.

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

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 -----------------------------------------------------------

# decode-family receivers. jsonwebtoken.decode NEVER verifies; PyJWT
# decode verifies only when a key + algorithms are present.
DECODE_RX = re.compile(
    r"(?<![\w.])(?:jwt|JWT|jsonwebtoken)\s*(?:\.|::)\s*decode\s*\(")

EXPLICIT_NOVERIFY = re.compile(
    r"verify_signature['\"]?\s*:\s*False|"
    r"verify['\"]?\s*:\s*false|"
    r"verify\s*=\s*False|"
    r"verifySignature['\"]?\s*:\s*false|"
    r"doVerify\s*=\s*false|"
    r"validate\s*=\s*false", re.I)

UNVERIFIED_API = re.compile(
    r"\bParseUnverified\s*\(|"
    r"\bUnsafeParseNoneClaims\b|"
    r"\bparseUnverified\s*\(|"
    r"\bdecodeUnverified\s*\(|"
    r"\binsecureDecode|"
    r"\bInsecureSkipVerify\b")

NAMED_UNVERIFIED = re.compile(
    r"\bjwtDecode\s*\(|"
    r"\bget_unverified_claims\s*\(|"
    r"\bdecodeToken\s*\(|"
    r"\bdecode_token\s*\(|"
    r"\bread_token\s*\(")

HEADER_PEEK = re.compile(
    r"\bget_unverified_header\s*\(|"
    r"(?<![\w.])jwt\s*\.\s*decode_header\s*\(|"
    r"(?<![\w.])jwt\s*\.\s*get_unverified")

VERIFY_RX = re.compile(
    r"(?<![\w.])(?:jwt|JWT|jsonwebtoken)\s*(?:\.|::)\s*verify\s*\(|"
    r"\bjwt\.verify\s*\(|\bJWT\.verify\s*\(|"
    r"\bverify_jwt|\bverifyToken|\bJwtParser|"
    r"\bparse\s*\(\s*token|\.ParseWithClaims\s*\(|"
    r"algorithms\s*[:=]\s*\[")

SIGN_RX = re.compile(
    r"(?<![\w.])(?:jwt|JWT|jsonwebtoken)\s*(?:\.|::)\s*sign\s*\(|"
    r"(?<![\w.])(?:jwt|JWT)\s*(?:\.|::)\s*encode\s*\(|"
    r"\bencode_token\s*\(|\bencode_jwt\s*\(|"
    r"\bSignedString\s*\(|\bGenerateToken\s*\(|"
    r"\bJWT::encode\s*\(")

# none-alg acceptance
ALG_NONE = re.compile(
    r"algorithms?['\"]?\s*[:=]\s*[\[\(][^\]\)]*['\"]none['\"]|"
    r"\balg['\"]?\s*[:=]\s*['\"]none['\"]|"
    r"(?<![\w.])Algorithm\s*\.\s*(?:none|NONE)\b|"
    r"algorithms\s*[:=]\s*['\"]none['\"]|"
    r"\bnoneAlg\b", re.I)

# asymmetric + symmetric mixed in one allow-list
ALG_LIST = re.compile(
    r"algorithms['\"]?\s*[:=]\s*[\[\(]([^\]\)]*)[\]\)]")
ASY = re.compile(r"['\"](?:RS|ES|PS)\d{3}['\"]")
SYM = re.compile(r"['\"]HS\d{3}['\"]")

# verify call WITHOUT an algorithm pin: jwt.verify(token, key) or
# jwt.verify(token, key, cb) where no algorithms option appears in window
VERIFY_CALL = re.compile(
    r"(?<![\w.])(?:jwt|JWT|jsonwebtoken)\s*(?:\.|::)\s*verify\s*\(")
ALG_PIN = re.compile(
    r"algorithms?\b|Algorithm\b|alg\b|WithVerifyMethod|"
    r"ValidMethods|AllowedMethod|SigningMethod")

# key-position string literals in sign/verify/encode/decode calls
KEY_LITERAL = re.compile(
    r"(?:jwt|JWT|jsonwebtoken)\s*(?:\.|::)\s*"
    r"(?:sign|verify|encode|decode)\s*\([^)]*?,\s*"
    r"(['\"])([^'\"\n]{1,200})\1")
CONFIG_SECRET = re.compile(
    r"(?:JWT_SECRET(?:_KEY)?|jwtSecret|jwt_secret|secretOrKey|"
    r"secretOrPrivateKey|IssuerSigningKey|SigningKey|"
    r"TOKEN_SECRET|AUTH_SECRET)\s*['\"]?\s*\]?\s*[:=]\s*"
    r"(['\"])([^'\"\n]{1,200})\1",
    re.I)
ENV_SRC = re.compile(
    r"process\.env|os\.environ|getenv\s*\(|ENV\s*\[|ENV\.fetch|"
    r"config\.|settings\.|secrets\.|System\.getenv|"
    r"process\.env\.|os\.getenv|\bSecretManager|vault|kms", re.I)

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

EXPIRY_VOCAB = re.compile(
    r"\bexp\b|expiresIn|expires_in|expiration|setExpiration|"
    r"Expiry|expiry|ttl|TTL|maxAge|max_age|lifetime|"
    r"Time\.now|time\.time|datetime|OffsetDateTime|Instant\.now|"
    r"NotBefore|setIssuedAt", re.I)

TOKEN_IN_URL = re.compile(
    r"req\.query\.(?:token|jwt|auth|access_token|id_token)\b|"
    r"req\.query\s*\[\s*['\"](?:token|jwt|access_token)|"
    r"\$_GET\s*\[\s*['\"](?:token|jwt|access_token)|"
    r"\$_REQUEST\s*\[\s*['\"](?:token|jwt|access_token)|"
    r"request\.(?:args|GET)\s*(?:\.get\(|\[)\s*['\"](?:token|jwt|"
    r"access_token|id_token)|"
    r"params\s*\[\s*:?['\"]?token|"
    r"c\.QueryParam\(\s*['\"]token|"
    r"r\.URL\.Query\(\)\.Get\(\s*['\"]token|"
    r"URLSearchParams[^;]{0,80}get\(\s*['\"](?:token|jwt)|"
    r"location\.search[^;]{0,120}(?:token|jwt)\b")

TOKEN_STORAGE = re.compile(
    r"(?:localStorage|sessionStorage)\s*\.\s*"
    r"(?:setItem|getItem)\s*\(\s*['\"][^'\"]*"
    r"(?:token|jwt|auth|session|access|id_t)|"
    r"(?:localStorage|sessionStorage)\s*\[\s*['\"][^'\"]*"
    r"(?:token|jwt|auth)")

KID_FROM_TOKEN = re.compile(
    r"(?:header|hdr|unverified)[\w.\[\]'\"]*\bkid\b|"
    r"\bkid\b[^;\n]{0,60}(?:header|token|jwt)|"
    r"decodedHeader\s*\.\s*kid|header\s*\.\s*kid")
KID_TO_FS = re.compile(
    r"readFileSync\s*\([^)]*\bkid|"
    r"readFile\s*\([^)]*\bkid|"
    r"path\.join\s*\([^)]*\bkid|"
    r"open\s*\([^)]*\bkid|"
    r"fs\.readFile[^;]{0,80}\bkid|"
    r"File\s*\([^)]*\bkid|"
    r"include[^;]{0,60}\bkid")

CALL_WINDOW = 300          # chars to inspect after a call's '('
PER_CODE_CAP = 40          # per file


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 call_window(text, start):
    """Text from a call's opening paren to roughly its close."""
    seg = text[start:start + CALL_WINDOW]
    depth, out = 0, []
    for ch in seg:
        if ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
            if depth == 0:
                break
        out.append(ch)
    return "".join(out)


def nest_depth(segment):
    """Net bracket nesting ({ [ ( opens minus closes) in a segment."""
    d = 0
    for ch in segment:
        if ch in "{[(":
            d += 1
        elif ch in "}])":
            d -= 1
    return d


def literal_is_keyarg(match):
    """True when the captured literal sits at argument depth — not
    inside an options object or algorithms list."""
    grp = match.group(0)
    open_idx = grp.find("(")
    if open_idx < 0:
        return True
    lit_idx = match.start(2) - match.start(0)
    return nest_depth(grp[open_idx + 1:lit_idx]) == 0


def looks_verified(window):
    """PyJWT / jjwt verified-decode shape: a second arg that is a key
    plus an algorithms list, or an explicit verify option true."""
    if EXPLICIT_NOVERIFY.search(window):
        return False
    if re.search(r"algorithms\s*[:=]\s*[\[\(]", window):
        return True
    # second positional arg that isn't an options dict = a key
    args = [a.strip() for a in window.split(",")]
    if len(args) >= 2 and args[1] and not args[1].startswith("{"):
        return bool(re.search(r"key|secret|cert|pub|passphrase|pem|"
                              r"['\"]", args[1], re.I))
    return False


def weak_secret(s):
    if len(s) < 16:
        return True
    return bool(WEAK_WORDS.match(s.strip()))


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


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_verify = bool(VERIFY_RX.search(text))
    findings = []
    counts = {}
    # absolute offset of each line inside `text`
    offsets = []
    _pos = 0
    for _ln in lines:
        offsets.append(_pos)
        _pos += len(_ln) + 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

        # explicit unverified APIs — always HIGH
        for m in UNVERIFIED_API.finditer(ln):
            emit("HIGH", "JWT-UNVERIFIED-READ", i,
                 "explicitly unverified token parse '%s' — signature "
                 "is never checked" % m.group(0).strip("("))

        # named-unverified readers — MEDIUM (dev-facing names admit it)
        for m in NAMED_UNVERIFIED.finditer(ln):
            emit("MEDIUM", "JWT-UNVERIFIED-READ", i,
                 "'%s' reads claims without verifying the signature — "
                 "safe only for display, never for authz"
                 % m.group(0).strip("("))

        for m in HEADER_PEEK.finditer(ln):
            emit("LOW", "JWT-UNVERIFIED-READ", i,
                 "unverified header read '%s' — fine for kid lookup, "
                 "never for claims" % m.group(0).strip("("))

        # none-alg
        if ALG_NONE.search(ln):
            emit("HIGH", "JWT-ALG-NONE", i,
                 "'none' appears in an accepted-algorithm position — "
                 "unsigned tokens pass")

        # verify call with no algorithm pin
        for m in VERIFY_CALL.finditer(ln):
            w = call_window(text, offsets[i - 1] + m.end() - 1)
            if not ALG_PIN.search(w):
                emit("MEDIUM", "JWT-ALG-UNPINNED", i,
                     "verify call carries no algorithms allow-list — "
                     "token header chooses the accepted algorithm")

        # mixed asymmetric+symmetric allow-list
        for m in ALG_LIST.finditer(ln):
            if ASY.search(m.group(1)) and SYM.search(m.group(1)):
                emit("HIGH", "JWT-ALG-CONFUSION", i,
                     "algorithm list mixes %s and %s — pubkey-as-HMAC "
                     "confusion possible"
                     % (ASY.search(m.group(1)).group(0),
                        SYM.search(m.group(1)).group(0)))

        # inline literal in key position
        for m in KEY_LITERAL.finditer(ln):
            sec = m.group(2)
            if ENV_SRC.search(ln) or not literal_is_keyarg(m):
                continue
            sev = "HIGH" if weak_secret(sec) else "MEDIUM"
            emit(sev, "JWT-INLINE-SECRET", i,
                 "literal key in %s position%s"
                 % (m.group(0).split("(")[0].split()[-1],
                    " (weak/default-looking, %d chars)" % len(sec)
                    if sev == "HIGH" else " (%d chars) — ships in "
                    "source" % len(sec)))

        for m in CONFIG_SECRET.finditer(ln):
            if ENV_SRC.search(ln):
                continue
            sec = m.group(2)
            sev = "HIGH" if weak_secret(sec) else "MEDIUM"
            emit(sev, "JWT-INLINE-SECRET", i,
                 "JWT secret config assigned a literal%s"
                 % (" (weak/default-looking, %d chars)" % len(sec)
                    if sev == "HIGH" else " — ships in source"))

        # token in URL query
        if TOKEN_IN_URL.search(ln):
            emit("LOW", "JWT-TOKEN-IN-URL", i,
                 "token read from the query string — URLs are logged "
                 "and leaked via referer")

        # token in web storage
        if TOKEN_STORAGE.search(ln):
            emit("LOW", "JWT-TOKEN-LOCALSTORAGE", i,
                 "token kept in web storage — readable by any XSS in "
                 "the origin")

        # kid from token used in a file read
        if KID_TO_FS.search(ln) or (
                KID_FROM_TOKEN.search(ln)
                and re.search(r"readFile|open\s*\(|path\.join|include|"
                              r"File\s*\(", ln)):
            emit("HIGH", "JWT-KID-UNTRUSTED", i,
                 "kid from the unverified header feeds a file/path "
                 "lookup — traversal resolves an attacker-chosen key")

        # sign/encode with no expiry in the call window
        for m in SIGN_RX.finditer(ln):
            w = call_window(text, offsets[i - 1] + m.end() - 1)
            # expiry may be in the payload var built just above —
            # also check the preceding 3 lines
            above = "\n".join(lines[max(0, i - 4):i])
            if not EXPIRY_VOCAB.search(w) and not EXPIRY_VOCAB.search(
                    above):
                emit("MEDIUM", "JWT-NO-EXPIRY", i,
                     "token minted with no expiry in call/payload "
                     "window — an exp-less JWT is a permanent bearer "
                     "credential")

    # ---- jwt.decode disambiguation ------------------------------------
    for m in DECODE_RX.finditer(text):
        # line number of this match
        ln_no = text.count("\n", 0, m.start()) + 1
        line = lines[ln_no - 1] if ln_no - 1 < len(lines) else ""
        if not line.strip() or IMPORT_LINE.match(line):
            continue
        w = call_window(text, m.end() - 1)
        if EXPLICIT_NOVERIFY.search(w):
            emit("HIGH", "JWT-UNVERIFIED-READ", ln_no,
                 "signature verification explicitly disabled in "
                 "decode options")
        elif looks_verified(w):
            continue                      # PyJWT verified shape — silent
        else:
            sev = "MEDIUM" if file_has_verify else "HIGH"
            emit(sev, "JWT-UNVERIFIED-READ", ln_no,
                 "bare decode with no key/algorithms — reads claims "
                 "without verifying (jsonwebtoken.decode never "
                 "verifies)")

    # ruby/php verify=false positional: JWT.decode token, nil, false
    for m in re.finditer(
            r"JWT\s*(?:\.|::)\s*decode\s*\([^)]*,\s*"
            r"(?:nil|null|None)\s*,\s*false", text):
        ln_no = text.count("\n", 0, m.start()) + 1
        emit("HIGH", "JWT-UNVERIFIED-READ", ln_no,
             "decode called with verify=false positional — signature "
             "skipped")

    findings.sort(key=lambda f: (f.where, f.line,
                                 SEV_ORDER.get(f.sev, 9)))
    return findings


def find_targets(root):
    if os.path.isfile(root):
        return [root]
    out = []
    for base, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
        for f in files:
            if f.endswith(TARGET_EXT):
                out.append(os.path.join(base, f))
    out.sort()
    return out


def render(findings, targets):
    lines = ["# JWT pre-scan — %d file(s) scanned" % len(targets), ""]
    by_sev = {}
    for f in findings:
        by_sev.setdefault(f.sev, []).append(f)
    lines.append("%d HIGH / %d MEDIUM / %d LOW"
                 % (len(by_sev.get("HIGH", [])),
                    len(by_sev.get("MEDIUM", [])),
                    len(by_sev.get("LOW", []))))
    for sev in ("HIGH", "MEDIUM", "LOW"):
        for f in by_sev.get(sev, []):
            lines.append("- **%s** `%s` %s:%s — %s"
                         % (sev, f.code, f.where, f.line, f.msg))
    return "\n".join(lines)


# --- selftest -------------------------------------------------------------
SELFTEST = [
    # --- JWT-UNVERIFIED-READ ---
    ("a.js", "const c = jwt.decode(token);\n", {"JWT-UNVERIFIED-READ"}),
    ("a.js", "const c = jwt.decode(token, {complete: true});\n",
     {"JWT-UNVERIFIED-READ"}),
    ("a.go", "t, _, _ := jwt.ParseUnverified(s, &claims{})\n",
     {"JWT-UNVERIFIED-READ"}),
    ("a.py", "c = jwt.decode(t, options={'verify_signature': False})\n",
     {"JWT-UNVERIFIED-READ"}),
    ("a.py", "c = jwt.decode(t, verify=False)\n",
     {"JWT-UNVERIFIED-READ"}),
    ("a.rb", "c = JWT.decode(token, nil, false)\n",
     {"JWT-UNVERIFIED-READ"}),
    ("a.js", "import { jwtDecode } from 'jwt-decode';\n"
             "const c = jwtDecode(token);\n", {"JWT-UNVERIFIED-READ"}),
    ("a.py", "claims = jwt.get_unverified_claims(t)\n",
     {"JWT-UNVERIFIED-READ"}),
    ("a.py", "h = jwt.get_unverified_header(t)\n",
     {"JWT-UNVERIFIED-READ"}),
    # decode + verify in same file -> decode drops to MEDIUM
    ("a.js", "const c = jwt.decode(token);\n"
             "jwt.verify(token, key, {algorithms: ['HS256']});\n",
     {"JWT-UNVERIFIED-READ"}),
    # verified PyJWT shape -> silent
    ("a.py", "c = jwt.decode(t, key, algorithms=['HS256'])\n", set()),
    ("a.py", "c = jwt.decode(token, secret, ['HS256'])\n", set()),
    # regular API decode without verify anywhere -> HIGH
    ("a.js", "function h(t) {\n  return jwt.decode(t).sub;\n}\n",
     {"JWT-UNVERIFIED-READ"}),

    # --- JWT-ALG-NONE ---
    ("a.js", "jwt.verify(t, k, {algorithms: ['none', 'HS256']});\n",
     {"JWT-ALG-NONE"}),
    ("a.py", "jwt.decode(t, algorithms=['none'])\n", {"JWT-ALG-NONE"}),
    ("a.java", "SignatureAlgorithm.NONE\n", set()),  # bare mention, no alg ctx
    ("a.js", "const alg = 'none';\n", {"JWT-ALG-NONE"}),

    # --- JWT-ALG-CONFUSION ---
    ("a.js", "jwt.verify(t, k, {algorithms: ['RS256', 'HS256']});\n",
     {"JWT-ALG-CONFUSION"}),
    ("a.py", "jwt.decode(t, k, algorithms=['ES256', 'HS512'])\n",
     {"JWT-ALG-CONFUSION"}),
    ("a.js", "jwt.verify(t, k, {algorithms: ['RS256']});\n", set()),

    # --- JWT-ALG-UNPINNED ---
    ("a.js", "jwt.verify(token, secret);\n", {"JWT-ALG-UNPINNED"}),
    ("a.js", "jwt.verify(t, k, {algorithms: ['HS256']});\n", set()),
    ("a.py", "jwt.decode(t, k, algorithms=['HS256'])\n", set()),

    # --- JWT-INLINE-SECRET ---
    ("a.js", "jwt.sign(payload, 'secret');\n", {"JWT-INLINE-SECRET",
                                               "JWT-NO-EXPIRY"}),
    ("a.js", "jwt.sign(p, process.env.JWT_SECRET);\n",
     {"JWT-NO-EXPIRY"}),
    ("a.js", "jwt.verify(t, 'this-is-a-fairly-long-secret-ok');\n",
     {"JWT-INLINE-SECRET", "JWT-ALG-UNPINNED"}),
    ("a.js", "const opts = { secretOrKey: 'shhh' };\n",
     {"JWT-INLINE-SECRET"}),
    ("a.py", "app.config['JWT_SECRET_KEY'] = 'changeme'\n",
     {"JWT-INLINE-SECRET"}),
    ("a.js", "jwt.sign(p, config.jwtSecret);\n", {"JWT-NO-EXPIRY"}),

    # --- JWT-NO-EXPIRY ---
    ("a.js", "jwt.sign({sub: u.id}, key, {expiresIn: '1h'});\n", set()),
    ("a.py", "jwt.encode({'sub': 1, 'exp': 999}, k)\n", set()),
    ("a.js", "jwt.sign({sub: u.id}, key);\n", {"JWT-NO-EXPIRY"}),
    # exp set in payload just above -> silent
    ("a.js", "const p = {sub: 1, exp: Date.now() + 9e5};\n"
             "jwt.sign(p, key);\n", set()),

    # --- JWT-TOKEN-IN-URL ---
    ("a.js", "const t = req.query.token;\n", {"JWT-TOKEN-IN-URL"}),
    ("a.php", "$t = $_GET['token'];\n", {"JWT-TOKEN-IN-URL"}),
    ("a.py", "t = request.args.get('token')\n", {"JWT-TOKEN-IN-URL"}),
    ("a.js", "const t = req.body.token;\n", set()),

    # --- JWT-TOKEN-LOCALSTORAGE ---
    ("a.js", "localStorage.setItem('auth_token', t);\n",
     {"JWT-TOKEN-LOCALSTORAGE"}),
    ("a.js", "const t = localStorage.getItem('jwt');\n",
     {"JWT-TOKEN-LOCALSTORAGE"}),
    ("a.js", "sessionStorage['token'] = t;\n", {"JWT-TOKEN-LOCALSTORAGE"}),

    # --- JWT-KID-UNTRUSTED ---
    ("a.js", "jwt.verify(t, fs.readFileSync(header.kid));\n",
     {"JWT-KID-UNTRUSTED", "JWT-ALG-UNPINNED"}),
    ("a.js", "const p = path.join(KEYS, decodedHeader.kid);\n",
     {"JWT-KID-UNTRUSTED"}),
    ("a.js", "const kid = header.kid; keys[kid];\n", set()),

    # --- interplay / FP guards ---
    ("test/a.js", "const c = jwt.decode(token);\n",
     {"JWT-UNVERIFIED-READ"}),   # downgraded, still present
    ("a.js", "import jwt from 'jsonwebtoken';\n", set()),
    ("a.js", "// jwt.decode(token)\n", set()),
    ("a.js", "/* jwt.verify(t, 'secret') */\n", set()),
    ("a.js", "const dec = base64.decode(x);\n", set()),
    ("a.js", "jwt.sign(p, k, {expiresIn: '1h', algorithm: 'HS256'});\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())
