owasp-audit
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 men
By briiirussell · 392 installs
npx skills add briiirussell/cybersecurity-skills --skill owasp-audit
Source repository · Upstream listing
OWASP Audit — Source Code Security Review
Perform a systematic security audit of application source code against the OWASP Top 10 (2021).
Scope the Audit
1. Identify the project's language, framework, and architecture
2. Map entry points (routes, API handlers, form processors)
3. Identify data flows (user input → processing → storage → output)
4. Locate authentication and authorization boundaries
Audit Checklist
Work through each category systematically. For each, grep for known vulnerability patterns, then read flagged files for deeper analysis.
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 / loader export
tRPC: every procedure
GraphQL: every resolver
Rails: non resource controller actions
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 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.
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 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 ).
Grep for: direct object references, missing auth middleware, user ID from request params, redirect(. from , redirect(. next , redirect(. returnTo
A02: Cryptographic Failures
Hardcoded 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:
1. Upgrade the plan or pin the CA bundle — restores cert verification
2. Accept the risk explicitly — leave 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 conditions
3. Restrict the network path — private subnet / VPC peering / no public exposure
Never 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:
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/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:
A03: 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 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.
Grep for: application/ld+json , html: JSON.stringify , window. + JSON.stringify
Fix: wrap with an escape helper that replaces < &\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:
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
SVG 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:
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
A04: Insecure Design
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:
Each 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:
1. Claim first with INSERT … ON CONFLICT DO NOTHING (DB UNIQUE constraint is the lock)
2. Call the provider
3. Persist with optimistic guard: UPDATE … SET externalId = ? WHERE externalId IS NULL and check 0 rows
4. On race loss, clean up the orphan via provider.delete(id) best effort; log on cleanup failure
Worker 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):
1. Signature shape prefilter before any DB work — reject signatures that aren't the exact length/charset the provider sen