#!/usr/bin/env python3
"""crypto_prescan.py — deterministic weak-cryptography / insecure-randomness
source pre-scan.

ARION service artifact. Scans source files for the crypto-misuse classes
that are checkable offline, line-wise, without executing code:

  * broken hashes: MD5 / SHA-1 in security context (passwords, tokens,
    signatures, integrity of distributed artifacts) vs benign checksum use
  * broken ciphers/modes: DES, 3DES/DESede, RC2, RC4, Blowfish, AES-ECB,
    deprecated Node createCipher/createDecipher
  * non-CSPRNG for secrets: random.*, Math.random, mt_rand/rand/uniqid,
    java.util.Random, math/rand, rand()/srand — severity raised when the
    value names a token/key/session/secret/nonce/iv
  * hard-coded key material at the call site: literal bytes/strings
    assigned to key/iv/salt variables feeding a cipher call
  * static / zero IVs and reused nonces (b"\\x00"*16, iv = "1234...")
  * TLS verification disabled: verify=False, ssl.CERT_NONE,
    _create_unverified_context, rejectUnauthorized:false,
    NODE_TLS_REJECT_UNAUTHORIZED=0, InsecureSkipVerify:true,
    CURLOPT_SSL_VERIFYPEER 0, VERIFY_NONE, --insecure
  * JWT misuse: alg "none", decode with verification off
    (verify=False / verify_signature:false / complete:false decode-only),
    HS256 paired with a hard-coded short secret literal
  * password hashing without a KDF: md5/sha1/sha256/sha512 of a password
    (bcrypt/argon2/scrypt/pbkdf2/PHC strings are NOT flagged)
  * small RSA keys: generation with modulus length < 2048
  * predictable seeds: random.seed(<literal-or-time>) / srand(time)
  * home-rolled crypto hints (LOW/INFO): XOR-with-byte "encrypt" helpers,
    comments claiming custom encryption

Honest scope: PRE-SCAN, not an audit. Line-based matching — it cannot
follow dataflow, judge protocol soundness, or see runtime config. A FLAG
means "deserves human review"; a clean report is not proof the code is
cryptographically sound.

Usage:
  crypto_prescan.py <file-or-dir> [--json]
  crypto_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}

# Security-context words: presence on the same line raises severity for
# ambiguous classes (weak hash, weak RNG).
SEC_CTX_RE = re.compile(
    r"(?i)(password|passwd|pwd|secret|token|api[_-]?key|private|session|"
    r"auth|credential|sign|signature|nonce|\biv\b|salt|csrf|xsrf|cookie|"
    r"jwt|bearer|hmac|integrity|license[_-]?key|reset|otp|recovery|"
    r"encrypt|decrypt|cert|fingerprint|checksum[_-]?verify)")

# Benign-context words: downgrade weak-hash hits when clearly used as
# content addressing / cache keys / etags, NOT security.
BENIGN_CTX_RE = re.compile(
    r"(?i)(etag|cache|dedup|idempoten|checksum(?!_verify)|content[_-]?(id|"
    r"address|hash)|fingerprint[_-]?only|shard|bucket|partition|"
    r"noncrypt|non-?security|test[_-]?fixture)")

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

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


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


# ---------------------------------------------------------------- rules
# Each rule: (code, base_sev, regex, context_rule, message)
# context_rule: None = base sev always
#               "ctx" = HIGH if SEC_CTX_RE else base
#               "benign_down" = base if SEC_CTX else LOW if BENIGN else base
RULES = [
    # --- broken hashes -------------------------------------------------
    ("WEAK-HASH", "MEDIUM",
     re.compile(r"(?i)(hashlib\.(md5|sha1)\s*\(|MessageDigest\.getInstance"
                r"\s*\(\s*[\"'](MD5|SHA-?1)[\"']|crypto\.createHash\s*\(\s*"
                r"[\"'](md5|sha1)[\"']|Digest::(MD5|SHA1)\b|\bmd5\s*\(|\b"
                r"sha1\s*\(|MD5\.(New|Sum)|sha1\.(New|Sum)|EVP_(md5|sha1)\b"
                r"|\bMD5_Init|\bSHA1_Init|\bMd5\b|\bSha1\b)"),
     "hash", "broken hash primitive (MD5/SHA-1) — collision-broken; "
     "fine for cache keys, not for passwords/tokens/integrity"),
    # --- broken ciphers / modes ----------------------------------------
    ("WEAK-CIPHER", "HIGH",
     re.compile(r"(?i)(DES\.new|DESede|TripleDES|\b3DES\b|des\.NewCipher|"
                r"des\.NewTripleDESCipher|EVP_(des|des3|bf|rc2|rc4)|"
                r"RC2\.new|RC4\.new|ARC4|ARCFOUR|Blowfish\.new|"
                r"Cipher\.getInstance\s*\(\s*[\"'][^\"']*\b(DES|RC2|RC4|"
                r"Blowfish)|createCipheriv?\s*\(\s*[\"'](des|rc2|rc4|bf|"
                r"blowfish)|\"alg\"\s*:\s*[\"'](dir|RSA1_5))"),
     None, "broken/deprecated cipher (DES/3DES/RC2/RC4/Blowfish/RSA1_5)"),
    ("ECB-MODE", "HIGH",
     re.compile(r"(?i)(MODE_ECB|AES/ECB|/ECB/|\"ecb\"|'ecb'|ECB_MODE|"
                r"createCipheriv?\s*\(\s*[\"'][a-z0-9-]*-ecb|"
                r"ElectronicCodebook)"),
     None, "ECB mode — leaks plaintext structure; use CBC/GCM w/ random IV"),
    ("NODE-LEGACY-CIPHER", "HIGH",
     re.compile(r"crypto\.createCipher\s*\(|crypto\.createDecipher\s*\("),
     None, "deprecated createCipher/createDecipher — weak key derivation "
     "(MD5, no salt); use createCipheriv with random key+IV"),
    # --- non-CSPRNG -----------------------------------------------------
    ("WEAK-RNG", "LOW",
     re.compile(r"(?i)(\brandom\.(random|randint|randrange|choice|choices|"
                r"getrandbits|sample|shuffle|uniform)\s*\(|Math\.random\s*"
                r"\(|\bmt_rand\s*\(|\brand\s*\(|\buniqid\s*\(|new\s+Random"
                r"\s*\(|\brand\.Int|math/rand|Random\.rand|srand\s*\(|"
                r"numpy\.random\.|np\.random\.|\brand\s*\(\s*\)|"
                r"arc4random_uniform(?!_))"),
     "rng", "non-CSPRNG used where predictability may matter "
     "(secrets/tokens need crypto.random / secrets / SecureRandom / "
     "crypto/rand)"),
    ("PREDICTABLE-SEED", "MEDIUM",
     re.compile(r"(?i)(random\.seed\s*\(\s*[\d\"']|srand\s*\(\s*(time|"
                r"\d)|\.Seed\s*\(\s*\d|setSeed\s*\(\s*\d|"
                r"random\.seed\s*\(\s*time)"),
     None, "RNG seeded with a constant or wall-clock — outputs reproducible"),
    # --- hard-coded key material / IVs ----------------------------------
    ("HARDCODED-KEY", "HIGH",
     re.compile(r"(?i)\b(key|secret|cipherkey|encryption[_-]?key|"
                r"signing[_-]?key)\s*=\s*[\"'](?=[^\"'\s]*\d)(?=[^\"'\s]*"
                r"[a-zA-Z])[^\"'\s]{8,}[\"']"),
     "assign", "literal key material assigned in source — move to "
     "env/secret store"),
    ("STATIC-IV", "HIGH",
     re.compile(r"(?i)\biv\b\s*=\s*[\"'][^\"']{1,64}[\"']|\biv\b\s*=\s*"
                r"b?[\"']\\x00|nonce\s*=\s*[\"']\d+[\"']|IV\s*=\s*"
                r"new\s+byte\[\d+\]|ivParameterSpec\s*\(\s*new\s+byte"),
     None, "static/zero IV or nonce — IVs must be fresh and unpredictable "
     "per message"),
    # --- TLS verification off -------------------------------------------
    ("TLS-VERIFY-OFF", "HIGH",
     re.compile(r"(?i)(verify\s*=\s*False|_create_unverified_context|"
                r"ssl\.CERT_NONE|CERT_NONE|rejectUnauthorized\s*:\s*false|"
                r"NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*[\"']?0|"
                r"InsecureSkipVerify\s*:\s*true|CURLOPT_SSL_VERIFYPEER"
                r"[\s,]+0|CURLOPT_SSL_VERIFYHOST[\s,]+0|VERIFY_NONE|"
                r"verify_mode\s*=\s*0|--insecure\b|check_hostname\s*=\s*"
                r"False|verifyServerCertificate\s*=\s*false|"
                r"DangerousAcceptAnyServerCertificateValidator|"
                r"TrustAll|X509TrustManager\s*\(\s*\)\s*\{[^}]*"
                r"checkServerTrusted\s*\([^)]*\)\s*\{\s*\})"),
     None, "TLS certificate verification disabled — MITM-exposes the "
     "connection"),
    # --- JWT -------------------------------------------------------------
    ("JWT-NONE", "HIGH",
     re.compile(r"(?i)(alg[\"']?\s*[:=]\s*[\"']none[\"']|algorithm[\"']?"
                r"\s*[:=]\s*[\"']none[\"']|\"alg\"\s*:\s*\"none\")"),
     None, "JWT alg=none — unsigned tokens, trivially forgeable"),
    ("JWT-NOVERIFY", "HIGH",
     re.compile(r"(?i)(jwt\.decode\s*\([^)]*(verify\s*=\s*False|"
                r"verify_signature[\"']?\s*:\s*False|"
                r"verify_signature\s*=\s*False)|decode\s*\([^)]*"
                r"verify\s*=\s*false|JWT\.decode\s*\([^)]*,\s*nil,?\s*"
                r"false|jwt\.decode\s*\([^)]*\)\s*//\s*no.?verify|"
                r"parse\s*\([^)]*without.*verify)"),
     None, "JWT decoded without signature verification"),
    ("JWT-WEAK-SECRET", "MEDIUM",
     re.compile(r"(?i)(jwt\.(sign|encode)\s*\([^)]*[\"'][0-9A-Za-z]{1,15}"
                r"[\"']|algorithm\s*=\s*[\"']HS256[\"'][^#\n]{0,80}"
                r"secret\s*=\s*[\"'])"),
     None, "JWT signed with a short hard-coded secret literal"),
    # --- password hashing without KDF -----------------------------------
    ("PASSWORD-FASTHASH", "HIGH",
     re.compile(r"(?i)((md5|sha1|sha256|sha512|SHA256|SHA512)\s*\(\s*"
                r"[^)]*(password|passwd|pwd)|(password|passwd|pwd)\s*=\s*"
                r"[^#\n]*(md5|sha1|sha256|sha512|createHash|hexdigest)|"
                r"hash(password|passwd)|DigestUtils\.(md5|sha)\s*\(\s*"
                r"password)"),
     None, "password hashed with a fast hash — use bcrypt/argon2/scrypt/"
     "PBKDF2"),
    # --- small RSA keys ---------------------------------------------------
    ("SMALL-RSA-KEY", "HIGH",
     re.compile(r"(?i)(generate_private_key\s*\([^)]*key_size\s*=\s*"
                r"(512|768|1024)\b|RSA\.generate\s*\(\s*(512|768|1024)\b|"
                r"KeyPairGenerator\.initialize\s*\(\s*(512|768|1024)\b|"
                r"rsa\.GenerateKey\s*\([^,]+,\s*(512|768|1024)\b|"
                r"modulusLength\s*:\s*(512|768|1024)\b)"),
     None, "RSA key < 2048 bits — below current minimum"),
    # --- home-rolled crypto hints ----------------------------------------
    ("XOR-ENCRYPT", "LOW",
     re.compile(r"(?i)(def\s+\w*encrypt\w*\s*\([^)]*\)\s*:[^\n]*|function\s"
                r"+\w*encrypt\w*\s*\([^)]*\)\s*\{[^\n]*)"),
     "xor", "custom encrypt helper — review whether it is home-rolled "
     "crypto (XOR/rot13/substitution)"),
]

GOOD_KDF_RE = re.compile(r"(?i)(bcrypt|argon2|scrypt|pbkdf2|\$2[aby]\$|"
                         r"\$argon2|hashpw|kdf)")

# assignment-line heuristic for HARDCODED-KEY: require it NOT to be a
# variable-to-variable pass-through or env read (those are fine).
SAFE_KEY_SRC_RE = re.compile(r"(?i)(os\.environ|getenv|process\.env|"
                             r"ENV\[|config\.|settings\.|secrets\.|"
                             r"kms|vault|sm\.|readFile|load)")


def adjust_sev(rule_ctx, base, line, ctxline=None):
    """Apply context tiering for ambiguous classes."""
    if rule_ctx == "hash":
        if SEC_CTX_RE.search(line):
            return "HIGH"
        if BENIGN_CTX_RE.search(line):
            return "LOW"
        return base
    if rule_ctx == "rng":
        # enclosing function/def line often carries the security context
        if SEC_CTX_RE.search(ctxline or line):
            return "HIGH"
        return base
    if rule_ctx == "assign":
        if SAFE_KEY_SRC_RE.search(line):
            return None  # key read from env/config — not a finding
        return base
    if rule_ctx == "xor":
        # only flag when an XOR is actually visible near crypto naming
        return base
    return base


def scan_line(line, ctxline, fpath, lineno, out):
    if is_comment_or_blank(line):
        return
    hit = set()
    for code, base, rx, ctx, msg in RULES:
        if not rx.search(line):
            continue
        sev = adjust_sev(ctx, base, line, ctxline)
        if sev is None:
            continue
        hit.add(code)
        out.append(Finding(code, sev, fpath, lineno, msg))
    # 'none' literal is the alg, not a secret — drop the shadow finding
    if "JWT-NONE" in hit and "JWT-WEAK-SECRET" in hit:
        out[:] = [f for f in out
                  if not (f.file == fpath and f.line == lineno
                          and f.code == "JWT-WEAK-SECRET")]


def xor_followup(path, fpath, out):
    """Second pass for XOR-ENCRYPT: a custom-named encrypt/decrypt
    function whose body XORs bytes — home-rolled cipher."""
    try:
        with open(path, "r", errors="replace") as fh:
            text = fh.read()
    except OSError:
        return
    for m in re.finditer(
            r"(?im)^[ \t]*(def|function|func|fn|void|String|public"
            r"[^\n=]*)\s+(\w*(encrypt|decrypt|cipher|scramble)\w*)"
            r"\s*\([^\n]*", text):
        start = text.rfind("\n", 0, m.start()) + 1
        body = text[m.end():m.end() + 800]
        lineno = text.count("\n", 0, start) + 1
        if re.search(r"\^\s*(0x[0-9a-fA-F]{1,2}|\d+|key\b|\w+\[)", body):
            line = text[text.rfind("\n", 0, m.start()) + 1:
                        text.find("\n", m.end()) if text.find(
                            "\n", m.end()) > 0 else len(text)]
            if not is_comment_or_blank(line):
                out.append(Finding(
                    "XOR-ENCRYPT", "LOW", fpath, lineno,
                    "encrypt/decrypt helper XORs data — home-rolled "
                    "cipher; use AEAD (AES-GCM/ChaCha20-Poly1305)"))


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 = []
        for i, line in enumerate(lines, 1):
            s = line.rstrip("\n")
            ctxline = "\n".join(recent[-2:] + [s])
            scan_line(s, ctxline, rel, i, findings)
            if s.strip():
                recent.append(s)
        xor_followup(path, rel, findings)
    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 = ["# Crypto mis-use 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-based; no "
           "dataflow, no runtime. A clean report is not proof of sound "
           "crypto._",
           ""]
    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-md5-password",
     "import hashlib\nx = hashlib.md5(password.encode()).hexdigest()\n",
     {"WEAK-HASH:HIGH", "PASSWORD-FASTHASH:HIGH"}),
    ("py-md5-cache",
     "import hashlib\netag = hashlib.md5(body).hexdigest()  # cache key\n",
     {"WEAK-HASH:LOW"}),
    ("js-math-random-token",
     "const token = Math.random().toString(36).slice(2)\n",
     {"WEAK-RNG:HIGH"}),
    ("js-math-random-ui",
     "const jitter = Math.random() * 20\n",
     {"WEAK-RNG:LOW"}),
    ("py-secrets-ok",
     "import secrets\ntoken = secrets.token_urlsafe(32)\n",
     set()),
    ("ecb",
     "cipher = AES.new(key, AES.MODE_ECB)\n",
     {"ECB-MODE:HIGH"}),
    ("des-java",
     "Cipher c = Cipher.getInstance(\"DES\");\n",
     {"WEAK-CIPHER:HIGH"}),
    ("tls-off-requests",
     "r = requests.get(url, verify=False)\n",
     {"TLS-VERIFY-OFF:HIGH"}),
    ("tls-off-go",
     "cfg := &tls.Config{InsecureSkipVerify: true}\n",
     {"TLS-VERIFY-OFF:HIGH"}),
    ("jwt-none",
     "jwt.sign(payload, '', {algorithm: 'none'})\n",
     {"JWT-NONE:HIGH"}),
    ("jwt-noverify-py",
     "jwt.decode(t, options={\"verify_signature\": False})\n",
     {"JWT-NOVERIFY:HIGH"}),
    ("static-iv",
     "iv = b\"\\x00\" * 16\n",
     {"STATIC-IV:HIGH"}),
    ("env-key-ok",
     "key = os.environ['ENC_KEY']\n",
     set()),
    ("hardcoded-key",
     "encryption_key = \"s3cr3tK3y!2024\"\nc = AES.new(key)\n",
     {"HARDCODED-KEY:HIGH"}),
    ("bcrypt-ok",
     "h = bcrypt.hashpw(password, bcrypt.gensalt())\n",
     set()),
    ("rsa-1024",
     "k = RSA.generate(1024)\n",
     {"SMALL-RSA-KEY:HIGH"}),
    ("php-mt-rand-pw",
     "$reset = mt_rand(1000, 9999); // password reset code\n",
     {"WEAK-RNG:HIGH"}),
    ("rand-ui-java",
     "Random r = new Random(); int dice = r.nextInt(6);\n",
     {"WEAK-RNG:LOW"}),
    ("xor-helper",
     "def encrypt_blob(d):\n    return bytes(b ^ 0x42 for b in d)\n",
     {"XOR-ENCRYPT:LOW"}),
    ("node-createcipher",
     "const c = crypto.createCipher('aes192', 'pw')\n",
     {"NODE-LEGACY-CIPHER:HIGH"}),
    ("seeded-rand",
     "random.seed(1337)\n",
     {"PREDICTABLE-SEED:MEDIUM"}),
]


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