GradientQL is an autonomous vulnerability scanner for GraphQL APIs. Give it an endpoint and a model API key, and a single language model runs the assessment without an operator: it reads the schema, signs up for an account when a flow needs one, works the attack surface, and writes a report. Each step is a single model call that returns one action from a fixed set, so a scan follows the model's own read of the target rather than a fixed payload list.
Warning
Only run GradientQL against a target you own or are authorized to test. It sends live attacks to the endpoint, runs unattended, and has no consent gate. Staying in scope is your responsibility.
flowchart TB
ep(["GraphQL endpoint"]) --> intro["Introspect<br/>(or recover schema<br/>from validation errors)"]
intro --> plan["Fingerprint engine<br/>+ draft attack plan"]
plan --> loop
subgraph loop["Agent loop (repeats until done, or budget runs out)"]
direction LR
b["Build prompt"] --> m["Model call"] --> a["Pick 1 action"] --> o["Run + observe"]
o -. "writes to memory" .-> b
end
loop --> rec["Collect OOB callbacks<br/>+ remove duplicates"]
rec --> rep(["Report"])
A scan is a short pipeline with the model in the middle:
- The agent reads the schema by introspection, or rebuilds it from the server's validation errors when introspection is disabled.
- It fingerprints the server engine and runs a set of misconfiguration checks.
- It drafts an initial attack plan from the schema.
- It hands control to the model for the main loop.
- On exit, it collects the out-of-band callbacks, removes duplicate findings, and prints the report.
The main loop is where the assessment happens. A run has a fixed step budget, and each step is one model call plus the single action it returns. On every turn the agent hands the model a compact view of the run: the schema, a summary of what it has already tried, and the facts and credentials gathered so far. The model replies with one action as JSON:
{"thought": "...", "action": "fuzz", "args": {"field": "userByToken"}, "learned": "...", "verdict": {}}The learned and verdict fields are how the model writes to its own memory: learned banks a fact,
and verdict marks a field dead, open, or exploited so the next prompt reflects it. Most actions send
a single request; battery actions like sweep and a full fuzz ladder fan out to roughly 16 requests
in one step. The budget setting caps the number of steps.
The agent works from a fixed set of about two dozen actions: raw queries and schema search, the payload batteries, JWT forgery, out-of-band probes, and an authentication flow that can register an account, read the confirmation email from a temporary mailbox, and log in. It retracts a finding when a later step disproves it, and removes duplicates before writing the report.
The agent works from a fixed set of actions that span the GraphQL attack surface:
| Area | Techniques |
|---|---|
| Reconnaissance | Introspection, schema recovery from validation-error suggestions when introspection is off, field brute-forcing (clairvoyance), engine fingerprinting, and misconfiguration checks |
| Access control | BOLA and IDOR, broken function-level authorization, and mass assignment, tested across anonymous, user, and forged-admin identities |
| Injection | SQL, NoSQL, OS command, SSTI, and template injection, fired as payload batteries at individual field arguments |
| Authentication | JWT forgery (alg:none, weak secret, kid, RS256 to HS256 confusion), credential and OTP brute-forcing, and full signup, email-confirmation, and login chains |
| Out-of-band | SSRF and blind injection confirmed through out-of-band callbacks, plus CSRF and CORS checks |
| Transport and protocol | Denial of service, request smuggling, race conditions, APQ cache poisoning, WebSocket subscriptions, and abuse of the @defer and @stream directives |
Clone the repository and install it in editable mode. You need Python 3.10 or newer and an API key for a model on OpenRouter.
pip install -e ".[dev]"
Two optional extras add capabilities. Skip either and the scanner still runs; you only lose that one feature.
semantic(pip install -e ".[dev,semantic]"): semantic schema search for large schemas (80+ fields), via FAISS, sentence-transformers, and CPU PyTorch. Without it, schema search falls back to a lexical match.ws(pip install -e ".[dev,ws]"): thesubscribeaction, which probes GraphQL subscriptions over WebSocket. Without it, that action reports itself unavailable.
Set your key and run against an endpoint you're authorized to test:
export OPENROUTER_API_KEY=sk-...
python -m gradientql --url https://your-target.example/graphql
The gradientql command is equivalent to python -m gradientql. Run it with no arguments to open the
interactive interface.
Both paths scan a bundled copy of DVGA, the standard intentionally-vulnerable GraphQL app. You supply only an OpenRouter key.
-
Colab. Open the notebook and run the cells; no local install needed.
-
Docker. Brings up DVGA and scans it in one command (the image includes the
semanticextra):OPENROUTER_API_KEY=sk-... docker compose -f docker/docker-compose.yml up --build
The base invocation points the scanner at a target and runs to completion:
python -m gradientql --url https://your-target.example/graphql
Add --trace to record every step. It's the main way to inspect a scan after it finishes (see
Output for what it writes):
python -m gradientql --url https://your-target.example/graphql --trace
GradientQL picks the output mode automatically:
- No
--urlin an interactive terminal opens the interactive interface. --url, a pipe or redirect, or--no-tuiprints plain logs.--tuiforces the interface when a terminal is attached, even with--urlset.
| Argument | Effect |
|---|---|
--url URL |
Target GraphQL endpoint. Overrides target.url from the settings file. |
--settings PATH |
Path to the settings file. Defaults to config/settings.yaml. |
--trace [PATH] |
Record every step to a .jsonl log and a paired .md digest. Bare --trace writes output/agent_trace_<timestamp>.*; pass a path or prefix to write elsewhere. |
-v, --verbose |
Print each step's full thought and observations (plain-log mode). |
--resume RUN_ID |
Resume a previous run from its last checkpoint: a run id (gql-...) or a checkpoint path. See output/checkpoints/. |
--max-tokens N |
Override the model's max output tokens per step (llm.attacker_max_tokens). |
--tui |
Force the interactive interface. Falls back to plain logs when no terminal is attached. |
--no-tui |
Force plain logs even in an interactive terminal. |
-h, --help |
Show usage and exit. |
Run gradientql with no arguments to open the interactive interface (TUI). It has three screens:
- Menu: the current target, budget, model, proxy, and whether an API key is set, with buttons to start a scan or open settings.
- Settings: the target URL, budget, model, proxy, request delay and timeout, and the fuzz payload cap, plus a submenu of per-technique attack toggles.
- Dashboard: shown once a scan starts. It first checks that the API key authenticates, stopping
with a clear message if the key is missing or rejected, then updates in place as the run proceeds:
- a status line: step, elapsed time, request rate, findings, and model;
- a coverage map of the schema;
- an activity feed streaming the model's thought and answer as they arrive;
- a loot pane with harvested credentials, the current session token, and recorded facts;
- a table of findings.
The coverage map marks each root field as untested, probed, auth-gated, data, exhausted, or a finding,
so you can watch the attack surface fill in as the agent works. Press m, or click the activity pane,
to open the full model output for any step.
Screenshots of each screen are in docs/: menu, settings, and
the live dashboard.
The agent runs on its own, but you can redirect it mid-scan. Whatever you send is injected into the
model's next prompt as an operator instruction that takes priority over its own plan, and it's
recorded in the trace. Use it to focus the run (test the upload field for path traversal), flag a
miss (you skipped importPaste), or change tack (stop recon, try DoS now).
- Interactive interface: type into the steering box at the bottom of the dashboard and press
Enter. The message shows in the activity feed as
operator: .... - Plain-log mode: in an interactive terminal, type a line and press Enter at any point during the scan; it's picked up on the next step. This is disabled when input is piped or redirected.
An operator message stays in view for a few steps so the agent doesn't lose it mid-action.
Settings are YAML. config/settings.yaml is a template you copy and edit, and --settings points the
scanner at a different file. Any key you omit falls back to a built-in default, so a config file only
needs the values you want to change. The knobs you'll reach for most:
scanner:
budget: 60 # max steps per scan (one model call each; a battery
# action sends several requests within a step)
safe_mode: false # turn off every destructive technique at once
attacks: # toggle individual techniques
injection: true
ssrf: true
dos: false # off by default; it can knock a target over
jwt: true
bola: true # systematic BOLA and BFLA across identities
http:
proxy: "" # route traffic through Burp or mitmproxy
llm:
attacker_model: "z-ai/glm-5.2"config/settings.yaml documents every field, and docs/CONFIGURATION.md is
the full reference: the API-key resolution order, proxy and TLS options, and out-of-band callback
settings.
Reaching authenticated objects, where bugs like broken object-level authorization live, needs a
session. Give the agent one by putting a valid token in target.headers, or let it earn its own
through the signup, email-confirmation, and login flow with the temp_mail action.
Against a fresh DVGA, three models each ran five times at a 30-step budget with the default attack config. All three reliably find the easy categories (introspection, batch-query denial of service, stack-trace leakage) and separate on the multi-step authentication chains, where glm leads. Mean findings per run: glm 7.4, qwen 6.0, gpt-oss 4.8. Per-category detection is in this chart.
A larger budget lets the strongest model go deeper. In one 200-step run, glm stopped itself at step
119 with 20 findings and no false positives; one chain used a confirmed command injection to read the
target's own source, recovered the hardcoded JWT_SECRET_KEY, and forged a token from it that
bypassed authentication. Full methodology, per-category counts, larger-budget runs, and token usage
are in docs/results.md.
Two images are provided: gradientql (the scanner) and gradientql-dvga (a patched DVGA target with
the gevent concurrency fix baked in). The Compose file brings up DVGA and scans it in one command, as
shown above. Full instructions, including scanning your own target from a
container, are in docs/docker.md.
Everything the scanner writes goes under output/, which is git-ignored:
agent_trace_<timestamp>.jsonland the paired.mddigest, written when--traceis on. Each step records the exact prompt sent to the model, the raw reply, the parsed action, the observation fed back in, and a state snapshot.vuln_stream.jsonl, the findings. They're written as they're confirmed, so a crash partway through a run doesn't lose them.
Caution
These files hold secrets. Traces contain the full prompts, including any credentials and tokens
harvested during the run, and checkpoints under output/checkpoints/ store the run's identity
headers and credentials. Don't attach them to bug reports or commit them anywhere.
python -m pytest -q
The suite is fast and runs entirely offline.
MIT. See LICENSE.