#!/usr/bin/env python3
"""auth_prescan.py — deterministic auth/session misconfiguration pre-scan.

ARION service artifact. Scans source files for the authentication and
session-handling classes that are checkable offline, line-wise — no
server, no runtime, no network.

Findings (severity):
  AUTH-JWT-NONE-ALG     HIGH   JWT "none" algorithm accepted or selected
                               — unsigned tokens are treated as valid
  AUTH-JWT-NOVERIFY     HIGH   signature verification explicitly disabled
                               (verify=False, verify_signature:false,
                               ruby JWT.decode(..., false), verify_jwt=0)
                        LOW    bare jwt.decode() call — decoded claims
                               without verification; ensure verify()
                               precedes any authz decision
  AUTH-PW-WEAKHASH      HIGH   md5/sha1 applied to a password/credential
                               — trivially brute-forced
  AUTH-PW-FASTHASH      MEDIUM single-pass fast hash (sha256/384/512)
                               applied to a password — needs a salted
                               KDF (bcrypt/argon2/scrypt/pbkdf2)
  AUTH-JWT-SECRET-LIT   MEDIUM jwt.sign/verify with an inline literal
                               secret — hardcoded signing keys leak
                               with the repo and get reused across envs
  AUTH-PW-HARDCODED     MEDIUM credential compared to a hardcoded
                               quoted literal (if password == "s3cret")
  AUTH-DISABLED         MEDIUM auth explicitly switched off: DRF
                               AllowAny, ASP.NET AllowAnonymous,
                               @Public/@SkipAuth, auth:false / skip_auth
                               / bypass_auth flags, auth-stub that
                               unconditionally returns true
                        LOW    auth decorator commented out
  AUTH-PREDICTABLE-TOKEN MEDIUM non-CSPRNG (Math.random, rand, mt_rand,
                               random.*, uniqid, time) used to mint a
                               token/session/reset/nonce/otp value
  AUTH-SESSION-FIXATION MEDIUM session id taken from request input —
                               attacker can fix the victim's session id
  AUTH-COOKIE-FLAGS     MEDIUM session/auth cookie set without HttpOnly
                               (script-readable) — LOW when HttpOnly is
                               present but Secure/SameSite are missing
  AUTH-TIMING-COMPARE   LOW    credential material compared with ==/!=/
                               .equals — non-constant-time compare is
                               byte-wise leakable; use hmac.compare_digest
                               / crypto.timingSafeEqual
  AUTH-LOCALSTORAGE-TOKEN LOW  token/session/jwt written to web storage —
                               readable by any injected script (XSS)
  AUTH-JWT-NO-EXP       LOW    single-line jwt.sign without exp/expiresIn
                               — tokens that never expire
  AUTH-BASIC-INLINE     LOW    hardcoded "Authorization: Basic <b64>"
                               literal in source

Targets: .js .jsx .ts .tsx .mjs .cjs .py .rb .go .java .kt .php .cs .rs
  .ex .exs source files.

Honest scope: PRE-SCAN, not an audit. It cannot see middleware order,
route-level overrides, runtime config, or whether a flagged line is
reachable — a FLAG means "deserves human review"; a clean report is
not proof the auth layer is safe.

Usage:
  auth_prescan.py <file-or-dir> [--json]
  auth_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",
)

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

PASS_KW = r"(?:pass(?:word|wd|phrase)?|pwd|credential)"
TOKEN_KW = (r"(?:token|session|secret|reset|nonce|otp|csrf|salt|"
            r"api_?key|password|passwd|pwd)")

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

JWT_NONE_RE = re.compile(
    r"(?i)\b(alg|algorithm|algorithms)\b[\w.\[\]]*\s*[:=,(\[{]*\s*"
    r"['\"]?none\b|allowNone|Algorithm\.none\b")

JWT_NOVERIFY_HIGH = [
    re.compile(r"(?i)\bverify_signature['\"]?\s*[:=]\s*false"),
    re.compile(r"(?i)jwt\.decode\([^)]*verify\s*=\s*False"),
    re.compile(r"(?i)\bJWT\.decode\([^)]*,\s*(nil|null|None)\s*,\s*false"),
    re.compile(r"(?i)\bJWT\.decode\([^)]*,\s*false\s*\)"),
    re.compile(r"(?i)(verify|check)_(jwt|token|signature)\s*[:=]\s*"
               r"(False|false|0|off)\b"),
    re.compile(r"(?i)\bskip(Jwt|Token|Signature)?Verification\b"),
]
JWT_DECODE_BARE_RE = re.compile(
    r"(?i)\bjwt\.decode\s*\(|(?<!\.)\bJWT\.decode\s*\(")

# weak/fast hash applied to a password — keyword must be inside the call
PW_WEAKHASH_RES = [
    re.compile(r"(?i)\b(md5|sha1|sha-1)\s*\([^)]*" + PASS_KW),
    re.compile(r"(?i)hashlib\.(md5|sha1)\s*\([^)]*" + PASS_KW),
    re.compile(r"(?i)createHash\(\s*['\"](md5|sha1)['\"]\s*\)\.update\([^)]*"
               + PASS_KW),
    re.compile(r"(?i)MessageDigest\.getInstance\(\s*['\"](MD5|SHA-?1)['\"]"),
    re.compile(r"(?i)\bhash\(\s*['\"](md5|sha1)['\"]\s*,[^)]*" + PASS_KW),
    re.compile(r"(?i)Digest::(MD5|SHA1)\.[a-z]+\([^)]*" + PASS_KW),
]
PW_FASTHASH_RES = [
    re.compile(r"(?i)\b(sha256|sha-256|sha384|sha-384|sha512|sha-512)\s*"
               r"\([^)]*" + PASS_KW),
    re.compile(r"(?i)hashlib\.sha(256|384|512)\s*\([^)]*" + PASS_KW),
    re.compile(r"(?i)createHash\(\s*['\"]sha(256|384|512)['\"]\s*\)"
               r"\.update\([^)]*" + PASS_KW),
    re.compile(r"(?i)\bhash\(\s*['\"]sha(256|384|512)['\"]\s*,[^)]*"
               + PASS_KW),
]
# password-hashing-safe markers — never flag these lines
PW_SAFE_RE = re.compile(
    r"(?i)bcrypt|argon2|scrypt|pbkdf2|password_hash|password_verify|"
    r"make_password|check_password|generate_password_hash|"
    r"check_password_hash|has_secure_password|devise|passlib|"
    r"hash_password|verify_password|Crypto\.PBKDF2|crypto\.scrypt|"
    r"crypto\.pbkdf2|randomBytes|wordlist|passphrase\s*[:=]?\s*['\"]")

JWT_SECRET_LIT_RE = re.compile(
    r"(?i)\bjwt\.(sign|verify)\s*\([^)]*,\s*['\"][^'\"\n]{1,64}['\"]|"
    r"hmacShaKeyFor\s*\(\s*['\"][^'\"\n]{1,64}['\"]|"
    r"new\s+SecretKeySpec\s*\(\s*['\"][^'\"\n]{1,64}['\"]")

PW_HARDCODED_RE = re.compile(
    r"(?i)\b\w*" + PASS_KW + r"\w*\s*(===?|!==?)\s*['\"][^'\"\n]{3,}['\"]|"
    r"['\"][^'\"\n]{3,}['\"]\s*(===?|!==?)\s*\w*" + PASS_KW)

AUTH_DISABLED_RES = [
    re.compile(r"(?i)permission_classes\s*=\s*[\[(][^\]\n]*AllowAny"),
    re.compile(r"\[?\s*AllowAnonymous\b|@AllowAnonymous"),
    re.compile(r"@(Public|SkipAuth|NoAuth|AllowAnon|Anonymous|"
               r"OptionalAuth)\s*[\(\n]"),
    re.compile(r"(?i)\b(skip|bypass|disable|no)_?auth\w*\s*[:=]\s*"
               r"(true|True|1)\b"),
    re.compile(r"(?i)\b(skip|bypass|disable)_?(auth|token|jwt|session)"
               r"\w*\s*\("),
    re.compile(r"(?i)\b\w*(authenticate|authorize|is_?admin|is_?auth|"
               r"check_?token|verify_?token|logged_?in)\w*\s*[:=(]"
               r"[^\n]*?(=>|:)\s*(true|True)\s*[,}\n;]?$"),
]
AUTH_ROUTE_FALSE_RE = re.compile(
    r"(?i)\b(auth|authenticate|authenticated|requires_?auth|"
    r"auth_?required|private)\s*[:=]\s*(false|null|none|nil)\b")
AUTH_COMMENTED_RE = re.compile(
    r"(?i)(login_required|auth_required|requires_auth|requires_login|"
    r"@authorize|@authenticated|jwt_required|fresh_login_required|"
    r"permission_required)")

PREDICTABLE_RNG = re.compile(
    r"(?i)Math\.random|mt_rand\s*\(|(?<![\w.])rand\s*\(|"
    r"random\.(random|randint|choice|getrandbits|uniform|sample)\s*\(|"
    r"new\s+Random\s*\(|System\.Random|uniqid\s*\(|"
    r"\brand\s*\([^)]*\)\s*$|srand\s*\(|arc4random\s*\(")
PREDICTABLE_SAFE = re.compile(
    r"(?i)secrets\.|os\.urandom|randomBytes|SecureRandom|randomUUID|"
    r"crypto\.|rand_core|getrandom|Sysrandom|devise|token_hex|"
    r"random_number|Random\.new\.urandom|SecureRandom\.hex")

SESSION_FIX_RE = re.compile(
    r"(?i)(session_?id|sess_?id|\bsid\b)\s*[:=]\s*"
    r"(req\.|request\.|params|query|_GET|_REQUEST|_POST|getParameter|"
    r"ctx\.|self\.request)|"
    r"session_id\s*\([^)]*(_GET|_REQUEST|_POST|request|param|query)|"
    r"session\s*[:=]\s*req\.(query|params|body)")

COOKIE_SET_RE = re.compile(
    r"(?i)res\.cookie\s*\(|response\.set_cookie|setcookie\s*\(|"
    r"setrawcookie\s*\(|cookies\.set\s*\(|ctx\.cookies\.set|"
    r"Set-Cookie\s*:|\.setHeader\(\s*['\"]Set-Cookie|"
    r"http\.SetCookie|c\.Cookie\s*\(|w\.Header\(\)\.Set\(\s*['\"]Set-Cookie")
COOKIE_NAME_RE = re.compile(
    r"(?i)(session|sess|auth|token|jwt|\bsid\b|login|remember)")
HTTPONLY_RE = re.compile(r"(?i)httponly|http_only|HttpOnly")
SECURE_RE = re.compile(r"(?i)\bsecure\b\s*[:=,)]|;\s*Secure\b")
SAMESITE_RE = re.compile(r"(?i)samesite|same_site|SameSite")

TIMING_RE = re.compile(
    r"(?i)\b\w*(?:pass(?:word|wd|phrase)?|pwd|credential|token|secret|"
    r"api_?key|hmac|signature|digest)\w*\s*(?:===?|!==?)\s*"
    r"([\w.()\[\]]+|['\"][^'\"\n]*['\"])")
TIMING_EQUALS_RE = re.compile(
    r"(?i)\b\w*" + PASS_KW + r"\w*\.equals(IgnoreCase)?\s*\(")
TIMING_SAFE_OPERAND = {
    "none", "null", "nil", "undefined", "true", "false", "this",
    "optional", "len", "sizeof", "empty", ""}
TIMING_SAFE_CALL = re.compile(
    r"(?i)compare_digest|timingSafeEqual|constant.?time|"
    r"secure_compare|fixed_time|hmac\.compare|password_verify|"
    r"check_password|verify_password")

LOCALSTORAGE_RE = re.compile(
    r"(?i)(localStorage|sessionStorage)\s*\.\s*setItem\s*\(\s*"
    r"['\"][^'\"]*" + TOKEN_KW)

JWT_SIGN_RE = re.compile(r"(?i)\bjwt\.sign\s*\((?P<body>[^)]*)\)")
JWT_EXP_RE = re.compile(r"(?i)\bexp\b|expiresIn|expires_at|expires|ttl|"
                        r"maxAge|notAfter|EXPIR")

BASIC_INLINE_RE = re.compile(
    r"(?i)authorization['\"]?\s*[:=,]\s*['\"]basic\s+[A-Za-z0-9+/=]{6,}")

COMMENT_RE = {
    ".py": re.compile(r"^\s*#"), ".rb": re.compile(r"^\s*#"),
    ".ex": re.compile(r"^\s*#"), ".exs": re.compile(r"^\s*#"),
}


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
    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:
            if fn.lower().endswith(TARGET_EXT):
                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()
    script_ext = ext in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs")

    for i, line in enumerate(lines, 1):
        if is_comment(line, ext):
            if AUTH_COMMENTED_RE.search(line):
                findings.append(Finding(
                    "AUTH-DISABLED", "LOW", rel, i,
                    "auth decorator/middleware appears commented out — "
                    "confirm the route is still protected"))
            continue

        if JWT_NONE_RE.search(line):
            findings.append(Finding(
                "AUTH-JWT-NONE-ALG", "HIGH", rel, i,
                "JWT 'none' algorithm accepted/selected — unsigned "
                "tokens would be treated as valid"))

        for cre in JWT_NOVERIFY_HIGH:
            if cre.search(line):
                findings.append(Finding(
                    "AUTH-JWT-NOVERIFY", "HIGH", rel, i,
                    "JWT signature verification explicitly disabled — "
                    "any forged token is accepted"))
                break
        else:
            if script_ext and JWT_DECODE_BARE_RE.search(line):
                findings.append(Finding(
                    "AUTH-JWT-NOVERIFY", "LOW", rel, i,
                    "jwt.decode() reads claims without verifying the "
                    "signature — ensure a verify() precedes any authz "
                    "decision"))

        if not PW_SAFE_RE.search(line):
            if any(cre.search(line) for cre in PW_WEAKHASH_RES):
                findings.append(Finding(
                    "AUTH-PW-WEAKHASH", "HIGH", rel, i,
                    "md5/sha1 applied to a password/credential — "
                    "trivially brute-forced; use a salted KDF"))
            elif any(cre.search(line) for cre in PW_FASTHASH_RES):
                findings.append(Finding(
                    "AUTH-PW-FASTHASH", "MEDIUM", rel, i,
                    "single-pass fast hash (sha256/384/512) applied to "
                    "a password — use bcrypt/argon2/scrypt/pbkdf2"))

        if JWT_SECRET_LIT_RE.search(line):
            findings.append(Finding(
                "AUTH-JWT-SECRET-LIT", "MEDIUM", rel, i,
                "jwt sign/verify with an inline literal secret — "
                "hardcoded signing keys leak with the repo"))

        if PW_HARDCODED_RE.search(line):
            findings.append(Finding(
                "AUTH-PW-HARDCODED", "MEDIUM", rel, i,
                "credential compared to a hardcoded literal — "
                "password/secret in source"))

        for cre in AUTH_DISABLED_RES:
            if cre.search(line):
                findings.append(Finding(
                    "AUTH-DISABLED", "MEDIUM", rel, i,
                    "authentication explicitly disabled — confirm this "
                    "route/feature is meant to be public"))
                break
        else:
            if AUTH_ROUTE_FALSE_RE.search(line):
                findings.append(Finding(
                    "AUTH-DISABLED", "MEDIUM", rel, i,
                    "auth flag set false/null — confirm the endpoint "
                    "is intended to be public"))

        if (PREDICTABLE_RNG.search(line)
                and re.search(TOKEN_KW, line, re.I)
                and not PREDICTABLE_SAFE.search(line)):
            findings.append(Finding(
                "AUTH-PREDICTABLE-TOKEN", "MEDIUM", rel, i,
                "non-CSPRNG used to mint a token/session/reset/nonce "
                "value — predictable by an attacker"))

        if SESSION_FIX_RE.search(line):
            findings.append(Finding(
                "AUTH-SESSION-FIXATION", "MEDIUM", rel, i,
                "session id taken from request input — attacker can "
                "fix the victim's session id"))

        if COOKIE_SET_RE.search(line) and COOKIE_NAME_RE.search(line):
            ctx = line
            if line.count("(") > line.count(")"):
                for nxt in lines[i:i + 8]:
                    ctx += " " + nxt.strip()
                    if ctx.count("(") <= ctx.count(")"):
                        break
            if not HTTPONLY_RE.search(ctx):
                findings.append(Finding(
                    "AUTH-COOKIE-FLAGS", "MEDIUM", rel, i,
                    "session/auth cookie set without HttpOnly — "
                    "readable by injected script"))
            elif not (SECURE_RE.search(ctx)
                      and SAMESITE_RE.search(ctx)):
                findings.append(Finding(
                    "AUTH-COOKIE-FLAGS", "LOW", rel, i,
                    "session/auth cookie missing Secure and/or "
                    "SameSite flag"))

        if not TIMING_SAFE_CALL.search(line):
            m = TIMING_RE.search(line)
            if m:
                operand = m.group(1).strip().strip("'\"")
                operand = operand.strip("()[];, ").lower()
                if (operand not in TIMING_SAFE_OPERAND
                        and not re.match(r"^\d+$", operand)
                        and not m.group(1).lstrip().startswith(
                            ("'", '"'))
                        and not operand.startswith(("len(", "sizeof("))):
                    findings.append(Finding(
                        "AUTH-TIMING-COMPARE", "LOW", rel, i,
                        "credential material compared with ==/!= "
                        "— non-constant-time compare leaks bytes; use "
                        "a timing-safe compare"))
            elif TIMING_EQUALS_RE.search(line):
                findings.append(Finding(
                    "AUTH-TIMING-COMPARE", "LOW", rel, i,
                    "credential material compared with .equals() "
                    "— non-constant-time compare leaks bytes; use "
                    "a timing-safe compare"))

        if LOCALSTORAGE_RE.search(line):
            findings.append(Finding(
                "AUTH-LOCALSTORAGE-TOKEN", "LOW", rel, i,
                "token/session written to web storage — readable by "
                "any injected script (XSS)"))

        m = JWT_SIGN_RE.search(line)
        if m and not JWT_EXP_RE.search(m.group("body")):
            findings.append(Finding(
                "AUTH-JWT-NO-EXP", "LOW", rel, i,
                "jwt.sign without an expiry — token never expires"))

        if BASIC_INLINE_RE.search(line):
            findings.append(Finding(
                "AUTH-BASIC-INLINE", "LOW", rel, i,
                "hardcoded 'Authorization: Basic' credential in "
                "source"))

    return findings


def render(findings, targets):
    out = ["AUTH/SESSION 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 auth layer is safe (middleware order, runtime "
                   "config and reachability are not evaluated)")
    out.append("")
    out.append("Scope: deterministic pre-scan only — not an audit.")
    return "\n".join(out)


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

SELFTEST = [
    # JWT none-alg
    ("x.py", 'jwt.decode(t, key, algorithms=["none", "HS256"])\n',
     {"AUTH-JWT-NONE-ALG"}),
    ("x.js", 'const a = jwt.verify(token, k, {algorithm: "none"});\n',
     {"AUTH-JWT-NONE-ALG"}),
    # JWT verify disabled
    ("x.py", 'jwt.decode(token, verify=False)\n',
     {"AUTH-JWT-NOVERIFY"}),
    ("x.rb", 'JWT.decode(tok, nil, false)\n',
     {"AUTH-JWT-NOVERIFY"}),
    ("x.js", 'const c = jwt.decode(token);\n',
     {"AUTH-JWT-NOVERIFY"}),
    # weak hash on password
    ("x.py", 'h = hashlib.md5(password.encode()).hexdigest()\n',
     {"AUTH-PW-WEAKHASH"}),
    ("x.php", '$h = md5($password);\n', {"AUTH-PW-WEAKHASH"}),
    ("x.js", 'const h = createHash("sha256").update(password).digest();\n',
     {"AUTH-PW-FASTHASH"}),
    # safe kdf -> clean
    ("x.py", 'h = bcrypt.hashpw(password, bcrypt.gensalt())\n', set()),
    # jwt literal secret
    ("x.js", 'jwt.sign(payload, "hunter2-secret")\n',
     {"AUTH-JWT-SECRET-LIT", "AUTH-JWT-NO-EXP"}),
    ("x.js", 'jwt.sign(payload, "s3k", {expiresIn: "1h"})\n',
     {"AUTH-JWT-SECRET-LIT"}),
    # hardcoded credential compare
    ("x.py", 'if password == "sup3rsecret":\n    ok()\n',
     {"AUTH-PW-HARDCODED"}),
    # auth disabled
    ("x.py", 'permission_classes = [AllowAny]\n', {"AUTH-DISABLED"}),
    ("x.cs", '[AllowAnonymous]\npublic IActionResult Login()\n',
     {"AUTH-DISABLED"}),
    ("x.ts", '@Public()\n@Get("health")\n', {"AUTH-DISABLED"}),
    ("x.js", 'const isAuthenticated = () => true;\n', {"AUTH-DISABLED"}),
    ("x.py", '# @login_required\ndef view(r):\n', {"AUTH-DISABLED"}),
    # predictable token
    ("x.js", 'const reset_token = Math.random().toString(36);\n',
     {"AUTH-PREDICTABLE-TOKEN"}),
    ("x.py", 'token = str(random.randint(0, 999999))\n',
     {"AUTH-PREDICTABLE-TOKEN"}),
    ("x.php", '$token = uniqid();\n', {"AUTH-PREDICTABLE-TOKEN"}),
    # safe token gen -> clean
    ("x.py", 'token = secrets.token_hex(32)\n', set()),
    # session fixation
    ("x.php", 'session_id($_GET["sid"]);\n', {"AUTH-SESSION-FIXATION"}),
    ("x.js", 'req.session_id = req.query.sid;\n',
     {"AUTH-SESSION-FIXATION"}),
    # cookie flags
    ("x.js", 'res.cookie("session", sid);\n', {"AUTH-COOKIE-FLAGS"}),
    ("x.py", 'response.set_cookie("auth", t, httponly=True)\n',
     {"AUTH-COOKIE-FLAGS"}),
    ("x.js", 'res.cookie("session", s, {httpOnly:true, secure:true,'
     ' sameSite:"lax"});\n', set()),
    # timing compare
    ("x.py", 'if token == db_token:\n    ok()\n',
     {"AUTH-TIMING-COMPARE"}),
    ("x.py", 'if hmac.compare_digest(token, db_token):\n', set()),
    ("x.js", 'if (password == null) return;\n', set()),
    # localstorage token
    ("x.js", 'localStorage.setItem("auth_token", t);\n',
     {"AUTH-LOCALSTORAGE-TOKEN"}),
    # basic inline
    ("x.py", 'headers = {"Authorization": "Basic YWRtaW46cHc="}\n',
     {"AUTH-BASIC-INLINE"}),
    # clean file
    ("x.js", 'const t = jwt.sign(p, key, {expiresIn: "1h"});\n'
     'jwt.verify(t, key);\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)
            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([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())
