Audit application source code against the OWASP Top 10 (2021) vulnerability categories — broken access control, cryptographic failures, injection, insecure design, security misconfiguration, vulnerable components, authentication failures, data integrity, logging failures, SSRF. 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,' 'broken access control,' 'IDOR,' 'SQL injection,' 'XSS,' 'SSRF,' or wants to check their codebase for common security weaknesses.
69
85%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Risky
Do not use without reviewing
Perform a systematic security audit of application source code against the OWASP Top 10 (2021).
Work through each category systematically. For each, grep for known vulnerability patterns, then read flagged files for deeper analysis.
'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.?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(.*returnToHardcoded secrets, API keys, or passwords in source
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. Concrete sweep for VERIFY_NONE / VERIFY_PEER:
grep -rn "VERIFY_NONE\|verify_mode" \
--include="*.rb" --include="*.yml" --include="*.yaml" \
--include="*.toml" --include="*.json" \
.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 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<>&\u2028\u2029 with their \uXXXX Unicode escapes before injecting.Rails ERB sinks: raw(), .html_safe, <%==, sanitize with a permissive allowlist, and simple_format on user input. Grep for these alongside dangerouslySetInnerHTML / 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:
[/\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 backstopSVG uploads as stored XSS. SVG files can carry <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.
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 use j / escape_javascript for field values — j emits \' and \$ (valid JS, invalid JSON), so JSON.parse fails on any field containing an apostrophe or $. Use this idiom instead:
<% schema = { "@context" => "https://schema.org",
"@type" => "Article",
"headline" => @post.title } %>
<script type="application/ld+json">
<%= json_escape(schema.to_json).html_safe %>
</script>to_json handles JSON escaping; json_escape covers <>&\u2028\u2029 against </script> breakout. Verify with round-trip: JSON.parse(json_escape(article.to_json)) equals the source hash.
Template injection: user input in template literals
Grep for: exec(, eval(, innerHTML, dangerouslySetInnerHTML, $where, raw SQL strings
Authentication flows with logic flaws
Missing rate limiting on sensitive endpoints (login, password reset, API)
Business logic constraints only enforced client-side
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.
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.
| Stack | Check |
|---|---|
| Ruby/Rails | grep -E "^ GEM_NAME " Gemfile.lock (4-space indent = top-level gems) |
| Node | grep "\"PACKAGE_NAME\":" package-lock.json or node -e "require('PACKAGE_NAME')" |
| Python | pip show PACKAGE_NAME |
| Go | grep PACKAGE_PATH go.sum |
For Rails: also verify the middleware is in the runtime stack — bundle exec rails middleware | grep -i FOO.
Debug mode enabled in production configs
Overly permissive CORS policies (Access-Control-Allow-Origin: *)
Missing HTTP security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options)
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
Baseline header starter values (paste-and-tune):
| Header | Value |
|---|---|
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload — preload requires verifying every *.example.com serves HTTPS first |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY (defence-in-depth alongside CSP frame-ancestors) |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | camera=(), microphone=(), geolocation=(), browsing-topics=() — note interest-cohort is the legacy FLoC name (Chrome ≤ 100); current Chrome uses browsing-topics |
Content-Security-Policy | start with frame-ancestors 'self'; full CSP needs per-site script audit (inline <style>, JSON-LD, analytics) |
HSTS preload submission is sticky — removal takes months. Verify before submitting.
Where security headers live, by framework:
next.config.{js,ts} headers() block; vercel.json headersconfig/initializers/secure_headers.rb, config/application.rbapp.use(helmet())SECURE_* settings in settings.pyRuntime-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:
grep -E "mount .+::(Engine|Web|UI)|mount .+::App" config/routes.rb
grep -rn "Rails.env.production?" config/initializers/ | \
grep -B1 -A5 "Auth::Basic\|authenticate\|secure_compare"The README examples for PgHero, Sidekiq::Web, Flipper, and Mission Control all wrap auth in if Rails.env.production?, which leaves staging, review apps, and preview deploys serving the admin UI anonymously. Fix shape: switch the guard to unless Rails.env.local? (Rails 7.1+ helper for development || test), and add a fail-closed check that refuses access when the auth env vars are unset.
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.
(Note: This bullet lives under A05 because the failure manifests as a misconfigured invariant guard. The race pattern itself spans A04 / A05 / A08 depending on framing.)
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 A05 — 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). Verify with curl -I against the deployed origin — config inspection alone misses the merge. Either 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 equivalentnpm audit --omit=dev alongside npm audit and triage by reachability:
dependencies) — must fixdependency-audit. A06 here is a one-line sanity check.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.catch \{\}, catch (_) \{\}, catch (_e) \{\}, .catch(() => {}), .catch(() => null), try { ... } catch { return null }error.name, status code) without PIIto: 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 influencehttp://allowed.com/ (when only https: is expected)https://user:pass@allowed.com/@-host trick: https://allowed.com@evil.com/ (hostname resolves to evil.com)https://allowed.com:8443/https://аllowed.com/ (Cyrillic а) or xn--llowed-pdc.comhttps://allowed.com./ (DNS-equivalent, often missed by string compare)https://allowed.com.evil.com/https://[::1]/https://127.0.0.1/http://2130706433/ → 127.0.0.1http://0x7f000001/http://0177.0.0.1/; zero-padded 0010.0.0.1/ → 8.0.0.1 (octal!)http://[::ffff:127.0.0.1]/ → block the whole ::ffff:* rangehttp://localhost./, http://metadata.google.internal./169.254.169.254, GCP metadata.google.internal, ECS 169.254.170.2100.64.0.0 – 100.127.255.255fe80::/10; unique-local IPv6: fc00::/7redirect: "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 inputAfter 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:
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 through the configured policy and confirm each is neutralized:
<img src=x onerror=alert(1)>
<a href="javascript:alert(1)">x</a>
<a href="data:text/html,<svg onload=alert(1)>">x</a>
<img srcset="javascript:alert(1) 1x,https://ok.com/a.png 2x">
<svg><script>alert(1)</script></svg>
<math><mtext></style><img src=x onerror=alert(1)></math>
<a href="//evil.com">protocol-relative</a>
<svg></svg><img/onerror=alert(1) src=x>A single-pass audit reliably catches the categories on the checklist but misses the specific bypasses that aren't in the checklist (localhost. vs localhost, IPv4-mapped IPv6, status-enumeration via ordered 404/400/401, callback-URL control chars, concurrent-execution races on paid endpoints).
After producing the first report AND after applying fixes, run a second 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 with the first pass as the higher-value finding.
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.For every OWASP category, document one of three states:
Include an "Items checked and found clean" section in the executive summary. Audit credibility comes from proving you looked, not just from the findings list.
Findings have three possible dispositions:
An "Accepted Risk" entry without all three fields is a real finding being silently dropped under a different label.
For each finding, document:
#### [SEVERITY] A0X: [Title]
**File:** `path/to/file.ts:42`
**CWE:** CWE-XXX
**Description:** [What the vulnerability is and why it matters]
**Vulnerable Code:**
[code snippet]
**Remediation:**
[Fixed code snippet with explanation]
**Verification:** Concrete adversarial input + the command or code path that proves the fix holds. 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 — static analysis has known blind spots for correctness bugs that happen to also be security fixes.Produce an executive summary:
# Security Audit Report
## Project: [name]
## Stack: [technologies]
## Date: [date]
### Summary
- Total findings: X
- Critical: X | High: X | Medium: X | Low: X | Info: X
### Findings
[Individual findings as above]
### Prioritized Remediation Plan
1. [Critical fixes — immediate]
2. [High fixes — this week]
3. [Medium/Low — scheduled]c9ade03
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.