About owasp-audit
owasp-audit is a skill for systematic source-code security reviews against the OWASP Top 10:2025. It is intended for authorized code audits and distinguishes guidance, full audits, and change-scoped reviews; candidate findings are independently verified and coverage is recorded.
Key features
- 01OWASP Top 10:2025 review workflow
- 02Surface and trust-boundary coverage ledger
- 03Independent verification of candidate findings
- 04JSON findings and coverage validation
- 05Report rendering from audit records
Use cases
- 01Review an authorized codebase for security issues
- 02Audit a PR or commit range before merge
- 03Track coverage and disposition across reruns
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ briiirussell/ cybersecurity-skills --skill owasp-auditAfter installing, type/owasp-auditin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/checklist-details.mdcoverage.schema.jsondiff-review.mdfindings-format.mdfindings.schema.jsonorchestration.mdrecon-and-coverage.mdseverity-calibration.mdto-sarif.mjsvalidate-coverage.mjsvalidate-findings.mjsverification.mdwebhooks-and-parsers.mdDetails
From the project READMEOWASP Audit — Source Code Security Review
Perform a systematic security audit of application source code against the OWASP Top 10:2025. Every candidate is traced, then independently challenged, before it becomes a finding. Coverage is recorded per surface and trust boundary, so "clean" means someone actually read the code.
Cross-references:
api-auditfor a surface-driven pass over every API endpoint (shares the recon, verification, and orchestration references)web-pentest"Combined white-box + black-box mode" when you have both the source and a running instance you're authorized to testbusiness-logic-testingto prove race conditions and logic flaws dynamically (A06)dependency-auditfor the full A03 supply-chain picture;secrets-auditfor leaked credentialsthreat-modelingfor the principal / trust-boundary vocabulary used in reconfinding-triageto take a single finding from this report to a defensible disposition
Authorization and Mode
Only audit code the user owns or is authorized to review. Any live check (for example curl -I against a deployed
origin) needs the same authorization for that host and stays non-destructive.
Pick the mode before starting:
- Guidance (default): a question, a single file, or "how do I secure X". Answer directly. Findings still follow the verdict rules below. No ledger required.
- Full audit: the user explicitly asks to audit or security-review a codebase, a release, or a PR. Run the full workflow below.
- Diff / PR-scoped: the user wants a branch, PR, or commit range reviewed, or a pre-merge gate. Run the full workflow limited to the change, using references/diff-review.md.
If it's ambiguous, ask one question: guidance pass or full audit (and which profile)?
Workflow (full audit)
- Scope: what's in, what's out, mode, and run profile
- Recon: write
architecture.mdandcoverage.jsonbefore hunting - Hunt: work the category checklist unit by unit (subagents for large codebases)
- Verify every finding: an independent refutation pass gives each candidate one verdict
- Coverage critic: look for unmapped surfaces and thin coverage
- Write and validate
findings.json - Render the report from the JSON
Write everything to security-audit/ at the repo root (ask whether to commit or gitignore it). Re-runs read the prior
files from there.
Step 1 — Scope
- Language, framework, runtimes, deploy target
- In scope / out of scope, with a reason for each exclusion
- Mode (full or diff) and run profile:
quick,standard(default), ordeep. Profiles change breadth and redundancy, never the evidence bar. See references/orchestration.md. - Prior run? Load the previous
findings.jsonandcoverage.jsonand carry forward by fingerprint (orchestration.md "Re-runs and carry-forward"). Never re-raise a rejected claim whose control is unchanged.
Step 2 — Recon (read-only)
Before grepping for vulnerabilities, map the system (references/recon-and-coverage.md):
- Every lower-trust principal (anonymous, user, other tenant, webhook sender, job runner) and the
file:linewhere its controls live - Entry surfaces: routes, server actions, loaders, tRPC procedures, GraphQL resolvers, webhooks, jobs, cron, uploads
- Sinks: DB writes/raw queries, exec, filesystem, outbound HTTP, HTML rendering, email, paid third-party calls, redirects
- What's deployed vs what's in the repo (proxy, CDN, IdP, env). These become
needs_validationblockers later
Write architecture.md (≤ ~1,000 words) and seed coverage.json with one planned unit per surface × boundary ×
OWASP category.
Step 3 — Hunt
Work through the Audit Checklist per ledger unit, not per category in the abstract. For each unit, read the entrypoint, follow data to every sink, and record what you checked even when nothing is wrong.
- Large codebase: split units across isolated hunter subagents, unauthenticated surfaces first. Only the coordinator
writes
coverage.json/findings.json. Hunter prompt, result shape, and the sequential fallback are in references/orchestration.md. - Source and a running app available (and authorized): use
web-pentest"Combined white-box + black-box mode" so code findings guide live tests and live anomalies prioritize code review. - A candidate needs a lower-trust principal, the input they control, the control that should stop them, the boundary crossed, the affected resource, and a concrete result. Missing any of these, it's a hardening note or needs validation, not a finding.
Step 4 — Verify every finding
Before anything reaches the report, run the per-finding refutation pass in
references/verification.md. One fresh verifier per candidate (a subagent where
supported; otherwise a separate step that doesn't re-read the hunting notes). It re-reads every cited file:line,
names the strongest control on the path, and returns exactly one verdict:
| Verdict | Bar | Severity |
|---|---|---|
confirmed | Complete entrypoint → control → sink → impact trace, or a PoC | Yes, per references/severity-calibration.md |
needs_validation | Code supports it; one decisive fact lives outside the code. Requires blockers[] + an owner-run validation_plan | No |
rejected | A named control at a named location runs on every reachable path and fails closed | No; kept so re-runs don't re-raise it |
Rules that matter most:
- "Potential" is retired. Use Needs validation, and only with a concrete blocker and plan. It's not a parking place for a hunch.
- Library trust, a control on a different path, a control at the wrong time, a fail-open control, a safe sibling, missing information, difficulty, "operator-configurable", and "internal" do not rule a candidate out.
- Record
counterevidenceon every verdict. A static-only trace capsconfidenceat medium. - An upgrade (higher verdict or severity) needs a new verifier.
- Gaps with no traced impact close as hardening notes (not findings, no severity).
Step 5 — Coverage critic
Before writing the report, run the critic in references/recon-and-coverage.md
over architecture.md + coverage.json: unmapped entry points, unchecked sibling paths, covered units with thin
checks, unjustified exclusions, candidates without verdicts. Add missing units and review them (or mark them
deferred with a reason). covered requires non-empty reviewed_paths and at least one check.
Step 6 — Write and validate findings.json
Write every verdict record to security-audit/findings.json (sorted by fingerprint; format in
references/findings-format.md), then validate both files:
# <skill-dir> = this skill's folder, e.g. .claude/skills/owasp-audit
node <skill-dir>/references/validate-findings.mjs security-audit/findings.json
node <skill-dir>/references/validate-coverage.mjs security-audit/coverage.json
Fix every validation error before writing prose. Fingerprints name the root cause and never include line numbers,
severity, or verdict. Optional SARIF export for GitHub code scanning: references/to-sarif.mjs (see findings-format.md).
Step 7 — Render the report
Render report.md from findings.json, coverage.json, and hardening-notes.md using the Output Format.
The JSON is the source of truth: prose never changes a verdict, severity, blocker, or demonstrated impact. If they
disagree, fix the JSON, re-validate, and re-render.
Audit Checklist
For each unit, grep for the patterns below, then read the flagged files and trace them.
Categories follow the OWASP Top 10:2025. Tag findings and coverage units A01:2025 … A10:2025. Mapping for reports that
used the 2021 list: A01 → A01 (SSRF, 2021's A10, is now part of A01); Cryptographic Failures A02 → A04; Injection A03 → A05;
Insecure Design A04 → A06; Security Misconfiguration A05 → A02; Vulnerable Components A06 → A03 Software Supply Chain
Failures (broader); A07 and A08 keep their numbers; A09 is now Logging and Alerting Failures; A10 Mishandling of
Exceptional Conditions is new.
A01: Broken Access Control
- Missing authorization checks on endpoints or routes
- IDOR — user-controlled IDs without ownership verification
- Auth-check ordering. Verify the authorization check runs before any branch that can reveal whether the resource exists, what state it's in, or any other resource-specific metadata. Returning 404 for "not found", 400 for "wrong state", and 401 for "not authenticated" is itself a leak — an attacker enumerates resource IDs and learns states without ever passing the auth gate. Recommended response shape: uniform 404 for everything an unprivileged caller should not see.
- Framework RPC surfaces that don't appear as routes. Server actions and equivalents are publicly-exposed RPCs that file scans miss. Enumerate and audit each one for auth + ownership:
- Next.js: every exported function in a file with
'use server' - Remix / React Router: every
action/loaderexport - tRPC: every procedure
- GraphQL: every resolver
- Rails: non-resource controller actions
- Next.js: every exported function in a file with
- IDOR via foreign keys in mutation payloads. Form posts a foreign-key UUID (
categoryId,projectId,teamId,organizationId) → server validates ownership of the primary record but blindly accepts the FK → ORM relation join later surfaces another tenant's data. Look forformData.get("<id>")/body.<id>passed straight to insert/update without a precedingfindFirst({ where: { id, userId } }). For ORM relation joins (Drizzlewith:, Prismainclude, ActiveRecordincludes), trace whether the join target is filtered by the same tenant/ownership predicate as the parent query. - Inbound webhooks authenticate the caller before DB or paid work. Email, SMS, payment, and git webhooks that identify the account from the payload (
toaddress, slug) but never verify the provider's signature over the raw body let anyone trigger paid work and create records in another account. Provider signature table and raw-body capture: references/webhooks-and-parsers.md. Grep:inbound,parse,sendgrid,mailgun,postmark,/api/.*email,multer(,bodyParser: false. - Missing CSRF protections on state-changing requests
- Role checks only on the frontend, not enforced server-side
- Open redirect via post-auth return-to parameter —
?from=,?next=,?returnTo=,?continue=,?redirect=passed unsanitized toredirect()/Response.redirect(). Restrict to same-origin paths under the expected scope, normalize (new URL(target, "http://localhost").pathname) to defeat traversal like/admin/../foo. Also reject control bytes in the path before redirect: tab/newline/null (\t,\n,\0) — URL parsers strip these and collapse/\tevilinto protocol-relative//evil; null bytes can turn the redirect into a 500. Reject any byte in[\x00-\x1F\x7F], any backslash, and any percent-encoded slash/backslash (%2f,%5c). - Grep for: direct object references, missing auth middleware, user ID from request params,
redirect(.*from,redirect(.*next,redirect(.*returnTo
SSRF (A10 in 2021; part of A01 since 2025)
- User-controlled URLs passed to server-side HTTP requests
- Missing URL validation and allowlisting
- Allow-lists that only check hostname. A real allow-list rejects wrong schemes, embedded credentials,
@-host tricks, non-default ports, punycode/IDN spoofs, trailing dots, subdomain confusion, IPv6 literals, decimal/hex/octal IPv4, IPv4-mapped IPv6, cloud metadata endpoints, CGNAT, and link-local/unique-local IPv6. Full test set: references/checklist-details.md. - Fetch-time guards:
redirect: "error"(don't follow attacker-controlled redirects), explicit timeout, no following 3xx into the metadata service - Note the TOCTOU between validation and fetch — DNS can resolve differently between the two (DNS rebinding). For high-risk callers, pin the resolved IP and connect by IP with
Host:header, or use a vetted proxy - Image-optimizer-as-proxy (Next.js, Nuxt, SvelteKit):
next.config.{ts,js}withimages.remotePatterns: [{ hostname: '**' }],domains: ['*'], or any wildcard entry lets attackers route arbitrary URLs through your CPU/bandwidth.- Grep for:
remotePatterns,domains:in image config - Fix: pin to specific known hostnames; leave empty if all images are local.
- Grep for:
- Grep for:
fetch(,axios(,http.get(,urllib,requests.get(with user input
A02: Security Misconfiguration
-
Debug mode enabled in production configs
-
Overly permissive CORS policies (
Access-Control-Allow-Origin: *). A finding only when credentials are allowed or a sensitive, non-public response is readable cross-origin; otherwise a hardening note. -
Missing HTTP security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options) are hardening notes, not findings, unless you trace an impact (e.g. missing
frame-ancestorson a page with a one-click sensitive action → clickjacking; missing CSP where a confirmed XSS sink exists). Baseline values and where headers live per framework: references/checklist-details.md. -
Deployed headers are a
needs_validationitem. Config shows intent; proxies, CDNs, and platform defaults change what's actually sent. Unless you ran it against an origin you're authorized to probe, record a blocker ("headers actually served at/embed") and an owner-run check:curl -sI https://<origin>/<path> | grep -iE 'content-security-policy|x-frame-options|strict-transport', with the expected output for confirmed vs rejected. -
Default credentials or configurations shipped
-
Verbose error messages exposing stack traces or internals — including validation libraries echoing schema details (e.g. Zod
err.issues, Joi error trees) to clients -
Runtime-API mismatch. Code running in Edge / Workers / V8-isolate runtimes can't load Node-only modules. Imports of
node:crypto,node:fs,node:buffer,node:net, etc. inside Next.jsmiddleware.ts/ Cloudflare Workers / Vercel Edge functions compile cleanly and fail at first request withFailed to load external module. Audit middleware and edge-marked routes for Node-only imports; prefer Web Crypto (crypto.subtle) for portable code -
Rails admin-engine mounts. Grep
config/routes.rbfor engine and dashboard mounts (PgHero, Sidekiq::Web, Flipper UI, Mission Control, Audit1984) and verify the auth middleware in the corresponding initializer applies in every environment reachable from the internet — not just production. The upstream README examples wrap auth inif Rails.env.production?, leaving staging, review apps, and preview deploys anonymous. Fix:unless Rails.env.local?plus a fail-closed check when auth env vars are unset. Grep commands: references/checklist-details.md. -
Concurrent-execution races on paid endpoints. When an endpoint triggers paid downstream work (LLM calls, third-party APIs, scrape jobs), look for
SELECTfollowed by arunX()call without an intervening atomic claim, and unconditionalUPDATE … SET status='processing'writes. Two concurrent requests can both pass the read-side check and both run. Fix: conditionalUPDATE … WHERE id = ? AND status = 'pending' RETURNING …, or a Postgres advisory lock. Charge rate-limit budget only on a successful claim so polling and retries don't burn quota. (Listed under A02 because the failure manifests as a misconfigured invariant guard; the race spans A02 / A06 / A08. Prove it dynamically withbusiness-logic-testing.) -
Source-tree hygiene. Grep for sync-conflict duplicates and dead code that could let a reviewer fix the canonical file while leaving a vulnerable copy in place:
find . \( -name '* 2.*' -o -name '* 3.*' -o -name '*.orig' \ -o -name '*~' -o -name '*.bak' \) -not -path '*/node_modules/*'Treat findings as A02 — the canonical file may be patched while the duplicate retains the vulnerability.
-
Next.js
headers()rule merging. Rules innext.config.tsheaders()match per route and merge — a more-specific rule does not override headers it doesn't redeclare. Shippingframe-ancestors 'none'+X-Frame-Options: DENYin a/:path*default plusframe-ancestors *in a/embedoverride results in bothframe-ancestors *andX-Frame-Options: DENYon the embed route (contradicting; older browsers may break framing). Config inspection alone misses the merge, so this isneeds_validationuntil someone runscurl -sI https://<origin>/embedagainst the deployed origin. Fix: set XFO in every rule or drop it entirely (CSPframe-ancestorssupersedes on modern browsers). -
Auth middleware that doesn't exempt bearer/HMAC routes. Cron jobs, Stripe / GitHub webhooks, and any route that authenticates via bearer token or HMAC need to be excluded from session-cookie middleware. Symptom: those routes silently 302 to
/loginon every deploy or scheduled invocation, the route-level signature check never runs, and the integration appears to "work" until it doesn't. -
Bearer-token compare with unset env interpolation. Comparisons that interpolate
process.env.Xwithout a presence check —\Bearer ${process.env.WEBHOOK_TOKEN}`— resolve to a literal"Bearer undefined"when the env var is missing. An attacker who guesses the env-not-set condition can replay that literal string. Assert presence at module load:if (!process.env.WEBHOOK_TOKEN) throw new Error(...)`. -
API routes returning HTML 302 redirects instead of JSON 401. Auth middleware that 302s every unauthenticated request to
/loginbreaksfetchclients (they follow the redirect and consume HTML), obscures auth state in monitoring, and lets attackers learn endpoint existence by 302 vs 404. API routes should return401 application/jsonwith a machine-readable body.
A03: Software Supply Chain Failures
- Supply chain covers more than known-CVE versions: the build, the registry, and everything that runs at install or CI time.
- Run
npm audit(Node),pip audit(Python), or equivalent. These query the registry, so they need network. In a no-network run, read the lockfile for versions you know are vulnerable, record each one asneeds_validationwith "advisory data not checked" as the blocker and the audit command as the plan, and mark the A03 advisory unitdeferred(the run is then partial). Don't call A03 clean from the lockfile alone. - Check lock files for known vulnerable dependency versions
- Flag dependencies with critical CVEs
- Run
npm audit --omit=devalongsidenpm auditand triage by reachability:- Runtime-reachable (in
dependencies) — must fix - Build-time-only (Vite, esbuild via drizzle-kit, postcss) — usually defer
- Dev-only (linters, test libs) — defer
- Runtime-reachable (in
- For the full CVE picture and triage by reachability, invoke
dependency-audit. Known-CVE versions are a one-line sanity check here. - Missing integrity checks on CI/CD pipelines
- No lockfile integrity verification (SRI hashes)
- Third-party scripts and styles loaded from a CDN without Subresource Integrity (
<script src=… integrity="sha384-…" crossorigin>), or from a host that serves different content per request. - Install-time code (
postinstall,prepare,setup.py, build plugins) and dependency confusion / typosquats: hand off todependency-audit. - Grep for:
<script src="https://,integrity=,"postinstall",uses: .*@(main|master|v\d+)$in.github/workflows/
A04: Cryptographic Failures
-
Hardcoded secrets, API keys, or passwords in source. Confirm the key type (publishable vs restricted vs secret) and the authority it grants before rating it: a public identifier called a "key" is not a secret.
-
Weak hashing (MD5, SHA1 for passwords instead of bcrypt/argon2/scrypt)
-
For bcrypt, also check the cost factor. OWASP 2024 guidance is ≥ 12 (cost 10 ≈ 10ms / 100 hashes/sec/core for an attacker)
-
Type coercion in cryptographic-verification paths. Numeric parsing (
parseInt,Number,parseFloat) silently producesNaNfor garbage input, andNaNcompares asfalsefor both<and>. A timestamp-freshness checkif (Math.abs(now - parsed) > tolerance) return falsefails to rejectNaN— becauseNaN > toleranceisfalse. Grep for:parseInt|parseFloat|Number\(.*\)insideverifySignature/validateToken/ signed-cookie / JWT-claim code. Each numeric extraction must be followed byif (!Number.isFinite(parsed)) return falsebefore any inequality. Same family:parseInt('0x123', 10) === 0,parseInt('1e10', 10) === 1,parseFloat('Infinity') === Infinity. -
Sensitive data in logs, URLs, or localStorage
-
Missing encryption at rest or in transit
-
Before recommending
VERIFY_PEERfor a TLS connection, identify the cert issuer at the deployment target. Many managed services ship self-signed cert chains at lower tiers (Heroku Redis Mini/Hobby, some ElastiCache configurations, Supabase legacy) —VERIFY_PEERfails there without an explicitca_file:pin. WhenVERIFY_PEERis genuinely infeasible, present three remediation options in priority order:- Upgrade the plan or pin the CA bundle — restores cert verification
- Accept the risk explicitly — leave
VERIFY_NONEwith (a) an in-line comment at every call site, (b) a documented compensating control (private network, internal-only routing), (c) a follow-up issue tracking re-verification conditions - Restrict the network path — private subnet / VPC peering / no public exposure
Never quietly recommend
VERIFY_PEERwithout checking that the cert chain at the deployment target is verifiable. -
Grep for generic secret names AND known provider key prefixes:
- Generic:
password,secret,api_key,private_key,MD5,SHA1,base64 - Stripe:
sk_live_,sk_test_,rk_live_,whsec_ - GitHub:
ghp_,gho_,ghu_,ghs_,ghr_ - AWS:
AKIA[0-9A-Z]{16},ASIA[0-9A-Z]{16} - Google Cloud:
AIza[0-9A-Za-z\-_]{35}, service-account JSON ("type": "service_account") - Slack:
xox[baprs]-,xoxe.xoxp- - OpenAI / Anthropic:
sk-,sk-ant- - Vercel:
vercel_blob_rw_ - Run via
git ls-files | xargs grep -lE 'sk_live|ghp_|AKIA[0-9A-Z]{16}|sk-ant-' 2>/dev/nullso binaries and gitignored files don't pollute output.
- Generic:
-
Include non-source file extensions in the sweep. Rails
cable.yml/database.yml/storage.yml, Kubernetes manifests, and Vercel / Netlify deploy configs routinely contain TLS or cert config that a source-only sweep misses:grep -rn "VERIFY_NONE\|verify_mode" --include="*.rb" --include="*.yml" --include="*.yaml" \ --include="*.toml" --include="*.json" .
A05: Injection
- SQL injection: raw queries with string concatenation, missing parameterized queries
- NoSQL injection: unsanitized user input in MongoDB/Convex queries
- Command injection:
exec(),spawn(),system()with user input - XSS: unescaped user input in HTML,
dangerouslySetInnerHTML,v-html. - Inline-script breakout via
JSON.stringify. Any<script type="application/ld+json">or<script>window.__DATA__ = ...</script>that interpolates server data throughJSON.stringifyis vulnerable —JSON.stringifydoes NOT escape<,>,&, U+2028, or U+2029. A stored title containing</script><script>alert(1)</script>will break out. The "internal-only object" framing only saves you when every field is guaranteed never to come from user-editable input.- Grep for:
application/ld+json,__html: JSON.stringify,window.__+JSON.stringify - Fix: wrap with an escape helper that replaces
<>&with their\uXXXXUnicode escapes before injecting.
- Grep for:
- Rails ERB sinks:
raw(),.html_safe,<%==,sanitizewith a permissive allowlist, andsimple_formaton user input. Grep for these alongsidedangerouslySetInnerHTML/v-html. - Sanitizer choice. When remediating an HTML/SVG XSS sink, the fix MUST use a vetted parser-based sanitizer (DOMPurify / isomorphic-dompurify / sanitize-html for JS; bleach for Python). Reject regex-based sanitizers in code review. If unavoidable, a regex sanitizer must:
- Treat
[/\s](not just\s) as the attribute-name separator — HTML accepts/between tag name and first attribute:<img/onerror=…> - Strip both SVG- and HTML-namespace dangerous elements (
<img>,<body>,<video>,<iframe>) — HTML elements instantiate even in SVG-rendering contexts - Include a final fallback pass that strips any
on*=regardless of surrounding context - Be paired with
Content-Security-Policy: script-src-attr 'none'as a browser-level backstop
- Treat
- SVG uploads as stored XSS. SVG files can carry
<script>/onload. Most blob / object storage serves uploads with the declared content-type. Rejectimage/svg+xmlin upload allow-lists unless you have a sanitizer (e.g. DOMPurify SVG profile) and serve withContent-Disposition: attachment. - Sanitize on write AND on render. For stored XSS / injection, sanitize at the trust boundary (write to DB) AND at the render boundary (defense in depth). On finding a stored-XSS bug, plan a one-time backfill migration to sanitize existing data — render-only fixes leave poisoned rows that any new render path will re-expose.
- Rails JSON-LD breakout: inside
<script type="application/ld+json">, do NOT usej/escape_javascriptfor field values — it emits\'and\$(valid JS, invalid JSON). Usejson_escape(schema.to_json).html_safe; full idiom and round-trip check in references/checklist-details.md. - Template injection: user input in template literals
- Grep for:
exec(,eval(,innerHTML,dangerouslySetInnerHTML,$where, raw SQL strings
A06: Insecure Design
-
Authentication flows with logic flaws
-
Missing rate limiting on sensitive endpoints (login, password reset, API)
-
Business logic constraints only enforced client-side
-
Request parsers have explicit limits (multipart, urlencoded, JSON depth/size). A 78-byte multipart request with
x[1000000]=1parsed into a million-element array (~5 MB of JSON) that the handler stored before validating it. Proxy size caps don't stop amplification. Checkmulter/busboy/formidablelimits,.none()for unused files,qs/body-parserparameterLimit/depth, Rack param limits; validate an allowlisted payload before logging or storing. Per-framework table: references/webhooks-and-parsers.md. -
Background / fire-and-forget jobs inherit the caller's auth context but lose the request-scoped guards. Re-check authorization inside the job, not just at enqueue. Grep for:
Promise.all(...).catch(,void someAsync(,.catch(noop), queueenqueue(without re-auth in the worker. -
Sister-route audit. When you find a state-machine or immutability guard on one handler (e.g.,
WHERE … AND signedAt IS NULLonPUT /api/foo/[id]), grep for every other handler that writes the same table:rg 'update\(\s*tableName\b|\.update\(tableName' --type ts -B1 -A8Each call site needs the same guard, the same
userIdpredicate, and the same conflict-handling (returning()+ 0-rows check). Common offender: aPOST /:id/sendorPOST /:id/convertroute that ships after thePUTwas hardened and was never re-audited. -
External-resource-create TOCTOU with billing implications. Any handler that does "SELECT to check, then
provider.create(), then INSERT to record the new resource ID" can create orphan resources on the provider side under concurrency. Stripe accounts, Auth0 / Clerk users, SendGrid templates, S3 buckets — all bill or count toward quota whether you stored the ID or not. Fix pattern:- Claim first with
INSERT … ON CONFLICT DO NOTHING(DB UNIQUE constraint is the lock) - Call the provider
- Persist with optimistic guard:
UPDATE … SET externalId = ? WHERE externalId IS NULLand check 0-rows - On race-loss, clean up the orphan via
provider.delete(id)best-effort; log on cleanup failure
- Claim first with
-
Worker-queue state transitions need atomic claim. Any cron / worker polling pending rows must atomically claim each row before processing.
SELECT+process()+UPDATEis a race — two workers (or two overlapping cron invocations) both see the same pending row and both call out, causing duplicate delivery. Fix:UPDATE … SET status='processing' WHERE id=? AND status='pending' RETURNING …— PostgresRETURNINGlets you claim and read in one round-trip. If the UPDATE returns 0 rows, someone else got it. Alternative:SELECT … FOR UPDATE SKIP LOCKED(Postgres / Cockroach) for higher-throughput queues. -
Race patterns need proof. A static read of a check-then-act race is a
confirmedfinding only with a complete trace; to demonstrate it (parallel requests, double-spend, limit overrun) against an authorized running instance, usebusiness-logic-testing. -
Multi-tenant webhook signature matching. When an unauthenticated webhook endpoint identifies its tenant by trying each tenant's secret in turn, every request — including garbage — does O(N) DB lookups + O(N) HMAC computations. Attackers flood with random signatures and amplify CPU/DB load without ever passing auth. Defences (compose them):
- Signature-shape prefilter before any DB work — reject signatures that aren't the exact length/charset the provider sends (e.g.,
/^[a-f0-9]{64}$/ifor HMAC-SHA256 hex) - Hard cap on per-request signature checks (e.g.,
LIMIT 200) - Per-IP rate limit on the endpoint
- If the provider supports it, embed the tenant ID in the webhook URL (
/api/webhooks/foo/<connection_id>) so lookup is O(1)
- Signature-shape prefilter before any DB work — reject signatures that aren't the exact length/charset the provider sends (e.g.,
-
Rate-limit key fallback. If your rate-limit key includes an attacker-controllable or potentially-missing identifier (IP, user-id, session-id), do NOT fall back to a shared constant string when it's absent. Either (a) refuse the request, (b) fall back to a per-resource identifier the attacker can't share (per-email for signup, per-Stripe-customer for billing), or (c) explicitly fail-open and log. A shared
'unknown'/'anon'bucket is a lockout vector — one attacker pinning the bucket locks out every user behind that proxy path. -
Configured-but-not-loaded check. Before declaring a security middleware (rate-limit, auth, CSRF, throttle) as "already configured," verify the gem/package is actually installed — not just that the initializer file exists. Initializers wrapped in
if defined? Foo/if PACKAGE in sys.modulessilently no-op when the package isn't bundled. Per-stack commands: references/checklist-details.md.
A07: Authentication Failures
- Weak password policies
- Session management issues (missing secure/httpOnly flags, no expiry, no rotation). Cookie-flag gaps are findings only with a traced impact (e.g. a session cookie readable by a confirmed XSS); otherwise hardening notes.
- Credential-as-cookie: the cookie value is the credential (e.g.
cookies.set("admin_token", process.env.ADMIN_PASSWORD)and equality-checked on read). Even withhttpOnlyandsecure, this is plaintext-credential storage (CWE-522) and lacks rotation/revocation. Replace with an HMAC-signed expiring token verified viacrypto.subtle.verify/crypto.timingSafeEqual - Non-constant-time credential comparison:
submitted === expectedfor passwords, API keys, or signatures leaks length and prefix-match via timing. Usecrypto.timingSafeEqual(Node) orcrypto.subtle.verify(Web Crypto) - Unauthenticated inbound webhooks that trust a payload field to pick the account: see A01 and references/webhooks-and-parsers.md.
- Missing rate limiting on login (credential stuffing risk)
- Broken password reset flows
- Rails / Devise —
password_lengthrequires:validatable.config.password_lengthinconfig/initializers/devise.rbis only enforced when the User model includes:validatablein itsdevise :...declaration. A model withdevise :database_authenticatable, :registerable, :recoverable, :rememberable(no:validatable) accepts passwords of any length and any email format, regardless of what the initializer says. Grep:^\s*devise\s+:inapp/models/; flag any line where:validatableis absent. Adding it on an existing app validates on create+update but does not retro-invalidate existing weak passwords. - NextAuth v5 / Auth.js footguns:
AUTH_SECRETunset silently derives a weak dev value — assert presence at module load:if (!process.env.AUTH_SECRET) throw ...- Credentials provider
authorize()has no built-in rate limit — wrap or add upstream limiter; otherwise credential stuffing is trivial - Account-existence enumeration via signup error strings ("email already exists") — return a uniform "check your email" response
- JWT strategy + DrizzleAdapter / PrismaAdapter — the adapter is a near no-op in this combination; revocation must be designed in explicitly
- Grep for:
cookies.set\(.*process\.env,=== .*PASSWORD,!== .*SECRET,!== .*Bearer.*\${
A08: Software or Data Integrity Failures
- Unsafe deserialization of user input
- External-resource overwrite without state check. Any handler that creates a provider-side resource (Stripe payment intent, subscription, webhook subscription) and stores the ID on a DB row should check whether a prior resource is still in-flight before overwriting. The old
clientSecret/ token may still be held by the client; on completion, the webhook handler may not find the row because the ID has changed (money lands, DB sits atprocessingforever). Fix: before creating a new resource, retrieve the existing one. If status is non-terminal (processing,requires_payment_method,requires_action) and parameters haven't changed, REUSE it — return the existing clientSecret. Only create a new resource when the prior one is canceled / succeeded or the parameters changed. - External side-effects before durable DB state. When a handler both writes to the DB and triggers an external side-effect (email, charge, webhook, S3 write), the external call should come after the DB write commits. The failure mode of "DB durable, external retried" is recoverable; "external done, DB stale" is not. Fix pattern: reserve-then-act. Issue a conditional UPDATE that flips the row to the post-action state guarded by the pre-action state (
WHERE status='draft'). If 0 rows, refuse. If 1 row, call the provider. On provider failure, the row already reflects intent — alert and retry. Bonus: this also makes the handler idempotent.
A09: Security Logging and Alerting Failures
- Auth events not logged (login, failure, privilege changes)
- Sensitive data written to logs (passwords, tokens, PII)
- No alerting on suspicious patterns
- Unauthenticated endpoints sending email from a verified domain to user-supplied addresses. Any handler reachable without auth that triggers an outbound email — confirmation, password-reset to-be-claimed, invite, "we got your message" — to an attacker-supplied
to:address, especially with attacker-influenced subject/body, is a phishing vector against your verified sender's deliverability reputation. Defences (pick one):- Don't auto-confirm — replies happen when a human responds
- Require ownership proof before any reply email (verification link / double opt-in)
- Use a separate, plainly-templated
noreply@sender with subject/body the attacker can't influence
- Error paths that never reach monitoring (swallowed exceptions, parser errors outside the boundary) are A10; they also blind alerting, so note both.
A10: Mishandling of Exceptional Conditions
New in 2025. Code that fails open, swallows, or leaks when something unexpected happens: an exception, a missing value, a timeout, a partial failure.
- Fail open on error. An authorization, signature, or policy check inside a
trywhosecatchreturnstrue, callsnext(), or falls through to the handler. Same for a policy service, feature-flag service, or IdP outage that defaults to allow. Grep for:catch.*return true,catch.*next\(\),catch \{\s*\}aroundverify/authorize/can(,?? true,|| trueon permission results. - Silent error swallowing. Empty catch blocks hide both bugs and attack signals (rate limiter falling over, deserialization errors, auth failures).
- Grep for:
catch \{\},catch (_) \{\},catch (_e) \{\},.catch(() => {}),.catch(() => null),try { ... } catch { return null } - Fix: at minimum log the error category (
error.name, status code) without PII
- Grep for:
- Parsing and middleware run inside the error boundary that reports to monitoring.
await runMiddleware(req, res, upload.any())above the handler'strymeans a missing multipart boundary or oversized field becomes a generic framework 500 and the handler's Sentry capture never runs, so malformed traffic and attacks are invisible. Check the method first, wrap parsing, map parser errors to 400/413 with structured logging, keep 5xx for real failures. Detail: references/webhooks-and-parsers.md. - Missing-value and NaN comparisons fail open. See A04 type coercion (
parseInt → NaNin signature freshness checks), A02Bearer ${process.env.X}with the env var unset, and A06 rate-limit keys that fall back to a shared bucket. - Partial failure leaves inconsistent state. A multi-step write without a transaction, or an error path that skips the rollback, lock release, or status reset, so the row sits in
processingforever or a retry double-charges. See A08 reserve-then-act. - One bad request takes the process down. An unhandled promise rejection or uncaught exception in a shared worker kills every in-flight request (a cheap denial of service). Check per-request error isolation and the
unhandledRejection/uncaughtExceptionpolicy. - Error responses that leak internals (stack traces, SQL, schema details) are listed under A02; tag them A10 when the leak comes from an unhandled error path rather than debug config.
Anti-patterns
Don't report these as findings:
- Checklist deviations presented as vulnerabilities. "No CSP", "bcrypt cost 10", "
VERIFY_NONEpresent": a flag is not a finding until you trace the impact. Without one, it's a hardening note. - Same-principal "self-impact." A user who can corrupt only their own data, or self-XSS that needs the victim to paste a payload, doesn't cross a trust boundary.
- Public identifiers treated as secrets. Publishable keys, client IDs, and Firebase/Maps browser keys are meant to ship. Check the key type and the authority it grants.
- "Could matter if chained" with unproven links. Each link in the chain needs its own evidence, or the chain is needs_validation at best.
- Severity on a needs_validation item, or "Potential" used as a catch-all.
- Issues that already require admin, shell, or physical access rated high or critical.
- Rejecting with "the framework handles it" without naming the control and location (see verification.md "What does NOT rule out a candidate").
- Category-level "clean" without per-unit
reviewed_pathsand checks.
Verify Fixes at Runtime
After applying a fix, exercise the affected code path — do not stop at typecheck or build. Modern frameworks have runtime-only failure modes that compile cleanly:
- Edge / Node split runtimes. Next.js middleware, Cloudflare Workers, Vercel Edge — Node-only imports (
node:crypto,node:fs) build successfully but throw on first request. - Lazy module loads. Adapters/plugins loaded via
import()or runtime DI surface only when the codepath runs. - Environment-variable fallthrough.
Bearer ${process.env.X}with X unset becomes a literal that the tests never hit because the test env defines X.
For each shipped fix, run the affected route or job and capture the response. tsc --noEmit + build success ≠ fix verified. For XSS / sanitizer-config fixes, run the canonical payload set in references/checklist-details.md through the configured policy and confirm each is neutralized. A fixed finding stays in findings.json with disposition Fixed; re-runs re-verify it.
Second-Opinion Pass (post-fix review)
The per-finding verification in Step 4 challenges each candidate. This pass is different: it runs after fixes ship
and reviews the fixes themselves and anything the checklist doesn't name (localhost. vs localhost, IPv4-mapped
IPv6, status-enumeration via ordered 404/400/401, callback-URL control chars, concurrent-execution races on paid
endpoints).
After applying fixes, run a pass with explicit adversarial framing ("assume the author is overconfident; find what they missed") — ideally with a different model or agent entirely, to break correlated blind spots. Treat any disagreement as a new candidate and send it through Step 4.
Common things to find in the second pass:
- New attack surface introduced by the fix itself (auth bypass via exempted routes, IDOR introduced by a new query)
- Comments that became stale during the rewrite
- Boundary conditions in the new code (env-unset fallthrough, empty input)
- Documentation drift between the fix and the report
- Fixes that configure third-party libraries. When the fix is a snippet from a library's own docs (auth, crypto, HTTP client, rate-limit middleware), the snippet may be correct and still not run on your code path. Before declaring fixed: grep the library at the pinned version, trace from your call site to the code path the config affects. Example: enabling Better Auth's
rateLimitdoesn't help if you callauth.api.signInEmail(...)programmatically — that bypasses the HTTP router where the limiter attaches.
Output Format
Render from findings.json + coverage.json + hardening-notes.md. Never hand-edit a verdict or severity in prose.
# Security Audit Report
## Project: [name] ## Stack: [technologies] ## Date: [date]
## Mode: full | diff-scoped (partial) — base [sha] → head [sha]
## Profile: quick | standard | deep ## Coverage: complete | partial ([N] units deferred/blocked)
## Prior run: [date / commit, or "none"] — carried forward: [N], re-verified: [N]
### Summary
- Confirmed: X (Critical X | High X | Medium X | Low X)
- Needs validation: X Rejected: X Hardening notes: X
- Units: X covered, X candidate, X not applicable, X deferred, X out of scope
### Confirmed findings
### Needs validation
### Hardening notes (not findings)
### Rejected (do not re-raise)
### Items checked and found clean
### Deferred / out of scope
### Prioritized Remediation Plan
1. [Critical — immediate] 2. [High — this week] 3. [Medium/Low — scheduled] 4. [Owner-run validation checks]
If coverage is partial or the run is diff-scoped, say so in the header and in the first summary line. A partial audit never claims the codebase is clean.
Confirmed findings, one block each:
#### [SEVERITY] A0X:2025: [Title] `fingerprint`
**File:** `path/to/file.ts:42` **CWE:** CWE-XXX **Confidence:** high | medium | low
**Trace:** entrypoint `file:line` → propagation `file:line` → sink `file:line`
**Conditions:** [authentication_level, network_routing, …]
**Description:** [What the vulnerability is, the boundary crossed, the concrete result]
**Counterevidence:** [Strongest control the verifier found and why it doesn't stop the attack]
**Vulnerable Code:** [snippet]
**Remediation:** [Fixed code snippet with explanation]
**Verification:** [Concrete adversarial input + the command or code path that proves the fix holds]
**Disposition:** Fixed | Deferred | Accepted Risk | Mitigated (platform/config) | Requires major migration
Verification examples: for XSS, the script-tag breakout payload that no longer breaks out; for open redirects, the off-host URL that now rejects; for password length, the 1-char password that now fails to save. "The linter says it's fine" is not verification.
Needs validation, one block each: title, location, what the code shows, Blockers (each the one external fact that decides it), and an Owner-run check (exact command, where to run it, and the expected output for confirmed vs rejected). No severity.
Hardening notes (not findings): one line each with location and suggested improvement. No severity.
Rejected (do not re-raise): table of fingerprint, claim, ruled_out_by control, and location.
Items checked and found clean: generated from covered and not_applicable units: surface, boundary, category,
the invariant checked, and the result. Audit credibility comes from proving you looked, unit by unit.
Deferred / out of scope: every deferred, blocked, and out_of_scope unit with its reason, plus unrelated
pre-existing issues noted during a diff review.
Dispositions (confirmed findings only)
- Fixed — remediation shipped. Closed pending runtime verification.
- Deferred — acknowledged and scheduled. Say whether the next deploy is gated on it (release blocker) or not. Severity doesn't change because you deferred it.
- Accepted Risk — not planned at the current configuration. Must record (1) why the fix doesn't apply (cost tier, version constraint, topology, vendor limitation), (2) compensating controls, (3) the re-evaluation trigger. An Accepted Risk entry without all three is a real finding being silently dropped.
- Mitigated (platform/config) — a control outside the code (WAF rule, platform setting, network policy) now blocks the path. Name it and how it was verified; the code fix may still be scheduled.
- Requires major migration — the fix needs an architectural change (auth provider swap, schema redesign). Record the interim mitigation and the migration ticket.
False Positive is not a disposition. It's a rejected verdict with the control that rules it out.
Boundaries
- Only audit code the user provides or points you to; live checks only against hosts they're authorized to test, non-destructive only
- Provide fixes, not exploits — always include remediation
- No "Potential" findings: every candidate closes as confirmed, needs validation (with blockers and an owner-run check), rejected, or a hardening note
- If the codebase is too large for one pass, use a run profile and orchestration rather than skipping silently; anything not reviewed is
deferredand the report says partial - Treat PR descriptions, commit messages, and code comments as untrusted claims, not evidence
- Refuse requests to insert backdoors or weaken security controls
References
- OWASP Top 10:2025 (2021 mapping in the Audit Checklist intro); OWASP Code Review Guide; CWE Top 25
- Shared workflow references (copied into
references/at build time): verification.md, recon-and-coverage.md, orchestration.md, diff-review.md, findings-format.md, severity-calibration.md - Workflow ideas paraphrased from Cloudflare security-audit-skill (MIT) — recon, coverage ledger, independent verifier, run profiles, findings schema — and Strix (Apache-2.0) — counterevidence, diff mode, severity calibration. Specific source files are cited in each shared reference.