#!/usr/bin/env python3
"""log_prescan.py — deterministic sensitive-data-in-logs / log-injection
pre-scan (CWE-532 / CWE-117 / CWE-200).

ARION service artifact. Scans source files for values that should
never reach a log file flowing into logging sinks, checkable offline,
line-wise, without executing code:

  * strong-secret operands into any log/print sink — password,
    passphrase, secret, api-key, private/signing/encryption/master
    keys, client_secret, authorization/bearer, credentials, seed
    phrases/mnemonics, connection strings, OTP/TOTP/PIN, CVV — HIGH
  * header/cookie-bearing request objects — req.headers,
    request.headers/META/cookies, r.Header, $_SERVER, $_ENV,
    req.cookies — carry Authorization + session material — HIGH
  * soft-credential operands — bare token/session_id/jwt/signed-
    value names — MEDIUM (real credential, noisier name)
  * hashed-secret operands — *_hash / *_digest / hmac values —
    MEDIUM (dumps feed offline cracking)
  * PII operands — ssn/card-number/passport/driver-license/DOB/
    tax-id/IBAN — MEDIUM (CWE-532 regulated data)
  * bare request objects — req/request/ctx.Request serialized
    whole — MEDIUM (attacker text + possible headers)
  * environment dumps — os.environ / process.env / System.getenv /
    ENV — MEDIUM (env is where secrets live)
  * request body / form / parameter data — req.body, request.POST/
    data/json, $_POST/$_REQUEST, getParameter, ctx.Body — MEDIUM
    (arbitrary attacker text incl. CRLF → log forging, CWE-117)
  * header/query values — req.headers.X, user-agent/referer,
    query/args values — LOW

Two sink tiers. LOG-tier (logging.*, logger/log/slog/zap/winston/
pino/bunyan/logrus/serilog receiver calls, syslog/error_log/
NSLog/Android Log.*, Rust log macros) runs the full class set.
PRINT-tier (print/console.*/fmt.Print/System.out/Console.Write/
printf-family/puts/echo-sh/IO.puts) runs only the credential/PII/
env classes — stdout IS the log stream in daemons, but request-
data-into-print is too idiomatic to flag.

Suppression: string-literal CONTENTS are stripped before operand
analysis — `logger.info("password reset for %s", user)` is silent;
JS template ${expr} interpolations still count as code. Operands
inside mask/redact/scrub/anonymize/obfuscate/sanitize calls are
silent. Metadata suffixes (_id except session_id, _name, _path,
_url, _type, _len/count/size/ttl/expiry/timeout/attempts, _alg,
_scope, _issuer, _endpoint...) suppress — `token_count` and
`api_key_id` are not secrets. Sliced operands (token[:4],
substring(0,…)) drop one tier — a prefix leak, not the value.
len()/strlen/.length wrappers suppress. Test/spec/fixture paths
downgrade one tier (LOW drops off). Standard access-log fields —
req.method/path/url/ip/route/query-key extraction — are silent.

Honest scope: PRE-SCAN, not an audit. Line- and window-based; no
dataflow. It cannot tell whether a same-named variable holds a real
credential or a label — a FLAG means "deserves human review"; a
clean report is not proof nothing sensitive reaches the logs.

Usage:
  log_prescan.py <file-or-dir> [--json]
  log_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", "docs", "Documentation"}

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*(\*|/\*|<!--)")

TEST_PATH_RE = re.compile(
    r"(?i)(^|/)(test|tests|spec|specs|fixtures?|mocks?|__tests__|"
    r"__mocks__|testdata|e2e|stubs?)(/|$)|(^|/)test_|_test\.|"
    r"\.test\.|\.spec\.|spec_")

# ---------------------------------------------------------------- sinks

# logger-receiver vocabulary — obj.METHOD(...) counts as a log sink
# only when obj looks like a logger.
LOG_RECV_RE = re.compile(
    r"(?i)\b(log|logger|logging|slog|zap|sugar|sugared|zerolog|logrus|"
    r"log15|glog|klog|xlog|winston|pino|bunyan|log4j|log4net|slf4j|"
    r"serilog|nlog|logback|tinylog|_log|_logger|m_log|auditlog|"
    r"applog|app_log|svc_log|mylogger|thelogger)\s*$")

# generic method names on a logger receiver. Single-letter methods are
# Android-Log only (see below).
LOG_METHOD_RE = re.compile(
    r"(?i)\.(debug|info|warn|warning|error|err|critical|crit|fatal|"
    r"panic|exception|log|trace|tracef|debugf|infof|warnf|errorf|"
    r"fatalf|panicf|notice|alert|emerg|verbose|fine|finer|finest|"
    r"write|writeln|print|println|printf|printfln|withfields|"
    r"log(?:trace|debug|information|warning|warn|error|critical|"
    r"fatal)|logd|logi|logw|loge|logv|wtf)\s*\(")

RECV_CALL_RE = re.compile(
    r"(?P<recv>[A-Za-z_$][\w$.]*)\s*(?:\.|->)\s*"
    r"(?P<m>[A-Za-z_][\w]*)\s*\(")

# module-level / package-level log sinks
MODULE_SINK_RE = re.compile(
    r"(?i)\b(logging\.(?:debug|info|warning|warn|error|critical|"
    r"exception|log|fatal)|syslog|os\.syslog|error_log|NSLog|"
    r"applog|log_message|write_log|writelog|el_log|cf_log|"
    r"Log\.(?:d|e|w|i|v|wtf|println)|t\.Logf?|log\.(?:Print|Println|"
    r"Printf|Fatal|Fatalf|Fatalln|Panic|Panicf)|slog\.\w+|"
    r"log\.(?:Debug|Info|Warn|Error|Fatal|Panic|Trace)\w*|"
    r"ilogger\.|_ilog\.|gLogger\.|KIT_LOG)\s*\(")

# Rust/log4rs-style macros — `info!(...)`, `println!(...)` etc.
MACRO_SINK_RE = re.compile(
    r"\b(?P<m>debug|info|warn|error|trace|println|eprintln|print|"
    r"eprint|panic|format_args)!\s*\(")

# print-tier (stdout = log stream in daemons): credential/PII/env
# classes only. PHP echo/print write the RESPONSE — excluded by design.
PRINT_SINKS = {
    ".py":   re.compile(r"\bprint\s*\("),
    ".js":   re.compile(r"\bconsole\.(log|info|warn|error|debug|trace|"
                        r"dir)\s*\("),
    ".mjs":  None, ".cjs": None, ".jsx": None, ".ts": None,
    ".tsx":  None,
    ".go":   re.compile(r"\bfmt\.Print(?:ln|f)?\s*\("),
    ".java": re.compile(r"\bSystem\.(out|err)\.print(?:ln|f)?\s*\("),
    ".kt":   re.compile(r"\bSystem\.(out|err)\.print(?:ln|f)?\s*\(|"
                        r"\bprintln\s*\("),
    ".kts":  None, ".scala": None,
    ".c":    re.compile(r"\b(?:printf|puts|putchar|perror|fputs?\s*\("
                        r"[^)]*(?:stderr|stdout)|fprintf\s*\(\s*"
                        r"(?:stderr|stdout))"),
    ".h":    None, ".cpp": None, ".cc": None, ".hpp": None,
    ".cs":   re.compile(r"\bConsole\.Write(?:Line)?\s*\("),
    ".rs":   re.compile(r"\b(?:println|eprintln|print|eprint)!\s*\("),
    ".rb":   re.compile(r"\b(?:puts|print|pp)\s*[(\s]"),
    ".sh":   re.compile(r"(?:^|\s)(?:echo|printf)\s+"),
    ".ex":   re.compile(r"\bIO\.(?:puts|inspect|write)\s*\("),
    ".exs":  None,
    ".lua":  re.compile(r"\bprint\s*\("),
    ".pl":   re.compile(r"\b(?:print|say|warn|croak|carp)\s*[(\s]"),
    ".pm":   None,
    ".swift": re.compile(r"\b(?:print|NSLog|os_log)\s*\("),
    ".r":    re.compile(r"\b(?:print|cat|message|warning)\s*\("),
}
for _a in (".mjs", ".cjs", ".jsx", ".ts", ".tsx"):
    PRINT_SINKS[_a] = PRINT_SINKS[".js"]
for _a in (".h", ".cpp", ".cc", ".hpp"):
    PRINT_SINKS[_a] = PRINT_SINKS[".c"]
PRINT_SINKS[".kts"] = PRINT_SINKS[".kt"]
PRINT_SINKS[".scala"] = PRINT_SINKS[".kt"]
PRINT_SINKS[".exs"] = PRINT_SINKS[".ex"]
PRINT_SINKS[".pm"] = PRINT_SINKS[".pl"]

# ------------------------------------------------------------- operand vocab

# STRONG: logging this identifier almost always leaks a live credential.
STRONG_SECRET_RE = re.compile(
    r"(?i)(password|passwd|passphrase|\bpwd\b|secret|api.?key|"
    r"private.?key|priv_?key|client.?secret|access.?key|secret.?key|"
    r"signing.?key|encryption.?key|master.?key|auth.?key|apikey|"
    r"privatekey|secretkey|seed.?phrase|seed.?words|mnemonic|"
    r"authorization|bearer|credential|conn(ection)?.?string|\bdsn\b|"
    r"database.?url|\botp\b|totp|hotp|\bpin\b|cvv|cvc2?|\bpw\b|"
    r"passcode|pass.?wd|userpass|"
    r"access.?token|refresh.?token|id.?token|auth.?token|"
    r"session.?token|csrf.?token|xsrf.?token|api.?token|"
    r"secret.?token|private.?token|aws.?secret|x.?api.?key|"
    r"recovery.?code|backup.?code|decrypt|keypair|shared.?secret|"
    r"authkey)")

# SOFT: real credentials with noisier names.
SOFT_CRED_RE = re.compile(
    r"(?i)(\btoken\b|_token\b|\btokens\b|session.?id|sessionid|"
    r"\bjwt\b|\bjws\b|cookie|\bauth\b|\boauth\b|signature|\bsig\b|"
    r"_sig\b|\bsig_|\bhmac\b|signed|keystore|truststore|\bcert\b|"
    r"certificate|webhook.?secret)")

# hashed material — dumps feed offline cracking
HASHED_RE = re.compile(
    r"(?i)(\w*(?:hash|hashed|digest|sha\d*|md5|bcrypt|argon|"
    r"scrypt|pbkdf|fingerprint)\w*)")

# PII — regulated personal data in logs (CWE-532/359)
PII_RE = re.compile(
    r"(?i)(\bssn\b|social.?security|card.?number|cardnumber|pan\b|"
    r"passport|driver.?licen[cs]e|\bdob\b|date.?of.?birth|tax.?id|"
    r"tin\b|iban\b|\bbic\b|aadhaar|national.?id|medicare|medicaid|"
    r"health.?record|patient)")

# header/cookie-bearing objects — Authorization + session material.
REQ_HDR_RE = re.compile(
    r"(?i)(\breq\.headers\b|\brequest\.headers\b|\brequest\.META\b|"
    r"\brequest\.cookies|\breq\.cookies|\br\.Header\b|"
    r"ctx\.Request\.Header|\$_SERVER\b|\$_ENV\b|\$_COOKIE\b|"
    r"\bc\.Request\.Header|\bheaders\s*=\s*(?:req|request)\.|"
    r"\breq\.raw_headers|getallheaders\s*\(|apache_request_headers|"
    r"\bself\.headers\b|wsgi\.environ|request\.environ)")

# bare request objects serialized whole.
REQ_BARE_RE = re.compile(
    r"(?i)\b(req|request|ctx\.Request|http\.Request)\b(?!\s*\()")
# safe attribute tails that make a bare request mention benign
REQ_SAFE_ATTR_RE = re.compile(
    r"(?i)\b(?:req|request)\.(?:method|path|url|uri|route|endpoint|"
    r"ip|remote_addr|remoteAddr|originalUrl|base_url|host|hostname|"
    r"protocol|scheme|httpVersion|id|requestId|request_id|trace|"
    r"span|start_time|duration|elapsed|timestamp|path_info|"
    r"build_absolute_uri|get_full_path|content_type|accepted|"
    r"secure|is_secure|xhr|ajax)\b")

# environment dumps — env is where secrets live.
ENV_RE = re.compile(
    r"(?i)(os\.environ|process\.env|System\.getenv|\bgetenv\s*\(|"
    r"\bENV\b(?=\s*[,\)\]]|\s*$)|env::vars|std\.env|"
    r"\bEnvironment\.GetEnvironmentVariable|Dotenv|dotenv\.|"
    r"\bwsgi\.environ|request\.environ|os\.getenv\b|"
    r"\bprintenv\b)")

# request body / form / param data — arbitrary attacker text (CRLF
# forging + credential submission bodies).
REQ_BODY_RE = re.compile(
    r"(?i)(\breq\.body|\brequest\.(?:body|data|json|POST|form|files|"
    r"FILES|content)|\$_POST\b|\$_REQUEST\b|\$_FILES\b|"
    r"getParameter(?:Map|Values)?\s*\(|r\.FormValue|r\.PostFormValue|"
    r"r\.ParseForm|ctx\.(?:Body|FormValue|PostArgs)|c\.(?:BodyParser|"
    r"FormValue|FormFile)|@request->(?:all|input|post|json)|"
    r"\$request->(?:all|input|post|json)|request\.input|"
    r"Input::(?:all|get)|body\.getString|req\.parsedBody)")

# header / query values — low-value forging vector.
REQ_VAL_RE = re.compile(
    r"(?i)(\breq\.(?:query|params|param|get\(|header\(|"
    r"headers\.(?!authorization|cookie|set-cookie|x-|proxy)\w+)|"
    r"\brequest\.(?:args|values|GET|query_params|headers\.get)|"
    r"\$_GET\b|r\.URL\.Query|headers\s*\[|\.headers\.get\s*\(|"
    r"\.Header\.Get\s*\(|request\.META\[|"
    r"ctx\.Query|request->(?:query|header)|user.?agent|referer|"
    r"referrer|x\.forwarded|X-Forwarded|req\.ips?\b|"
    r"c\.(?:Query|GetHeader|Param))")

# named auth-bearing header reads — the credential itself.
HDR_CRED_RE = re.compile(
    r"(?i)\b(?:req|request|ctx\.Request|http\.Request|r|c)\.headers?"
    r"\s*(?:\[\s*['\"]?(?:authorization|cookie|x-?api-?key|"
    r"x-?auth\w*|proxy-?authorization)|\.(?:authorization|cookie|"
    r"proxy-?authorization|x-?api-?key|x-?auth\w*)\b)")

# ---------------------------------------------------------------- helpers

MASK_CALL_RE = re.compile(
    r"(?i)\b\w*(mask|redact|scrub|sanitiz|obfuscat|anonymiz|censor|"
    r"pseudonym|desensitiz)\w*\s*\(")

# metadata suffixes — identifiers ending in these hold facts ABOUT a
# secret, not the secret itself.
META_SUFFIX_RE = re.compile(
    r"(?i)(_?(path|file|dir|url|uri|endpoint|name|arn|type|kind|"
    r"format|algorithm|algo|method|scheme|provider|issuer|audience|"
    r"scope|length|len|count|size|ttl|expiry|expires|exp|timeout|"
    r"attempts|version|status|state|mode|level|reason|error|err|msg|"
    r"message|text|label|tag|id|ids|ref|hash_algo|cost|rounds|salt|"
    r"iterations|rotation|created|updated|index|idx|num|max|min|"
    r"list|map|config|cfg|path|file|dir|policy|role|region|env|var|"
    r"param|arg|field|column|attr|key_name|keyid|info|width|height|"
    r"bits|flags|modulus|depth|offset|pos|start|end|row|col|line))$")

# _id suppression exception — a live session id IS the credential.
ID_EXCEPTION_RE = re.compile(r"(?i)(session.?id|sessionid)")

# lexer/cache "token" vocabulary — not credentials.
BENIGN_TOKEN_RE = re.compile(
    r"(?i)(cache_?token|tokenize|tokenizer|_ttype\b|"
    r"token_?(?:type|kind|string|range|start|end|stream|list|seq)|"
    r"token\.(?:type|kind|string|exact_type|ttype|start|end|line|"
    r"col|text|image)|tok_?name|next_?token|lookahead)")

# `pwd` = print-working-directory when the file does cwd work.
PWD_CTX_RE = re.compile(r"(?i)(getcwd|cwd\b|chdir|working.?dir)")

# wrappers that only report the length
LEN_WRAP_RE = re.compile(
    r"(?i)\b(len|strlen|sizeof|length|count|size)\s*\(\s*[\w$.[\]'\"]+"
    r"\s*\)|[\w$.]+\.(?:length|len|size|count)\s*$")

# sliced operands — prefix leak, drop one tier
SLICE_RE = re.compile(r"\[[\s\d]*:[^\]]*\]|\.(?:substring|substr|slice|"
                      r"left|right|take|truncate)\s*\(\s*\d")

IDENT = r"[A-Za-z_$][\w$]*"
IDENT_ANY_RE = re.compile(IDENT)
EXPR_TOKEN_RE = re.compile(
    r"[A-Za-z_$][\w$.]*(?:\[[^\]]*\]|\([^()]*\))?")


STRING_RE = re.compile(
    r"(?i)([a-z$]*)?(\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'|"
    r"`(?:\\.|[^`\\])*`)")
# extensions whose string literals interpolate $var
DOLLAR_INTERP_EXT = {".php", ".sh", ".rb", ".pl", ".pm", ".r", ".lua"}


def code_only(text, ext=""):
    """Strip string-literal contents; keep interpolation expressions —
    JS/PHP-shell ${…}, Ruby #{…}, f-string/$-string {…}, $var inside
    literals for interpolating languages."""
    exprs = []
    for m in STRING_RE.finditer(text):
        pre, lit = (m.group(1) or "").lower(), m.group(2)
        exprs += re.findall(r"\$\{([^}]*)\}", lit)
        exprs += re.findall(r"#\{([^}]*)\}", lit)
        if "f" in pre or "$" in pre:
            exprs += re.findall(r"(?<!\{)\{([^{}]+)\}(?!\})", lit)
        if ext in DOLLAR_INTERP_EXT:
            exprs += re.findall(r"\$[A-Za-z_]\w*", lit)
        elif ext in (".ex", ".exs"):
            exprs += re.findall(r"#\{([^}]*)\}", lit)
    t = STRING_RE.sub("''", text)
    return t + " " + " ".join(exprs)


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


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 sink_spans(line, ext):
    """Yield (start,end,tier) spans of log-call openings on this line.
    tier: 'log' full classes, 'print' credentials/PII/env only."""
    spans = []
    for m in MODULE_SINK_RE.finditer(line):
        spans.append((m.start(), len(line), "log"))
    for m in MACRO_SINK_RE.finditer(line):
        tier = "print" if m.group("m") in (
            "println", "eprintln", "print", "eprint") else "log"
        spans.append((m.start(), len(line), tier))
    for m in RECV_CALL_RE.finditer(line):
        recv, meth = m.group("recv"), m.group("m")
        tail = recv.rsplit(".", 1)[-1]
        if re.fullmatch(r"(?i)log", tail) or LOG_RECV_RE.search(tail):
            if LOG_METHOD_RE.search("." + meth + "("):
                spans.append((m.start(), len(line), "log"))
        elif re.fullmatch(r"(?i)Log", tail) and re.fullmatch(
                r"(?i)(d|e|w|i|v|wtf|println)", meth):
            spans.append((m.start(), len(line), "log"))
        elif tail in ("self", "this", "cls", "cls_", "m") and re.search(
                r"(?i)log", recv):
            pass  # self.log(...) caught via recv tail above
        # logger attribute receivers: self._logger.info( → tail _logger
        if LOG_RECV_RE.search(tail) is None and re.search(
                r"(?i)(^|_|\.)(log|logger|logging)$", recv):
            if LOG_METHOD_RE.search("." + meth + "("):
                spans.append((m.start(), len(line), "log"))
    pr = PRINT_SINKS.get(ext)
    if pr:
        for m in pr.finditer(line):
            spans.append((m.start(), len(line), "print"))
    return spans


def masked_positions(blob):
    out = []
    for m in MASK_CALL_RE.finditer(blob):
        depth, i = 0, m.end()
        start = m.start()
        while i < len(blob):
            c = blob[i]
            if c in "([{":
                depth += 1
            elif c in ")]}":
                if depth == 0:
                    break
                depth -= 1
            i += 1
        out.append((start, i))
    return out


def in_spans(pos, spans):
    return any(s <= pos <= e for s, e in spans)


def presence_only(code, tok):
    """True when tok occurs only in presence/boolean positions —
    `x === null`, `null !== x`, `!x`, `if (x)`, `Boolean(x)`. Those
    log a yes/no, not the value."""
    t = re.escape(tok)
    c = re.sub(t + r"\s*(===|!==|==|!=)\s*"
               r"(null|undefined|None|nil|NULL|''|\"\")", "", code)
    c = re.sub(r"(null|undefined|None|nil|NULL|''|\"\")\s*"
               r"(===|!==|==|!=)\s*" + t, "", c)
    c = re.sub(r"!\s*" + t + r"\b", "", c)
    c = re.sub(r"\b(if|elif|while|assert|return)\s*\(?\s*" + t
               + r"\s*\)?\s*(?=[:{)\n]|$)", "", c)
    c = re.sub(r"Boolean\s*\(\s*" + t + r"\s*\)", "", c)
    return not re.search(r"(?<![\w$])" + t, c)


def classify_operand(tok, ctx=None):
    """Return (code, sev, note) for an identifier-ish operand."""
    ctx = ctx or {}
    sliced = bool(SLICE_RE.search(tok))
    t = tok.strip().strip("()[]{},;")
    if not t or t in ("''", '""', "``"):
        return None
    last = t.rsplit(".", 1)[-1].strip("_")
    if ID_EXCEPTION_RE.search(t):
        return ("LOG-CRED", "MEDIUM", "session id")
    if last and META_SUFFIX_RE.search(last):
        # token_count, api_key_id, secret_name, password_length …
        return None
    if re.search(r"(?i)(_?id|_?ids)$", last):
        return None  # *_id = a reference, not the value
    if BENIGN_TOKEN_RE.search(t):
        return None
    if re.search(r"(?i)\bpwd\b", t) and ctx.get("cwd"):
        return None  # pwd = os.getcwd() in this file
    if HASHED_RE.search(t):
        # *_hash / digest material — checked before STRONG so that
        # password_hash etc. land at MEDIUM
        return ("LOG-HASHED", "MEDIUM", "hashed credential material")
    if STRONG_SECRET_RE.search(t):
        if sliced:
            return ("LOG-SECRET", "MEDIUM", "sliced secret prefix")
        return ("LOG-SECRET", "HIGH", "secret material")
    if PII_RE.search(t):
        return ("LOG-PII", "MEDIUM", "regulated personal data")
    if SOFT_CRED_RE.search(t):
        if sliced:
            return ("LOG-CRED", "LOW", "sliced credential prefix")
        return ("LOG-CRED", "MEDIUM", "credential material")
    return None


REQ_ATTR_CHAIN_RE = re.compile(
    r"(?i)\b(?:req|request|ctx\.Request|http\.Request)\s*(?:\.|->)\s*"
    r"[\w$.[\]()'\"$-]*")

# subscripted/dot-accessed header reads — a single named header is not
# the whole headers object; auth-bearing names stay flagged.
HDR_SUBSCRIPT_RE = re.compile(
    r"(?i)\b(?:req|request|ctx\.Request|http\.Request|r|c)\.headers?"
    r"(?:\s*\[[^\]]*\]|\.(?:get|Get)\s*\([^)]*\)|"
    r"\.(?!authorization\b|cookie\b|x-|proxy|set-cookie)\w+)")


def scan_blob(blob, fpath, lineno, tier, ext, ctx, out):
    code = code_only(blob, ext)
    masks = masked_positions(code)

    # structural request/env classes — log-tier only
    if tier == "log":
        if HDR_CRED_RE.search(code):
            out.append(Finding(
                "LOG-SECRET", "HIGH", fpath, lineno,
                "auth-bearing header value into log — Authorization / "
                "cookie / api-key material reaches the log file"))
        if REQ_HDR_RE.search(HDR_SUBSCRIPT_RE.sub("", code)):
            out.append(Finding(
                "LOG-REQOBJ", "HIGH", fpath, lineno,
                "request headers/cookies object into log — carries "
                "Authorization + session material (CWE-532)"))
        tmp = REQ_ATTR_CHAIN_RE.sub("", code)
        if REQ_BARE_RE.search(tmp):
            out.append(Finding(
                "LOG-REQOBJ", "MEDIUM", fpath, lineno,
                "request object serialized into log — attacker text "
                "and possibly headers reach the log file"))
        if REQ_BODY_RE.search(code):
            out.append(Finding(
                "LOG-BODY", "MEDIUM", fpath, lineno,
                "request body/form data into log — arbitrary attacker "
                "text (CRLF forging, CWE-117; bodies often carry "
                "credentials)"))
        elif REQ_VAL_RE.search(code):
            out.append(Finding(
                "LOG-INPUT", "LOW", fpath, lineno,
                "request header/query value into log — log-forging "
                "text (CWE-117)"))
    if ENV_RE.search(code):
        out.append(Finding(
            "LOG-ENV", "MEDIUM", fpath, lineno,
            "environment block into log — env is where secrets are "
            "stored (CWE-532)"))

    # operand vocabulary — identifiers + small expressions
    for m in EXPR_TOKEN_RE.finditer(code):
        tok = m.group(0)
        if in_spans(m.start(), masks):
            continue
        if LEN_WRAP_RE.match(tok + ")") or LEN_WRAP_RE.match(tok):
            continue
        hit = classify_operand(tok, ctx)
        if not hit:
            continue
        if presence_only(code, tok):
            continue  # `x === null`-style presence check, no value out
        c, sev, note = hit
        if tier == "print" and sev == "LOW":
            continue  # print-tier: credentials/PII/env only
        out.append(Finding(
            c, sev, fpath, lineno,
            f"{note} `{tok}` into log sink — reaches the log file "
            "(CWE-532)"))


def scan_line(lines, i, fpath, ctx, out):
    line = lines[i]
    if is_comment_or_blank(line):
        return
    ext = os.path.splitext(fpath)[1].lower()
    spans = sink_spans(line, ext)
    if not spans:
        return
    before = len(out)
    for start, _end, tier in spans:
        # blob = from sink to end of line + up to 3 continuation lines
        # until parens look balanced
        blob = line[start:]
        depth = blob.count("(") - blob.count(")")
        j = i + 1
        while depth > 0 and j < len(lines) and j < i + 4:
            nxt = lines[j]
            blob += "\n" + nxt
            depth += nxt.count("(") - nxt.count(")")
            j += 1
        scan_blob(blob, fpath, i + 1, tier, ext, ctx, out)
    # dedup same code per line, keep strongest severity
    if len(out) > before:
        best = {}
        for f in out[before:]:
            k = f.code
            if k not in best or SEV_ORDER[f.sev] < SEV_ORDER[best[k].sev]:
                best[k] = f
        out[before:] = [best[k] for k in
                        sorted(best, key=lambda k: SEV_ORDER[best[k].sev])]


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
    test_target = bool(TEST_PATH_RE.search(target))
    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.read().splitlines()
        except OSError:
            continue
        scanned += 1
        ctx = {"cwd": bool(PWD_CTX_RE.search("\n".join(lines)))}
        local = []
        for i in range(len(lines)):
            scan_line(lines, i, rel, ctx, local)
        is_test = test_target or bool(TEST_PATH_RE.search(rel))
        if is_test:
            dem = []
            for f in local:
                if f.sev == "HIGH":
                    f.sev = "MEDIUM"
                elif f.sev == "MEDIUM":
                    f.sev = "LOW"
                elif f.sev == "LOW":
                    continue
                dem.append(f)
            local = dem
        findings.extend(local)
    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 = ["# Sensitive-data-in-logs / log-injection 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 nothing sensitive "
           "reaches the logs._",
           ""]
    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, ext, source_lines, expected code:sev set)
    ("py-log-password", ".py",
     ["import logging", 'logger.info("login attempt for", password)'],
     {"LOG-SECRET:HIGH"}),
    ("py-label-ok", ".py",
     ['logger.info("password reset requested for %s", username)'],
     set()),
    ("py-logging-mod", ".py",
     ['logging.warning("bad api key %s", api_key)'],
     {"LOG-SECRET:HIGH"}),
    ("py-print-secret", ".py",
     ['print("secret=", secret)'],
     {"LOG-SECRET:HIGH"}),
    ("py-print-input-ok", ".py",
     ['print("got body", request.data)'],
     set()),  # print-tier: input classes off
    ("py-req-headers", ".py",
     ['logger.debug("ctx %s", request.headers)'],
     {"LOG-REQOBJ:HIGH"}),
    ("py-req-bare", ".py",
     ['logger.info("req %r", request)'],
     {"LOG-REQOBJ:MEDIUM"}),
    ("py-req-method-ok", ".py",
     ['logger.info("%s %s", request.method, request.path)'],
     set()),
    ("py-req-body", ".py",
     ['logger.info("payload %s", request.data)'],
     {"LOG-BODY:MEDIUM"}),
    ("py-req-args", ".py",
     ['logger.debug("q=%s", request.args.get("q"))'],
     {"LOG-INPUT:LOW"}),
    ("py-env-dump", ".py",
     ['logger.info("env %s", os.environ)'],
     {"LOG-ENV:MEDIUM"}),
    ("py-masked-ok", ".py",
     ['logger.info("tok %s", mask(token))'],
     set()),
    ("py-token-soft", ".py",
     ['logger.info("auth ok %s", token)'],
     {"LOG-CRED:MEDIUM"}),
    ("py-session-id", ".py",
     ['logger.info("sid %s", session_id)'],
     {"LOG-CRED:MEDIUM"}),
    ("py-token-count-ok", ".py",
     ['logger.info("usage %d", token_count)'],
     set()),
    ("py-keyid-ok", ".py",
     ['logger.info("key %s", api_key_id)'],
     set()),
    ("py-hash-med", ".py",
     ['logger.info("ph %s", password_hash)'],
     {"LOG-HASHED:MEDIUM"}),
    ("py-sliced", ".py",
     ['logger.info("tok %.4s", token[:4])'],
     {"LOG-CRED:LOW"}),
    ("py-len-ok", ".py",
     ['logger.info("plen %d", len(password))'],
     set()),
    ("py-fstr-secret", ".py",
     ['logger.info(f"user logged key={api_key}")'],
     {"LOG-SECRET:HIGH"}),
    ("py-exception-ok", ".py",
     ['logger.exception("login failed")'],
     set()),
    ("js-console-secret", ".js",
     ['console.log("pwd:", password);'],
     {"LOG-SECRET:HIGH"}),
    ("js-logger-token", ".js",
     ['logger.info("t", sessionToken);'],
     {"LOG-SECRET:HIGH"}),
    ("js-req-headers", ".js",
     ['log.debug("h", req.headers);'],
     {"LOG-REQOBJ:HIGH"}),
    ("js-req-body", ".js",
     ['logger.info("b", req.body);'],
     {"LOG-BODY:MEDIUM"}),
    ("js-req-query", ".js",
     ['log.info("q", req.query);'],
     {"LOG-INPUT:LOW"}),
    ("js-req-safe-ok", ".js",
     ['log.info(`${req.method} ${req.url}`);'],
     set()),
    ("js-tmpl-secret", ".js",
     ['logger.info(`key=${apiKey}`);'],
     {"LOG-SECRET:HIGH"}),
    ("js-tmpl-label-ok", ".js",
     ['logger.info(`password reset for ${username}`);'],
     set()),
    ("js-process-env", ".js",
     ['console.log(process.env);'],
     {"LOG-ENV:MEDIUM"}),
    ("js-authz-hdr", ".js",
     ['log.info(req.headers.authorization);'],
     {"LOG-SECRET:HIGH", "LOG-REQOBJ:HIGH"}),
    ("js-redact-ok", ".js",
     ['log.info("t", redact(token));'],
     set()),
    ("js-mathlog-ok", ".js",
     ['const y = Math.log(x);'],
     set()),
    ("js-catalog-ok", ".js",
     ['catalog.log(item);'],
     set()),
    ("go-log-secret", ".go",
     ['log.Println("secret", secretKey)'],
     {"LOG-SECRET:HIGH"}),
    ("go-slog-jwt", ".go",
     ['slog.Info("login", "jwt", jwt)'],
     {"LOG-CRED:MEDIUM"}),
    ("go-fmt-print-pw", ".go",
     ['fmt.Printf("pw %s", password)'],
     {"LOG-SECRET:HIGH"}),
    ("go-req-header", ".go",
     ['log.Printf("hdr %v", r.Header)'],
     {"LOG-REQOBJ:HIGH"}),
    ("go-zap-secret", ".go",
     ['logger.Info("x", zap.String("password", pw))'],
     {"LOG-SECRET:HIGH"}),
    ("java-slf4j", ".java",
     ['log.info("secret {}", clientSecret);'],
     {"LOG-SECRET:HIGH"}),
    ("java-sysout-pw", ".java",
     ['System.out.println("pw " + passwd);'],
     {"LOG-SECRET:HIGH"}),
    ("java-getparam", ".java",
     ['logger.info("p " + request.getParameter("q"));'],
     {"LOG-BODY:MEDIUM"}),
    ("php-errorlog", ".php",
     ['error_log("pw " . $password);'],
     {"LOG-SECRET:HIGH"}),
    ("php-server", ".php",
     ['$logger->info(json_encode($_SERVER));'],
     {"LOG-REQOBJ:HIGH"}),
    ("php-post", ".php",
     ['$log->debug("in", $_POST);'],
     {"LOG-BODY:MEDIUM"}),
    ("php-echo-ok", ".php",
     ['echo "pw " . $password;'],
     set()),  # response output — debug/xss territory, not a log
    ("php-interp-pw", ".php",
     ['error_log("pw is $password");'],
     {"LOG-SECRET:HIGH"}),
    ("rb-logger", ".rb",
     ['Rails.logger.info("t #{token}")'],
     {"LOG-CRED:MEDIUM"}),
    ("rb-puts-pw", ".rb",
     ['puts "pw #{password}"'],
     {"LOG-SECRET:HIGH"}),
    ("cs-serilog", ".cs",
     ['_logger.LogInformation("key {Key}", apiKey);'],
     {"LOG-SECRET:HIGH"}),
    ("cs-console-pw", ".cs",
     ['Console.WriteLine("pw " + password);'],
     {"LOG-SECRET:HIGH"}),
    ("rust-info", ".rs",
     ['info!("token {}", token);'],
     {"LOG-CRED:MEDIUM"}),
    ("rust-println-pw", ".rs",
     ['println!("pw {}", password);'],
     {"LOG-SECRET:HIGH"}),
    ("rust-info-plain-ok", ".rs",
     ['info!("request done in {}ms", elapsed);'],
     set()),
    ("sh-echo-pw", ".sh",
     ['echo "password is $password"'],
     {"LOG-SECRET:HIGH"}),
    ("sh-echo-ok", ".sh",
     ['echo "deploy finished"'],
     set()),
    ("multi-line-call", ".py",
     ['logger.info(', '    "secret:",', '    client_secret,', ')'],
     {"LOG-SECRET:HIGH"}),
    ("pii-ssn", ".py",
     ['logger.info("user ssn", ssn)'],
     {"LOG-PII:MEDIUM"}),
    ("card-number", ".js",
     ['log.info("cc", cardNumber);'],
     {"LOG-PII:MEDIUM"}),
    ("cvv-strong", ".js",
     ['log.info(cvv);'],
     {"LOG-SECRET:HIGH"}),
    ("normal-log-ok", ".py",
     ['logger.info("order %s shipped to %s", order_id, city)'],
     set()),
    ("log-msg-ok", ".js",
     ['log.info(message);'],
     set()),
    ("environ-dict-ok", ".py",
     ['data = {"env": env_name}'],
     set()),
    ("auth-generic-med", ".js",
     ['log.debug("ctx", req.auth);'],
     {"LOG-CRED:MEDIUM"}),
    ("connstring-high", ".py",
     ['logger.info("db %s", connection_string)'],
     {"LOG-SECRET:HIGH"}),
]


def selftest():
    import tempfile
    fails = []
    for name, ext, lines, expect in SELFTEST:
        src = "\n".join(lines) + "\n"
        with tempfile.NamedTemporaryFile(
                "w", suffix=ext, 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))
