Audit application source code against the OWASP Top 10:2025 categories — broken access control (incl. SSRF), security misconfiguration, software supply chain failures, cryptographic failures, injection, insecure design, authentication failures, software and data integrity, logging and alerting failures, mishandling of exceptional conditions — with recon, a coverage ledger, per-finding verification, and a findings.json report. Use when the user mentions 'OWASP,' 'OWASP Top 10,' 'security audit,' 'security review,' 'secure code review,' 'code security review,' 'vulnerability audit,' 'find vulnerabilities,' 'appsec review,' 'application security audit,' 'check for security issues,' 'review this PR for security,' 'pre-merge security check,' 'diff review,' 'broken access control,' 'IDOR,' 'SQL injection,' 'XSS,' 'SSRF,' 'webhook security,' or wants to check their codebase for common security weaknesses.
92%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
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-audit for 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-testing to prove race conditions and logic flaws dynamically (A06)dependency-audit for the full A03 supply-chain picture; secrets-audit for leaked credentialsthreat-modeling for the principal / trust-boundary vocabulary used in reconfinding-triage to take a single finding from this report to a defensible dispositionOnly 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:
If it's ambiguous, ask one question: guidance pass or full audit (and which profile)?
architecture.md and coverage.json before huntingfindings.jsonWrite everything to security-audit/ at the repo root (ask whether to commit or gitignore it). Re-runs read the prior
files from there.
quick, standard (default), or deep. Profiles change breadth and
redundancy, never the evidence bar. See references/orchestration.md.findings.json and coverage.json and carry forward by fingerprint (orchestration.md
"Re-runs and carry-forward"). Never re-raise a rejected claim whose control is unchanged.Before grepping for vulnerabilities, map the system (references/recon-and-coverage.md):
file:line where its
controls liveneeds_validation blockers laterpackage.json dependencies and config files against the detection table in
references/frameworks/README.md (for example next + next.config.* → Next.js,
@supabase/* or supabase/config.toml → Supabase). Load only the matching playbooks, add their surfaces to the
ledger, and work their checks in Step 3. Their findings use the same categories, verdicts, and format as the restWrite architecture.md (≤ ~1,000 words) and seed coverage.json with one planned unit per surface × boundary ×
OWASP category.
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.
coverage.json / findings.json. Hunter prompt, result shape, and the sequential fallback are in
references/orchestration.md.web-pentest "Combined white-box + black-box mode" so
code findings guide live tests and live anomalies prioritize code review.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. Apply the fixed exclusion rules (test,
generated, vendored, docs) first. In Claude Code, dispatch the plugin's read-only finding-verifier subagent per candidate.
| 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:
counterevidence on every verdict. A static-only trace caps confidence at medium.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.
findings.jsonWrite 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.jsonFix 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).
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.
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.
'use server'action / loader exportcategoryId, 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 for formData.get("<id>") / body.<id> passed straight to insert/update without a preceding findFirst({ where: { id, userId } }). For ORM relation joins (Drizzle with:, Prisma include, ActiveRecord includes), trace whether the join target is filtered by the same tenant/ownership predicate as the parent query.to address, 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.?from=, ?next=, ?returnTo=, ?continue=, ?redirect= passed unsanitized to redirect() / 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 /\tevil into 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).redirect(.*from, redirect(.*next, redirect(.*returnTo@-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.redirect: "error" (don't follow attacker-controlled redirects), explicit timeout, no following 3xx into the metadata serviceHost: header, or use a vetted proxynext.config.{ts,js} with images.remotePatterns: [{ hostname: '**' }], domains: ['*'], or any wildcard entry lets attackers route arbitrary URLs through your CPU/bandwidth.
remotePatterns, domains: in image configfetch(, axios(, http.get(, urllib, requests.get( with user inputDebug 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-ancestors on 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_validation item. 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.js middleware.ts / Cloudflare Workers / Vercel Edge functions compile cleanly and fail at first request with Failed 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.rb for 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 in if 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 SELECT followed by a runX() call without an intervening atomic claim, and unconditional UPDATE … SET status='processing' writes. Two concurrent requests can both pass the read-side check and both run. Fix: conditional UPDATE … 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 with business-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 in next.config.ts headers() match per route and merge — a more-specific rule does not override headers it doesn't redeclare. Shipping frame-ancestors 'none' + X-Frame-Options: DENY in a /:path* default plus frame-ancestors * in a /embed override results in both frame-ancestors * and X-Frame-Options: DENY on the embed route (contradicting; older browsers may break framing). Config inspection alone misses the merge, so this is needs_validation until someone runs curl -sI https://<origin>/embed against the deployed origin. Fix: set XFO in every rule or drop it entirely (CSP frame-ancestors supersedes 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 /login on 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.X without 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 /login breaks fetch clients (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 return 401 application/json with a machine-readable body.
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 as needs_validation with "advisory data not checked" as the blocker and the audit command as the plan, and mark the A03 advisory unit deferred (the run is then partial). Don't call A03 clean from the lockfile alone.npm audit --omit=dev alongside npm audit and triage by reachability:
dependencies) — must fixdependency-audit. Known-CVE versions are a one-line sanity check here.<script src=… integrity="sha384-…" crossorigin>), or from a host that serves different content per request.postinstall, prepare, setup.py, build plugins) and dependency confusion / typosquats: hand off to dependency-audit.<script src="https://, integrity=, "postinstall", uses: .*@(main|master|v\d+)$ in .github/workflows/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 produces NaN for garbage input, and NaN compares as false for both < and >. A timestamp-freshness check if (Math.abs(now - parsed) > tolerance) return false fails to reject NaN — because NaN > tolerance is false. Grep for: parseInt|parseFloat|Number\(.*\) inside verifySignature / validateToken / signed-cookie / JWT-claim code. Each numeric extraction must be followed by if (!Number.isFinite(parsed)) return false before 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_PEER for 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_PEER fails there without an explicit ca_file: pin. When VERIFY_PEER is genuinely infeasible, present three remediation options in priority order:
VERIFY_NONE with (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 conditionsNever quietly recommend VERIFY_PEER without checking that the cert chain at the deployment target is verifiable.
Grep for generic secret names AND known provider key prefixes:
password, secret, api_key, private_key, MD5, SHA1, base64sk_live_, sk_test_, rk_live_, whsec_ghp_, gho_, ghu_, ghs_, ghr_AKIA[0-9A-Z]{16}, ASIA[0-9A-Z]{16}AIza[0-9A-Za-z\-_]{35}, service-account JSON ("type": "service_account")xox[baprs]-, xoxe.xoxp-sk-, sk-ant-vercel_blob_rw_git ls-files | xargs grep -lE 'sk_live|ghp_|AKIA[0-9A-Z]{16}|sk-ant-' 2>/dev/null so binaries and gitignored files don't pollute output.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" .exec(), spawn(), system() with user inputdangerouslySetInnerHTML, v-html.JSON.stringify. Any <script type="application/ld+json"> or <script>window.__DATA__ = ...</script> that interpolates server data through JSON.stringify is vulnerable — JSON.stringify does 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.
application/ld+json, __html: JSON.stringify, window.__ + JSON.stringify<>&
with their \uXXXX Unicode escapes before injecting.raw(), .html_safe, <%==, sanitize with a permissive allowlist, and simple_format on user input. Grep for these alongside dangerouslySetInnerHTML / v-html.[/\s] (not just \s) as the attribute-name separator — HTML accepts / between tag name and first attribute: <img/onerror=…><img>, <body>, <video>, <iframe>) — HTML elements instantiate even in SVG-rendering contextson*= regardless of surrounding contextContent-Security-Policy: script-src-attr 'none' as a browser-level backstop<script> / onload. Most blob / object storage serves uploads with the declared content-type. Reject image/svg+xml in upload allow-lists unless you have a sanitizer (e.g. DOMPurify SVG profile) and serve with Content-Disposition: attachment.<script type="application/ld+json">, do NOT use j / escape_javascript for field values — it emits \' and \$ (valid JS, invalid JSON). Use json_escape(schema.to_json).html_safe; full idiom and round-trip check in references/checklist-details.md.exec(, eval(, innerHTML, dangerouslySetInnerHTML, $where, raw SQL stringsAuthentication 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]=1 parsed into a million-element array (~5 MB of JSON) that the handler stored before validating it. Proxy size caps don't stop amplification. Check multer/busboy/formidable limits, .none() for unused files, qs/body-parser parameterLimit/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), queue enqueue( 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 NULL on PUT /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 userId predicate, and the same conflict-handling (returning() + 0-rows check). Common offender: a POST /:id/send or POST /:id/convert route that ships after the PUT was 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:
INSERT … ON CONFLICT DO NOTHING (DB UNIQUE constraint is the lock)UPDATE … SET externalId = ? WHERE externalId IS NULL and check 0-rowsprovider.delete(id) best-effort; log on cleanup failureWorker-queue state transitions need atomic claim. Any cron / worker polling pending rows must atomically claim each row before processing. SELECT + process() + UPDATE is 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 … — Postgres RETURNING lets 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 confirmed finding only with a complete trace; to demonstrate it (parallel requests, double-spend, limit overrun) against an authorized running instance, use business-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):
/^[a-f0-9]{64}$/i for HMAC-SHA256 hex)LIMIT 200)/api/webhooks/foo/<connection_id>) so lookup is O(1)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.modules silently no-op when the package isn't bundled. Per-stack commands: references/checklist-details.md.
cookies.set("admin_token", process.env.ADMIN_PASSWORD) and equality-checked on read). Even with httpOnly and secure, this is plaintext-credential storage (CWE-522) and lacks rotation/revocation. Replace with an HMAC-signed expiring token verified via crypto.subtle.verify / crypto.timingSafeEqualsubmitted === expected for passwords, API keys, or signatures leaks length and prefix-match via timing. Use crypto.timingSafeEqual (Node) or crypto.subtle.verify (Web Crypto)password_length requires :validatable. config.password_length in config/initializers/devise.rb is only enforced when the User model includes :validatable in its devise :... declaration. A model with devise :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+: in app/models/; flag any line where :validatable is absent. Adding it on an existing app validates on create+update but does not retro-invalidate existing weak passwords.AUTH_SECRET unset silently derives a weak dev value — assert presence at module load: if (!process.env.AUTH_SECRET) throw ...authorize() has no built-in rate limit — wrap or add upstream limiter; otherwise credential stuffing is trivialcookies.set\(.*process\.env, === .*PASSWORD, !== .*SECRET, !== .*Bearer.*\${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 at processing forever). 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.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.to: address, especially with attacker-influenced subject/body, is a phishing vector against your verified sender's deliverability reputation. Defences (pick one):
noreply@ sender with subject/body the attacker can't influenceNew in 2025. Code that fails open, swallows, or leaks when something unexpected happens: an exception, a missing value, a timeout, a partial failure.
try whose catch returns true, calls next(), 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*\} around verify/authorize/can(, ?? true, || true on permission results.catch \{\}, catch (_) \{\}, catch (_e) \{\}, .catch(() => {}), .catch(() => null), try { ... } catch { return null }error.name, status code) without PIIawait runMiddleware(req, res, upload.any()) above the handler's try means 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.parseInt → NaN in signature freshness checks), A02 Bearer ${process.env.X} with the env var unset, and A06 rate-limit keys that fall back to a shared bucket.processing forever or a retry double-charges. See A08 reserve-then-act.unhandledRejection / uncaughtException policy.Don't report these as findings:
VERIFY_NONE present": a flag is not a finding until you trace the impact. Without one, it's a hardening note.reviewed_paths and checks.After applying a fix, exercise the affected code path — do not stop at typecheck or build. finding-triage's fix verification (re-run the original check, look for variants, make sure the sink didn't just move, add a regression test) decides when the record becomes Fixed. Modern frameworks have runtime-only failure modes that compile cleanly:
node:crypto, node:fs) build successfully but throw on first request.import() or runtime DI surface only when the codepath runs.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.
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:
rateLimit doesn't help if you call auth.api.signInEmail(...) programmatically — that bypasses the HTTP router where the limiter attaches.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 migrationVerification 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.
finding-triage Step 7).False Positive is not a disposition. It's a rejected verdict with the control that rules it out.
deferred and the report says partialreferences/ at build time): verification.md, recon-and-coverage.md, orchestration.md, diff-review.md, findings-format.md, severity-calibration.mdbriiirussell/cybersecurity-skills
c79bb8b
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.