#!/usr/bin/env python3
"""graphql_prescan.py — deterministic GraphQL-API security pre-scan.

ARION service artifact. Scans application source for the signatures of
an unsafe GraphQL surface: introspection and dev consoles explicitly
enabled, request batching allowed, resolver arguments flowing into
exec/SQL/HTTP sinks without review, mutation resolvers defined in
files with no authorisation vocabulary, servers with no query
depth/complexity limit vocabulary, and stack/exception detail returned
through the error channel. Line-wise taint (name assignment only),
fully offline, no inference.

Findings (base severity):
  GQL-INPUT-EXEC        HIGH    a value derived from resolver input
                                (args/variables/input/parent) reaches
                                eval/exec/os.system/subprocess/
                                execSync/Runtime.exec/ProcessBuilder/
                                new Function/vm.runIn*/pickle.loads/
                                __import__ — resolver input is
                                attacker-controlled query data
  GQL-INPUT-SQL         HIGH    resolver input into .execute/
                                .executemany/.raw/.query/$queryRaw —
                                a field argument becomes a query
                                fragment
  GQL-INPUT-FETCH       MEDIUM  resolver input into requests.*, fetch,
                                axios, urlopen, http.Get — the query
                                chooses the outbound target (SSRF
                                channel through the API)
  GQL-INTROSPECTION-ON  MEDIUM  introspection explicitly enabled
                                (introspection: true / introspection=
                                True / IntrospectionQuery allowed) —
                                full schema disclosure; only the
                                explicit-ON shape flags, an absent
                                flag is framework-default and silent
  GQL-DEBUG-CONSOLE     MEDIUM  a dev console mounted on the serving
                                path — graphiql: true / playground:
                                true / GraphiQL( / voyager / debug:
                                true inside a server config
  GQL-BATCHING-ON       MEDIUM  array request batching explicitly
                                allowed (allowBatchedHttpRequests,
                                batching: true, graphql-batch, yoga
                                batching) — one HTTP request carries
                                N operations: rate limits and
                                lockouts divide by N
  GQL-MUTATION-NO-AUTH  MEDIUM  file-level: Mutation resolvers are
                                defined and the file carries zero
                                authorisation vocabulary (auth/
                                permission/shield/rule/guard/policy/
                                role/context.user) — every mutation
                                executes for anonymous callers
  GQL-ERROR-STACK       LOW     stacktrace/exception detail returned
                                through the error channel (extensions
                                .stacktrace, formatError returning
                                err, debug exception payloads)
  GQL-NO-QUERY-LIMIT    LOW     file-level: the file wires a GraphQL
                                server/endpoint and contains zero
                                depth/complexity-limit vocabulary
                                (depthLimit/maxDepth/max_depth/
                                queryComplexity/complexityLimit/
                                costLimit/maxComplexity/estimated
                                Cost/persistedQuery/validationRules) —
                                nested and widened queries burn the
                                resolver graph unchecked; LOW,
                                informational

Context rules:
  * comment lines and block-comment bodies are stripped first
  * import/require lines never flag
  * test/spec/fixture paths downgrade one tier
  * taint is name-assignment only: `x = args.foo`, destructuring
    `= args` / `= variables` / `= input`, and inline args./
    variables./input. references in a sink line; no cross-function
    or cross-file flow
  * `context` / `ctx` are NOT taint sources — they carry server-set
    auth state; `parent`/`root`/`info` ARE resolver-supplied
  * all findings require GraphQL vocabulary somewhere in the file —
    a sink next to a coincidence-named `args` does not flag
  * file-level codes (MUTATION-NO-AUTH, NO-QUERY-LIMIT) need server
    or resolver-definition vocabulary, not a bare import

Scope: deterministic pre-scan for human review — flags the line
shapes where the GraphQL surface crosses a trust boundary; it cannot
see framework defaults, gateway policy, or WAF rules. Clean output
!= a safe API.

  graphql_prescan.py <file-or-dir> [--json]
  graphql_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", ".graphql",
    ".gql", ".ex", ".exs",
)

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.]+;|"
    r"alias\s+[\w.]+|defmodule\s)")

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

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

# strong markers — the file actually wires GraphQL: schema/resolver
# definitions, server constructors, tagged-template queries, framework
# imports. A prose 'graphql' or a URL string never gates a file open.
GQL_STRONG = re.compile(
    r"ApolloServer|graphqlHTTP|GraphQLSchema|makeExecutableSchema|"
    r"createHandler|createYoga|\btypeDefs\b|\bresolvers\b|"
    r"\bgql\s*`|graphql\.buildSchema|buildSchema\s*\(|"
    r"GraphQLObjectType|GraphQLField|graphene\.|"
    r"(?:ObjectType|Field|Schema|Query|Mutation|InputObjectType)"
    r"\s*=\s*graphene|"
    r"makeExecutableSchema|executableSchema|"
    r"import\s+[\w{}*,\s]+from\s+['\"](?:graphql|@apollo|"
    r"@graphql-yoga|mercurius|@graphql-tools|express-graphql|"
    r"graphql-http|@envelop|pothos|@pothos|type-graphql|"
    r"@nestjs/graphql|graphene|ariadne|strawberry|"
    r"absinthe|sangria)[\w./-]*['\"]|"
    r"require\(\s*['\"](?:graphql|@apollo/server|express-graphql|"
    r"graphql-yoga|mercurius|graphql-http)[\w./-]*['\"]\s*\)|"
    r"from\s+(?:graphene|ariadne|strawberry|gql|graphql)\b|"
    r"import\s+(?:graphene|ariadne|strawberry)|"
    r"Absinthe\.|defmodule.*Resolvers|"
    r"Schema\.Definition|HotChocolate|"
    r"type\s+(?:Query|Mutation|Subscription)\s*\{|"
    r"extend\s+type\s+(?:Query|Mutation)\s*\{", re.I)

# server/endpoint wiring — gates the file-level NO-QUERY-LIMIT code
SERVER_VOCAB = re.compile(
    r"new\s+ApolloServer|graphqlHTTP\s*\(|createHandler\s*\(|"
    r"createYoga\s*\(|express-graphql|graphql-http|useGraphQL|"
    r"GraphQL\.use|graphiql\s*[:=]|playground\s*[:=]|"
    r"/graphql|execute\s*\(.*schema|startStandaloneServer|"
    r"GraphQLView|graphql_app|GraphQLApp|"
    r"add_url_rule\s*\([^)]*graphql|route.*graphql", re.I)

# mutation-definition vocabulary — gates MUTATION-NO-AUTH; a bare
# `resolvers = {` proves nothing about Mutation existing in-file
RESOLVER_DEF = re.compile(
    r"\bMutation\s*:\s*\{|\bMutations\s*:\s*\{|"
    r"type\s+Mutation\s*\{|extend\s+type\s+Mutation\s*\{|"
    r"class\s+\w*Mutation|def\s+mutate\s*\(|"
    r"field\s+['\"]?\w+['\"]?,\s*mutations\.|"
    r"@mutation|mutation\s*=\s*Field|"
    r"mutationType|MutationType|Mutation\s*=\s*"
    r"(?:graphene\.)?ObjectType", re.I)

# introspection explicitly ON
INTROSPECT_ON = re.compile(
    r"introspection\s*[:=]\s*(?:true|True|1)|"
    r"introspection\s*=\s*[^\s,}\)]*?enable|"
    r"allowIntrospection|NoSchemaIntrospectionCustomRule.{0,20}"
    r"(?:remove|exclude|disable)|"
    r"disable_introspection\s*[:=]\s*(?:false|False|0)", re.I)

# dev consoles / debug mounted on the serving path
CONSOLE_ON = re.compile(
    r"graphiql\s*[:=]\s*(?:true|True|1)|"
    r"playground\s*[:=]\s*(?:true|True|1)|"
    r"GraphiQL\s*\(|renderGraphiQL|graphQLIDE|"
    r"voyager\s*[:=]\s*(?:true|True)|altair|"
    r"graphqlPlayground|ApolloServerPluginLandingPage"
    r"(?!.*(?:disabled|disabledPage|LocalDefault))|"
    r"\bdebug\s*[:=]\s*(?:true|True|1)", re.I)

# request batching explicitly allowed
BATCH_ON = re.compile(
    r"allowBatchedHttpRequests\s*:\s*true|batching\s*[:=]\s*"
    r"(?:true|True|1)|graphql[-_]batch|useBatching|"
    r"batch\.(?:true|enabled)|GraphQLBatch|"
    r"apollo-link-batch-http|dataloader.*batch.{0,30}request", re.I)

# resolver-input sources — attacker-controlled query data.
# parent/root/info/context are deliberately absent: context/ctx carry
# server-set auth state, and parent/root/info are too generic as bare
# attribute receivers (DOM nodes, logging.info, lxml root).
INPUT_SRC = re.compile(
    r"\b(?:args|variables|variableValues|input|_args|_parent|"
    r"_root|req\.(?:body|query|params)|ctx\.(?:req|request))\s*[.\[]|"
    r"\binfo\.variableValues\b", re.I)

# names assigned FROM resolver input get tainted (whole-object copy)
INPUT_ASSIGN = re.compile(
    r"=\s*(?:await\s+)?(?:args|variables|variableValues|_args|"
    r"info\.variableValues|req\.(?:body|query|params))\b")

# destructuring `const { url, id } = args` / `= variables` / `= input`
DESTRUCT_SRC = re.compile(
    r"=\s*(?:args|variables|variableValues|input|req\.body)\s*;?\s*$")

# resolver signatures — JS `field: (parent, args)` / `f: (_p, {url})`
# and Python/Ruby `def resolve_field(root, info, name)` — the second
# (JS) or third-plus (Py/Rb) parameter is attacker-controlled input
JS_SIG2 = re.compile(
    r"[:=]\s*(?:async\s+)?\(\s*[_\w$]+\s*,\s*(\{[^}]*\}|"
    r"[A-Za-z_]\w{0,39})\s*[,)]")
PY_SIG = re.compile(
    r"def\s+(?:resolve|mutate|perform)_?\w*\s*\(([^)]*)\)")
EX_SIG = re.compile(
    r"def\s+\w+\s*\(([^)]*,[^)]*)\)\s+do\b")

# exec / code-eval sinks
EXEC_SINK = re.compile(
    r"\beval\s*\(|\bexec\s*\(|execSync\s*\(|execFileSync\s*\(|"
    r"os\.system\s*\(|os\.popen\s*\(|"
    r"subprocess\.(?:call|run|Popen|check_output|check_call)\s*\(|"
    r"child_process|spawnSync\s*\(|"
    r"Runtime\.getRuntime\(\)\.exec|ProcessBuilder\s*\(|"
    r"new\s+Function\s*\(|vm\.runIn\w*\s*\(|"
    r"pickle\.loads\s*\(|marshal\.loads\s*\(|"
    r"__import__\s*\(|importlib\.import_module\s*\(|"
    r"shell_exec\s*\(|passthru\s*\(|pcntl_exec\s*\(|"
    r"System\.cmd\s*\(|Port\.open\s*\(|:os\.cmd\s*\(")

# sql sinks
SQL_SINK = re.compile(
    r"\.execute(?:many|script)?\s*\(|\.raw\s*\(|\.query\s*\(|"
    r"\$queryRaw|\$executeRaw|executeQuery\s*\(|"
    r"sequelize\.query|knex\.raw|db\.run\s*\(|conn\.exec\s*\(|"
    r"execute_query\s*\(|pg_query|mysql_query")

# outbound-http sinks (query-chosen target = SSRF channel)
FETCH_SINK = re.compile(
    r"requests\.(?:get|post|put|head|request)\s*\(|"
    r"urllib\.request\.urlopen|urlopen\s*\(|\bfetch\s*\(|"
    r"axios\.(?:get|post|put|request)\s*\(|got\.(?:get|post)\s*\(|"
    r"http\.Get\s*\(|http\.NewRequest|http\.Post\s*\(|"
    r"needle\.(?:get|post)|superagent\.|wp_remote_get|"
    r"file_get_contents\s*\(|curl_init|RestTemplate|"
    r"\.getForObject|WebClient|HTTPoison|Finch|"
    r"Net::HTTP|Faraday")

# error channel leaking internals
ERROR_STACK = re.compile(
    r"stacktrace|stack_?trace|formatError\s*[:(=].*"
    r"(?:err|error)\b|extensions\s*[:=].*exception|"
    r"exposeServerErrors|debug_exception|"
    r"error\?\.\s*stack|\.stack\b.{0,40}(?:extensions|response|"
    r"return|format)", re.I)

# authorisation vocabulary — absence gates MUTATION-NO-AUTH
AUTH_VOCAB = re.compile(
    r"\bauth\b|authenticat|authori[sz]|permission|"
    r"graphql-shield|\bshield\b|requireAuth|"
    r"\bcontext\.(?:user|auth|currentUser|viewer)\b|"
    r"ctx\.(?:user|auth|state\.user)|current_user|"
    r"\bguard\b|\bcan(?:\(| |_)|policy|policies|"
    r"\brole[s]?\b|@auth|hasScope|checkScope|"
    r"isAuthenticated|ensureLoggedIn|allowIf|"
    r"forbid|deny|authorize\s*\(|"
    r"rule\s*\(|rules\s*=", re.I)

# depth/complexity-limit vocabulary — absence gates NO-QUERY-LIMIT
LIMIT_VOCAB = re.compile(
    r"depthLimit|maxDepth|max_depth|depth_limit|"
    r"queryDepthLimiter|complexityLimit|maxComplexity|"
    r"max_complexity|queryComplexity|complexity_analysis|"
    r"costLimit|maxCost|estimatedCost|query_cost|"
    r"persistedQuer|validationRules|createValidationRules|"
    r"max_tokens|maxAliases|maxDirectives|"
    r"validation_rules|analyze_complexity|"
    r"timeout\s*[:=]|parseOptions|query_limiter", 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 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
        st = s.strip()
        if st.startswith(("//", "#", "*", "<!--")):
            out.append("")
            continue
        out.append(s)
    return out


def downgrade(sev):
    return "MEDIUM" if sev == "HIGH" else "LOW"


ASSIGN_RX = re.compile(
    r"^\s*(?:const|let|var|final|static|auto|val|String|self\.)?"
    r"\s*\.?\s*([A-Za-z_]\w{0,39})\s*"
    r"(?::\s*[\w<>\[\],. |]+\s*)?=\s*(.+?);?\s*$")
SELF_ASSIGN = re.compile(r"^\s*self\.([A-Za-z_]\w{0,39})\s*=\s*(.+)$")


def rhs_taints(rhs, tainted):
    """True if the RHS references any tainted name (attr/index/etc)."""
    for name in tainted:
        if re.search(r"\b%s\b" % re.escape(name), rhs):
            return True
    return False


def names_in_line(ln, tainted):
    """Yield tainted names referenced on a line."""
    for name in tainted:
        if re.search(r"\b%s\b" % re.escape(name), ln):
            yield name


def scan_file(path, rel):
    try:
        raw = open(path, encoding="utf-8", errors="replace").read()
    except OSError:
        return []
    is_test = bool(TEST_PATH.search(rel))
    lines = strip_comments(raw.splitlines())
    text = "\n".join(lines)

    # file-level gate — no GraphQL wiring: not a GQL file, all silent
    if not GQL_STRONG.search(text):
        return []

    file_has_server = bool(SERVER_VOCAB.search(text))
    file_has_resolver = bool(RESOLVER_DEF.search(text))
    file_has_auth = bool(AUTH_VOCAB.search(text))
    file_has_limit = bool(LIMIT_VOCAB.search(text))
    findings = []
    counts = {}
    tainted = set()

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

    # ---- pass 1: taint propagation + per-line findings ------------------
    for i, ln in enumerate(lines, 1):
        st = ln.strip()
        if not st or IMPORT_LINE.match(ln):
            continue

        # taint assignment: name = <input source> or name = <tainted..>
        m = ASSIGN_RX.match(ln) or SELF_ASSIGN.match(ln)
        if m:
            name, rhs = m.group(1), m.group(2)
            if INPUT_SRC.search(rhs) or INPUT_ASSIGN.search("=" + rhs) \
                    or rhs_taints(rhs, tainted):
                tainted.add(name)
        # destructuring: const { url, id } = args
        elif DESTRUCT_SRC.search(ln) and "{" in ln:
            for nm in re.findall(r"[A-Za-z_]\w{0,39}", ln):
                if nm not in ("const", "let", "var", "args",
                              "variables", "input"):
                    tainted.add(nm)

        # resolver signature params are input
        for sm in JS_SIG2.finditer(ln):
            grp = sm.group(1)
            if grp.startswith("{"):
                for nm in re.findall(r"[A-Za-z_]\w{0,39}", grp):
                    tainted.add(nm)
            else:
                tainted.add(grp)
        pm = PY_SIG.search(ln)
        if pm:
            params = [p.split("=")[0].split(":")[0].strip()
                      for p in pm.group(1).split(",")]
            for nm in params[2:]:
                if re.match(r"^[A-Za-z_]\w{0,39}$", nm):
                    tainted.add(nm)
        em = EX_SIG.search(ln)
        if em and re.search(r"\bResolvers?\b|Absinthe", text):
            params = [p.strip() for p in em.group(1).split(",")]
            for nm in params[1:2]:        # absinthe: (_parent, args, _r)
                if re.match(r"^[A-Za-z_]\w{0,39}$", nm):
                    tainted.add(nm)

        inline_input = bool(INPUT_SRC.search(ln))
        line_taints = list(names_in_line(ln, tainted))
        if inline_input:
            line_taints.append("resolver-input")

        # -- sinks consuming resolver input --
        if line_taints:
            src = ", ".join(sorted(set(line_taints))[:3])
            if EXEC_SINK.search(ln):
                emit("HIGH", "GQL-INPUT-EXEC", i,
                     "resolver input (%s) reaches an exec/eval sink — "
                     "a field argument becomes executable code" % src)
            if SQL_SINK.search(ln):
                emit("HIGH", "GQL-INPUT-SQL", i,
                     "resolver input (%s) into a SQL sink — a field "
                     "argument becomes a query fragment" % src)
            if FETCH_SINK.search(ln):
                emit("MEDIUM", "GQL-INPUT-FETCH", i,
                     "resolver input (%s) into an HTTP fetch — the "
                     "query chooses the outbound target (SSRF channel)"
                     % src)

        # -- surface configuration --
        if INTROSPECT_ON.search(ln):
            emit("MEDIUM", "GQL-INTROSPECTION-ON", i,
                 "introspection explicitly enabled — full schema "
                 "disclosure to any caller")
        if CONSOLE_ON.search(ln):
            emit("MEDIUM", "GQL-DEBUG-CONSOLE", i,
                 "dev console / debug flag on the serving path — "
                 "interactive schema browser exposed")
        if BATCH_ON.search(ln):
            emit("MEDIUM", "GQL-BATCHING-ON", i,
                 "request batching allowed — one HTTP request carries "
                 "N operations; rate limits divide by N")
        if ERROR_STACK.search(ln):
            emit("LOW", "GQL-ERROR-STACK", i,
                 "stack/exception detail returned through the error "
                 "channel — internals leak to callers")

    # ---- file-level: mutations with zero auth vocabulary -----------------
    if file_has_resolver and not file_has_auth:
        emit_at = None
        for i, ln in enumerate(lines, 1):
            if not ln.strip() or IMPORT_LINE.match(ln):
                continue
            if RESOLVER_DEF.search(ln):
                emit_at = i
                break
            if emit_at is None:
                emit_at = i
        emit("MEDIUM", "GQL-MUTATION-NO-AUTH", emit_at or 1,
             "Mutation resolvers defined in a file with zero "
             "authorisation vocabulary — every mutation runs for "
             "anonymous callers (verify guards upstream)")

    # ---- file-level: server with zero depth/complexity vocabulary --------
    if file_has_server and not file_has_limit:
        emit_at = None
        for i, ln in enumerate(lines, 1):
            if not ln.strip() or IMPORT_LINE.match(ln):
                continue
            if SERVER_VOCAB.search(ln):
                emit_at = i
                break
            if emit_at is None:
                emit_at = i
        emit("LOW", "GQL-NO-QUERY-LIMIT", emit_at or 1,
             "GraphQL endpoint wired with zero depth/complexity-limit "
             "vocabulary — nested and widened queries burn the "
             "resolver graph unchecked")

    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):
                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 = ["# graphql_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 != a safe API")
    return "\n".join(rows)


# --- selftest -------------------------------------------------------------
SELFTEST = [
    # --- input -> exec / sql / fetch sinks ---
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const resolvers = { Query: { run: (p, args) => {\n"
     "  const cmd = args.command;\n"
     "  execSync(cmd);\n"
     "}}};\n",
     {"GQL-INPUT-EXEC"}),
    ("a.ts",
     "import { ApolloServer } from '@apollo/server';\n"
     "const resolvers = { Query: { f: async (_p, { url }) => {\n"
     "  eval(url);\n"
     "}}};\n",
     {"GQL-INPUT-EXEC"}),
    ("a.py",
     "import graphene\n"
     "class Query(graphene.ObjectType):\n"
     "    def resolve_lookup(root, info, table):\n"
     "        return db.execute('SELECT * FROM ' + table)\n",
     {"GQL-INPUT-SQL"}),
    ("a.js",
     "import { gql } from 'graphql-tag';\n"
     "const resolvers = { Mutation: { go: (p, a) => {\n"
     "  db.query('DELETE WHERE id=' + a.id);\n"
     "}}};\n",
     {"GQL-INPUT-SQL", "GQL-MUTATION-NO-AUTH"}),
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const resolvers = { Query: { proxy: (p, args) => {\n"
     "  return fetch(args.url);\n"
     "}}};\n",
     {"GQL-INPUT-FETCH"}),
    ("a.py",
     "import ariadne\n"
     "def resolve_fetch(obj, info, url):\n"
     "    return requests.get(url)\n",
     {"GQL-INPUT-FETCH"}),
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const resolvers = { Query: { proxy: (p, args) => {\n"
     "  const target = args.url;\n"
     "  return axios.get(target);\n"
     "}}};\n",
     {"GQL-INPUT-FETCH"}),
    ("a.js",
     "import { gql } from 'graphql-tag';\n"
     "const resolvers = { Mutation: { save: (p, { input }) => {\n"
     "  db.execute('INSERT INTO t VALUES (' + input.name + ')');\n"
     "}}};\n",
     {"GQL-INPUT-SQL", "GQL-MUTATION-NO-AUTH"}),
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const resolvers = { Query: { q: (p, args) => {\n"
     "  db.execute(query % variables['x']);\n"
     "}}};\n",
     {"GQL-INPUT-SQL"}),
    # --- surface configuration ---
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const s = new ApolloServer({ typeDefs, resolvers,\n"
     "  introspection: true });\n",
     {"GQL-INTROSPECTION-ON", "GQL-NO-QUERY-LIMIT"}),
    ("a.py",
     "from flask import Flask\n"
     "from graphene import Schema\n"
     "app.add_url_rule('/graphql', view_func=GraphQLView.as_view(\n"
     "    'gql', schema=schema, graphiql=True))\n",
     {"GQL-DEBUG-CONSOLE", "GQL-NO-QUERY-LIMIT"}),
    ("a.js",
     "import { createYoga } from 'graphql-yoga';\n"
     "const yoga = createYoga({ schema, graphiql: true,\n"
     "  batching: true });\n",
     {"GQL-DEBUG-CONSOLE", "GQL-BATCHING-ON",
      "GQL-NO-QUERY-LIMIT"}),
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const s = new ApolloServer({ typeDefs, resolvers,\n"
     "  introspection: process.env.NODE_ENV !== 'production' });\n",
     {"GQL-NO-QUERY-LIMIT"}),
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const s = new ApolloServer({ typeDefs, resolvers,\n"
     "  allowBatchedHttpRequests: true });\n",
     {"GQL-BATCHING-ON", "GQL-NO-QUERY-LIMIT"}),
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const s = new ApolloServer({ typeDefs, resolvers,\n"
     "  debug: true });\n",
     {"GQL-DEBUG-CONSOLE", "GQL-NO-QUERY-LIMIT"}),
    # --- error channel ---
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const s = new ApolloServer({ typeDefs, resolvers,\n"
     "  formatError: (err) => err });\n",
     {"GQL-ERROR-STACK", "GQL-NO-QUERY-LIMIT"}),
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const s = new ApolloServer({ typeDefs, resolvers,\n"
     "  formatError: (err) => ({ message: 'failed' }) });\n",
     {"GQL-ERROR-STACK", "GQL-NO-QUERY-LIMIT"}),
    # --- file-level gates ---
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const resolvers = { Mutation: { set: (p, a, ctx) => {\n"
     "  if (!ctx.user) throw new Error('deny');\n"
     "  return save(a);\n"
     "}}};\n"
     "const s = new ApolloServer({ typeDefs, resolvers });\n",
     {"GQL-NO-QUERY-LIMIT"}),   # ctx.user = auth vocab
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const resolvers = { Mutation: { set: (p, a) => save(a) } };\n"
     "const s = new ApolloServer({ typeDefs, resolvers,\n"
     "  validationRules: [depthLimit(7)] });\n",
     {"GQL-MUTATION-NO-AUTH"}), # limit vocab present
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const resolvers = { Query: { ok: () => true } };\n"
     "const s = new ApolloServer({ typeDefs, resolvers,\n"
     "  maxDepth: 10 });\n",
     set()),                      # no Mutation, limits present
    ("a.js",
     "import { graphqlHTTP } from 'express-graphql';\n"
     "app.use('/graphql', graphqlHTTP({ schema }));\n",
     {"GQL-NO-QUERY-LIMIT"}),   # server, no resolvers-in-file
    # --- FP guards ---
    ("a.js",
     "const args = process.argv.slice(2);\n"
     "execSync(args[0]);\n",
     set()),                      # no GQL vocab
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const resolvers = { Query: { me: (p, a, ctx) => {\n"
     "  db.query('SELECT * FROM users WHERE id=' + ctx.user.id);\n"
     "}}};\n"
     "const s = new ApolloServer({ typeDefs, resolvers });\n",
     {"GQL-NO-QUERY-LIMIT"}),   # ctx is not resolver input
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "// const s = new ApolloServer({ introspection: true });\n"
     "const s = new ApolloServer({ typeDefs, resolvers });\n",
     {"GQL-NO-QUERY-LIMIT"}),   # commented flag stripped
    ("test/test_a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const resolvers = { Query: { r: (p, args) => eval(args.x) } };\n",
     {"GQL-INPUT-EXEC"}),       # EXEC downgraded MEDIUM, code same
    ("a.py",
     "import os\n"
     "args = sys.argv\n"
     "os.system(args[1])\n",
     set()),
    ("a.js",
     "import { gql } from 'graphql-tag';\n"
     "const Q = gql`query { me { id } }`;\n",
     set()),                      # query doc only, no server/resolver
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const s = new ApolloServer({ typeDefs, resolvers });\n",
     {"GQL-NO-QUERY-LIMIT"}),
    ("a.ex",
     "defmodule MyApp.Resolvers do\n"
     "  def run(_p, args, _r) do\n"
     "    System.cmd('sh', [args.cmd])\n"
     "  end\n"
     "end\n",
     {"GQL-INPUT-EXEC"}),
    ("a.js",
     "import { ApolloServer } from '@apollo/server';\n"
     "const resolvers = { Mutation: { go: (p, a) => {\n"
     "  if (!context.user) return null;\n"
     "  return save(a);\n"
     "}}};\n",
     set()),                      # context.user = auth vocab present
]


def run_selftest():
    import tempfile
    fails = []
    for i, (name, src, expect) in enumerate(SELFTEST):
        with tempfile.TemporaryDirectory() as td:
            p = os.path.join(td, name)
            os.makedirs(os.path.dirname(p), exist_ok=True)
            open(p, "w").write(src)
            got = {f.code for f in scan_file(p, name)}
            missing = expect - got
            extra = got - expect
            if missing or extra:
                fails.append(("v%d %s" % (i, name),
                              "missing=%s extra=%s" % (missing, extra)))
    if fails:
        for f_ in fails:
            print("SELFTEST FAIL", f_)
        return 1
    print("SELFTEST PASS (%d vectors)" % len(SELFTEST))
    return 0


def main():
    args = [a for a in sys.argv[1:] if a != "--json"]
    want_json = "--json" in sys.argv
    if "--selftest" in sys.argv:
        return run_selftest()
    if not args:
        print(__doc__)
        return 2
    root = args[0]
    targets = find_targets(root)
    findings = []
    for p in targets:
        rel = os.path.relpath(p, root) if os.path.isdir(root) \
            else os.path.basename(p)
        findings += scan_file(p, rel)
    if want_json:
        print(json.dumps({"findings": [f.as_dict() for f in findings],
                          "scanned": len(targets)}, indent=1))
    else:
        print(render(findings, targets))
    return 1 if any(f.sev in ("HIGH", "MEDIUM") for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
