#!/usr/bin/env python3
"""tls_prescan.py — deterministic transport-security / TLS
misconfiguration pre-scan.

ARION service artifact. Scans source, scripts, and config files for
the line shapes where transport encryption or peer authentication is
explicitly weakened: certificate verification switched off, hostname
checks bypassed, legacy protocol versions pinned, broken cipher
classes kept in suite strings, http:// bound into transport
positions, database connection strings with TLS disabled,
package-manager and tooling TLS bypasses, IaC skip-verify keys, and
legacy Python wrap_socket contexts that never authenticate a peer.
Line-wise, fully offline, no inference.

Findings (base severity):
  TLS-VERIFY-OFF      HIGH    certificate chain verification
                              explicitly disabled — requests/httpx
                              verify=False, ssl.CERT_NONE /
                              cert_reqs=CERT_NONE /
                              verify_mode=CERT_NONE, VERIFY_NONE
                              (Ruby/Perl/mbedTLS), unverified-context
                              helpers, rejectUnauthorized:false,
                              NODE_TLS_REJECT_UNAUTHORIZED=0,
                              InsecureSkipVerify:true,
                              CURLOPT_SSL_VERIFYPEER 0,
                              verify_peer=>false, verify_ssl=false,
                              aiohttp-style ssl=False on connector/
                              client lines,
                              DangerousAcceptAnyServerCertificateValidator,
                              certificate-validation callbacks that
                              return true unconditionally, empty
                              X509TrustManager check*Trusted bodies,
                              trust-all manager class names,
                              badCertificateCallback=>true,
                              rust danger_accept_invalid_certs(true)
  TLS-HOSTNAME-OFF    MEDIUM  hostname/CN check disabled while the
                              chain check may survive —
                              check_hostname/assert_hostname=False,
                              CURLOPT_SSL_VERIFYHOST 0 or legacy 1,
                              verify_peer_name=>false,
                              checkServerIdentity => undefined/noop,
                              Noop/AllowAll hostname verifiers,
                              hostnameVerifier lambdas -> true,
                              verify_hostname=>0,
                              danger_accept_invalid_hostnames(true)
  TLS-WEAK-VERSION    MEDIUM  legacy protocol pinned —
                              PROTOCOL_SSLv2/v3/TLSv1/TLSv1_1,
                              TLSVersion.TLSv1/TLSv1_1,
                              secureProtocol/minVersion/maxVersion
                              TLSv1.x/SSLv3, tls.VersionTLS10/11,
                              SSLContext.getInstance("TLSv1"…),
                              SslProtocols/SecurityProtocolType
                              Ssl3/Tls/Tls11,
                              STREAM_CRYPTO_METHOD_TLSv1_0,
                              CURL_SSLVERSION_TLSv1_0/1_1/SSLv3,
                              nginx ssl_protocols / Apache
                              SSLProtocol listing SSLv3/TLSv1(.x),
                              --tlsv1.0/--sslv3-style CLI flags
  TLS-WEAK-CIPHER     MEDIUM  broken cipher classes kept in a suite
                              string — RC4/RC2/DES/3DES/EXPORT/NULL/
                              aNULL/eNULL/ADH/AECDH/MD5/IDEA/SEED/LOW
                              markers inside set_ciphers/ssl_ciphers/
                              SSLCipherSuite/cipherSuites/--ciphers
                              values (negated !TOKEN exclusions stay
                              silent — they are hardening)
  TLS-PLAINTEXT       MEDIUM  http:// URL bound into a transport
                              position — assignment to url/endpoint/
                              base/host/dsn/webhook/broker-named
                              variables or config keys, or passed as
                              the URL argument of a request call;
                              localhost/private/example/schema-id
                              hosts excluded
  TLS-DB-OFF          MEDIUM  database TLS switched off in a
                              connection string/DSN — sslmode=
                              disable, ssl-mode=DISABLED,
                              useSSL=false, Encrypt=false on a
                              Server=/jdbc:sqlserver line, ?ssl=false
                              or ssl=false gated on db vocabulary
  TLS-PM-BYPASS       MEDIUM  tooling/package-manager TLS bypass —
                              strict-ssl=false (npmrc/yarnrc),
                              http.sslVerify false /
                              GIT_SSL_NO_VERIFY, PYTHONHTTPSVERIFY=0,
                              pip --trusted-host / trusted-host key,
                              curl -k / --insecure / --proxy-insecure,
                              wget --no-check-certificate, composer
                              disable-tls / secure-http false, apt
                              [trusted=yes] / AllowInsecureRepositories
                              / Verify-Peer "false", yum sslverify=0,
                              Ansible validate_certs: no
  TLS-CONFIG-SKIP     MEDIUM  IaC/config skip-verify keys —
                              *_skip_verify / insecure-skip-tls-
                              verify / insecureSkipVerify = true,
                              proxy-ssl-verify off, ssl_verify:false,
                              verify_peer: false, provider
                              insecure=true near TLS vocabulary,
                              bare 'verify: false' gated on a TLS/
                              ssl/cert vocabulary window
  TLS-WRAP-NOVERIFY   LOW     legacy Python peer-auth gaps —
                              ssl.wrap_socket() with no cert_reqs on
                              the call, SSLContext(PROTOCOL_SSLv23 /
                              PROTOCOL_TLS) — both default to
                              CERT_NONE; suppressed file-wide when a
                              cert_reqs=CERT_REQUIRED/
                              create_default_context marker exists

Context rules:
  * comment lines and block-comment bodies are stripped first
  * import/require lines never flag
  * test/spec/fixture paths downgrade one tier
  * constant definitions (NAME = ..._NONE) and def/class signature
    lines never flag as VERIFY-OFF — a default on a signature is API
    surface, not a disabled call
  * generic 'verify: false' / 'insecure = true' switches require TLS
    vocabulary (ssl|tls|cert|x509|https|cipher|peer|hostname) within
    a two-line window — unrelated feature flags stay silent
  * generic 'ssl=False' kwargs require a client/connector/session
    vocabulary on the same line — non-TLS ssl feature flags stay silent
  * only explicit weakening is flagged — a file that never touches
    TLS settings is silent (library/framework defaults may be
    correct); hardened shapes stay silent: verify=True,
    rejectUnauthorized:true, cert_reqs=CERT_REQUIRED, negated cipher
    tokens, TLSv1.2/1.3 selections, https:// endpoints
  * overlaps crypto_prescan.py's single TLS-VERIFY-OFF rule by
    design — this tool owns the whole transport family; ws://
    plaintext is owned by ws_prescan.py

Scope: deterministic pre-scan for human review — flags the line
shapes where transport security is explicitly reduced; it cannot
see which endpoints are actually called at runtime, whether a
reverse proxy terminates TLS downstream, or whether the flagged
call path is reachable — clean output != hardened transport.

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

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

TARGET_EXT = (
    ".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
    ".php", ".rb", ".go", ".java", ".kt", ".cs", ".groovy",
    ".rs", ".scala", ".dart", ".vue", ".svelte", ".pl",
    ".ini", ".cfg", ".conf", ".properties", ".yml", ".yaml",
    ".toml", ".env", ".sh", ".bash", ".zsh", ".tf", ".hcl",
    ".nginx", ".repo", ".list", ".curlrc",
)

TARGET_NAMES = {
    "php.ini", ".user.ini", ".npmrc", ".yarnrc", "pip.conf",
    ".gitconfig", ".curlrc", "Dockerfile", "docker-compose.yml",
    "sources.list",
}

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

IMPORT_LINE = re.compile(
    r"^\s*(?:from\s+[\w.]+\s+import\b|import\s+[\w.{*]|"
    r"(?:const|let|var)\s+[\w${},\s]*=\s*require\s*\(|"
    r"\buse\s+[\w:]+|require\s*\(['\"][\w./-]+['\"]\s*\)?\s*;?\s*$|"
    r"#include|package\s+[\w.]+;?\s*$|using\s+[\w.]+;)")

TEST_PATH = re.compile(
    r"(^|/)(test|tests|spec|specs|fixtures?|mocks?|__tests__|__mocks__|"
    r"e2e|cypress|playwright)(/|$)|(_test|_spec|\.test|\.spec)\.",
    re.I)

PER_CODE_CAP = 40

# --- vocabulary --------------------------------------------------------------

# TLS context — gates the generic 'verify: false' / 'insecure = true'
# switches so unrelated feature flags stay silent. Deliberately does
# NOT include 'verify'/'insecure' themselves (self-triggering).
TLS_VOCAB = re.compile(
    r"ssl|tls|cert|x509|https|cipher|peer|hostname|keystore|"
    r"truststore|pki\b", re.I)

# database vocabulary — gates generic ssl=false / Encrypt=false on
# connection context
DB_VOCAB = re.compile(
    r"postgres|pgsql|mysql|maria|jdbc:|mongodb|mongo|redis|mssql|"
    r"sqlserver|sql_server|Server\s*=|Data Source=|database|dbname|"
    r"cockroach|clickhouse|cassandra|oracle|snowflake", re.I)

# signature-verification vocabulary — 'verify=False' on signature or
# digest checks is not a TLS finding
SIG_VOCAB = re.compile(
    r"signature|sig\b|hmac|digest|checksum|hash|jwt|recover|ecdsa|"
    r"signed|cosign", re.I)

# aiohttp/async-style 'ssl=False' kwarg needs client vocabulary on the
# same line — a bare ssl feature flag is not enough
SSL_CLIENT_VOCAB = re.compile(
    r"connector|aiohttp|client|session|mqtt|paho|imap|smtp|redis|"
    r"amqp|pool|kafka|celery|http|socket|tcp|webhook", re.I)

# --- per-line patterns -------------------------------------------------------

VERIFY_OFF = re.compile(
    r"\bverify\s*=\s*False\b|"
    r"_create_unverified_context\s*\(|"
    r"rejectUnauthorized\s*:\s*false|"
    r"NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['\"]?0\b|"
    r"process\.env\.NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['\"]?0|"
    r"InsecureSkipVerify\s*[:=]\s*true|"
    r"CURLOPT_SSL_VERIFYPEER[\"'\s,\]\)]*\s*(?:false|0)\b|"
    r"verify_peer[\"'\s]*=>\s*false|"
    r"verify_ssl\s*[:=]\s*(?:False|false|0|no)\b|"
    r"verifyServerCertificate\s*=\s*false|"
    r"DangerousAcceptAnyServerCertificateValidator|"
    r"(?:ServerCertificateValidationCallback|"
    r"RemoteCertificateValidationCallback)\s*\+?=\s*[^;\n]*"
    r"(?:->|=>)\s*true|"
    r"badCertificateCallback\s*[:=]\s*\([^)]*\)\s*=>?\s*true|"
    r"check(?:Server|Client)Trusted\s*\([^)]*\)\s*"
    r"(?:throws\s+[\w.,\s]+)?\{\s*\}|"
    r"(?:TrustAll|TrustAnything|AcceptAll|Permissive)[\w]*"
    r"(?:Trust)?Manager|"
    r"TrustAllCerts|NaiveTrustManager|DummyX509TrustManager|"
    r"EmptyTrustManager|"
    r"danger_accept_invalid_certs\s*\(\s*true\s*\)|"
    r"MBEDTLS_SSL_VERIFY_NONE|"
    r"SSL_verify_mode\s*=>?\s*(?:0x?0*|SSL_VERIFY_NONE)|"
    r"with_no_certificate_verification|certificate_verification_none",
    re.I)

# CERT_NONE/VERIFY_NONE bound into a call or attribute — gated:
# requires ( . or : on the line so bare signature-continuation and
# enum-definition lines stay silent
VERIFY_NONE_CTX = re.compile(
    r"[.:_](?:CERT_NONE|VERIFY_NONE)\b|"
    r"cert_?reqs\s*[=:,]>?\s*['\"]?CERT_NONE|"
    r"verify_mode\s*=>?\s*['\"]?(?:OpenSSL::SSL::)?(?:CERT_NONE|"
    r"VERIFY_NONE)",
    re.I)

USE_POSITION = re.compile(r"[.:(]")

# constant-definition lines (NAME = ..._NONE) are enum/alias surface,
# not a disabled call
CONST_DEF = re.compile(
    r"^\s*[\w.]*(?:CERT_NONE|VERIFY_NONE|VERIFY_PEER|CERT_OPTIONAL|"
    r"CERT_REQUIRED)\s*=")

# def/class signature lines — a default on a signature is API surface
SIG_DEF = re.compile(r"^\s*(?:async\s+def|def|class)\s")

# comparison probes (verify_mode != CERT_NONE) inspect state, they do
# not disable it
COMPARE_PROBE = re.compile(r"[!=]=")

# pure alias/enum lines — a bare name bound to a constant or listed in
# an enum is definition surface, not a disabled call
BARE_MEMBER = re.compile(
    r"^\s*(?:PROTOCOL_\w+|TLSv\w+|SSLv\w+|"
    r"[A-Z_]*(?:CERT|VERIFY|TLS|SSL)[A-Z_]*)"
    r"[\w.]*\s*(?:=[^=]|,|$)")

# 'verify=False' carries signature-check ambiguity — drop when sig
# vocabulary shares the line
SIG_GUARD = SIG_VOCAB

# strip string literals for paren-balance counting inside multi-line
# def signatures (strings can carry unbalanced parens)
STRING_LIT = re.compile(
    r"'[^'\\]*(?:\\.[^'\\]*)*'|\"[^\"\\]*(?:\\.[^\"\\]*)*\"")


def code_part(ln):
    return STRING_LIT.sub("''", ln)

# bare ssl=False — needs client vocabulary on the line
SSL_KWARG_OFF = re.compile(r"\bssl\s*=\s*False\b")

HOSTNAME_OFF = re.compile(
    r"CURLOPT_SSL_VERIFYHOST[\"'\s,\]\)]*\s*[01]\b|"
    r"verify_peer_name[\"'\s]*=>\s*false|"
    r"checkServerIdentity\s*[:=]\s*\(?[\w,\s]*\)?\s*=>?\s*"
    r"(?:undefined|null|\{\s*\})|"
    r"NoopHostnameVerifier|AllowAllHostnameVerifier|"
    r"ALLOW_ALL_HOSTNAME_VERIFIER|"
    r"setHostnameVerifier\s*\([^)]*(?:ALLOW_ALL|NoopHostname|"
    r"allowAll)|"
    r"\(hostname\w*,\s*session\w*\)\s*->\s*true|"
    r"hostnameVerifier[\w]*\s*[:=]\s*\(?[\w,\s]*\)?\s*=>?\s*true|"
    r"danger_accept_invalid_hostnames\s*\(\s*true\s*\)",
    re.I)

# kwarg-name shapes — gated: require ( . or : on the line so
# signature-continuation lines stay silent
HOSTNAME_KWARG = re.compile(
    r"check_hostname\s*[=:]\s*False|"
    r"assert_hostname\s*[=:]\s*False|"
    r"verify_hostname\s*=>?\s*(?:false|0)\b",
    re.I)

WEAK_VERSION = re.compile(
    r"PROTOCOL_SSLv[23]\b|PROTOCOL_TLSv1\b|PROTOCOL_TLSv1_1\b|"
    r"PROTOCOL_SSL\b|"
    r"TLSVersion\.TLSv1\b|TLSVersion\.TLSv1_1\b|"
    r"secureProtocol\s*[:=]\s*['\"]?(?:SSLv[23]|TLSv1(?:_1)?)_method|"
    r"minVersion\s*[:=]\s*['\"]?(?:SSLv3|TLSv1(?:\.[01]|_1)?)(?![\w.])|"
    r"maxVersion\s*[:=]\s*['\"]?(?:SSLv3|TLSv1(?:\.[01]|_1)?)(?![\w.])|"
    r"tls\.VersionTLS1[01]\b|"
    r"SSLContext\.getInstance\(\s*['\"](?:SSL|SSLv3|TLSv1(?:\.1)?)"
    r"['\"]|"
    r"(?:SecurityProtocolType|SslProtocols)\.(?:Ssl3|Tls|Tls11)\b|"
    r"STREAM_CRYPTO_METHOD_(?:SSLv3|TLSv1_0)_|"
    r"CURL_SSLVERSION_(?:SSLv3|TLSv1_0|TLSv1_1)\b|"
    r"ssl_version\s*[=:]\s*:?(?:SSLv3|TLSv1(?:_1)?)\b|"
    r"(?:ssl_protocols|SSLProtocol)\s[^;\n]*\b(?:SSLv3|TLSv1\.1|"
    r"TLSv1(?![\d._]))|"
    r"--(?:sslv3|tlsv1\.0|tlsv1\.1|tls1_0|tls1_1)\b",
    re.I)

# cipher-suite argument context
CIPHER_CTX = re.compile(
    r"set_ciphers|ssl_ciphers|SSLCipherSuite|cipherSuites|"
    r"cipher_suite|ciphersuites|--ciphers|cipher-list|"
    r"\bciphers\s*[:=(]", re.I)

# suite tokens, including !-negated exclusions
CIPHER_TOKEN = re.compile(r"[!+]?[A-Za-z][\w+.-]*")

# broken cipher markers inside a NON-negated suite token — substring
# match because suite names are underscore/hyphen-joined
CIPHER_WEAK = re.compile(
    r"RC4|RC2|DES|EXPORT|EXP|NULL|ADH|AECDH|MD5|IDEA|SEED|LOW|"
    r"anon|SSLv2", re.I)

# transport-position names for plaintext URLs
URL_ASSIGN = re.compile(
    r"[\w.\[\]'\"]*"
    r"(?:url|uri|endpoint|host|dsn|webhook|broker|origin|upstream|"
    r"backend|gateway|addr|address|connection|conn|server|api|base|"
    r"proxy|callback|redirect)"
    r"[\w.\[\]'\"]*\s*[:=]+\s*['\"]?http://", re.I)

URL_ARG = re.compile(
    r"(?:requests|httpx|axios|got|fetch|urlopen|urlretrieve|"
    r"HttpClient|urllib|client|session|curl|wget|http)"
    r"\w*(?:\.\w+)*\s*\(\s*['\"]http://|"
    r"new\s+(?:URL|Uri|URI)\s*\(\s*['\"]http://",
    re.I)

HTTP_LIT = re.compile(r"['\"]?http://([^\s'\"<>\\)]+)")

# hosts that are identifiers, docs, or loopback — never plaintext
# findings
SAFE_HOST = re.compile(
    r"^(localhost|127\.|0\.0\.0\.0|::1|\[::1\]|.*\.example|"
    r"example\.|.*\.test\b|.*\.localhost|.*\.invalid|"
    r".*\.internal\b|.*\.lan\b|.*\.local\b|.*\.corp\b|"
    r"www\.w3\.org|w3\.org|.*\.w3\.org|schemas\.|xmlns|"
    r".*\.apache\.org|apache\.org|opensource\.org|gnu\.org|"
    r"json-schema\.org|.*\.ietf\.org|ietf\.org|doi\.org|"
    r"schema\.org|purl\.org|registry-1\.docker\.io)",
    re.I)

DB_OFF = re.compile(
    r"sslmode\s*=\s*disable|ssl-?mode\s*=\s*['\"]?DISABLED|"
    r"useSSL\s*=\s*false|"
    r"require_secure_transport\s*=\s*(?:0|OFF)\b", re.I)

DB_OFF_GATED = re.compile(
    r"[?&]?\bssl\s*=\s*false\b|"
    r"Encrypt\s*=\s*false|encrypt\s*[:=]\s*false", re.I)

PM_BYPASS = re.compile(
    r"strict-ssl\s*[=: ]\s*false|strict_ssl\s*=\s*False|"
    r"secure-http[\"']?\s*[=: ]\s*false|"
    r"http\.sslVerify\s*[= ]+\s*false|"
    r"GIT_SSL_NO_VERIFY\s*=\s*(?:true|1)|"
    r"PYTHONHTTPSVERIFY\s*=\s*0\b|"
    r"--trusted-host\b|^\s*trusted-host\s*[=: ]|"
    r"(?:curl|wget)\b[^\n]*?(?:\s-[a-zA-Z]*k\b|--insecure\b|"
    r"--proxy-insecure\b|--no-check-certificate\b)|"
    r"disable-tls\s*[=: ]\s*true|composer[^\n]*disable-tls|"
    r"\[trusted=yes\]|AllowInsecureRepositories|"
    r"Acquire::https::Verify-Peer\s+\"?false|"
    r"sslverify\s*=\s*(?:0|false)|"
    r"validate_certs\s*[:=]\s*(?:no|false)\b",
    re.I)

CONFIG_SKIP = re.compile(
    r"(?:insecure_skip_verify|skip_tls_verify|ssl_skip_verify|"
    r"tls_skip_verify|insecure-skip-tls-verify|"
    r"skip_verify)\s*[:=]\s*['\"]?(?:true|yes|1)\b|"
    r"proxy-ssl-verify\s*[:=\s]\s*['\"]?off\b|"
    r"tls_insecure\s*[:=]\s*true|"
    r"check_certificate\s*[:=]\s*(?:false|no)\b|"
    r"ssl_verify\s*[:=]\s*['\"]?(?:false|no|0)\b|"
    r"verify_peer[\"']?\s*:\s*false", re.I)

CONFIG_SKIP_GATED = re.compile(
    r"(?<![-\w])verify\s*[:=]\s*(?:false|no)\b|"
    r"\binsecure\s*=\s*true\b", re.I)

WRAP_NOVERIFY_CALL = re.compile(
    r"ssl\.wrap_socket\s*\(|"
    r"SSLContext\s*\(\s*(?:ssl\.)?PROTOCOL_(?:SSLv23|TLS)\b(?!_)")

# file-wide suppressors for the legacy-wrap class — the file verifies
# peers somewhere, flagging the wrap line would be noise
VERIFY_MARKER = re.compile(
    r"cert_?reqs\s*[=:]\s*['\"]?CERT_REQUIRED|CERT_REQUIRED|"
    r"create_default_context|load_verify_locations|"
    r"set_verify\s*\(|setVerifyMode")


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

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


# --- helpers -----------------------------------------------------------------
def strip_comments(lines):
    """Remove full-line comments and /* */ block bodies. Keeps line count."""
    out, in_block = [], False
    for ln in lines:
        s = ln
        if in_block:
            if "*/" in s:
                s = s.split("*/", 1)[1]
                in_block = False
            else:
                out.append("")
                continue
        while "/*" in s:
            if "*/" in s.split("/*", 1)[1]:
                s = s.replace(s[s.index("/*"):s.index("*/") + 2], " ", 1)
            else:
                s = s.split("/*", 1)[0]
                in_block = True
        t = s.strip()
        if t.startswith(("#", "//", "<!--", "*")):
            out.append("")
        else:
            out.append(s)
    return out


def downgrade(sev):
    return {"HIGH": "MEDIUM", "MEDIUM": "LOW"}.get(sev, "LOW")


def scan_file(path, rel):
    try:
        raw = open(path, encoding="utf-8", errors="replace").read()
    except OSError:
        return []
    if not raw.strip():
        return []
    is_test = bool(TEST_PATH.search(rel.replace(os.sep, "/")))
    findings = []
    counts = {}
    lines = strip_comments(raw.splitlines())

    # file-wide verification marker suppresses the legacy-wrap class
    has_verify_marker = bool(VERIFY_MARKER.search(raw))

    def emit(sev, code, ln, msg):
        if is_test:
            sev = downgrade(sev)
        if counts.get(code, 0) >= PER_CODE_CAP:
            return
        counts[code] = counts.get(code, 0) + 1
        findings.append(Finding(sev, code, rel, ln, msg))

    def tls_vocab_near(i):
        lo = max(0, i - 2)
        hi = min(len(lines), i + 3)
        seg = "\n".join(lines[lo:hi])
        return bool(TLS_VOCAB.search(seg))

    in_sig = False
    sig_depth = 0
    sig_start = 0

    for i, ln in enumerate(lines, 1):
        if not ln.strip() or IMPORT_LINE.match(ln):
            continue

        # multi-line def signature: kwarg defaults are API surface,
        # not a disabled call — suppress the kwarg-shaped classes.
        # paren balance is counted on string-stripped code and bounded
        if in_sig:
            sig_depth += (code_part(ln).count("(")
                          - code_part(ln).count(")"))
            if sig_depth <= 0 or i - sig_start > 60:
                in_sig = False
            continue
        if SIG_DEF.match(ln):
            d = (code_part(ln).count("(")
                 - code_part(ln).count(")"))
            if d > 0:
                in_sig, sig_depth, sig_start = True, d, i
            continue

        if VERIFY_OFF.search(ln):
            # 'verify=False' on a signature/digest path is not TLS;
            # constant definitions and def signatures are API surface
            sig_kwarg = re.search(r"\bverify\s*=\s*False", ln)
            if (not (sig_kwarg and SIG_GUARD.search(ln))
                    and not CONST_DEF.search(ln)
                    and not SIG_DEF.search(ln)):
                emit("HIGH", "TLS-VERIFY-OFF", i,
                     "certificate verification explicitly disabled — "
                     "any TLS peer is accepted; MITM reads and rewrites "
                     "the channel")

        if (VERIFY_NONE_CTX.search(ln)
                and USE_POSITION.search(ln)
                and not CONST_DEF.search(ln)
                and not SIG_DEF.search(ln)
                and not COMPARE_PROBE.search(ln)
                and not BARE_MEMBER.search(ln)):
            emit("HIGH", "TLS-VERIFY-OFF", i,
                 "peer-auth constant CERT_NONE/VERIFY_NONE bound into "
                 "a call or attribute — certificate chain unchecked")

        if (SSL_KWARG_OFF.search(ln) and SSL_CLIENT_VOCAB.search(ln)
                and not SIG_DEF.search(ln)):
            emit("HIGH", "TLS-VERIFY-OFF", i,
                 "ssl=False on a client/connector line — peer "
                 "certificates are not authenticated")

        if (HOSTNAME_OFF.search(ln)
                or (HOSTNAME_KWARG.search(ln)
                    and USE_POSITION.search(ln)
                    and not SIG_DEF.search(ln))):
            emit("MEDIUM", "TLS-HOSTNAME-OFF", i,
                 "hostname/CN check disabled — a valid certificate "
                 "for ANY domain satisfies the channel (MITM window)")

        if (WEAK_VERSION.search(ln)
                and "getattr" not in ln
                and not BARE_MEMBER.search(ln)):
            emit("MEDIUM", "TLS-WEAK-VERSION", i,
                 "legacy protocol explicitly selected — SSLv3/TLSv1.x "
                 "expose downgrade + known-attack surface")

        if CIPHER_CTX.search(ln):
            for tok in CIPHER_TOKEN.findall(ln):
                if tok.startswith("!"):
                    continue
                if CIPHER_WEAK.search(tok):
                    emit("MEDIUM", "TLS-WEAK-CIPHER", i,
                         "cipher suite keeps a broken class (RC4/DES/"
                         "EXPORT/NULL/MD5 family) — token not negated "
                         "by '!'")
                    break

        # plaintext URL in a transport position
        m = URL_ASSIGN.search(ln) or URL_ARG.search(ln)
        if m:
            hm = HTTP_LIT.search(ln)
            if hm and not SAFE_HOST.match(hm.group(1)):
                emit("MEDIUM", "TLS-PLAINTEXT", i,
                     "http:// endpoint bound to a transport name/arg — "
                     "credentials and payloads travel unencrypted")

        if DB_OFF.search(ln) or (
                DB_OFF_GATED.search(ln) and DB_VOCAB.search(ln)):
            emit("MEDIUM", "TLS-DB-OFF", i,
                 "database connection TLS explicitly off — queries and "
                 "credentials cross the wire in plaintext")

        if PM_BYPASS.search(ln):
            emit("MEDIUM", "TLS-PM-BYPASS", i,
                 "tooling/package-manager TLS bypass — artifact and "
                 "credential fetches accept any peer")

        if (CONFIG_SKIP.search(ln)
                or (CONFIG_SKIP_GATED.search(ln)
                    and tls_vocab_near(i - 1))):
            emit("MEDIUM", "TLS-CONFIG-SKIP", i,
                 "config/IaC skip-verify key set — the deployed client "
                 "stops authenticating its TLS peers")

        if (WRAP_NOVERIFY_CALL.search(ln)
                and "cert_reqs" not in ln
                and "CERT_REQUIRED" not in ln
                and not has_verify_marker):
            emit("LOW", "TLS-WRAP-NOVERIFY", i,
                 "legacy wrap_socket/SSLContext defaults to CERT_NONE "
                 "— peer is never authenticated")

    return findings


def find_targets(root):
    if os.path.isfile(root):
        return [root]
    out = []
    for dp, dns, fns in os.walk(root):
        dns[:] = [d for d in dns if d not in SKIP_DIRS]
        for fn in fns:
            if fn.endswith(TARGET_EXT) or fn in TARGET_NAMES:
                out.append(os.path.join(dp, fn))
    return sorted(out)


def render(findings, targets):
    findings.sort(key=lambda f: (SEV_ORDER[f.sev], f.where, f.line))
    rows = ["# tls_prescan report", "",
            "files scanned: %d — findings: %d" % (len(targets),
                                                  len(findings)), ""]
    cur = None
    for f in findings:
        if f.sev != cur:
            cur = f.sev
            rows.append("## %s" % cur)
        rows.append("- `%s` %s:%d — %s" % (f.code, f.where, f.line,
                                           f.msg))
    if not findings:
        rows.append("no HIGH/MEDIUM/LOW signatures found — this is a "
                    "pre-scan; clean output != hardened transport")
    return "\n".join(rows)


# --- selftest ---------------------------------------------------------------
SELFTEST = [
    # --- verify off ---
    ("a.py",
     "r = requests.get(url, verify=False)\n",
     {"TLS-VERIFY-OFF"}),
    ("a.py",
     "r = requests.get(url, verify=True)\n",
     set()),
    ("a.py",
     "ok = sig.verify(payload, signature)\n",
     set()),
    ("a.py",
     "cfg.verify = False\n",
     {"TLS-VERIFY-OFF"}),
    ("a.py",
     "ctx = self._ctx; result = verify_signature(x, verify=False)\n",
     set()),   # signature-check ambiguity suppressed
    ("a.js",
     "const a = new https.Agent({ rejectUnauthorized: false });\n",
     {"TLS-VERIFY-OFF"}),
    ("a.js",
     "const a = new https.Agent({ rejectUnauthorized: true });\n",
     set()),
    ("a.go",
     "cfg := &tls.Config{InsecureSkipVerify: true}\n",
     {"TLS-VERIFY-OFF"}),
    ("a.go",
     "cfg := &tls.Config{InsecureSkipVerify: false}\n",
     set()),
    ("a.php",
     "curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);\n",
     {"TLS-VERIFY-OFF"}),
    ("a.php",
     "curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);\n",
     set()),
    ("a.rb",
     "http.verify_mode = OpenSSL::SSL::VERIFY_NONE\n",
     {"TLS-VERIFY-OFF"}),
    ("a.rb",
     "http.verify_mode = OpenSSL::SSL::VERIFY_PEER\n",
     set()),
    ("a.py",
     "ctx.verify_mode = ssl.CERT_NONE\n",
     {"TLS-VERIFY-OFF"}),
    ("a.py",
     "CERT_NONE = VerifyMode.CERT_NONE\n",
     set()),   # enum definition — API surface
    ("a.py",
     "def wrap_socket(sock, cert_reqs=CERT_NONE):\n    pass\n",
     set()),   # signature default — API surface
    ("a.cs",
     "h.ServerCertificateValidationCallback = (s, c, ch, e) => true;\n",
     {"TLS-VERIFY-OFF"}),
    ("a.java",
     "public void checkServerTrusted(X509Certificate[] c, String t) {}\n",
     {"TLS-VERIFY-OFF"}),
    ("a.java",
     "public void checkServerTrusted(X509Certificate[] c, String t) {\n"
     "  if (c.length == 0) throw new CertificateException();\n}\n",
     set()),
    ("a.py",
     "ctx = ssl._create_unverified_context()\n",
     {"TLS-VERIFY-OFF"}),
    ("a.py",
     "ctx = ssl.create_default_context()\n",
     set()),
    ("a.env",
     "NODE_TLS_REJECT_UNAUTHORIZED=0\n",
     {"TLS-VERIFY-OFF"}),
    ("a.dart",
     "client.badCertificateCallback = (cert) => true;\n",
     {"TLS-VERIFY-OFF"}),
    ("a.rs",
     "let c = builder.danger_accept_invalid_certs(true);\n",
     {"TLS-VERIFY-OFF"}),
    ("a.php",
     "$ctx = stream_context_create(['ssl' => ['verify_peer' => false]]);\n",
     {"TLS-VERIFY-OFF"}),
    ("a.py",
     "connector = aiohttp.TCPConnector(ssl=False)\n",
     {"TLS-VERIFY-OFF"}),
    ("a.py",
     "opts = dict(ssl=False)  # widget feature flag\n",
     set()),   # no client vocab
    # --- hostname off ---
    ("a.py",
     "ctx.check_hostname = False\n",
     {"TLS-HOSTNAME-OFF"}),
    ("a.py",
     "ctx.check_hostname = True\n",
     set()),
    ("a.php",
     "curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);\n",
     {"TLS-HOSTNAME-OFF"}),
    ("a.js",
     "tls.connect({ checkServerIdentity: () => undefined });\n",
     {"TLS-HOSTNAME-OFF"}),
    ("a.java",
     "client.setHostnameVerifier(new AllowAllHostnameVerifier());\n",
     {"TLS-HOSTNAME-OFF"}),
    ("a.java",
     "builder.hostnameVerifier((hostname, session) -> true);\n",
     {"TLS-HOSTNAME-OFF"}),
    ("a.pl",
     "$ua->ssl_opts(verify_hostname => 0);\n",
     {"TLS-HOSTNAME-OFF"}),
    # --- weak version ---
    ("a.py",
     "ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1)\n",
     {"TLS-WEAK-VERSION"}),
    ("a.py",
     "ctx.minimum_version = ssl.TLSVersion.TLSv1_3\n",
     set()),
    ("a.js",
     "tls.connect({ minVersion: 'TLSv1' });\n",
     {"TLS-WEAK-VERSION"}),
    ("a.js",
     "tls.connect({ minVersion: 'TLSv1.3' });\n",
     set()),
    ("a.go",
     "MinVersion: tls.VersionTLS10,\n",
     {"TLS-WEAK-VERSION"}),
    ("a.java",
     "SSLContext.getInstance(\"TLSv1\");\n",
     {"TLS-WEAK-VERSION"}),
    ("a.java",
     "SSLContext.getInstance(\"TLSv1.3\");\n",
     set()),
    ("a.nginx",
     "ssl_protocols TLSv1 TLSv1.1 TLSv1.2;\n",
     {"TLS-WEAK-VERSION"}),
    ("a.nginx",
     "ssl_protocols TLSv1.2 TLSv1.3;\n",
     set()),
    ("a.cs",
     "ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls11;\n",
     {"TLS-WEAK-VERSION"}),
    ("a.sh",
     "curl --tlsv1.0 https://api.acme-corp.io\n",
     {"TLS-WEAK-VERSION"}),
    # --- weak cipher ---
    ("a.py",
     "ctx.set_ciphers('RC4-SHA:AES128-SHA')\n",
     {"TLS-WEAK-CIPHER"}),
    ("a.py",
     "ctx.set_ciphers('HIGH:!RC4:!MD5:!aNULL:!DES')\n",
     set()),
    ("a.nginx",
     "ssl_ciphers 'ECDHE:RC4-SHA:DES-CBC3-SHA';\n",
     {"TLS-WEAK-CIPHER"}),
    ("a.java",
     "socket.setEnabledCipherSuites(new String[]{\"TLS_RSA_WITH_RC4_128_MD5\"});\n",
     {"TLS-WEAK-CIPHER"}),
    ("a.py",
     "ciphers = \"AES256-GCM-SHA384\"\nctx.set_ciphers(ciphers)\n",
     set()),   # var-indirect value — not a literal suite line
    # --- plaintext ---
    ("a.py",
     "API_BASE_URL = 'http://api.acme-corp.io/v2'\n",
     {"TLS-PLAINTEXT"}),
    ("a.py",
     "API_BASE_URL = 'https://api.acme-corp.io/v2'\n",
     set()),
    ("a.py",
     "XSD = 'http://www.w3.org/2001/XMLSchema'\n",
     set()),
    ("a.py",
     "LOCAL = 'http://localhost:8080/api'\n",
     set()),
    ("a.js",
     "fetch('http://payments.acme.net/charge', opts);\n",
     {"TLS-PLAINTEXT"}),
    ("a.yml",
     "webhook_url: http://hooks.corp.example.io/ingest\n",
     set()),   # .example excluded host
    ("a.yml",
     "webhook_url: http://hooks.acme-corp.io/ingest\n",
     {"TLS-PLAINTEXT"}),
    ("a.py",
     "doc_link = 'see http://wiki.acme-corp.io/page'\n",
     set()),   # not a transport position
    # --- db off ---
    ("a.py",
     "DSN = 'postgresql://u@h/db?sslmode=disable'\n",
     {"TLS-DB-OFF"}),
    ("a.java",
     "String url = \"jdbc:mysql://db:3306/app?useSSL=false\";\n",
     {"TLS-DB-OFF"}),
    ("a.cs",
     "var cs = \"Server=db;Database=a;Encrypt=false\";\n",
     {"TLS-DB-OFF"}),
    ("a.py",
     "opts = {'ssl': False}  # feature toggle for widget\n",
     set()),
    ("a.env",
     "DATABASE_URL=mysql://u@h/db?ssl=false\n",
     {"TLS-DB-OFF"}),
    # --- pm bypass ---
    ("a.npmrc",
     "strict-ssl=false\n",
     {"TLS-PM-BYPASS"}),
    ("a.sh",
     "pip install --trusted-host pypi.local flask\n",
     {"TLS-PM-BYPASS"}),
    ("a.sh",
     "curl -k https://10.0.0.5/health\n",
     {"TLS-PM-BYPASS"}),
    ("a.sh",
     "curl -s https://api.acme-corp.io/data\n",
     set()),
    ("a.sh",
     "wget --no-check-certificate https://mirror.acme.io/pkg.tgz\n",
     {"TLS-PM-BYPASS"}),
    ("a.yml",
     "    validate_certs: no\n",
     {"TLS-PM-BYPASS"}),
    ("a.gitconfig",
     "    sslVerify = false\n",
     {"TLS-PM-BYPASS"}),
    ("a.gitconfig",
     "    http.sslVerify = false\n",
     {"TLS-PM-BYPASS"}),
    ("a.env",
     "GIT_SSL_NO_VERIFY=true\n",
     {"TLS-PM-BYPASS"}),
    ("a.env",
     "PYTHONHTTPSVERIFY=0\n",
     {"TLS-PM-BYPASS"}),
    ("a.list",
     "deb [trusted=yes] http://archive.acme-corp.io jammy main\n",
     {"TLS-PM-BYPASS"}),
    # --- config skip ---
    ("a.yml",
     "insecure-skip-tls-verify: true\n",
     {"TLS-CONFIG-SKIP"}),
    ("a.yml",
     "insecure-skip-tls-verify: false\n",
     set()),
    ("a.tf",
     "provider \"vault\" {\n  skip_tls_verify = true\n}\n",
     {"TLS-CONFIG-SKIP"}),
    ("a.yml",
     "tls:\n  skip_verify: true\n",
     {"TLS-CONFIG-SKIP"}),
    ("a.yml",
     "feature_flag:\n  verify: false\n",
     set()),   # no TLS vocab near
    ("a.yml",
     "ssl:\n  verify: false\n",
     {"TLS-CONFIG-SKIP"}),
    ("a.yml",
     "ssl_verify: false\n",
     {"TLS-CONFIG-SKIP"}),
    # --- wrap noverify ---
    ("a.py",
     "s = ssl.wrap_socket(sock)\n",
     {"TLS-WRAP-NOVERIFY"}),
    ("a.py",
     "s = ssl.wrap_socket(sock, cert_reqs=ssl.CERT_REQUIRED)\n",
     set()),
    ("a.py",
     "ctx = ssl.create_default_context()\ns = ssl.wrap_socket(sock)\n",
     set()),   # file verifies elsewhere
    ("a.py",
     "ctx = ssl.SSLContext(ssl.PROTOCOL_TLS)\n",
     {"TLS-WRAP-NOVERIFY"}),
    # --- comment stripping ---
    ("a.py",
     "# r = requests.get(url, verify=False)\nr = requests.get(url)\n",
     set()),
    ("a.js",
     "// rejectUnauthorized: false\nconst a = {};\n",
     set()),
    # --- import lines silent ---
    ("a.py",
     "from ssl import CERT_NONE\n",
     set()),
    # --- test path downgrade (HIGH -> MEDIUM, still emitted) ---
    ("tests/a.py",
     "r = requests.get(url, verify=False)\n",
     {"TLS-VERIFY-OFF"}),
]


def run_selftest():
    bad = 0
    for name, src, want in SELFTEST:
        tmp = os.path.join("/tmp",
                           "tls_selftest_" + name.replace("/", "_"))
        with open(tmp, "w") as fh:
            fh.write(src)
        got = {f.code for f in scan_file(tmp, name)}
        os.unlink(tmp)
        if got != want:
            bad += 1
            print("SELFTEST FAIL %s: want %s got %s"
                  % (name, want, got))
    if bad:
        print("selftest: %d/%d failed" % (bad, len(SELFTEST)))
        return 1
    print("selftest: %d/%d cases pass" % (len(SELFTEST), len(SELFTEST)))
    return 0


def main(argv):
    args = [a for a in argv[1:] if not a.startswith("-")]
    if "--selftest" in argv:
        return run_selftest()
    if not args:
        print(__doc__)
        return 2
    root = args[0]
    targets = find_targets(root)
    findings = []
    for t in targets:
        findings.extend(scan_file(t, os.path.relpath(t, root)
                                  if os.path.isdir(root) else t))
    if "--json" in argv:
        print(json.dumps({"files": len(targets),
                          "findings": [f.as_dict() for f in findings]},
                         indent=2))
    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(sys.argv))
