#!/usr/bin/env python3
"""timing_prescan.py — deterministic timing-attack / observable
side-channel-comparison pre-scan.

ARION service artifact. Scans source files for the comparison shapes
that leak secret content through response time (CWE-208 / CWE-385 /
CWE-203), checkable offline, line-wise, without executing code:

  * early-exit equality on secrets: ==, !=, ===, !==, .equals(),
    .equalsIgnoreCase(), strcmp/memcmp applied to token/secret/
    signature/hmac/digest/key/password/otp/nonce/challenge-named
    operands — character-by-character compares bail on the first
    differing byte, so each probe round-trip leaks a prefix position
  * non-constant-time LIBRARY compares on secrets: bytes.Equal,
    bytes.Compare, strings.Compare, reflect.DeepEqual, Arrays.equals,
    Objects.equals, String.CompareTo-family — correct result, wrong
    timing contract
  * prefix / substring probes: startswith/startsWith/strncmp/
    strncasecmp/substr_compare/strings.HasPrefix/String.StartsWith/
    start_with? — early-exit at first mismatch position
  * membership probes: `guess in secret` / `secret.includes(guess)` /
    strings.Contains / strstr / strpos with an attacker-named needle —
    confirms substrings without ever knowing the whole value
  * hand-rolled comparator loops: for/while loops indexing secret-named
    buffers with a char compare and an early `return false/0` — the
    classic home-brew compare that leaks position per iteration
  * length-only pre-checks: len(secret) != len(x) / .length compares —
    minor oracle (rejects wrong lengths before comparing)
  * raw secret as DB lookup key: WHERE token = ?, find_by(token:),
    findOne({token:}) — the pre-hash lookup form; store hashes instead
    (also makes DB dumps usable as-is)

Suppression: any line touching a known constant-time primitive
(compare_digest, hmac.compare_digest, secrets.compare_digest,
hash_equals, timingSafeEqual, subtle.ConstantTime*, CRYPTO_memcmp,
sodium_memcmp, timingsafe_bcmp, CryptographicOperations.FixedTimeEquals,
MessageDigest.isEqual, secure_compare, password_verify, checkpw,
argon2 verify, fixed-time wrappers) is skipped — that IS the fix.
Null/empty/boolean literal compares (token == null, secret == None)
are presence checks, not value compares — skipped.

Honest scope: PRE-SCAN, not an audit. Line- and window-based; no
dataflow. It cannot tell whether an early-exit compare guards a real
secret or a benign same-named field — a FLAG means "deserves human
review"; a clean report is not proof of constant-time behavior.

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

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

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

SRC_EXT = {".py", ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".go",
           ".java", ".rb", ".php", ".c", ".h", ".cpp", ".cc", ".hpp",
           ".cs", ".rs", ".swift", ".kt", ".kts", ".scala", ".pl", ".pm",
           ".ex", ".exs", ".lua", ".r", ".sh"}

COMMENT_PREFIX = ("#", "//")
BLOCK_COMMENT_RE = re.compile(r"^\s*(\*|/\*|<!--)")

# Secret-identifying vocabulary, two tiers. CONF names are almost
# always genuine secrets and fire unconditionally. NOISY names
# (token/signature/sig/mac/hash/cookie/digest-free words) are also real
# secret names but collide with lexer/parser/zip-magic/lru-cache
# vocabulary — they fire only when the ±2-line context carries an
# auth/verify action word.
CONF_SECRET_RE = re.compile(
    r"(?i)(secret|hmac|digest|api.?key|private.?key|priv_?key|"
    r"signing.?key|encryption.?key|password|passwd|passphrase|\bpwd\b|"
    r"\botp\b|_otp\b|\botp_|totp|credential|bearer|jwt|"
    r"session.?token|session.?id|csrf|xsrf|verifier|auth.?code|"
    r"confirm\w*.?code|reset.?code|recovery|\bpin\b|\bcrypt\b|"
    r"_crypt\b|\bcrypt_|shared.?secret|master.?key|privatekey|keypair|"
    r"authkey|secretkey|secret.?key)")

NOISY_SECRET_RE = re.compile(
    r"(?i)(token|signature|\bsig\b|_sig\b|\bsig_|\bmac\b|cookie|"
    r"\bhash\b|_hash\b|\bhash_(?:pw|pwd|key|token|secret|value|digest|"
    r"code|ed)\b)")

# auth/verify action words — what the ±2-line context must contain for
# NOISY-tier hits to fire.
AUTH_CTX_RE = re.compile(
    r"(?i)(verif|\bvalid|invalid(?!ation)|authent|authoriz|expect|"
    r"provided|supplied|stored|mismatch|reject|deny|denied|grant|"
    r"challenge|compare|mismatch|login|\bcheck\w*|correct|wrong|"
    r"failed|attest|approve|unauthor|forbidden|expired|mismatch|"
    r"40[13]|guard|middleware)")

ANY_SECRET_RE = re.compile(
    "(?:" + CONF_SECRET_RE.pattern[5:-1] + ")|(?:"
    + NOISY_SECRET_RE.pattern[5:-1] + ")", re.I)


CONST_ENUM_RE = re.compile(r"^[A-Z0-9_]{4,}$")


def secret_tier(tok):
    t = clean_tok(tok)
    if not t or is_quoted(t):
        return None
    if CONF_SECRET_RE.search(t):
        return "conf"
    if NOISY_SECRET_RE.search(t):
        # ALLCAPS enum-style terminal segments (CHECKED_HASH,
        # centdir[_CD_SIGNATURE]) are constants, not secrets
        last_seg = re.split(r"[.\[\](){}]", t)[-1].strip("_")
        if CONST_ENUM_RE.match(last_seg) and re.search(r"[A-Z]", last_seg):
            return None
        return "noisy"
    return None


def is_secret_val(tok):
    return secret_tier(tok) is not None


def gated(tokens, ctxline):
    """Fire if any operand is conf-tier, or a noisy-tier operand has
    auth context."""
    tiers = [secret_tier(t) for t in tokens]
    if "conf" in tiers:
        return True
    return "noisy" in tiers and bool(AUTH_CTX_RE.search(ctxline))


def ident_tokens(text):
    return re.findall(IDENT, text or "")

# type-tag / lexer operands — `token_type == 'comment'` is a kind-tag
# compare, not a secret compare
TYPE_TAG_RE = re.compile(r"(?i)(_type|\.type|_kind|\.kind|_ttype)$|"
                         r"tokenize\.|_type\b.*\._type")

# signal-handler context — `sig == signal.SIGINT` is a signal number,
# not a signature
SIGNAL_CTX_RE = re.compile(r"(?i)(signal\.SIG|\bSIG[A-Z]{2,}\b|sigaction|"
                           r"sigwait|os\.kill|killpg|pthread_sig|"
                           r"signalnum|sigprocmask)")

# punctuation / very short literal other-operand — `token == '.'` in a
# lexer is a char-kind test, not a secret compare
PUNCT_LIT_RE = re.compile(r"^[\"'][^a-zA-Z0-9]{0,4}[\"']$")

# typeof guard — `typeof token === 'string'` is a type check
TYPEOF_LIT_RE = re.compile(
    r"^[\"'](string|number|boolean|object|function|undefined|symbol|"
    r"bigint)[\"']$")
TYPEOF_RE = re.compile(r"\btypeof\b")

# Constant-time comparison primitives — a line touching any of these is
# already doing it right (or at least invoking the right primitive).
CT_SAFE_RE = re.compile(
    r"(?i)(compare_digest|constant.?time|timing.?safe.?equal|"
    r"ConstantTime(Compare|ByteEq|Select|Eq)|hmac\.Equal|"
    r"MessageDigest\.isEqual|hash_equals|CRYPTO_memcmp|sodium_memcmp|"
    r"timingsafe_bcmp|OPENSSL_memcmp|FixedTimeEquals|secure_compare|"
    r"SecurityUtils|password_verify|checkpw|check_password|"
    r"verify_password|password_hash|argon2|scrypt\.verify|bcrypt)")

# Operands that make an equality test a presence/sentinel check, not a
# value compare.
NULLISH_RE = re.compile(
    r"^(None|null|nil|NULL|undefined|True|False|true|false|\"\"|''|``|"
    r"\[\]|\{\}|\d+|self|this)$")

# Identifier-ish token (dotted attrs, subscripts, calls).
IDENT = r"[A-Za-z_$][\w$.\[\]()'\"]*"

# a == b / a != b / === / !== (operands may be quoted literals)
OPERAND = "(?:" + IDENT + r"|\"[^\"\n]*\"|'[^'\n]*')"
EQ_CMP_RE = re.compile(
    r"(?P<l>" + OPERAND + r")\s*(?P<op>===|!==|==|!=)\s*(?P<r>" + OPERAND
    + r")")

# x.equals(y) / x.equalsIgnoreCase(y)
DOT_EQUALS_RE = re.compile(
    r"(?P<o>" + IDENT + r")\.(?P<m>equals|equalsIgnoreCase|contentEquals|"
    r"Equals)\s*\((?P<a>" + IDENT + r")")

# static helper receivers — covered by NONCT_LIB, not instance-equals
STATIC_RECV = {"Arrays", "Objects", "Collections", "Comparator",
               "String", "Strings", "StringUtils", "crypto", "Crypto"}

# receiver-method compares that are not constant-time
METHOD_NONCT_RE = re.compile(
    r"(?P<o>" + IDENT + r")\.(?P<m>SequenceEqual|CompareTo|compareTo)"
    r"\s*\((?P<a>" + IDENT + r")")

# char-index access — the T-EARLY-EXIT loop detector owns these
CHAR_IDX_RE = re.compile(r"(\[\s*(i|idx|pos|n|j|k|offset)\s*\]|"
                         r"\.charAt\s*\(|\.charCodeAt\s*\(|ord\s*\()")

# length-expression operand — T-LEN-LEAK owns these
LEN_EXPR_RE = re.compile(r"(\.length|\.size|\.count|\.len\s*$|"
                         r"^len\s*\(|^strlen\s*\()")

# strcmp-family full compares (strncmp handled by prefix rule).
STRCMP_RE = re.compile(
    r"\b(?P<f>strcmp|strcasecmp|memcmp|bcmp|wcscmp|strings\.Compare|"
    r"bytes\.Compare)\s*\((?P<args>[^)]*)\)")

# non-constant-time library compares on secrets.
NONCT_LIB_RE = re.compile(
    r"\b(?P<f>bytes\.Equal|reflect\.DeepEqual|Arrays\.equals|"
    r"Objects\.equals|Objects\.Equals|Comparator\.comparing|"
    r"\w+\.CompareTo|SequenceEqual|Enumerable\.SequenceEqual)\s*"
    r"\((?P<args>[^)]*)\)")

# prefix compares.
PREFIX_RE = re.compile(
    r"(?P<o>" + IDENT + r")\.(?P<m>startswith|startsWith|StartsWith|"
    r"start_with\?|hasPrefix)\s*\((?P<a>" + IDENT + r")|"
    r"\b(?P<f>strncmp|strncasecmp|wcsncmp|substr_compare|"
    r"str_starts_with|strings\.HasPrefix|bytes\.HasPrefix)\s*"
    r"\((?P<args>[^)]*)\)")

# membership probes: `needle in secret`, `secret.includes(needle)`,
# strings.Contains(secret, needle), strstr/strpos(secret, needle).
MEMBER_PY_RE = re.compile(
    r"\b(?P<n>\w+)\s+in\s+(?P<h>" + IDENT + r")")
MEMBER_JS_RE = re.compile(
    r"(?P<h>" + IDENT + r")\.(?P<m>includes|contains|Contains|"
    r"includes\?|indexOf)\s*\((?P<a>" + IDENT + r")")
MEMBER_FN_RE = re.compile(
    r"\b(?P<f>strings\.Contains|bytes\.Contains|strstr|strchr|strpos|"
    r"stripos|StringUtils\.contains)\s*\((?P<args>[^)]*)\)")

# length-only compare on secrets.
LEN_CMP_RE = re.compile(
    r"(?:len\s*\(\s*(?P<p>" + IDENT + r")\s*\)|(?P<o>" + IDENT + r")"
    r"\.(?:length|size|count|len)\b|strlen\s*\(\s*(?P<s>" + IDENT + r")"
    r"\s*\))\s*(?P<op>===|!==|==|!=|<|>)\s*(?P<r>[\w.()\[\]]+)")

# raw secret as a DB lookup key. (?!<) dodges TS generic type args
# (query<{ token: string }>); hashed-field names and null values are
# the correct pattern, not findings.
DB_KEY_RE = re.compile(
    r"(?i)(find_one|findOne|find_by|findBy|get_by|where|filter_by|"
    r"query|first_or_create)\s*(?!<)\(?[^\n]{0,80}"
    r"(?P<field>\w*(token|api_?key|secret|session_?id)\w*)\s*[=:]"
    r"\s*(?P<val>[\w'\"$.[\]]{0,20})|"
    r"WHERE\s+(?P<f2>\w*(token|api_?key|secret)\w*)\s*=")

HASHED_FIELD_RE = re.compile(r"(?i)(hash|digest|sha\d*|md5|hmac)")

# attacker-named needle for membership probes.
NEEDLE_VOCAB_RE = re.compile(
    r"(?i)(guess|attempt|candidate|input|req\b|request|query|needle|"
    r"part|substr|prefix|frag|sub|chunk|given|supplied|provided|user|"
    r"try|test|probe)")

LOOP_HEAD_RE = re.compile(r"\b(for|while)\b")
INDEX_RE = re.compile(r"(\[\s*(i|idx|pos|n|j|k|offset)\s*\]|"
                      r"\.charAt\s*\(|\.charCodeAt\s*\(|ord\s*\()")
EARLY_RET_RE = re.compile(r"\breturn\s+(False|false|0\b|-1\b|NULL\b)")


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

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


def is_comment_or_blank(line):
    s = line.strip()
    return (not s or any(s.startswith(p) for p in COMMENT_PREFIX)
            or BLOCK_COMMENT_RE.match(s))


def is_quoted(tok):
    t = (tok or "").strip()
    return len(t) > 1 and t[0] in "\"'" and t[-1] == t[0]


def is_nullish(tok):
    return bool(NULLISH_RE.match(clean_tok(tok)))


def clean_tok(tok):
    return (tok or "").strip().strip("()[]{},;")


def is_len_expr(tok):
    return bool(LEN_EXPR_RE.search(clean_tok(tok)))


def scan_line(line, recent, fpath, lineno, out):
    if is_comment_or_blank(line) or CT_SAFE_RE.search(line):
        return
    before = len(out)
    ctxline = "\n".join(list(recent)[-2:] + [line])

    # --- early-exit equality on secrets -------------------------------
    for m in EQ_CMP_RE.finditer(line):
        l, r, op = m.group("l"), m.group("r"), m.group("op")
        lt, rt = secret_tier(l), secret_tier(r)
        ls, rs = bool(lt), bool(rt)
        if ls and SIGNAL_CTX_RE.search(line):
            continue  # signal-number compare, not signature
        if ls != rs:
            # exactly one side is secret-named: real value compare unless
            # the other side is a presence-check literal
            secret_tok, other = (l, r) if ls else (r, l)
            if is_nullish(other) or PUNCT_LIT_RE.match(other.strip()):
                continue
            if (TYPEOF_RE.search(line)
                    and TYPEOF_LIT_RE.match(other.strip())):
                continue  # type guard, not a value compare
            if TYPE_TAG_RE.search(secret_tok):
                continue  # kind-tag compare, not a secret value
            if CHAR_IDX_RE.search(secret_tok) or CHAR_IDX_RE.search(other):
                continue  # char-index compare — T-EARLY-EXIT owns it
            if is_len_expr(secret_tok):
                continue  # length compare — T-LEN-LEAK owns it
            if not gated([secret_tok], ctxline):
                continue  # noisy-tier name with no auth context nearby
            out.append(Finding(
                "T-SECRET-EQ", "HIGH", fpath, lineno,
                f"early-exit `{op}` on secret-named operand — leaks the "
                "first differing byte per probe; use a constant-time "
                "compare (compare_digest / timingSafeEqual / "
                "subtle.ConstantTimeCompare / hash_equals)"))
        elif ls and rs:
            if (TYPE_TAG_RE.search(l) or CHAR_IDX_RE.search(l)
                    or is_len_expr(l) or is_len_expr(r)):
                continue
            if not gated([l, r], ctxline):
                continue
            out.append(Finding(
                "T-SECRET-EQ", "HIGH", fpath, lineno,
                f"early-exit `{op}` comparing two secret-named operands "
                "— use a constant-time compare primitive"))

    # --- .equals() family ----------------------------------------------
    for m in DOT_EQUALS_RE.finditer(line):
        if m.group("o").split(".")[-1] in STATIC_RECV:
            continue  # static helper call — NONCT_LIB owns it
        if is_nullish(m.group("a")):
            continue
        if gated([m.group("o"), m.group("a")], ctxline):
            out.append(Finding(
                "T-SECRET-EQ", "HIGH", fpath, lineno,
                f"`.{m.group('m')}()` on a secret-named operand is an "
                "early-exit compare — use a constant-time primitive"))

    # --- receiver-method non-CT compares --------------------------------
    for m in METHOD_NONCT_RE.finditer(line):
        if gated([m.group("o"), m.group("a")], ctxline):
            out.append(Finding(
                "T-LIBCMP-NONCT", "MEDIUM", fpath, lineno,
                f"`.{m.group('m')}()` is not constant-time — correct "
                "result, wrong timing contract for secret comparison"))

    # --- strcmp / memcmp full compares ----------------------------------
    for m in STRCMP_RE.finditer(line):
        args = m.group("args")
        if gated(ident_tokens(args), ctxline):
            out.append(Finding(
                "T-SECRET-EQ", "HIGH", fpath, lineno,
                f"`{m.group('f')}` on secret-named data early-exits at "
                "the first differing byte — use CRYPTO_memcmp / "
                "sodium_memcmp / a constant-time compare"))

    # --- non-constant-time library compares -----------------------------
    for m in NONCT_LIB_RE.finditer(line):
        blob = m.group("args")
        if gated(ident_tokens(blob), ctxline):
            out.append(Finding(
                "T-LIBCMP-NONCT", "MEDIUM", fpath, lineno,
                f"`{m.group('f')}` is not constant-time — correct "
                "result, wrong timing contract for secret comparison"))

    # --- prefix compares -------------------------------------------------
    for m in PREFIX_RE.finditer(line):
        if m.group("o") is not None:
            fire = gated([m.group("o"), m.group("a")], ctxline)
        else:
            fire = gated(ident_tokens(m.group("args")), ctxline)
        if fire:
            out.append(Finding(
                "T-PREFIX-EQ", "MEDIUM", fpath, lineno,
                "prefix compare on secret-named data — early-exits at "
                "the first mismatch, leaking the matching length"))

    # --- membership probes -----------------------------------------------
    for m in MEMBER_PY_RE.finditer(line):
        n, h = m.group("n"), m.group("h")
        if (gated([h], ctxline) and not secret_tier(n)
                and NEEDLE_VOCAB_RE.search(n)):
            out.append(Finding(
                "T-MEMBER-EQ", "MEDIUM", fpath, lineno,
                f"`{n} in {h}` — membership probe on a secret-named "
                "value; substring oracle"))
    for m in MEMBER_JS_RE.finditer(line):
        h, a = m.group("h"), m.group("a")
        if gated([h], ctxline) and not secret_tier(a):
            out.append(Finding(
                "T-MEMBER-EQ", "MEDIUM", fpath, lineno,
                f"`.{m.group('m')}()` membership/substring probe on a "
                "secret-named value — confirm-by-position oracle"))
    for m in MEMBER_FN_RE.finditer(line):
        args = [a.strip() for a in m.group("args").split(",")]
        if args and gated([args[0]], ctxline) and (
                len(args) < 2 or not secret_tier(args[1])):
            out.append(Finding(
                "T-MEMBER-EQ", "MEDIUM", fpath, lineno,
                f"`{m.group('f')}` substring search with secret-named "
                "haystack — position-leaking oracle"))

    # --- length-only compares ---------------------------------------------
    m = LEN_CMP_RE.search(line)
    if m:
        tok = m.group("p") or m.group("o") or m.group("s")
        other_len = clean_tok(m.group("r") or "")
        if other_len in {"0", "1", "None", "null", "nil"}:
            pass  # emptiness check, not a length oracle
        elif gated([tok], ctxline):
            out.append(Finding(
                "T-LEN-LEAK", "LOW", fpath, lineno,
                "length compare on secret-named data — length oracle "
                "(also short-circuits the real compare)"))

    # --- raw secret as DB lookup key ----------------------------------------
    m = DB_KEY_RE.search(line)
    if m:
        field = m.group("field") or m.group("f2") or ""
        val = (m.group("val") or "").strip()
        if not HASHED_FIELD_RE.search(field) and (
                not val or not NULLISH_RE.match(val)):
            out.append(Finding(
                "T-DB-TOKEN-KEY", "LOW", fpath, lineno,
                "raw token/api-key used as a DB lookup key — index "
                "timing leaks existence and the stored value is usable "
                "as-is; store and look up hashes instead"))

    # --- hand-rolled comparator loops -------------------------------------
    if EARLY_RET_RE.search(line):
        window = list(recent)[-12:] + [line]
        wtext = "\n".join(window)
        if (any(LOOP_HEAD_RE.search(w) for w in window)
                and INDEX_RE.search(wtext)
                and re.search(r"(===|!==|==|!=)", wtext)
                and ANY_SECRET_RE.search(wtext)):
            out.append(Finding(
                "T-EARLY-EXIT", "MEDIUM", fpath, lineno,
                "hand-rolled comparator loop — early return inside an "
                "indexed char compare over secret-named data leaks the "
                "mismatch position; use a constant-time primitive"))

    # one T-SECRET-EQ per line is enough signal
    if len(out) > before:
        seen = set()
        deduped = []
        for f in out[before:]:
            if f.code == "T-SECRET-EQ" and f.code in seen:
                continue
            seen.add(f.code)
            deduped.append(f)
        out[before:] = deduped


def iter_source_files(target):
    if os.path.isfile(target):
        yield target
        return
    for root, dirs, files in os.walk(target):
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
        for f in sorted(files):
            if os.path.splitext(f)[1].lower() in SRC_EXT:
                yield os.path.join(root, f)


def scan(target):
    findings = []
    scanned = 0
    for path in iter_source_files(target):
        rel = os.path.relpath(path, target) if os.path.isdir(target) \
            else os.path.basename(path)
        try:
            with open(path, "r", errors="replace") as fh:
                lines = fh.readlines()
        except OSError:
            continue
        scanned += 1
        recent = deque(maxlen=14)
        for i, line in enumerate(lines, 1):
            s = line.rstrip("\n")
            scan_line(s, recent, rel, i, findings)
            if s.strip():
                recent.append(s)
    findings.sort(key=lambda f: (SEV_ORDER.get(f.sev, 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.sev] = counts.get(f.sev, 0) + 1
    out = ["# Timing / side-channel comparison pre-scan",
           "",
           f"- target: `{target}`",
           f"- source files scanned: {scanned}",
           f"- findings: HIGH {counts['HIGH']} · MEDIUM {counts['MEDIUM']}"
           f" · LOW {counts['LOW']} · INFO {counts['INFO']}",
           "",
           "_Deterministic pre-scan — not an audit. Line/window-based; "
           "no dataflow. A clean report is not proof of constant-time "
           "behavior._",
           ""]
    if not findings:
        out.append("No findings.")
        return "\n".join(out)
    cur = None
    for f in findings:
        if f.sev != cur:
            cur = f.sev
            out += ["", f"## {cur}", ""]
        out.append(f"- `{f.file}:{f.line}` **{f.code}** — {f.msg}")
    return "\n".join(out)


SELFTEST = [
    # (name, source, expected_codes_with_sev)
    ("py-token-neq",
     "def check(request):\n    if request.token != expected_token:\n"
     "        abort(401)\n",
     {"T-SECRET-EQ:HIGH"}),
    ("py-none-check-ok",
     "if token is None:\n    return 400\n",
     set()),
    ("py-null-empty-ok",
     "if token == \"\":\n    return\nif secret != \"\":\n    pass\n",
     set()),
    ("literal-compare",
     "if api_key != \"hardcoded-expected\":\n    pass\n",
     {"T-SECRET-EQ:HIGH"}),
    ("py-compare-digest-ok",
     "ok = hmac.compare_digest(sig, expected)\n",
     set()),
    ("js-sig-eq",
     "if (sig === expectedSig) { fail() }\n",
     {"T-SECRET-EQ:HIGH"}),
    ("js-timing-safe-ok",
     "ok = crypto.timingSafeEqual(Buffer.from(mac), want)\n",
     set()),
    ("go-bytes-equal",
     "if !bytes.Equal(mac, expectedMac) {\n",
     {"T-LIBCMP-NONCT:MEDIUM"}),
    ("go-subtle-ok",
     "if subtle.ConstantTimeCompare(mac, want) != 1 {\n",
     set()),
    ("java-equals",
     "if (apiToken.equals(provided)) grant();\n",
     {"T-SECRET-EQ:HIGH"}),
    ("java-msgdigest-ok",
     "boolean ok = MessageDigest.isEqual(a, b);\n",
     set()),
    ("java-arrays-eq",
     "if (Arrays.equals(mac, expectedMac)) {\n",
     {"T-LIBCMP-NONCT:MEDIUM"}),
    ("c-strcmp",
     "if (strcmp(input, secret_key) == 0) {\n",
     {"T-SECRET-EQ:HIGH"}),
    ("early-exit-loop",
     "int check(char *token, char *expected) {\n"
     "  for (int i = 0; i < n; i++) {\n"
     "    if (token[i] != expected[i]) {\n"
     "      return 0;\n    }\n  }\n  return 1;\n}\n",
     {"T-EARLY-EXIT:MEDIUM"}),
    ("prefix-probe",
     "function checkPrefix(request) {\n"
     "    if (request.token.startsWith(prefix)) {\n",
     {"T-PREFIX-EQ:MEDIUM"}),
    ("member-py",
     "if guess in secret_value:\n    pass\n",
     {"T-MEMBER-EQ:MEDIUM"}),
    ("member-js",
     "if (secret.includes(guess)) {\n",
     {"T-MEMBER-EQ:MEDIUM"}),
    ("member-go",
     "func verifyTag(probe string) {\n"
     "    if strings.Contains(token, probe) {\n",
     {"T-MEMBER-EQ:MEDIUM"}),
    ("len-leak",
     "if (token.length !== expected.length) return\n",
     {"T-LEN-LEAK:LOW"}),
    ("db-key",
     "user = db.findOne({ token: supplied })\n",
     {"T-DB-TOKEN-KEY:LOW"}),
    ("sql-key",
     "q = \"SELECT id FROM keys WHERE api_key = ?\"\n",
     {"T-DB-TOKEN-KEY:LOW"}),
    ("non-secret-eq-ok",
     "if (count == total) done()\n",
     set()),
    ("sig-substring-fp-ok",
     "if (design == expectedDesign) render()\n"
     "assign(target, src)\n",
     set()),
    ("null-lit-ok",
     "if (sessionToken == null) redirect()\n",
     set()),
    ("php-hash-equals-ok",
     "$ok = hash_equals($expected, $token);\n",
     set()),
    ("php-strpos-member",
     "if (strpos($secret, $probe) !== false) {\n",
     {"T-MEMBER-EQ:MEDIUM"}),
    ("rb-start-with",
     "def verify_prefix(token, prefix)\n"
     "    token.start_with?(prefix)\n",
     {"T-PREFIX-EQ:MEDIUM"}),
    ("password-verify-ok",
     "ok = bcrypt.checkpw(candidate, stored_hash)\n",
     set()),
    ("dotnet-sequence-equal",
     "if (mac.SequenceEqual(expected)) {\n",
     {"T-LIBCMP-NONCT:MEDIUM"}),
    # FP regressions: parser tokens / signals / type tags
    ("lexer-token-type-ok",
     "return [x for x in xs if x.token_type == 'comment']\n",
     set()),
    ("lexer-punct-ok",
     "elif token == '.':\n    advance()\n",
     set()),
    ("signal-ctx-ok",
     "if sig == signal.SIGINT:\n    handler()\n",
     set()),
    ("otp-substring-ok",
     "if leftdigits == dotplace:\n    pass\n",
     set()),
    ("module-token-ok",
     "if ttype == tokenize.NEWLINE:\n    break\n",
     set()),
    ("quoted-field-ok",
     "if \"api_key\" == column_name:\n    pass\n",
     set()),
    ("typeof-guard-ok",
     "if (answer.status === 'approved' && typeof answer.token === 'string') {\n",
     set()),
    ("hashed-lookup-ok",
     "      where s.token_hash = $1 and s.expires_at > now()\n",
     set()),
    ("ts-generic-ok",
     "const r = await pool.query<{ token: string | null }>(sql)\n",
     set()),
    ("null-field-ok",
     "const x = { server: 'x', token: null }\n",
     set()),
    ("hardcoded-secret-cmp",
     "if (form.get('client_secret') !== 'cps_board') reject()\n",
     {"T-SECRET-EQ:HIGH"}),
    ("raw-lookup",
     "      where token = $1 and expires_at > now()\n",
     {"T-DB-TOKEN-KEY:LOW"}),
]


def selftest():
    import tempfile
    fails = []
    for name, src, expect in SELFTEST:
        with tempfile.NamedTemporaryFile(
                "w", suffix=".py", delete=False) as tf:
            tf.write(src)
            path = tf.name
        try:
            findings, _ = scan(path)
            got = {f"{f.code}:{f.sev}" for f in findings}
            if got != expect:
                fails.append((name, sorted(expect), sorted(got)))
        finally:
            os.unlink(path)
    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": [f.as_dict() for f in findings]},
                         indent=2))
    else:
        print(report_md(findings, scanned, target))
    return 1 if any(SEV_ORDER[f.sev] < 2 for f in findings) else 0


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