#!/usr/bin/env python3
"""session_prescan.py — deterministic session-management / cookie-security
misconfiguration pre-scan.

ARION service artifact. Scans source + server-config files for the
line shapes where session protection is explicitly weakened — cookies
set without HttpOnly/Secure or with SameSite=None, session ids taken
from request input or placed in URLs, auth tokens written to web
storage, month-long session lifetimes, hardcoded middleware secrets,
and leading-dot cookie domains shared across every subdomain.
Line-wise, fully offline, no inference.

Findings (base severity):
  SES-HTTPONLY-OFF    HIGH    session/auth cookie explicitly loses
                              HttpOnly — httponly:false, setHttpOnly(false),
                              HttpOnly = false, SESSION_COOKIE_HTTPONLY=False,
                              session.cookie_httponly=0 — readable by any
                              injected script (session theft via XSS);
                              MEDIUM when a hand-built Set-Cookie line for a
                              session-vocabulary cookie simply omits the flag
  SES-SECURE-OFF      MEDIUM  cookie marked secure:false / setSecure(false) /
                              SESSION_COOKIE_SECURE=False /
                              session.cookie_secure=0 — travels over plain HTTP
  SES-SAMESITE-NONE   MEDIUM  SameSite=None explicit — samesite:'none',
                              SameSiteMode.None, SameSiteNoneMode,
                              session.cookie_samesite=None — cookie rides
                              cross-site requests (CSRF surface)
  SES-FIXATION        HIGH    session id taken from request input —
                              session_id($_GET/$_REQUEST/$_POST),
                              req.sessionID = req.query/body/params/cookies,
                              session_id = request.GET/args/form/cookies —
                              attacker plants a known id before login
                              (session fixation)
  SES-TRANS-SID       MEDIUM  URL session-id transport switched on —
                              session.use_trans_sid=1, session.use_only_
                              cookies=0 — ids leak via links, logs, Referer
  SES-TOKEN-STORAGE   MEDIUM  auth/session token written to
                              localStorage/sessionStorage —
                              setItem('token'|'jwt'|'auth*'|'*_token'|
                              'session*'|'bearer') — readable by any XSS
  SES-DOC-COOKIE      MEDIUM  document.cookie assignment carrying an
                              auth/session/token name — script-side session
                              writes are readable and modifiable in-page
  SES-URL-ID          MEDIUM  a session-id name embedded in URL query
                              construction — ?sessionid= / &sid= /
                              ?PHPSESSID= / JSESSIONID= — ids leak through
                              logs, analytics, and Referer headers
  SES-URL-TOKEN       LOW     generic credential name (token, jwt,
                              access_token, id_token, auth_token) placed in
                              a URL query — same leak surface, weaker proof
                              of session semantics
  SES-LONG-LIVED      LOW     session/cookie lifetime configured for
                              >=30 days — maxAge/max-age/MaxAge/expires/
                              lifetime/ttl/cookie_age literal, or
                              permanent_session_lifetime timedelta —
                              stolen credential stays valid for weeks
  SES-SECRET-LIT      MEDIUM  session middleware configured with a
                              hardcoded secret literal — session({...,
                              secret: 'literal'}), cookieSession({...}),
                              app.set('sessionSecret'|'jwtSecret','lit')
  SES-WIDE-DOMAIN     LOW     cookie Domain scoped to a leading-dot
                              parent (.example.com) or domain:true —
                              every subdomain can read/write the cookie

Context rules:
  * comment lines and block-comment bodies are stripped first
  * import/require lines never flag
  * test/spec/fixture paths downgrade one tier
  * httponly/secure/domain flags require cookie-or-session vocabulary
    within a two-line window — generic 'secure: false' options on
    sockets/TLS/transport stay silent
  * only explicit weakening is flagged — a file that never sets cookie
    flags is silent (framework defaults or middleware elsewhere may be
    correct); good values stay silent: httponly:true, secure:true,
    SameSite=Lax/Strict, session.use_only_cookies=1

Scope: deterministic pre-scan for human review — flags the line
shapes where session protection is explicitly reduced; it cannot see
framework defaults applied at runtime, reverse-proxy cookie rewrites,
or whether the flagged cookie actually carries a session — clean
output != hardened session management.

  session_prescan.py <file-or-dir> [--json]
  session_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",
    ".ini", ".cfg", ".conf", ".properties", ".yml", ".yaml",
    ".toml", ".env",
)

TARGET_NAMES = {"php.ini", ".user.ini"}

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)

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

# cookie/session context — required within a two-line window for the
# generic secure/httponly/domain switches (they also exist on sockets,
# TLS options, transports)
COOKIE_VOCAB = re.compile(
    r"cookie|set-cookie|setcookie|session|sesid|morsel", re.I)

# storage key names that carry credentials
AUTH_KEY = re.compile(
    r"['\"]?(?:token|jwt|auth|auth_?token|access_?token|id_?token|"
    r"refresh_?token|session|session_?id|sessionid|sid|bearer|"
    r"csrf_?token|xsrf_?token)['\"]?", re.I)

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

# httponly explicitly off — vocab-gated at emit time
HTTPONLY_OFF = re.compile(
    r"httponly\s*[:=]\s*(?:false|0|off)\b|"
    r"setHttpOnly\s*\(\s*false\s*\)|"
    r"HttpOnly\s*=\s*false|"
    r"httpOnly\s*:\s*false|"
    r"['\"]httponly['\"]\s*[:=]>?\s*(?:false|0)\b|"
    r"cookie_httponly\s*[,= ]\s*(?:0|false|'0'|\"0\")\b|"
    r"session\.cookie_httponly\s*=\s*(?:0|off|false)\b|"
    r"SESSION_COOKIE_HTTPONLY\s*=\s*False\b", re.I)

# secure explicitly off — vocab-gated at emit time
SECURE_OFF = re.compile(
    r"\bsecure\s*[:=]\s*(?:false|0|off)\b|"
    r"setSecure\s*\(\s*false\s*\)|"
    r"\bSecure\s*=\s*false|"
    r"['\"]secure['\"]\s*[:=]>?\s*(?:false|0)\b|"
    r"cookie_secure\s*[,= ]\s*(?:0|false|'0'|\"0\")\b|"
    r"session\.cookie_secure\s*=\s*(?:0|off|false)\b|"
    r"SESSION_COOKIE_SECURE\s*=\s*False\b", re.I)

# hand-built Set-Cookie for a session-name cookie with no HttpOnly flag
SETCOOKIE_NAKED = re.compile(
    r"set-cookie['\"]\s*,\s*['\"][^;\"]{0,60}?"
    r"(?:session|sess|sid|auth|token|jwt)[^;\"]*['\"]", re.I)

SAMESITE_NONE = re.compile(
    r"samesite\s*[:=]\s*['\"]?none\b|"
    r"same_?site\s*[:=]\s*['\"]?none\b|"
    r"SameSite\s*=\s*(?:SameSiteMode\.)?None\b|"
    r"SameSiteNoneMode|"
    r"cookie_samesite\s*[,= ]\s*['\"]?none\b|"
    r"session\.cookie_samesite\s*=\s*['\"]?none\b|"
    r"SESSION_COOKIE_SAMESITE\s*=\s*['\"]None", re.I)
SAMESITE_NONE_OK = re.compile(
    r"samesite\s*[:=]\s*['\"]?(?:lax|strict)|"
    r"same_?site\s*[:=]\s*['\"]?(?:lax|strict)|"
    r"SameSite\s*=\s*(?:SameSiteMode\.)?(?:Lax|Strict)|"
    r"SameSite(?:Lax|Strict)Mode|"
    r"cookie_samesite\s*[,= ]\s*['\"]?(?:lax|strict)|"
    r"session\.cookie_samesite\s*=\s*['\"]?(?:lax|strict)", re.I)

FIXATION = re.compile(
    r"session_id\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE)\b|"
    r"(?:req|request)\.sessionID\s*=\s*(?:req|request)\."
    r"(?:query|body|params|cookies|headers)\b|"
    r"\bsessionID\s*=\s*(?:req|request)\.(?:query|body|params|"
    r"cookies|headers)\b|"
    r"\bsession_id\s*=\s*request\.(?:GET|POST|args|form|cookies|"
    r"headers)\b|"
    r"setSessionId\s*\(\s*(?:req|request)\.", re.I)

TRANS_SID = re.compile(
    r"session\.use_trans_sid\s*=\s*(?:1|on|true)\b|"
    r"session\.use_only_cookies\s*=\s*(?:0|off|false)\b|"
    r"use_trans_sid['\"\]]*\s*[,=]>?\s*(?:1|true)\b|"
    r"use_only_cookies['\"\]]*\s*[,=]>?\s*(?:0|false)\b|"
    r"enableUrlRewriting\s*[:=]\s*true|"
    r"session-id-rewrite|url_rewriter\.tags", re.I)

TOKEN_STORAGE = re.compile(
    r"(?:localStorage|sessionStorage)\s*(?:\[\s*['\"]|\.)\s*"
    r"(?:setItem\s*\(\s*['\"])?"
    r"(?:token|jwt|auth|authToken|auth_token|accessToken|"
    r"access_token|idToken|id_token|refreshToken|refresh_token|"
    r"session|sessionId|session_id|sessionid|bearer|csrf|xsrf)"
    r"['\"]?\s*(?:[\],)]|\s*=)", re.I)

DOC_COOKIE = re.compile(r"document\.cookie\s*=", re.I)
DOC_COOKIE_AUTH = re.compile(
    r"document\.cookie\s*=\s*['\"`][^;\"`]{0,80}?"
    r"(?:session|sess|sid|auth|token|jwt|login)\w*\s*=", re.I)

URL_SESSION_ID = re.compile(
    r"[?&]\s*(?:sessionid|session_id|sessid|phpsessid|jsessionid|"
    r"aspsessionid|cfid|cftoken|sid)\s*=", re.I)

URL_TOKEN = re.compile(
    r"[?&]\s*(?:token|jwt|access_?token|id_?token|auth_?token|"
    r"api_?key)\s*=", re.I)
# silent contexts: URL-parsing lines reading params are fine — only
# flag construction (string literal or concatenation with a variable)
URL_READ_OK = re.compile(
    r"URLSearchParams|getQueryString|parse_qs|\.get\(|query\(|"
    r"searchParams|GetArgument|req\.query|request\.args|"
    r"Request\.QueryString|find_param|param\(", re.I)

LONGLIFE_MS = re.compile(
    r"(?:maxAge|max-age|max_age)\s*[:=,]\s*['\"]?"
    r"(3[0-9]{9,}|[4-9][0-9]{9,}|[0-9]{11,})", re.I)
LONGLIFE_SEC = re.compile(
    r"(?:cookie_?age|cookie_?lifetime|gc_?maxlifetime|session_?"
    r"lifetime|session_?cookie_?age|lifetime|ttl|expire[ds]?_?time|"
    r"validity)\s*[:=, ]\s*['\"]?(3[0-9]{6,}|[4-9][0-9]{6,}|[0-9]{8,})"
    r"(?!\d)", re.I)
LONGLIFE_GO = re.compile(
    r"\bMaxAge\s*[:=]\s*(3[0-9]{6,}|[4-9][0-9]{6,}|[0-9]{8,})\b")
LONGLIFE_TD = re.compile(
    r"permanent_session_lifetime\s*=\s*timedelta\s*\(\s*days\s*=\s*"
    r"([0-9]{2,})")
LONGLIFE_DATE = re.compile(
    r"\bexpires?\s*[:=]\s*['\"]\w{3},? \d{1,2} \w{3} 20(?:3[6-9]|"
    r"[4-9][0-9])\b", re.I)

SECRET_LIT = re.compile(
    r"(?:session|cookieSession|expressSession)\s*\(\s*\{[^}\n]{0,300}?"
    r"secret\s*:\s*['\"][^'\"\n]{3,}['\"]|"
    r"\.set\s*\(\s*['\"](?:sessionSecret|session_secret|jwtSecret|"
    r"jwt_secret|appSecret)['\"]\s*,\s*['\"][^'\"\n]{3,}['\"]", re.I)
SECRET_LIT_OK = re.compile(
    r"secret\s*:\s*(?:process\.env|config\.|processEnv|os\.environ|"
    r"getenv|require\s*\(|props\.|cfg\.)")

WIDE_DOMAIN = re.compile(
    r"\bdomain\s*[:=]\s*['\"]\.[\w-]+\.[\w.]{2,}['\"]|"
    r"setDomain\s*\(\s*['\"]\.|"
    r"Domain\s*=\s*\.[\w-]+\.[\w.]{2,}|"
    r"cookie-domain\s*[:=]\s*\.[\w-]+|"
    r"\bdomain\s*[:=]\s*true\b", re.I)

PER_CODE_CAP = 40


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 = {}
    text = raw
    lines = strip_comments(raw.splitlines())
    is_go = rel.endswith(".go")

    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 vocab_near(i):
        lo = max(0, i - 2)
        hi = min(len(lines), i + 3)
        seg = "\n".join(lines[lo:hi])
        return bool(COOKIE_VOCAB.search(seg))

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

        if HTTPONLY_OFF.search(ln) and vocab_near(i - 1):
            emit("HIGH", "SES-HTTPONLY-OFF", i,
                 "HttpOnly explicitly off on a cookie/session line — "
                 "any injected script can read and replay the session")

        if SECURE_OFF.search(ln) and vocab_near(i - 1):
            emit("MEDIUM", "SES-SECURE-OFF", i,
                 "Secure explicitly off on a cookie/session line — "
                 "the credential travels over plain HTTP")

        if (SETCOOKIE_NAKED.search(ln) and "httponly" not in ln.lower()
                and "http_only" not in ln.lower()):
            emit("MEDIUM", "SES-HTTPONLY-OFF", i,
                 "hand-built Set-Cookie for a session-name cookie with "
                 "no HttpOnly flag on the line")

        if SAMESITE_NONE.search(ln) and not SAMESITE_NONE_OK.search(ln):
            emit("MEDIUM", "SES-SAMESITE-NONE", i,
                 "SameSite=None — the cookie rides cross-site "
                 "requests; CSRF protections must live elsewhere")

        if FIXATION.search(ln):
            emit("HIGH", "SES-FIXATION", i,
                 "session id taken from request input — an attacker "
                 "can plant a known id before the victim logs in")

        if TRANS_SID.search(ln):
            emit("MEDIUM", "SES-TRANS-SID", i,
                 "URL session-id transport enabled — ids leak through "
                 "links, logs, analytics, and Referer headers")

        if TOKEN_STORAGE.search(ln):
            emit("MEDIUM", "SES-TOKEN-STORAGE", i,
                 "auth/session token written to web storage — "
                 "readable by any injected script (XSS -> token theft)")

        if DOC_COOKIE_AUTH.search(ln):
            emit("MEDIUM", "SES-DOC-COOKIE", i,
                 "document.cookie writes an auth/session-named "
                 "cookie — script-side session state, no HttpOnly "
                 "possible")

        if (URL_SESSION_ID.search(ln) and not URL_READ_OK.search(ln)
                and ("?" in ln or "&" in ln)):
            emit("MEDIUM", "SES-URL-ID", i,
                 "session id embedded in a URL query — ids leak "
                 "through logs, analytics, and Referer headers")

        if (URL_TOKEN.search(ln) and not URL_SESSION_ID.search(ln)
                and not URL_READ_OK.search(ln)
                and ("?" in ln or "&" in ln)):
            emit("LOW", "SES-URL-TOKEN", i,
                 "credential name placed in a URL query — tokens in "
                 "links leak via logs and Referer")

        m = LONGLIFE_MS.search(ln)
        if not m and is_go:
            m = LONGLIFE_GO.search(ln)
        if not m:
            m = LONGLIFE_SEC.search(ln)
        if not m:
            m2 = LONGLIFE_TD.search(ln)
            if m2 and int(m2.group(1)) >= 30:
                emit("LOW", "SES-LONG-LIVED", i,
                     "permanent session lifetime %sd — stolen "
                     "credentials stay valid for weeks"
                     % m2.group(1))
        if not m:
            m3 = LONGLIFE_DATE.search(ln)
            if m3:
                emit("LOW", "SES-LONG-LIVED", i,
                     "cookie expiry set far into the future — "
                     "long-lived credential window")
        if m and ("cookie" in ln.lower() or "session" in ln.lower()
                  or "maxage" in ln.lower() or "max_age" in ln.lower()
                  or "lifetime" in ln.lower() or vocab_near(i - 1)):
            val = m.group(1)
            emit("LOW", "SES-LONG-LIVED", i,
                 "cookie/session lifetime %s — >=30-day validity "
                 "for a stolen credential" % val)

        if WIDE_DOMAIN.search(ln) and vocab_near(i - 1):
            emit("LOW", "SES-WIDE-DOMAIN", i,
                 "cookie Domain scoped to a leading-dot parent — "
                 "every subdomain can read and write it")

    # window scan: multi-line session({...}) literal secrets
    for m in SECRET_LIT.finditer(text):
        seg = m.group(0)
        if SECRET_LIT_OK.search(seg):
            continue
        ln = text.count("\n", 0, m.start()) + 1
        emit("MEDIUM", "SES-SECRET-LIT", ln,
             "session middleware configured with a hardcoded secret "
             "literal — forgeable signatures and repo-scoped blast "
             "radius")

    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 = ["# session_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 sessions")
    return "\n".join(rows)


# --- selftest ---------------------------------------------------------------
SELFTEST = [
    # --- httponly off ---
    ("a.js",
     "res.cookie('session', id, { httpOnly: false });\n",
     {"SES-HTTPONLY-OFF"}),
    ("a.js",
     "res.cookie('session', id, { httpOnly: true });\n",
     set()),
    ("a.java",
     "cookie.setHttpOnly(false);\n",
     {"SES-HTTPONLY-OFF"}),
    ("a.py",
     "SESSION_COOKIE_HTTPONLY = False\n",
     {"SES-HTTPONLY-OFF"}),
    ("a.ini",
     "session.cookie_httponly = 0\n",
     {"SES-HTTPONLY-OFF"}),
    ("a.js",
     "res.setHeader('Set-Cookie', 'session=' + sid + '; Path=/');\n",
     {"SES-HTTPONLY-OFF"}),
    ("a.js",
     "res.setHeader('Set-Cookie', 'session=' + sid + '; Path=/; "
     "HttpOnly');\n",
     set()),
    # --- secure off ---
    ("a.js",
     "res.cookie('session', id, { secure: false });\n",
     {"SES-SECURE-OFF"}),
    ("a.py",
     "SESSION_COOKIE_SECURE = False\n",
     {"SES-SECURE-OFF"}),
    ("a.js",
     "const ws = new WebSocket(url, { secure: false });\n",
     set()),                                        # no cookie vocab
    ("a.ini",
     "session.cookie_secure = 0\n",
     {"SES-SECURE-OFF"}),
    # --- samesite ---
    ("a.js",
     "res.cookie('session', id, { sameSite: 'none' });\n",
     {"SES-SAMESITE-NONE"}),
    ("a.go",
     "c := http.Cookie{Name: \"s\", SameSite: http.SameSiteNoneMode}\n",
     {"SES-SAMESITE-NONE"}),
    ("a.cs",
     "opt.SameSite = SameSiteMode.None;\n",
     {"SES-SAMESITE-NONE"}),
    ("a.py",
     "SESSION_COOKIE_SAMESITE = 'Lax'\n",
     set()),
    ("a.ini",
     "session.cookie_samesite = Strict\n",
     set()),
    # --- fixation ---
    ("a.php",
     "session_id($_GET['sid']);\n",
     {"SES-FIXATION"}),
    ("a.js",
     "req.sessionID = req.query.session_id;\n",
     {"SES-FIXATION"}),
    ("a.py",
     "session_id = request.args.get('sid')\n",
     {"SES-FIXATION"}),
    ("a.py",
     "session_id = secrets.token_hex(16)\n",
     set()),
    # --- trans sid ---
    ("a.ini",
     "session.use_trans_sid = 1\n",
     {"SES-TRANS-SID"}),
    ("a.ini",
     "session.use_only_cookies = 0\n",
     {"SES-TRANS-SID"}),
    ("a.ini",
     "session.use_only_cookies = 1\n",
     set()),
    # --- token storage ---
    ("a.js",
     "localStorage.setItem('token', tok);\n",
     {"SES-TOKEN-STORAGE"}),
    ("a.js",
     "sessionStorage.setItem('access_token', tok);\n",
     {"SES-TOKEN-STORAGE"}),
    ("a.js",
     "localStorage.setItem('theme', 'dark');\n",
     set()),
    ("a.ts",
     "localStorage.jwt = res.token;\n",
     {"SES-TOKEN-STORAGE"}),
    # --- document.cookie ---
    ("a.js",
     "document.cookie = 'session=' + sid;\n",
     {"SES-DOC-COOKIE"}),
    ("a.js",
     "document.cookie = 'theme=dark';\n",
     set()),
    # --- url ids ---
    ("a.js",
     "res.redirect('/home?sessionid=' + req.sessionID);\n",
     {"SES-URL-ID"}),
    ("a.js",
     "const u = base + '?PHPSESSID=' + id;\n",
     {"SES-URL-ID"}),
    ("a.js",
     "const sid = new URLSearchParams(q).get('sessionid');\n",
     set()),
    ("a.js",
     "window.location = '/cb?token=' + jwt;\n",
     {"SES-URL-TOKEN"}),
    ("a.js",
     "const t = params.get('token');\n",
     set()),
    # --- long lived ---
    ("a.js",
     "app.use(session({ secret: s, cookie: { maxAge: 31536000000 } }));\n",
     {"SES-LONG-LIVED"}),
    ("a.py",
     "SESSION_COOKIE_AGE = 31536000\n",
     {"SES-LONG-LIVED"}),
    ("a.py",
     "permanent_session_lifetime = timedelta(days=365)\n",
     {"SES-LONG-LIVED"}),
    ("a.js",
     "res.cookie('s', v, { maxAge: 900000 });\n",
     set()),
    ("a.ini",
     "session.cookie_lifetime = 3600\n",
     set()),
    ("a.go",
     "c := http.Cookie{MaxAge: 31536000}\n",
     {"SES-LONG-LIVED"}),
    # --- secret literal ---
    ("a.js",
     "app.use(session({ secret: 'keyboard cat', resave: false }));\n",
     {"SES-SECRET-LIT"}),
    ("a.js",
     "app.use(session({ secret: process.env.SESSION_SECRET }));\n",
     set()),
    ("a.js",
     "app.use(cookieSession({ secret: 'hunter2' }));\n",
     {"SES-SECRET-LIT"}),
    # --- wide domain ---
    ("a.js",
     "res.cookie('s', v, { domain: '.example.com' });\n",
     {"SES-WIDE-DOMAIN"}),
    ("a.java",
     "cookie.setDomain('.example.com');\n",
     {"SES-WIDE-DOMAIN"}),
    ("a.js",
     "const domain = 'api.example.com';\n",
     set()),
    # --- comment / import silence ---
    ("a.js",
     "// res.cookie('session', id, { httpOnly: false });\n",
     set()),
    ("a.js",
     "import expressSession from 'express-session';\n",
     set()),
]


def run_selftest():
    bad = 0
    for name, src, want in SELFTEST:
        tmp = os.path.join("/tmp", "sess_selftest_" + name)
        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))
