AI-driven OWASP API security testing for our own NestJS APIs, run before an ISO 27001 audit. Claude is the reasoning engine; every safety guardrail is enforced in deterministic code it cannot bypass. Findings map OWASP API Top 10 2023 → OWASP ASVS 5.0 → ISO 27001:2022 Annex A.
The API edition is 2023 — there is no OWASP API Top 10 2025 (the Nov-2025 list is the separate web-app Top 10; its supply-chain item maps to API10 here).
src/http/client.ts is the only code that sends a network request, and it calls
src/guardrails/scope.ts before every send. The model's send_request tool routes through it, so
it cannot exceed scope no matter what it decides or what a response tells it to do. Hardened against:
- redirect-based host escape (
maxRedirects: 0), - path-based origin escape (absolute / protocol-relative paths rejected;
../normalized then host re-checked), readOnlyvsallowMethodscontradiction (readOnly wins: only GET/HEAD survive),- deny-path encoding bypass (matched on raw and decoded path),
- token leakage (identity tokens injected from config, never seen by the model; response
set-cookie/authorizationredacted; configured secret values scrubbed from bodies).
pnpm install
cp config/targets.example.json config/targets.json # fill in local tokens + authorized hosts
pnpm run tscconfig/targets.json is gitignored — keep real tokens and hosts out of version control.
Zod-validated on load. Full schema:
This is deliberate and standard. The top API risks — API1 BOLA and API5 BFLA — are, in OWASP's words, cases where by design a legitimate user can reach the endpoint; the failure is at the object or function level. You can only test that with a valid session, by authenticating as one identity and trying to reach another's data. An unauthenticated scan only sees the public surface and misses the entire authorization layer, which is where the real risk lives.
Industry practice ("authenticated" / "credentialed" DAST) keeps it safe:
- Use dedicated test accounts on staging, never a real customer's token and never production.
- Prefer short-lived, revocable tokens; rotate or revoke them after the run.
- Give the least privilege per role needed to exercise the flows.
The tool is built to match: tokens live only in the gitignored targets.json, are injected by the
HTTP layer, are never shown to the AI model, and are scrubbed from every report.
pnpm run dev -- spec --config config/targets.json # print route inventory (M1)
pnpm run dev -- scan --config config/targets.json --out ./out # deterministic baseline (M2)
pnpm run dev -- scan --config config/targets.json --ai # + AI engine (M3/M5): authz + Haiku-triaged API3/4/8/6
pnpm run dev -- scan --config config/targets.json --static-only # NestJS source scan only (M6, needs sourcePath)
pnpm run dev -- scan --config config/targets.json --html --pdf # also emit report.html / report.pdf
pnpm run dev -- report --in ./out/report.json --html --pdf # render a prior runThe static scanner (S1–S6) runs automatically when sourcePath is set in targets.json, unless
--dynamic-only is passed. It is deterministic (confidence: "confirmed") with file:line evidence,
and suppresses false positives: no S1 when a global guard (APP_GUARD) is present or a route is
@Public(), no S3 for DTOs that already carry class-validator decorators.
--ai is the only command that spends on the Anthropic API — it is opt-in. It needs credentials
(ANTHROPIC_API_KEY, or an ant auth login profile). Cost is bounded by maxAgentStepsPerRoute
(hard max_iterations cap) × routes × maxAgentConcurrency; the run prints its agent-run count.
All 10 categories have at least one check. Depth varies — deterministic dynamic/static checks are
confidence: "confirmed"; AI checks are needs-human-review. ✅ full, 🟡 partial.
| Category | Depth | How it is tested |
|---|---|---|
| API1 BOLA | ✅ | dynamic (cross-identity object read) + AI authz agent |
| API2 Broken Authentication | ✅ | dynamic (auth-required route accepts a corrupted token) |
| API3 Property-level authz | ✅ | AI (triage-gated) + static S2/S3 |
| API4 Resource consumption | ✅ | AI (triage-gated) + static S4 |
| API5 Function-level authz | ✅ | dynamic (anon on auth-required route) + AI authz + static S1 |
| API6 Sensitive business flows | ✅ | AI (triage-gated) |
| API7 SSRF | 🟡 | dynamic (URL-valued query param triggers a server-side fetch of an unresolvable probe); body-based SSRF not reachable under readOnly |
| API8 Security misconfiguration | ✅ | AI (triage-gated) + static S5/S6 |
| API9 Improper inventory | 🟡 | static (source route not in the OpenAPI spec = shadow endpoint); needs sourcePath |
| API10 Unsafe consumption | 🟡 | static S7 (plaintext/non-TLS upstream calls) + AI source review: a read_source-equipped agent reads the outbound-HTTP files and judges whether upstream data is validated (needs-human-review, needs --ai + sourcePath) |
AI source review (API10): when --ai runs and sourcePath is set, one app-level agent reads the
files that make outbound HTTP calls (found by a quick scan) via a read_source tool confined to the
source root, and records API10 findings with file:line + code evidence. This is the "you provide the
source, the AI does the review" path — findings are needs-human-review.
Known gaps (by design): API7 misses body/POST SSRF under readOnly; API9 needs sourcePath and
can false-flag if a setGlobalPrefix isn't in the spec; API10's AI review is decision-support
(needs-human-review), not a deterministic verdict. Called out so the audit story stays honest.
| Milestone | Scope | State |
|---|---|---|
| M1 | Spec parser + CLI + config/zod + guardrails + HTTP choke point | done |
| M2 | Mechanical replay matrix (identity × status) | done |
| M3 | AI engine: tools + API1/API5 agent loop | done |
| M4 | Report (JSON + HTML + PDF) with OWASP/ISO mapping | done |
| M5 | Remaining AI categories (API3/4/8/6) + Haiku triage | done |
| M6 | NestJS static scanner S1–S6 | done |
| M7 | Prompt-cache tuning, task budgets, token-redaction audit, CI codes | done |
- Reasoning + report:
claude-opus-5-5; bulk triage:claude-haiku-5-5. - Agent loop:
client.beta.messages.toolRunner+betaZodTool(from@anthropic-ai/sdk/helpers/beta/zod). - Thinking
{type: "adaptive"};output_config.effortset tohighexplicitly (Opus 5.5 defaults tomedium). - Haiku triage uses structured outputs (
client.messages.parse+zodOutputFormat), not the deprecatedoutput_format; it gates which routes earn a full Opus run for API3/4/8/6. - Source review (API10): a
read_sourcetool confined tosourcePath(path-traversal blocked) lets one agent read the outbound-HTTP files and reason over the code — the model does the review, you supply the source. - PDF via
pdfkit(pure-JS, no headless Chromium) — one runtime dep; renders text literally, so no HTML-injection surface.
The framework findings are verified against lives in src/report/framework.ts —
the single source of truth. Each OWASP API Top 10 2023 category (API1–API10) is defined with a
summary, common weaknesses, test approach, the evidence that confirms it, and its ASVS 5.0 / ISO
27001:2022 mapping. src/report/mapping.ts derives the slim lookup from it; the report appendix and
every finding cite it. Inspect or export it:
pnpm run dev -- framework # print the full definition
pnpm run dev -- framework --json data/owasp-api-2023.json # export for audit verificationscanexits non-zero when any finding is at or above--fail-on <severity>(defaulthigh) — the CI gate.- Set
ai.taskBudgetTokens(≥ 20000) intargets.jsonto give each agent run a token budget (betatask-budgets-2026-03-13) on top of the hardmaxAgentStepsPerRoutecap. - The stable per-category system prompt is cached (
cache_control); per-route context sits after the breakpoint so only the volatile part is re-sent. - Identity tokens are injected by the HTTP layer and never reach the model; response
set-cookie/authorizationare redacted, and configured secret values are scrubbed from every finding's evidence before the report is written.
pnpm testDeterministic units (no network, no Anthropic calls): scope guardrails (allowlist, deny-path encoding, readOnly precedence, host escape), config resolution, secret redaction, the static scanner against test/fixtures/nest-sample (asserts all six rules fire and the false-positive guards hold), and the AI engine with an injected fake client (asserts it runs the authz agent, routes send_request through the HTTP client, and records an AI finding, all without spending on the API).
data/asvs-5.0.csvis the official OWASP ASVS 5.0.0 requirement set — 345 requirements across chapters V1–V17, sourced from OWASP/ASVS (5.0/docs_en/OWASP_Application_Security_Verification_Standard_5.0.0_en.csv), reduced tochapter,req_id,level(official descriptions omitted — not used by the tool; add them back if a report needs the full text).- OWASP reference URLs in
src/report/framework.tsare verified against the live OWASP API Top 10 2023 index (api-security.owasp.org).
- Dependency versions in
package.jsonare best-effort;pnpm installresolves the real ones — re-runpnpm run tscafter.
{ "baseUrl": "https://staging.example.test", // required; its host must appear in scope.allowHosts "specPath": "./openapi.json", // required; compiled OpenAPI 3.x spec of the target "sourcePath": "../our-nest-api/src", // optional; enables the NestJS static scanner (S1–S6) // One entry per role you want to test as. "headers" is sent VERBATIM on every request, so any // auth scheme works: bearer JWT, API key header, cookie, basic auth. role "none" = unauthenticated. "identities": [ { "name": "admin", "role": "admin", "headers": { "Authorization": "Bearer <ADMIN_JWT>" } }, { "name": "user", "role": "user", "headers": { "Authorization": "Bearer <USER_JWT>" } }, { "name": "anonymous", "role": "none", "headers": {} } ], // For BOLA (API1): ids each identity legitimately owns. The tool has one identity request another // identity's object to check object-level authorization. Keys must match the path parameter names. "ownedObjects": { "user": { "orderId": "1001" }, "admin": { "orderId": "2002" } }, "scope": { "allowHosts": ["staging.example.test"], // required; every request host is checked against this "denyPathPatterns": ["*/delete-all*"], // glob; matched on raw and percent-decoded path "readOnly": true, // true = only GET/HEAD are ever sent (default) "allowMethods": ["GET", "HEAD"] // intersected with readOnly }, "limits": { "maxRequestsPerSecond": 5, "requestTimeoutMs": 10000, "maxAgentStepsPerRoute": 12, // hard cap on each AI agent loop "maxAgentConcurrency": 2, "maxFindingTokens": 4000 }, "ai": { "reasoningModel": "claude-opus-5-5", "triageModel": "claude-haiku-5-5", "effort": "high", "taskBudgetTokens": 20000 // optional; per-run token budget (>= 20000) } }