#15API4 · API1/3/5 · A03

GraphQL Security

Introspection, query cost/depth, batching, and resolver authorization.

Download .md

How to use this prompt

  1. 1
    Install SecureNow in your project (then optionally npx securenow login):
    $ npm install securenow
  2. 2Copy the prompt below and paste it into your AI coding agent (Claude Code, Cursor, Codex…) opened at the root of your project.
  3. 3It generates four files into threat/15-graphql-security/ — open graphql-security-code-findings.html (the audit) and graphql-security-detection-mitigation.html (the defenses) in your browser.

🔒Runs entirely in your environment — your codebase is never uploaded or shared. The generated HTML reports are self-contained and work offline.

The prompt

# GraphQL Security Threat Model — Generator Prompt

A **copy-paste prompt** for customers. Paste the entire prompt below into an AI coding agent
(Claude Code, Cursor, Codex, …) opened at the root of a project whose primary surface is a
**GraphQL API**, and that has the `securenow` CLI installed and logged in. The agent will
inventory the GraphQL surface (schema, resolvers, the `/graphql` endpoint), build an exhaustive
**GraphQL security** threat model mapped to the **OWASP API Security Top 10:2023** plus
**A03:2021 (Injection)**, audit the code for query-language-specific flaws, and emit a
SecureNow-branded set of reports in **Markdown + self-contained HTML** across **two tracks** —
a **Detection & Mitigation** runbook (what to run in SecureNow: the detection rules to create,
the mitigation commands to run, how to test each one) and a **Code Findings & Recommendations**
audit (the code-level findings, audited, **not** fixed) — plus which threats still need the
SecureNow team. Every alert rule and command is **grounded in the installed SecureNow SDK** and
emitted as a **ready-to-copy** unit.

This is a **focused split of the [API security model](../14-api-security/api-security-threat-model-prompt.md)**
for stacks where GraphQL is a primary surface. A single `/graphql` endpoint creates an attack
surface that REST tooling models poorly: the *client* chooses the shape, depth, and breadth of
each request, so one path can be a flood, a DoS amplifier, a recon tool, and a mass-extraction
pipe at once. This model owns that query-language-specific surface — schema disclosure
(introspection/field suggestions), query-cost amplification (depth/complexity/alias/batch),
resolver-level authz nuance, mass extraction via list fields, mutation abuse, injection through
variables, verbose-error leakage, and persisted-query/APQ abuse.

> **Run this model only if GraphQL is a *significant* surface.** If GraphQL is incidental (one
> admin query, a embedded BFF), keep its rows inside the [API security model](../14-api-security/api-security-threat-model-prompt.md)
> instead of maintaining a second report. This model **references and defers** — it does not
> re-derive — its siblings:
> - **[../14-api-security/](../14-api-security/api-security-threat-model-prompt.md)** — the parent
>   API model owns transport, REST routes, SSRF, CORS/headers, webhooks, upload, cache, and
>   inventory. GraphQL-as-transport abuse (flood/fuzzing/5xx on `/graphql`) is detected the same
>   way; GraphQL rows live *here* only because GraphQL is significant. Cross-reference, don't
>   duplicate.
> - **[../02-authorization/](../02-authorization/authorization-threat-model-prompt.md)** —
>   object/field authorization depth (BOLA/BOPLA/BFLA). This model notes *where* resolver authz
>   gaps appear and the traffic-observable symptom; the deep authz model owns the enforcement
>   design.
> - **[../06-injection/](../06-injection/injection-threat-model-prompt.md)** — injection via
>   GraphQL variables into downstream SQL/NoSQL/OS/template sinks. This model flags the GraphQL
>   *entry point*; the injection model owns the sink taxonomy and the system signature rules.

> SecureNow is fundamentally an **API / traffic** security layer (firewall, rate-limit,
> challenge, exploit-signature instant-block, ASN enrichment, forensics). For GraphQL it has
> **MEDIUM native coverage**: traffic to `/graphql` is captured automatically, so SecureNow can
> detect query-volume spikes, error-rate spikes, batch abuse, response-size anomalies, and
> introspection probes, and **rate-limit / challenge / block** the source at the edge. But because
> `/graphql` is usually **one path**, SecureNow cannot see *inside* the query body by default —
> **cost analysis, depth/complexity limits, persisted-query allowlisting, and resolver-level
> authz are app fixes**. This model pairs edge containment WITH the app fix on every such row.

Requirements on the customer machine: `npm i -g securenow && securenow login` (admin auth +
app runtime connected). Everything else is discovered by the agent.

---

<!-- ════════════════ COPY EVERYTHING BELOW THIS LINE ════════════════ -->

# Generate a GraphQL Security Threat Model Report (SecureNow)

You are a senior application-security engineer specializing in GraphQL and API security. Produce
an **exhaustive GraphQL security threat model for THIS codebase**, organized along the **OWASP
API Security Top 10:2023** (primarily **API4** resource consumption, **API1/API3/API5** authz,
**API9** inventory) and **OWASP A03:2021 (Injection)**, mapped to **SecureNow** detections and
mitigations, with a ready-to-run action plan **and** a code-level audit of every GraphQL-layer
flaw you find. Every rule and command MUST be grounded in the **installed** SecureNow SDK
(Phase 0.5) and emitted as a **ready-to-copy** unit (Phase 4). You write **FOUR deliverables**
across **two tracks** into `threat/15-graphql-security/` (create the folder if needed):

1. `graphql-security-detection-mitigation.md` — the **operational runbook**: what to run in
   SecureNow (detection rules, mitigation commands, tests).
2. `graphql-security-detection-mitigation.html` — the same runbook as a **self-contained** HTML
   page (inline CSS + JS, no network requests) with **copy buttons** on every command block.
3. `graphql-security-code-findings.md` — the **code audit**: GraphQL-layer issues in the codebase
   + recommendations (described, **never** applied).
4. `graphql-security-code-findings.html` — the same audit as a **self-contained** HTML page.

The two tracks **cross-link** each other: the gaps/instrumentation rows in the Detection report
link to the relevant code finding, and the app/config fixes in the Code Findings report link back
to the Detection-report row they back.

Work in the seven phases below (0, 0.5, 1, 2, 3, 4, 5), in order. **Never invent facts**: if something is not in the
codebase or not returned by a CLI command, say "not found" — do not guess. **Do not modify
application code.** You are auditing: every code-level fix is *described in the report*, never
applied to the repo.

**Scope discipline.** This model owns the **query-language-specific** GraphQL surface: schema
disclosure (introspection / field suggestions), query-cost amplification (depth, complexity,
alias, batching), missing cost/depth/complexity analysis & timeouts, mass extraction via
unbounded list fields, mutation rate/idempotency abuse, verbose-error schema leakage, and
persisted-query / APQ abuse. It also **flags GraphQL entry points** for resolver-level authz and
variable injection. It does **not** re-derive:

- **Transport-level API abuse** (flood/fuzzing/5xx/SSRF/CORS/headers/webhooks/upload/cache/
  inventory) → defer to the **[API security model](../14-api-security/api-security-threat-model-prompt.md)**.
  GraphQL traffic is detected with the same traffic rules; reference them.
- **Object/field authorization depth** (BOLA/BOPLA/BFLA) → defer to the
  **[authorization model](../02-authorization/authorization-threat-model-prompt.md)**. Model only
  the *traffic-observable* symptom (401/403 patterns, nested-traversal enumeration) here.
- **Downstream injection sink taxonomy** (SQL/NoSQL/OS/SSTI) → defer to the
  **[injection model](../06-injection/injection-threat-model-prompt.md)**. Flag the GraphQL
  *variable entry point* and rely on the **system signature rules** for pattern detection.

Put these in a **"Deferred to sibling models"** subsection in Phase 2 — list them, link them,
add one matrix row each marked *"deferred — see linked model"*, and only model their
traffic-observable symptom where SecureNow adds value.

---

## Phase 0 — Verify SecureNow tooling

Run and record (use `--json` where supported):

```bash
securenow doctor              # connectivity must be healthy
securenow whoami              # admin auth + runtime app
securenow status --json       # app key(s), environment, firewall state
securenow alerts rules --json # detection rules that already exist (incl. system signature rules)
securenow automation --json   # blocklist automations that already exist
securenow challenge list --json   # CAPTCHA / proof-of-work challenge rules
securenow env --json          # resolved SDK config (service name, endpoints)
```

If the CLI is missing or not logged in, **stop** and tell the user to run
`npm i -g securenow && securenow login`, then re-run this prompt. Capture the **app key**
(UUID) — every rule and command in the report must use it. If multiple apps exist, ask the
user which app this codebase maps to before continuing. Note the **firewall state** and any
**system signature rules** (SQLi/XSS/RCE) already present — those are the backbone of
variable-injection coverage and must not be duplicated.

---

## Phase 0.5 — Ground every rule & command in the INSTALLED SDK

Before writing any SQL or CLI, read the SecureNow SDK that is actually installed in this repo so
every alert rule and command is correct for THIS version — never guess flags, subcommands, event
names, or SQL columns:

```bash
cat node_modules/securenow/package.json    # installed SDK version (record it in both reports)
ls node_modules/securenow                  # exported modules: events, sessions, register, run, …
ls node_modules/securenow/dist 2>/dev/null # built entrypoints / bundled CLI
npx securenow --help                       # top-level commands available in this version
npx securenow alerts rules --help          # exact create flags: --name/--sql/--apps/--severity/--schedule/--nlp
npx securenow event --help                 # `event send` shape for synthetic tests
npx securenow ratelimit --help; npx securenow challenge --help
npx securenow blocklist --help; npx securenow automation --help; npx securenow trusted --help
```

If `node_modules/securenow` is absent, run `npm ls securenow`; if still missing, tell the user to
`npm i securenow` (or `npm i -g securenow`) and stop. EVERY command, flag, `track('…')` event
name, and SQL column you emit MUST be one the installed SDK/CLI actually exposes. If the installed
version lacks a capability this prompt references, emit the rule but annotate it
`# requires securenow >= <version>` instead of a broken command. Record the resolved version in
the appendix of BOTH reports.

In Phase 4 and Phase 5, treat `node_modules/securenow` + `--help` as the source of truth: the
`securenow/events` `track()` signatures, the `securenow alerts rules` SQL columns, and every
mitigation subcommand are discoverable there. Cross-check before emitting.

---

## Phase 1 — Inventory the GraphQL surface (codebase analysis)

GraphQL security starts with knowing **what the schema exposes** and **what the runtime allows**,
not what the docs intend. A single `/graphql` endpoint is the entire surface — the variability is
inside the request body. Cover at minimum:

- **GraphQL server & transport** — the library/framework (Apollo Server, GraphQL Yoga,
  Mercurius, express-graphql, graphql-ruby, graphene, gqlgen, Hot Chocolate, Hasura, PostGraphile,
  AppSync, …), the **endpoint path(s)** (`/graphql`, `/api/graphql`, `/v1/graphql`, subscriptions
  WS path), and whether GET is enabled (cacheable/queries-in-URL) in addition to POST. Note any
  **GraphQL-over-WebSocket** subscription transport (defer connection abuse to the
  [realtime model](../17-realtime-websocket-sse/) and [API model](../14-api-security/), but record it).
- **Schema** — locate the SDL / type definitions / code-first builders. Enumerate **Query**,
  **Mutation**, **Subscription** root fields. Flag **list fields** (return arrays — extraction
  risk), **deeply nestable / recursive types** (`User → posts → author → posts …` — depth risk),
  and **expensive fields** (search, aggregate, export, anything resolving to a DB/upstream call).
- **Introspection posture** — is `__schema` / `__type` introspection **enabled in production**?
  (Apollo `introspection: true`, default-on in dev; many servers leave it on in prod.) Is the
  **GraphQL Playground / GraphiQL / Apollo Sandbox** UI mounted in prod? Are **field suggestions**
  ("Did you mean …?") enabled even when introspection is off (still leaks schema)?
- **Query-cost controls** — is there **query depth limiting** (`graphql-depth-limit`, etc.),
  **complexity/cost analysis** (`graphql-cost-analysis`, `graphql-query-complexity`,
  `@cost` directives), an **alias count / field duplication** limit, a **max query size / token**
  limit, and a **resolver/operation timeout**? Note where **none** exist. (API4 — the core gap.)
- **Batching posture** — does the server accept a **JSON array of operations** (Apollo batched
  requests, `apollo-link-batch-http`)? Is there a **per-batch cap**? Do per-request rate limits
  apply **per operation** or **per HTTP request** (batch bypass)? (API4.)
- **Pagination ceilings** — do list fields enforce a **max `first`/`limit`/`last`** and reject
  unbounded fetches? Is there a connection-spec cursor with a hard page cap, or can a client ask
  for `first: 1000000`? (API4 / API6 mass extraction.)
- **Resolver-level authorization** — is authz enforced **per resolver / per field** (field-level
  guards, `@auth` directives, shield rules) or only **at the endpoint**? Are nested resolvers
  (`user(id) { orders { paymentMethod } }`) each checked, or do they inherit a parent's context
  and over-return? Identify object-owner checks on `id`-argument fields. (API1/API3/API5 — depth
  deferred to authz model; flag entry points here.)
- **Mutations** — enumerate state-changing mutations (create/update/delete, payment, payout,
  invite, coupon, export, send-email/SMS, LLM/image-gen). Note which lack **rate limiting**,
  **idempotency keys**, or **per-operation authz**. (API4/API6.)
- **Variable injection sinks** — for each resolver, trace whether **variable values** flow into
  raw SQL / NoSQL operators / OS exec / template engines / file paths / outbound URLs without
  parameterization or validation. (A03 / API entry → defer sink taxonomy to
  [injection model](../06-injection/).)
- **Error & debug verbosity** — does the server return **stack traces**, **resolver paths**,
  **DB errors**, **`extensions.exception`**, or **schema hints** in `errors[]`? Is
  `formatError`/error masking configured for prod? (API8 / schema leakage.)
- **Persisted queries / APQ** — is **Automatic Persisted Queries (APQ)** enabled (client sends a
  hash; server caches the query)? Is there a **persisted-query allowlist** (only known query
  hashes accepted)? Can a client **bypass** the allowlist by sending a full query? Is the APQ
  cache keyed safely (no poisoning via attacker-supplied hash↔query mapping)? (API4/API8.)
- **Telemetry & redaction** — confirm the SecureNow SDK/log pipeline redacts `Authorization`,
  `Cookie`, bearer tokens, and **GraphQL variable values that may carry PII/secrets** before
  ingestion. GraphQL request bodies frequently carry emails/tokens in variables — if not redacted,
  create a high-severity finding.
- **SecureNow instrumentation already present** — `securenow/register` / `securenow run` /
  `securenow init` (gives `/graphql` traffic spans automatically), any `securenow/events`
  `track()` calls, and whether the firewall is engaged. This determines what works *today* vs
  *after instrumentation*.

Output of this phase = the report's **GraphQL surface & inventory** section: the endpoint
catalog (path/method/transport/visibility), the **root-field catalog** (Query/Mutation/
Subscription, flagging list/recursive/expensive fields), the **controls table** (Introspection /
Depth limit / Complexity-cost / Alias cap / Batch cap / Pagination ceiling / Operation timeout /
Persisted-query allowlist — present/absent per control), the **resolver-authz posture**, the
**variable-injection sink list**, the **error-verbosity posture**, the **APQ/persisted-query
posture**, the **telemetry redaction status**, and a short paragraph naming the real GraphQL
attack surface for this schema.

---

## Phase 2 — Enumerate threats (exhaustive catalog)

Evaluate **every** threat below against the discovered surface. Each item is either **modeled**
(a row in the threat matrix) or **explicitly N/A** (one line in an "Out of scope" subsection
with the reason — e.g. "Subscriptions: N/A, no subscription transport in this schema"). Never
silently drop an item. Add stack-specific threats you discover that are not listed — this catalog
is the floor, not the ceiling. Tag each modeled row with its **OWASP API Top 10:2023** code
(API1–API10) and/or **A03:2021** where it is an injection class.

**A. Schema disclosure & recon (OWASP API9 · API8)**
1. Introspection enabled in production → full `__schema` dump (every type/field/arg) for recon
2. GraphQL Playground / GraphiQL / Apollo Sandbox UI mounted in production
3. Field-suggestion leakage ("Did you mean `email`?") leaking field names when introspection is off
4. Verbose `errors[]` / `extensions.exception` / stack traces leaking schema, resolver paths, internals
5. Schema exposed via a static `.graphql` SDL file, source map, or build artifact reachable in prod

**B. Query-cost amplification & DoS (OWASP API4)**
6. Deep nested query — depth amplification (`a { b { c { … } } }`) with no depth limit
7. Circular / recursive query through mutually referencing types (unbounded traversal)
8. Field/complexity amplification — one request resolving N expensive resolvers (no cost analysis)
9. Alias-based amplification — same expensive field aliased many times in one query
10. Field-duplication amplification — repeating a field/fragment to multiply resolver work
11. Query batching abuse — JSON array of operations bypassing per-request rate limits
12. Oversized query document — huge query string / token count (parser/memory exhaustion)
13. Missing operation/resolver timeout → a single slow query holds a worker (Slowloris-class)
14. Introspection-as-DoS — repeated full introspection queries (large, expensive responses)

**C. Mass data extraction (OWASP API6 · API4)**
15. Unbounded list field — `first`/`limit`/`last` with no ceiling → mass pull per request
16. No pagination ceiling on connections → `first: 1000000` style bulk extraction
17. Automated scraping at scale on read root fields (harvesting via repeated queries)
18. Nested-traversal extraction — one query walking `org { members { ... } }` to dump a tenant
19. Anomalous response-size extraction from a single client (bulk pull, traffic-observable)

**D. Resolver-level authorization (OWASP API1 / API3 / API5 — depth deferred)**
20. Object-level authz gap — resolver returns objects by `id` without owner check (BOLA via GraphQL)
21. Field-level authz gap (BOPLA) — sensitive field (`paymentMethod`, `ssn`) returned without per-field check
22. Function-level authz gap (BFLA) — admin/privileged mutation reachable by non-admin
23. Nested-traversal authz bypass — child resolver inherits parent context, over-returns across owners
24. Authz enforced inconsistently across resolvers (some guarded, some not) — gap surfaces as 200s
   *(20–23 are deferred to the [authorization model](../02-authorization/); model only the traffic-observable symptom here)*

**E. Mutation abuse (OWASP API4 / API6)**
25. State-changing mutation with no rate limit (spam create/update/delete)
26. Mutation without idempotency → replay causes double charge / double order / double email
27. Sensitive-flow mutation farming (signup/coupon/referral/payout) via the GraphQL endpoint
28. Race condition on a single-use mutation (coupon/gift-card/balance) via parallel operations

**F. Injection through GraphQL variables (OWASP A03:2021 — sink taxonomy deferred)**
29. SQL injection via a variable flowing into raw SQL
30. NoSQL / operator injection via a variable (`$gt`/`$ne`/`$where` smuggled through a JSON variable)
31. OS / command injection (RCE) via a variable reaching `exec`
32. SSTI / template injection via a variable rendered server-side
33. Path traversal / LFI via a variable used to build a file path
   *(29–33: flag the GraphQL entry point; detection = system signature rules; deep sink model = [injection](../06-injection/))*

**G. Persisted queries & caching (OWASP API4 / API8)**
34. Persisted-query allowlist bypass — full ad-hoc query accepted when only hashes should be
35. APQ cache poisoning — attacker registers a hash↔query mapping that other clients then execute
36. APQ hash-mismatch / `PersistedQueryNotFound` abuse to probe or force full-query mode
37. GET-query caching on a CDN → per-user GraphQL responses cached & served cross-user

**H. Negative-space & evasion**
38. Introspection via aliased/obfuscated `__schema` to dodge a naive introspection block
39. Batching to dodge a per-request depth/complexity limit applied only to the first operation
40. `X-Forwarded-For` spoofing to evade per-IP rate limits on `/graphql`
41. Direct-origin access to `/graphql` bypassing the CDN/WAF (hitting the origin host)

**I. Deferred — modeled in sibling models (reference, do not re-derive)**
42. Transport-level flood/fuzzing/5xx/SSRF/CORS/upload on `/graphql` → [API security model](../14-api-security/api-security-threat-model-prompt.md)
43. Object/field/function authorization depth (BOLA/BOPLA/BFLA) → [authorization model](../02-authorization/authorization-threat-model-prompt.md)
44. Downstream injection sink taxonomy (SQL/NoSQL/OS/SSTI) → [injection model](../06-injection/injection-threat-model-prompt.md)
45. GraphQL-over-WebSocket subscription connection flood / per-message authz → [realtime model](../17-realtime-websocket-sse/) + [API model](../14-api-security/)

> For 42–45, add **one** matrix row each marked *"deferred — see linked model"*, and only note
> the SecureNow traffic-observable symptom (e.g. request-rate spike on `/graphql`, 401/403 spike,
> signature match) here. The full detection/mitigation lives in the other reports.

**J. Observable abuse (what `/graphql` telemetry actually catches — the workhorse rules)**
46. Query-volume spike on `/graphql` from one IP or ASN (flood, scraping, abuse)
47. Error-rate spike on `/graphql` from one client (400/422/500 — fuzzing, broken cost limit, depth rejections)
48. Anomalous request-body / response-size on `/graphql` (deep/batched query in, mass data out)
49. Batch abuse — anomalous count of operations per HTTP request (where instrumented)
50. Introspection-probe pattern — requests matching `__schema`/`__type` (where instrumented)
51. 429 ceiling repeatedly hit by one client on `/graphql` (ignoring back-off)
52. Exploit-signature match in a `/graphql` variable (SQLi/XSS/RCE patterns) → instant block

> Because `/graphql` is **one path**, detect by **request volume, error rate, and response/body
> size** rather than by distinct paths. Distinct-path fuzzing rules from the API model do **not**
> apply to GraphQL — adapt them to per-operation / per-body-size signals instead.

---

## Phase 3 — Audit the code (findings only — do not fix)

For **each** modeled threat that maps to real code, locate the responsible code and record a
**finding** for the report's "Code-level findings" section. A finding is:

- **Location** — `file:line` (clickable), the resolver / schema / server-config / middleware name.
- **Pattern** — quote the 1–8 relevant lines. State the missing control precisely (e.g.
  "`new ApolloServer({ introspection: true })` in prod → schema disclosure"; "no
  `depthLimit()` in `validationRules` → unbounded nesting"; "resolver `order(id)` calls
  `Order.findById(id)` with no `ctx.user` owner check → BOLA"; "list resolver reads
  `args.first` with no max → unbounded extraction"; "`formatError` not set → stack traces in
  `errors[]`"; "batched array accepted with no `maxOperations` → rate-limit bypass").
- **Why exploitable** — the concrete GraphQL operation an attacker sends and what they achieve.
- **Severity** — critical / high / medium / low (impact × reachability).
- **Recommended fix (described, not applied)** — the specific change: e.g. "disable
  introspection and the Playground UI in production (`introspection: process.env.NODE_ENV !==
  'production'`)"; "add `depthLimit(7)` and a complexity/cost limit (`createComplexityLimitRule`
  / `graphql-query-complexity`) to `validationRules`"; "cap aliases and batch size, reject query
  documents over N tokens / N bytes"; "enforce a max `first`/`limit` and a hard connection page
  cap"; "add a per-resolver owner/role guard (or `graphql-shield` rule / `@auth` directive) to
  every field that returns user/tenant data"; "add an operation/resolver timeout"; "set
  `formatError` to mask internal errors in prod and strip `extensions.exception`"; "require a
  persisted-query allowlist and reject ad-hoc full queries in prod"; "parameterize the DB call
  and validate the variable type". Reference the secure pattern, not a code diff. **You must not
  edit the codebase.**

If a control exists and is correct (introspection disabled in prod, depth + cost limits present,
list fields capped, resolver authz uniform, errors masked, persisted-query allowlist enforced),
note it as a **strength** — the posture must be honest. Absence of a control where the surface
exists is itself a finding ("no depth limit on any operation").

---

## Phase 4 — Map every modeled threat to SecureNow detection + mitigation

Classify each threat with exactly one coverage badge:

- 🟢 **COVERED** — detectable + mitigable with SecureNow today (existing rule, system signature
  rule, or a rule you provide the SQL for, on telemetry already flowing). For GraphQL this is
  the **traffic-observable** set: query-volume spikes, error-rate spikes, 429-ceiling abuse,
  response/body-size anomalies, and variable injection (system signature rules + `instant.block`).
- 🟡 **PARTIAL** — works after the customer adds instrumentation (`track('graphql.*')` events for
  depth/complexity/batch/introspection the body parse sees but traffic doesn't), **or** SecureNow
  can only *contain the abuser at the edge* (rate-limit/challenge/block the source) while the real
  fix is an **app fix** (depth/complexity/cost limit, batch cap, pagination ceiling, persisted-
  query allowlist, resolver authz, error masking, disable introspection). **Most GraphQL DoS &
  schema-disclosure rows are 🟡** — pair edge containment WITH the app fix.
- 🔴 **GAP** — SecureNow cannot detect or mitigate this today. **Still include it**: give the
  app-level fix, then add the line *"Requires SecureNow team — contact your SecureNow account
  contact (or in-dashboard support) to request support for this threat."* Collect all gaps in the
  report's "Known gaps & SecureNow feature requests" section.

> **Be honest about what is edge-detectable vs an app fix.** SecureNow sees **`/graphql` traffic**
> and **events**; it contains actors via firewall / rate-limit / challenge / block / signature
> instant-block. It **cannot** parse the GraphQL query body by default — so depth, complexity,
> alias count, batch size, and "is this introspection?" are **invisible to traffic alone**. Those
> become detectable only via a `graphql.*` `track()` event emitted at the validation/parse point
> (Phase 4a), and the **real fix is always the app-level cost control**. A deep query that the
> app *should* have rejected but didn't is an **app fix** (add the depth limit); SecureNow detects
> the *abuse pattern* (volume/error/size) and contains the source. Pair the control with the app
> fix on every cost/schema/authz row.

Use **only** the SecureNow building blocks below. Never invent CLI flags, event names, or SQL
columns.

### 4a. Instrumentation (what GraphQL detections feed on)

Once the app runs under `securenow run` / `securenow/register` / `securenow init`, **HTTP traffic
to `/graphql` is captured automatically** — status codes (incl. **429** and **5xx**), method,
the `/graphql` path, client IPs, and response sizes. **Volume, error-rate, 429-ceiling, and
response-size rules need no events.** But because the interesting GraphQL signals live *inside the
request body*, add `securenow/events` `track()` at the **validation / parse / cost-analysis
point** — propose `graphql.*` events **only where genuinely needed** (i.e. where the app already
parses the query and traffic cannot see the signal). These never throw:

```js
const { track } = require('securenow/events');

// The app rejected a query for exceeding a cost/depth/complexity/alias/batch limit (API4):
track('graphql.cost.rejected', { ip, attributes: { operation: 'searchUsers', reason: 'depth|complexity|alias|batch|tokens|timeout', value: '14', limit: '7' } });

// An introspection query was received (recon probe) — high-signal when introspection is off:
track('graphql.introspection.seen', { ip, attributes: { operation: 'IntrospectionQuery', blocked: 'true|false' } });

// A batched request arrived with an anomalous operation count (batch-abuse signal):
track('graphql.batch.seen', { ip, attributes: { operations: '40' } });

// A persisted-query allowlist bypass / APQ anomaly was rejected (API4/API8):
track('graphql.persisted.rejected', { ip, attributes: { reason: 'not_in_allowlist|hash_mismatch|ad_hoc_query', hash: '<hash>' } });

// A resolver-level authz check failed (per-field/per-object) — feeds authz-symptom detection (defer depth to authz model):
track('graphql.authz.failed', { userId, ip, attributes: { operation: 'user', field: 'paymentMethod', reason: 'object|field|function' } });

// A list/connection field was asked for more than the ceiling (mass-extraction attempt, API6):
track('graphql.list.capped', { ip, attributes: { field: 'orders', requested: '1000000', ceiling: '100' } });
```

> Hash or omit any PII before it becomes an attribute value (emails, raw tokens, variable values).
> See the Phase 1 **telemetry redaction** check — GraphQL variables routinely carry PII; attributes
> feed detection, they must not become a new leak path. Do **not** put raw query bodies in attributes.

Recommended GraphQL event taxonomy — rules match these **exact strings**:

| Event | Emit when |
|---|---|
| `graphql.cost.rejected` | a query is rejected for depth/complexity/alias/batch/token/timeout limit |
| `graphql.introspection.seen` | an introspection (`__schema`/`__type`) query is received |
| `graphql.batch.seen` | a batched (array-of-operations) request is received (records op count) |
| `graphql.persisted.rejected` | a persisted-query/APQ request is rejected (not allowlisted / hash mismatch / ad-hoc) |
| `graphql.authz.failed` | a per-resolver object/field/function authz check fails |
| `graphql.list.capped` | a list/connection field request exceeds the pagination ceiling |

Custom `attributes` become queryable as `attributes_string['<key>']` (e.g.
`attributes_string['operation']`, `attributes_string['reason']`). Ingest enriches every IP with
**ASN/org** (`client.asn`, `client.as_org`) — enabling botnet/datacenter-origin detection with no
extra code.

> **Most GraphQL coverage is traffic-based and needs none of these events.** Add a `graphql.*`
> event only when the app already computes the signal (it parsed the query to enforce a limit)
> and you want SecureNow to detect the *abuse rate* and contain the source. If the app does **not**
> compute the signal (no depth limit exists), the row is an **app fix first** — the event is part
> of the recommended fix, not a substitute for it.

### 4b. Detection rules — test mode for false-positive-prone rules

Alert rules have a lifecycle **mode**: `test` = **detect-only, NO mitigation** vs `prod` = full
(mitigation / auto-action armed) — plus a **status** (`Active | Disabled | Paused`). Manage with:

```bash
securenow alerts rules update <RULE_ID> --mode test     # detect-only: fires notifications, takes NO action
# …observe real traffic for several days; tune the threshold; add securenow fp exclusions for any FPs…
securenow alerts rules update <RULE_ID> --mode prod      # promote: arm the mitigation / auto-action
securenow alerts rules update <RULE_ID> --status Paused  # or --enable / --disable / --pause shortcuts
```

**Rule of thumb:** any detection that can **false-positive** — heuristic thresholds (flood / scrape /
enumeration counts, query-volume / error-rate / response-size on `/graphql`, depth/complexity/batch
rejection counts), broad patterns, anomaly / volume rules, anything tuned to YOUR traffic — must
ship in **`--mode test` first**. Run it detect-only for **3–7 days of real traffic**, review what it
flags (a legitimate dashboard issuing deep queries, a partner's bulk exporter), raise/lower the
threshold and add `securenow fp` exclusions for legitimate hits, then `--mode prod` to arm
mitigation. Only **high-precision** rules (exploit-signature SQLi/XSS/RCE matches in a variable,
exact-match IoCs, known-bad ASN hits) may go straight to `prod`. In the report, **tag each rule
`test-first` or `prod-ready`** and say why. (`securenow alerts rules test <id> --mode dry_run
--wait` is the separate one-off *query* validation — run it before either.)

### 4b. Detection rules — SQL conventions

Two query shapes. Both **must** keep the tenant scope and **must** select an `ip` column
(per-IP aggregation is what remediation/auto-block keys on). **The tenant-scope column differs
by table** — using the wrong one fails with `UNKNOWN_IDENTIFIER`:

- **logs/events** (`signoz_logs.distributed_logs_v2`) → `resources_string['service.name'] IN (__USER_APP_KEYS__)`
- **traces/HTTP** (`signoz_traces.distributed_signoz_index_v3`) → `` `resource_string_service$$name` IN (__USER_APP_KEYS__) ``

When grouping by `ip`, add `HAVING ip != '' AND …` so rows with no client IP don't aggregate
into an empty-key bucket. Traffic columns proven available: `response_status_code`, `kind`
(server span = 2), `ts_bucket_start`, `attributes_string['http.target']`, the `client_ip`
coalesce below. Confirm any other column (`http.method`, response size) with a `--mode dry_run`
before relying on it; OTEL SDK versions vary (`http.method` vs `http.request.method`).

> **`/graphql` is one path.** Filter traffic rules by the GraphQL path
> (`attributes_string['http.target'] LIKE '/graphql%'` — adjust to the discovered endpoint) and
> detect by **request volume**, **error rate**, and **response size** — *not* by distinct paths.

**Traffic-based — query-volume flood on `/graphql` (single IP), no events needed:**

```sql
WITH coalesce(nullIf(attributes_string['http.client_ip'], ''), nullIf(attributes_string['net.peer.ip'], ''), nullIf(attributes_string['network.peer.address'], '')) AS client_ip
SELECT client_ip AS ip,
       count() AS requests
FROM signoz_traces.distributed_signoz_index_v3
WHERE `resource_string_service$$name` IN (__USER_APP_KEYS__)
  AND timestamp >= now64(9) - INTERVAL 5 MINUTE
  AND ts_bucket_start >= toUInt64(toUnixTimestamp(now() - INTERVAL 5 MINUTE)) - 1800
  AND kind = 2
  AND attributes_string['http.target'] LIKE '/graphql%'
GROUP BY ip
HAVING ip != '' AND requests >= 300
```

**Traffic-based — GraphQL error-rate spike (depth/complexity rejections, fuzzing, broken cost limit):**

```sql
WITH coalesce(nullIf(attributes_string['http.client_ip'], ''), nullIf(attributes_string['net.peer.ip'], ''), nullIf(attributes_string['network.peer.address'], '')) AS client_ip
SELECT client_ip AS ip,
       countIf(response_status_code IN ('400','422','500')) AS errors,
       count() AS total
FROM signoz_traces.distributed_signoz_index_v3
WHERE `resource_string_service$$name` IN (__USER_APP_KEYS__)
  AND timestamp >= now64(9) - INTERVAL 10 MINUTE
  AND ts_bucket_start >= toUInt64(toUnixTimestamp(now() - INTERVAL 10 MINUTE)) - 1800
  AND kind = 2
  AND attributes_string['http.target'] LIKE '/graphql%'
GROUP BY ip
HAVING ip != '' AND errors >= 30
```

**Traffic-based — anomalous GraphQL response size (deep/batched query in → mass data out):**
*(confirm the response-size column with `--mode dry_run`; OTEL exposes it as
`attributes_string['http.response_content_length']` or `attributes_number['http.response.body.size']`
depending on SDK — dry-run first, then keep whichever resolves.)*

```sql
WITH coalesce(nullIf(attributes_string['http.client_ip'], ''), nullIf(attributes_string['net.peer.ip'], ''), nullIf(attributes_string['network.peer.address'], '')) AS client_ip
SELECT client_ip AS ip,
       count() AS requests,
       sum(toUInt64OrZero(attributes_string['http.response_content_length'])) AS total_bytes
FROM signoz_traces.distributed_signoz_index_v3
WHERE `resource_string_service$$name` IN (__USER_APP_KEYS__)
  AND timestamp >= now64(9) - INTERVAL 15 MINUTE
  AND ts_bucket_start >= toUInt64(toUnixTimestamp(now() - INTERVAL 15 MINUTE)) - 1800
  AND kind = 2
  AND attributes_string['http.target'] LIKE '/graphql%'
GROUP BY ip
HAVING ip != '' AND total_bytes >= 52428800
```

**Events-based — query-cost / depth / complexity / alias / batch limit repeatedly hit (API4; logs table):**

```sql
SELECT
  attributes_string['http.client_ip'] AS ip,
  attributes_string['reason']         AS reason,
  count() AS rejections
FROM signoz_logs.distributed_logs_v2
WHERE resources_string['service.name'] IN (__USER_APP_KEYS__)
  AND attributes_string['event.type'] = 'graphql.cost.rejected'
  AND timestamp >= now() - INTERVAL 15 MINUTE
GROUP BY ip, reason
HAVING ip != '' AND rejections >= 10
```

**Events-based — introspection probing (recon) — high-signal when introspection is off:**

```sql
SELECT
  attributes_string['http.client_ip'] AS ip,
  count() AS probes
FROM signoz_logs.distributed_logs_v2
WHERE resources_string['service.name'] IN (__USER_APP_KEYS__)
  AND attributes_string['event.type'] = 'graphql.introspection.seen'
  AND timestamp >= now() - INTERVAL 30 MINUTE
GROUP BY ip
HAVING ip != '' AND probes >= 3
```

**Events-based — batch abuse / persisted-query bypass / resolver-authz probing (same logs shape):**

```sql
SELECT
  attributes_string['http.client_ip'] AS ip,
  attributes_string['event.type']     AS signal,
  count() AS attempts
FROM signoz_logs.distributed_logs_v2
WHERE resources_string['service.name'] IN (__USER_APP_KEYS__)
  AND attributes_string['event.type'] IN ('graphql.batch.seen','graphql.persisted.rejected','graphql.authz.failed','graphql.list.capped')
  AND timestamp >= now() - INTERVAL 15 MINUTE
GROUP BY ip, signal
HAVING ip != '' AND attempts >= 5
```

The remaining events follow the **same shape** — swap the `event.type` filter and threshold:
`graphql.batch.seen` (≥5 large batches/15m → batch-rate abuse; pair with a per-operation count
threshold on `attributes_string['operations']`), `graphql.persisted.rejected` (≥10/15m → allowlist
probing), `graphql.authz.failed` (≥10/15m → BOLA/BOPLA probing — also feeds the authz model),
`graphql.list.capped` (≥5/15m → mass-extraction attempts).

**Injection via variables (catalog F) — use the SecureNow system signature rules, don't write SQL.**
SQLi / XSS / RCE detection ships as **system signature rules** with synchronous **`instant.block`**
(blocks a matching request in ~2.6s on ingest), and they match on request content regardless of
whether the payload arrives as a REST field or a GraphQL variable. Confirm they're present and
enabled for this app via `securenow alerts rules --json`; enable `instant.block` rather than
authoring duplicate pattern SQL. The **sink taxonomy** (what the variable reaches) is owned by the
[injection model](../06-injection/).

Useful attributes/columns: `event.type`, `http.client_ip`, `http.target`,
`response_status_code`, `kind`, `client.asn`, `client.as_org`, and your GraphQL attributes
(`operation`, `reason`, `value`, `limit`, `operations`, `field`, `hash`).

**Emit every detection as a ready-to-copy command unit.** A detection is never a SQL fragment in
isolation — it is a *complete, copyable unit* so the user can run it without editing. For each
rule emit, in order, each as its own fenced block (so it copies cleanly):

1. the **SQL**, with a first comment line `-- rules/<name>.sql`;
2. the line **saving it** to `rules/<name>.sql` (so `--sql @rules/<name>.sql` works);
3. the full **`securenow alerts rules create …`** command — flags exactly as
   `securenow alerts rules --help` showed in Phase 0.5 (`--name` / `--sql` / `--apps` /
   `--severity` / `--schedule` / `--nlp`);
4. the **dry-run test** (`securenow alerts rules test <RULE_ID> --mode dry_run --wait`) to validate
   before it runs live.

Example (the query-volume flood rule above, as the full unit):

```sql
-- rules/graphql-flood.sql
WITH coalesce(nullIf(attributes_string['http.client_ip'], ''), nullIf(attributes_string['net.peer.ip'], ''), nullIf(attributes_string['network.peer.address'], '')) AS client_ip
SELECT client_ip AS ip,
       count() AS requests
FROM signoz_traces.distributed_signoz_index_v3
WHERE `resource_string_service$$name` IN (__USER_APP_KEYS__)
  AND timestamp >= now64(9) - INTERVAL 5 MINUTE
  AND ts_bucket_start >= toUInt64(toUnixTimestamp(now() - INTERVAL 5 MINUTE)) - 1800
  AND kind = 2
  AND attributes_string['http.target'] LIKE '/graphql%'
GROUP BY ip
HAVING ip != '' AND requests >= 300
```

```bash
# save the SQL so --sql @rules/<name>.sql resolves
mkdir -p rules && cat > rules/graphql-flood.sql <<'SQL'
WITH coalesce(nullIf(attributes_string['http.client_ip'], ''), nullIf(attributes_string['net.peer.ip'], ''), nullIf(attributes_string['network.peer.address'], '')) AS client_ip
SELECT client_ip AS ip, count() AS requests
FROM signoz_traces.distributed_signoz_index_v3
WHERE `resource_string_service$$name` IN (__USER_APP_KEYS__)
  AND timestamp >= now64(9) - INTERVAL 5 MINUTE
  AND ts_bucket_start >= toUInt64(toUnixTimestamp(now() - INTERVAL 5 MINUTE)) - 1800
  AND kind = 2 AND attributes_string['http.target'] LIKE '/graphql%'
GROUP BY ip HAVING ip != '' AND requests >= 300
SQL
```

```bash
securenow alerts rules create \
  --name "GraphQL: query-volume flood (single IP)" \
  --sql @rules/graphql-flood.sql \
  --apps <APP_KEY> \
  --severity high \
  --schedule "*/5 * * * *" \
  --nlp "single IP making 300+ requests to /graphql in 5 minutes"

securenow alerts rules test <RULE_ID> --mode dry_run --wait     # validate before it runs live
```

Note pre-existing/system rules (from Phase 0) instead of duplicating them. The exact flags must
match `securenow alerts rules --help` from Phase 0.5 — never invent flags. Because **`/graphql` is
one path**, every traffic rule above detects by **request volume**, **error rate**, and **response
size** (filtered to the discovered GraphQL path) rather than by distinct paths — adapt
distinct-path rules from the API model into per-operation / per-body-size signals.

### 4c. Mitigation commands (the only allowed remediation surface)

For GraphQL abuse, SecureNow **contains the actor at the edge**; the **app fix** removes the
underlying weakness (the missing cost limit, the enabled introspection, the unbounded list, the
missing resolver guard). **Always pair them** — for GraphQL the app fix is the *primary* fix on
nearly every cost/schema/authz/extraction row, and SecureNow is containment.

Once a threat is confirmed, **choose the narrowest effective mitigation(s) from ALL of these** and
combine them (e.g. rate-limit `/graphql` + block the worst IPs + challenge a NAT egress). Re-check
every command/flag against the installed SDK in Phase 0.5 (`securenow <cmd> --help`); annotate
`# requires securenow >= <ver>` if absent. Scope by **app / env / route / method / IP / duration**
to avoid hitting real users — `/graphql` is one path, so route-scoped rate-limit and challenge are
especially relevant here.

| # | Mitigation | Command (ready-to-copy) | Use / scope |
|---|---|---|---|
| 1 | **Free firewall (network)** | `securenow firewall enable --app <APP_KEY> --env production` · `securenow run --firewall-only` · test `securenow firewall test-ip <ip> --path /x --method GET` | 500k+ known-bad IPs, hourly refresh; drop scanners before the app. No app change. |
| 2 | **Exploit-signature instant block** | enable the `instant` config on the system SQLi/XSS/RCE signature rules (dashboard / MCP `securenow_alert_rule_instant_update`); custom rule → create with `--execution-mode instant` | synchronous ~2.6s block of the matching request (injection through a GraphQL variable, catalog F). Don't duplicate pattern SQL. |
| 3 | **IP block — global** | `securenow blocklist add <ip> --app <APP_KEY> --env production --reason "..."` | confirmed-malicious source, all routes. |
| 4 | **IP block — scoped to route (+ method)** | `securenow blocklist add <ip> --route /graphql --mode prefix --method ALL --app <APP_KEY> --env production --reason "..."` (`--mode exact\|prefix\|regex`, `--method GET\|POST\|…\|ALL`) | block an IP only on `/graphql`; least collateral. |
| 5 | **IP block — temporary / time-boxed** | `securenow blocklist add <ip> --duration 24h --reason "..."` (`30m`,`24h`,`7d`) · reverse `securenow blocklist unblock <id> --reason "..."` | auto-expiring containment; audit-preserving unblock. |
| 6 | **Rate limit — per IP** | `securenow ratelimit add <ip> --limit 100 --window 1m --duration 24h --reason "..."` | throttle one abusive client across the app. |
| 7 | **Rate limit — per route (all clients, per-IP budget)** | `securenow ratelimit add --route /graphql --mode prefix --method POST --limit 60 --window 1m --key-by ip` | cap the `/graphql` endpoint for everyone, budgeted per IP — floods, scraping, expensive-query/batch abuse. |
| 8 | **Rate limit — per route + IP** | `securenow ratelimit add <ip> --route /graphql --mode prefix --method POST --limit 30 --window 1m --duration 24h` · NL `securenow ratelimit from-text "rate limit /graphql to 30/min for 24h" --yes` · test `securenow ratelimit test <ip> --path /graphql --method POST` | precise throttle of one client on `/graphql`. |
| 9 | **CAPTCHA / proof-of-work challenge** | `securenow challenge add --route /graphql --difficulty 16 --clearance 30m` (route-wide) **or** `securenow challenge add <ip> --route /graphql --difficulty 18 --clearance 30m` · test `securenow challenge test <ip> --path /graphql --method POST` | bot scraping / introspection probing / business-flow abuse from **shared / NAT / CGNAT** egress — a human passes once, a script can't. Prefer over a hard block when real users share the IP. |
| 10 | **Auto-block (risk-scored)** | `securenow automation defaults --yes` (≥95→7d, 90–94→72h, 85–89→24h) · custom `securenow automation create --conditions '[...]' --actions '[...]'` · preview `securenow automation dry-run <id>` | hands-off blocking by risk score; actions include block / rate_limit / requireCaptcha. |
| 11 | **Session revocation** | `securenow revoke …` (SDK `securenow/sessions` `guard()` / `isRevoked()`) | session theft / account takeover via authenticated GraphQL mutations — kill the stolen session, not the IP. |
| 12 | **Trusted IP (suppress)** | `securenow trusted add <ip> --label "Office VPN / partner / monitor"` | stop false positives from known-good infra (internal GraphQL clients, partner batch jobs) — suppresses detection **and** mitigation. NOT deny-by-default. |
| 13 | **Allowlist (deny-by-default)** | `securenow allowlist add <ip> --label "..." --reason "..."` ⚠️ once any entry exists, ONLY listed IPs reach the app | lockdown of an internal/admin-only GraphQL surface. Never for a public app. |
| 14 | **False-positive exclusion** | `securenow fp create --conditions '[...]' --rule-scope this_rule --reason "..."` · `securenow fp mark <notification-id> <ip> --rule-scope this_rule` · preview `securenow fp dry-run --conditions '[...]'` | keep a noisy rule quiet (e.g. a legitimate dashboard issuing deep queries) without weakening it. |
| 15 | **App / config / code fix (primary for root cause)** | *described in the Code-Findings report, never auto-applied* | the actual fix: disable introspection + Playground in prod, add depth + complexity/cost limits, cap aliases & batch size, enforce pagination ceilings, add per-resolver authz, mask errors, enforce a persisted-query allowlist, parameterize variable-fed queries, add operation timeouts. SecureNow contains; the fix removes. |

**Choosing per threat** — by **confidence**: exploit-signature/exact IoC (injection in a variable)
→ instant-block or block; probable bot/scraper/introspection-probe on shared egress → **challenge**;
noisy/legit-mixed traffic (deep queries, volume/error/size spikes) → **rate-limit (test-mode
first)**; session compromise via authenticated mutations → **revoke**; known-good noise → **trusted /
fp**. By **blast radius**: always scope to the narrowest `route` (`/graphql`) / `method` / `IP` /
`duration` that stops the abuse; on NAT/CGNAT/shared IPs prefer challenge/rate-limit over a hard
block. Always pair an edge mitigation with the **app/config fix** (Code-Findings report) — for
GraphQL the app fix is the *primary* remediation on nearly every cost/schema/authz/extraction/
persisted-query row, and SecureNow is containment. State both on every such row.

### 4d. Testing every detection and mitigation

Only test against apps/environments the user owns; prefer `--env local`/staging. For synthetic
source IPs use TEST-NET ranges (`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`).

```bash
# Synthetic cost-limit rejections — exercise the events-based cost rule end to end:
for i in $(seq 1 12); do
  securenow event send graphql.cost.rejected --ip 203.0.113.60 \
    --attrs operation=searchUsers,reason=depth,value=14,limit=7,test=true
done

# Synthetic introspection probing:
for i in $(seq 1 4); do
  securenow event send graphql.introspection.seen --ip 203.0.113.60 \
    --attrs operation=IntrospectionQuery,blocked=false,test=true
done

# Synthetic batch abuse + persisted-query bypass + resolver authz probing:
securenow event send graphql.batch.seen --ip 203.0.113.61 --attrs operations=40,test=true
securenow event send graphql.persisted.rejected --ip 203.0.113.61 --attrs reason=ad_hoc_query,test=true
for i in $(seq 1 12); do
  securenow event send graphql.authz.failed --ip 203.0.113.61 \
    --attrs operation=user,field=paymentMethod,reason=field,test=true
done

# Validate a rule query without waiting for the schedule:
securenow alerts rules test <RULE_ID> --mode dry_run --wait

# Traffic-based rules (volume / error-rate / response-size on /graphql) — generate spans, then check:
securenow test-span "threat-model.graphql.smoke"
securenow forensics "requests and 4xx/5xx to /graphql by IP in the last hour" --env production

# Injection signatures — confirm a payload in a GraphQL variable triggers the system rule + instant block (staging):
#   POST a query to a staging /graphql with a benign-but-matching marker in a variable
#   (e.g. { "variables": { "q": "' OR '1'='1" } }), then verify the block fired:
securenow firewall test-ip 203.0.113.60 --app <APP_KEY> --env production

# Mitigation verification:
securenow ratelimit test 203.0.113.60 --path /graphql --method POST
securenow challenge test 203.0.113.60 --path /graphql --method POST

# Confirm + clean up:
securenow notifications list --limit 10
securenow blocklist list      # then: securenow blocklist unblock <id> --reason "threat-model test"
securenow challenge list      # then: securenow challenge remove <id>
```

Every 🟢/🟡 threat row in the report must have a concrete test recipe (commands + expected
outcome: which rule fires, which notification appears, what the mitigation does).

---

## Phase 5 — Write the FOUR reports (two tracks)

Write **four** files into `threat/15-graphql-security/`: the **Detection & Mitigation** track
(`.md` + `.html`) and the **Code Findings & Recommendations** track (`.md` + `.html`). The two
tracks **cross-link**: the gaps/instrumentation rows in the Detection report link to the relevant
code finding, and the app/config fixes in the Code Findings report link back to the Detection-report
row they back. The SecureNow-runnable detections/mitigations live in the Detection report; the
code/config changes live in the Code-Findings report — split honestly, drop nothing.

### 5a. Detection & Mitigation report (`graphql-security-detection-mitigation.md` + `.html`)

The **operational runbook** — what to run in SecureNow. Sections, in order (both .md and .html):

1. **Executive summary** — stats line (threats modeled · covered · partial · gaps · rules to
   create · mitigations), top 3 **detectable** GraphQL risks for this specific schema, the
   installed `securenow` version (from Phase 0.5) + app key + firewall state, and a one-line
   coverage note (which OWASP API codes + A03 are owned here vs deferred to the API / authz /
   injection models).
2. **SDK & environment** — installed SDK version (from `node_modules/securenow`), app key(s),
   environment, firewall state, existing rules / automations / challenge rules (from Phase 0), and
   the system signature rules present (SQLi/XSS/RCE) — the backbone of variable-injection coverage.
3. **Threat → Detection → Mitigation matrix** — one row per modeled threat:
   `# | Threat | OWASP/A03 | Coverage 🟢/🟡/🔴 | Detection rule | Signal (threshold+window) | Schedule | Sev | Mode | Mitigation`.
   Severity ∈ {critical, high, medium, low}. The **Mitigation** cell must pick **specific, scoped
   mitigation(s) from the §4c toolbox** (firewall · instant-block · block [global/route/method/
   temporary] · rate-limit [IP/route/IP+route] · challenge · auto-block · revoke · trusted ·
   allowlist · fp · app-fix) — never a generic "block the IP." The **Mode** cell tags each rule
   `test-first` or `prod-ready` (per §4b: FP-prone heuristics → `test-first`; high-precision
   signature/IoC → `prod-ready`). Then the "Out of scope" N/A list, and the deferred-to-sibling
   rows (transport / authz / injection / subscriptions) pointing to the API / authz / injection /
   realtime models.
4. **Detection rules to create** — each as the **ready-to-copy unit** from Phase 4
   (SQL → save to `rules/<name>.sql` → full `securenow alerts rules create …` → dry-run test).
   Mark each rule **`test-first` or `prod-ready`** (per §4b); for every `test-first` rule include the
   `securenow alerts rules update <RULE_ID> --mode test` → observe (3–7 days) →
   `securenow alerts rules update <RULE_ID> --mode prod` promotion step. Injection-class rows
   reference the **system signature rules + `instant.block`**, not duplicate pattern SQL. Note rules
   that already exist (from Phase 0) instead of duplicating them.
5. **Instrumentation the detections need** — only the `track('graphql.*')` events the rules above
   consume, each as a copyable snippet; point to the **code-findings report** for *where* (file +
   line) to add them. Most coverage is traffic-based and needs none of these.
6. **Mitigation mechanisms** — render the **full §4c 15-row toolbox table** (firewall · exploit-
   signature instant-block · block [global / route / method / temporary] · rate-limit [IP / route /
   IP+route] · challenge · auto-block · revoke · trusted · allowlist · fp · **app-config fix**) plus
   the "Choosing per threat" guidance, then the per-threat ready-to-copy mitigation command +
   reversibility. Make explicit that the **app fix is primary** for cost/schema/authz/extraction/
   persisted-query and the SecureNow control is containment (link each app fix to its row in the
   code-findings report).
7. **Action plan (copy-paste, ordered)** — ① firewall + signature instant-block, ② add the
   `graphql.*` instrumentation where the body signal is needed, ③ create rules — **FP-prone rules
   created in `--mode test`** (detect-only), high-precision rules straight to `prod`, ④ enable
   automations / challenge rules, ⑤ test, ⑥ verify, ⑦ **promote each `test-first` rule
   `--mode test` → `--mode prod` after N days** of observing real traffic (with a "promote after
   3–7 days" step that tunes thresholds and adds `securenow fp` exclusions for legitimate hits),
   ⑧ schedule the app/config fixes (from the code report — disable introspection in prod, add depth
   + complexity limits, cap aliases/batch, enforce pagination ceilings, mask errors, persisted-query
   allowlist). Real commands only, `<APP_KEY>` already substituted.
8. **Testing & validation** — per-rule recipe from Phase 4d: `securenow event send …` /
   `test-span` / dry-run + expected outcome + cleanup (TEST-NET IPs 192.0.2 / 198.51.100 /
   203.0.113).
9. **Response runbooks** — for each notification type: confirm a true positive → respond command
   (copy) → reverse command (copy); for cost/schema/authz rows, name the **app fix** (link to the
   code report) the team must ship so the abuse stops structurally.
10. **Known gaps & SecureNow feature requests** — every 🔴 threat: why it's not coverable today,
    the interim app fix (link to the code report), and the "contact the SecureNow team" line.
11. **Appendix** — resolved SDK/CLI version, app key, environment, rule IDs created, GraphQL
    server/library + version, date, link to the code-findings report.

### 5b. Code Findings & Recommendations report (`graphql-security-code-findings.md` + `.html`)

State at the top: *"Findings only — no application code was modified."* Sections, in order
(both .md and .html):

1. **Executive summary** — findings by severity (critical / high / medium / low), top 3 code
   risks for this schema, one-paragraph posture verdict.
2. **Surface & inventory** — the Phase 1 GraphQL inventory: the endpoint catalog
   (path/method/transport/visibility), the root-field catalog (Query/Mutation/Subscription,
   flagging list/recursive/expensive fields), the controls table (Introspection / Depth /
   Complexity-cost / Alias / Batch / Pagination ceiling / Timeout / Persisted-query allowlist),
   resolver-authz posture, variable-injection sink list, error-verbosity posture, APQ posture,
   telemetry redaction status.
3. **Threat catalog** — the exhaustive Phase 2 catalog (A1–J52, grouped), each tagged
   OWASP API Top 10:2023 and/or A03:2021, modeled or explicit N/A; with the deferred-to-sibling
   subsection.
4. **Code-level findings (audit)** — a table
   `# | Location (file:line) | Threat | OWASP/A03 | Sev | Issue | Recommended fix`, each with the
   quoted 1–8 line snippet and the described fix (**never applied**).
5. **Strengths** — controls already present and correct (introspection disabled in prod, depth +
   cost limits, list fields capped, resolver authz uniform, errors masked, persisted-query
   allowlist enforced) — the posture must be honest.
6. **App / config fixes (primary remediation)** — the config/code changes that remove the root
   cause (described, not applied), each linked to the **detection-report row** it backs.
7. **Instrumentation recommendations** — the `track('graphql.*')` calls to add and the exact
   `file:line` to add them, so the detection rules light up.
8. **Appendix** — files reviewed, resolved SDK version, date, link to the detection-mitigation
   report.

### 5c. HTML skeletons — self-contained, offline, with copy buttons

Both HTML files share the `<head>` (brand tokens + copy-button styles) and the copy `<script>` at
the end of `<body>`. Change only the `<title>`, the sidebar subtitle, and the section content per
track (5a or 5b). Keep this domain's title/subtitle (GraphQL Security). **Wrap EVERY
command/SQL block as a `.cmd`** (so it gets a Copy button); the stats numbers must equal the
matrix/findings row counts.

**Skeleton 1 — Detection & Mitigation HTML** (`graphql-security-detection-mitigation.html`):

```html
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Detection &amp; Mitigation — GraphQL Security — SecureNow</title>
<style>
  :root{--bg:#0f1419;--panel:#161c24;--panel2:#1b2330;--border:#26303d;--txt:#dbe3ec;--muted:#8b97a7;
    --accent:#3ea6ff;--accent2:#16c79a;--crit:#ff5c6c;--high:#ff9f43;--med:#f7c948;--low:#8b97a7;
    --ok:#16c79a;--info:#3ea6ff;--rev:#b388ff;}
  *{box-sizing:border-box}html{scroll-behavior:smooth}
  body{margin:0;background:var(--bg);color:var(--txt);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
  a{color:var(--accent);text-decoration:none}
  code{background:#0b0f14;border:1px solid var(--border);border-radius:5px;padding:.08em .4em;font:13px/1.4 ui-monospace,"SF Mono",Menlo,Consolas,monospace;color:#9fe0c0}
  .wrap{display:grid;grid-template-columns:240px 1fr;max-width:1280px;margin:0 auto}
  nav{position:sticky;top:0;align-self:start;height:100vh;overflow:auto;padding:28px 18px;border-right:1px solid var(--border);background:var(--panel)}
  nav .brand{font-weight:700;font-size:15px;letter-spacing:.3px}nav .brand span{color:var(--accent)}
  nav .sub{color:var(--muted);font-size:12px;margin-bottom:22px}
  nav a{display:block;color:var(--muted);padding:7px 10px;border-radius:7px;font-size:13.5px}
  nav a:hover{background:var(--panel2);color:var(--txt)}
  main{padding:36px 40px 80px;min-width:0}
  header.top h1{margin:0 0 6px;font-size:26px}header.top p{margin:0;color:var(--muted)}
  .pill{display:inline-block;font-size:11px;font-weight:600;padding:3px 9px;border-radius:999px;border:1px solid var(--border);color:var(--muted);background:var(--panel)}
  .stats{display:grid;grid-template-columns:repeat(5,1fr);gap:14px;margin:26px 0 34px}
  .stat{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:16px 18px}
  .stat .n{font-size:26px;font-weight:700}.stat .l{color:var(--muted);font-size:12.5px;margin-top:2px}
  section{margin:0 0 40px}
  h2{font-size:18px;margin:0 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
  h2 .num{color:var(--accent);font-weight:700;margin-right:8px}
  table{width:100%;border-collapse:collapse;font-size:13.5px;background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden}
  th,td{text-align:left;padding:11px 13px;border-bottom:1px solid var(--border);vertical-align:top}
  th{background:var(--panel2);color:var(--muted);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.4px}
  tr:last-child td{border-bottom:none}tr:hover td{background:#19212c}
  .rid{font:12px ui-monospace,Menlo,Consolas,monospace;color:#7fd1ff;white-space:nowrap}
  .b{display:inline-block;font-size:11px;font-weight:700;padding:2px 8px;border-radius:6px;white-space:nowrap}
  .b.crit{background:rgba(255,92,108,.15);color:var(--crit);border:1px solid rgba(255,92,108,.35)}
  .b.high{background:rgba(255,159,67,.13);color:var(--high);border:1px solid rgba(255,159,67,.32)}
  .b.med{background:rgba(247,201,72,.13);color:var(--med);border:1px solid rgba(247,201,72,.32)}
  .b.low{background:rgba(139,151,167,.13);color:var(--low);border:1px solid rgba(139,151,167,.32)}
  .c{display:inline-block;font-size:11px;font-weight:700;padding:2px 8px;border-radius:6px;white-space:nowrap}
  .c.cov{background:rgba(22,199,154,.13);color:var(--ok);border:1px solid rgba(22,199,154,.35)}
  .c.part{background:rgba(247,201,72,.13);color:var(--med);border:1px solid rgba(247,201,72,.32)}
  .c.gap{background:rgba(255,92,108,.15);color:var(--crit);border:1px solid rgba(255,92,108,.35)}
  .owasp,.cwe{display:inline-block;font:11px ui-monospace,Menlo,Consolas,monospace;color:var(--accent);border:1px solid rgba(62,166,255,.3);border-radius:6px;padding:1px 6px;white-space:nowrap}
  .cwe{color:var(--rev);border-color:rgba(179,136,255,.3)}
  .m{display:inline-block;font-size:11px;font-weight:600;padding:2px 8px;border-radius:6px;border:1px solid var(--border)}
  .m.block{color:var(--crit);border-color:rgba(255,92,108,.35)}.m.rate{color:var(--info);border-color:rgba(62,166,255,.35)}
  .m.challenge{color:var(--accent2);border-color:rgba(22,199,154,.35)}.m.firewall{color:var(--ok);border-color:rgba(22,199,154,.35)}
  .m.signature{color:var(--crit);border-color:rgba(255,92,108,.35)}.m.notify{color:var(--muted)}.m.appfix{color:var(--high);border-color:rgba(255,159,67,.35)}
  .card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px}
  .grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
  pre{background:#0b0f14;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow:auto;font:13px ui-monospace,Menlo,Consolas,monospace;color:#cfe8da;margin:0}
  .cmd{position:relative;margin:10px 0}
  .copy{position:absolute;top:8px;right:8px;font:11px ui-monospace,Menlo,Consolas,monospace;color:var(--muted);background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:3px 9px;cursor:pointer}
  .copy:hover{color:var(--txt);border-color:var(--accent)}.copy.done{color:var(--ok);border-color:var(--ok)}
  .flow{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:6px 0 14px}
  .flow .step{background:var(--panel2);border:1px solid var(--border);border-radius:9px;padding:8px 12px;font-size:13px}.flow .arr{color:var(--accent);font-weight:700}
  .note{border-left:3px solid var(--high);background:rgba(255,159,67,.06);padding:10px 14px;border-radius:0 8px 8px 0;color:#e7d3bd;font-size:13.5px;margin:10px 0}
  footer{color:var(--muted);font-size:12px;border-top:1px solid var(--border);padding-top:18px;margin-top:30px}
  @media(max-width:880px){.wrap{grid-template-columns:1fr}nav{display:none}.stats,.grid2{grid-template-columns:1fr 1fr}main{padding:24px 18px}}
</style></head>
<body>
<div class="wrap">
  <nav>
    <div class="brand">Secure<span>Now</span></div>
    <div class="sub">Detection &amp; Mitigation · GraphQL Security</div>
    <!-- one <a href="#…"> per section of track 5a -->
  </nav>
  <main>
    <header class="top"><h1>GraphQL Security — Detection &amp; Mitigation</h1>
      <p><code><!-- app name / domain --></code> · <span class="pill">securenow <!-- installed version --></span></p></header>
    <div class="stats">
      <div class="stat"><div class="n"><!-- N --></div><div class="l">threats modeled</div></div>
      <div class="stat"><div class="n" style="color:var(--ok)"><!-- N --></div><div class="l">covered</div></div>
      <div class="stat"><div class="n" style="color:var(--med)"><!-- N --></div><div class="l">partial</div></div>
      <div class="stat"><div class="n" style="color:var(--crit)"><!-- N --></div><div class="l">gaps — SecureNow team</div></div>
      <div class="stat"><div class="n" style="color:var(--accent)"><!-- N --></div><div class="l">rules to create</div></div>
    </div>
    <!-- <section id="…"> blocks mirroring the Markdown sections of track 5a -->
    <footer>Generated by the SecureNow GraphQL security threat-model prompt · <!-- date --> · securenow <!-- version --> · app <code><!-- APP_KEY --></code></footer>
  </main>
</div>
<script>
document.querySelectorAll('.copy').forEach(function(b){b.addEventListener('click',function(){
  var pre=b.parentElement.querySelector('pre'); if(!pre)return; var t=pre.innerText;
  function done(){b.textContent='Copied';b.classList.add('done');setTimeout(function(){b.textContent='Copy';b.classList.remove('done');},1500);}
  function fb(){var ta=document.createElement('textarea');ta.value=t;ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();try{document.execCommand('copy');}catch(e){}document.body.removeChild(ta);done();}
  if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(t).then(done,fb);}else{fb();}
});});
</script>
</body></html>
```

**Skeleton 2 — Code Findings HTML** (`graphql-security-code-findings.html`): identical `<head>`
(same `<style>` block, including the `.cmd`/`.copy` rules) and the same copy `<script>` at the end
of `<body>`. Change only the `<title>`, the sidebar subtitle, the header, and the stats labels:

```html
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Code Findings — GraphQL Security — SecureNow</title>
<style>/* …EXACT same :root tokens + rules as Skeleton 1, including .cmd/.copy… */</style></head>
<body>
<div class="wrap">
  <nav>
    <div class="brand">Secure<span>Now</span></div>
    <div class="sub">Code Findings · GraphQL Security</div>
    <!-- one <a href="#…"> per section of track 5b -->
  </nav>
  <main>
    <header class="top"><h1>GraphQL Security — Code Findings &amp; Recommendations</h1>
      <p><code><!-- app name / domain --></code> · <span class="pill">securenow <!-- installed version --></span> · Findings only — no code was modified</p></header>
    <div class="stats">
      <div class="stat"><div class="n" style="color:var(--crit)"><!-- N --></div><div class="l">critical</div></div>
      <div class="stat"><div class="n" style="color:var(--high)"><!-- N --></div><div class="l">high</div></div>
      <div class="stat"><div class="n" style="color:var(--med)"><!-- N --></div><div class="l">medium</div></div>
      <div class="stat"><div class="n" style="color:var(--low)"><!-- N --></div><div class="l">low</div></div>
      <div class="stat"><div class="n"><!-- N --></div><div class="l">threats cataloged</div></div>
    </div>
    <!-- <section id="…"> blocks mirroring the Markdown sections of track 5b -->
    <footer>Generated by the SecureNow GraphQL security threat-model prompt · <!-- date --> · securenow <!-- version --></footer>
  </main>
</div>
<script>/* …EXACT same copy script as Skeleton 1… */</script>
</body></html>
```

Every SQL/command block in the **Detection & Mitigation** HTML uses the copyable wrapper:

```html
<div class="cmd"><button class="copy" type="button">Copy</button><pre>securenow alerts rules create \
  --name "GraphQL: query-volume flood (single IP)" --sql @rules/graphql-flood.sql \
  --apps &lt;APP_KEY&gt; --severity high --schedule "*/5 * * * *" \
  --nlp "single IP making 300+ requests to /graphql in 5 minutes"</pre></div>
```

Badge usage: severity `<span class="b crit|high|med|low">`; coverage `<span class="c cov|part|gap">`;
OWASP `<span class="owasp">API4</span>` (use `A03` for injection rows); CWE `<span class="cwe">CWE-…</span>`;
mitigation `<span class="m firewall|signature|rate|challenge|block|notify|appfix">`; rule IDs
`<span class="rid">`. Stats numbers must equal the matrix/findings row counts. The Code-Findings
HTML may omit copy buttons on prose, but still wraps any example/fix command in `.cmd`.

---

## Quality bar (the report is rejected if any of these fail)

- Phase 0.5 ran: the resolved installed `securenow` version appears in both reports' appendix, and
  no command/flag/event/column is emitted that the installed SDK/CLI does not expose (else it is
  annotated `# requires securenow >= <version>`).
- Every detection rule is a **complete copyable unit** (SQL → `rules/<name>.sql` → full
  `securenow alerts rules create …` → dry-run test); flags match `alerts rules --help`.
- **Four** files are written to `threat/15-graphql-security/` (detection-mitigation .md+.html,
  code-findings .md+.html); the two tracks cross-link; both HTML files are self-contained (inline
  CSS/JS, no network) and every command block in the detection HTML has a working Copy button.
- The split is honest: SecureNow-runnable detections/mitigations live in the Detection report;
  code/config changes live in the Code-Findings report; nothing security-relevant is dropped.
- Every catalog item A1–J52 is either a matrix row or an explicit N/A line; each modeled row
  carries its OWASP API Top 10:2023 tag and/or A03:2021 (or "—").
- The GraphQL-specific threats are each modeled or explicit N/A: introspection-in-prod (A1),
  Playground UI exposed (A2), field-suggestion leakage (A3), verbose errors (A4), deep/circular
  queries (B6/B7), complexity/alias/field-duplication amplification (B8/B9/B10), query-batching
  abuse (B11), oversized documents & missing timeout (B12/B13), missing cost/depth/complexity
  analysis (covered across B + the controls table), mass extraction via unbounded list fields
  (C15/C16), resolver-level authz gaps & nested-traversal BOLA/BOPLA (D20–D24), mutation
  rate/idempotency abuse (E25–E28), injection through variables (F29–F33), verbose-error schema
  leakage (A4), persisted-query bypass (G34), APQ cache poisoning (G35), and field-suggestion
  leakage when introspection is off (A3).
- Transport-level abuse, authz depth, and injection sink taxonomy are **deferred** to the
  [API](../14-api-security/), [authorization](../02-authorization/), and [injection](../06-injection/)
  models (rows present, linked, not re-derived) — this model does not duplicate them.
- Every matrix row has a concrete signal (threshold + window), severity, and mitigation — no
  "monitor for suspicious activity" filler.
- Every code finding in section 4 has a `file:line`, the quoted snippet, and a described fix —
  and **no application code was modified** (this is an audit).
- Every detection SQL keeps `__USER_APP_KEYS__` scoping (correct table column), selects an `ip`
  column, and uses `HAVING ip != ''`; traffic queries keep the `ts_bucket_start` + `kind = 2`
  guards and filter `/graphql` by path; they detect on volume/error-rate/response-size, not
  distinct paths.
- Injection coverage references the **system signature rules + `instant.block`**, not duplicate
  pattern SQL; the variable sink taxonomy is deferred to the injection model.
- `graphql.*` events are proposed **only where genuinely needed** (body-internal signals the app
  already computes); volume/error/429/response-size rules use traffic alone.
- Only commands, flags, events, and SQL columns from this prompt's building blocks appear
  (including `securenow challenge …`, `firewall`, and the `graphql.*` events).
- Detection vs. fix is honest: where SecureNow can only contain the actor at the edge
  (cost/schema/authz/extraction/persisted-query), the row pairs the control with the **app fix**,
  and the app fix is named as the **primary** remediation.
- The Detection report's mitigation section presents the **full toolbox** (§4c: firewall · instant-block ·
  block [global / route / method / temporary] · rate-limit [IP / route / IP+route] · challenge · auto-block ·
  revoke · trusted · allowlist · fp · app-fix), and **each modeled threat's matrix row selects specific,
  scoped mitigation(s) from it** — never a generic "block the IP."
- **Every false-positive-prone rule is tagged `test-first`** and carries the `--mode test` → observe (3–7
  days) → `--mode prod` promotion workflow; only high-precision rules are `prod-ready`. The action plan
  creates the test-first rules in `--mode test` and has an explicit "promote after N days" step.
- Every 🔴 gap appears in the gaps section with an interim app fix **and** the "contact the
  SecureNow team" line.
- The action plan runs top-to-bottom with `<APP_KEY>` substituted in, app fixes prioritized.
- Both HTML files are self-contained (no CDN, no fonts, no external scripts) and the stats cards
  match the table/findings counts.
- A one-line summary is printed back to the user: per-track file paths, threat counts,
  rules-to-create count, code findings by severity, gaps, resolved SDK version, OWASP/A03 coverage.

<!-- ════════════════ END OF PROMPT ════════════════ -->