SKILL← All skills

Security Audit

Audit a codebase against table-stakes security hygiene and verify the product's security/privacy promises actually hold up in code.

Drop the folder into your agent's skills directory (Claude Code reads from ~/.claude/skills/security-audit/).

SKILL.md
CopyDownload
---
name: security-audit
description: Audit a codebase for table-stakes security hygiene (the basics that would be embarrassing to miss) and verify that the security/privacy promises in legal pages, marketing copy, and in-product UI actually hold up in code. Use when the user mentions "security audit", "security review", "security check", "harden this app", "are we doing the security basics", "audit our privacy claims", "do we actually do what our privacy policy says", or asks about specific basics (secrets in repo, password hashing, security headers, cookie flags, CORS, CSRF, rate limiting, dependency CVEs). Skill is harness-agnostic — works in Claude Code, Codex, Cursor, and any other agent host.
---

# Security Audit

You are auditing a codebase against **table-stakes security hygiene** — the obvious basics where, if a journalist looked at the code, they'd write a piece. You are also checking whether the **security and privacy promises the product makes to users** (in legal pages, marketing copy, the UI itself) are actually backed by code.

The bar is "would this be embarrassing on Hacker News?", not "would this pass a SOC 2 audit." A small SaaS doesn't need enterprise hardening, but it does need to not commit `.env` to git, not log passwords, and not ship `dangerouslySetInnerHTML(userInput)`. Match the bar of the surrounding code (same rule as adversarial code review): don't demand exhaustive input validation in a 200-line side project.

**Functionality is sacred.** Never propose a fix that breaks a working flow without flagging the breakage. Prefer additive changes (add a header, set a flag, hash a column on the next write) over restrictive ones that could lock users out (forcing 2FA, tightening CORS, rotating session cookies without a migration plan). Every finding includes a `Breakage risk:` line.

The audit has two halves and produces one report:

1. **Code/config hygiene** — the table-stakes checklist applied to source, configuration, and infra-as-code.
2. **Promise audit** — read what the product says it does about security/privacy and verify the code backs it up.

## Mode and scope

- If `$ARGUMENTS` is a path (file or directory), scan only that path.
- Otherwise, scan the whole repo, excluding: `node_modules`, `.git`, `dist`, `build`, `out`, `.next`, `.nuxt`, `vendor`, `target`, `__pycache__`, `.venv`, lock files, minified assets, and anything in `.gitignore` that looks like build output.
- Always announce the scope before scanning so the user can redirect you.
- Detect the stack early (look at manifest files: `package.json`, `Gemfile`, `requirements.txt`/`pyproject.toml`, `go.mod`, `Cargo.toml`, `composer.json`, `pubspec.yaml`, `mix.exs`). The stack drives which patterns matter and which dependency scanner to run.

---

## 1. Code/config checklist

Each item below is what to look for, why it matters, and a "bad → good" example where useful. A finding's severity comes from this section. Use `grep` / `rg` / `find` / `git log` — they work in every host. If you suspect a finding involves other files, grep for callers before flagging; speculation isn't a finding.

### Critical (the "embarrassing on HN" tier)

| Issue | Detection hint | Bad → Good |
|---|---|---|
| **Secrets committed to git** | Check `.env*` files tracked by git (`git ls-files \| grep -E '^\.env'`); scan working tree and history for high-entropy strings, `sk_live_`, `xoxb-`, `AKIA[0-9A-Z]{16}`, `-----BEGIN.*PRIVATE KEY-----`, `ghp_`, `glpat-`, `AIza[0-9A-Za-z_-]{35}`. Run `git log -p -S` on suspicious patterns. | `STRIPE_SECRET_KEY=sk_live_…` in `.env` → key rotated, `.env` removed from history, `.env.example` used as the tracked template. |
| **Plaintext passwords or weak hashing** | Grep for password column writes; confirm `bcrypt`/`argon2`/`scrypt`/`pbkdf2`. Flag `md5(`, `sha1(`, `===` against a `password` field, `plaintext_password`. | `User.password = md5(input)``User.password_digest = BCrypt::Password.create(input)`. |
| **SQL built by string concatenation/interpolation with user input** | Grep for query strings containing `${`, `+ req.`, `" % `, `f"…{` near `execute`/`query`/`raw`. Distinguish parameterized queries that use template strings for static SQL from genuine interpolation of untrusted input. | `db.query("SELECT * FROM users WHERE id = " + req.params.id)` → parameterized: `db.query("SELECT * FROM users WHERE id = $1", [req.params.id])`. |
| **XSS sinks with user input** | `innerHTML =`, `dangerouslySetInnerHTML`, `v-html`, Mustache `{{{ }}}`, `document.write`, `Element.outerHTML`, Rails `raw(`/`html_safe`, Django `\|safe`, Jinja `\|safe`. | `el.innerHTML = userBio` → text node + sanitizer (DOMPurify, sanitize-html, Rails `sanitize`). |
| **Code-execution sinks on user data** | `eval(`, `new Function(`, `setTimeout("string"`, `vm.runInNewContext`, `child_process.exec(` with interpolation, `pickle.loads`, `Marshal.load`, `unserialize(`, YAML `load` (not `safe_load`). | `eval(req.body.expr)` → a real parser, or remove the feature. |
| **Auth missing on admin/internal routes** | Find admin route files (`admin/`, `internal/`, `/api/admin`) and check each handler has auth middleware applied. Grep for `before_action :authenticate` / `requireAuth` / `@login_required` and find the gaps. | `app.post('/api/admin/users/:id/impersonate', handler)` → same line wrapped in `requireAdmin` middleware. |
| **IDOR — direct object reference without authorization** | Handlers that take a resource ID from params and query without scoping by the current user/tenant. Look for `Model.find(params[:id])` patterns. | `Invoice.find(params[:id])``current_user.invoices.find(params[:id])`. |
| **JWT with `algorithm: 'none'`, hardcoded weak secret, or `verify: false`** | Grep `jwt.verify`, `jwt.decode`, `algorithms`, `alg: 'none'`, `verify: false`, `secret = "..."` in JWT config. | `jwt.verify(token, secret, { algorithms: ['none', 'HS256'] })``algorithms: ['RS256']` (or single strong alg), secret from env. |
| **Open storage ACLs / public buckets** | In IaC (Terraform, CloudFormation, Pulumi), check S3/GCS bucket policies for `PublicRead`, `*` principals, `allUsers`. Signed URLs with no expiry. | `acl = "public-read"` on a user-uploads bucket → `acl = "private"` + presigned URLs with `Expires`. |
| **Debug / verbose errors enabled in production** | `DEBUG=True` in prod env, `app.debug = True`, `config.consider_all_requests_local`, framework error pages reachable. Look at prod env files and CI/deploy config. | `DEBUG=True` shipped → false in prod, generic error page returned, full trace only in logs. |
| **`Access-Control-Allow-Origin: *` with `Allow-Credentials: true`** | Grep CORS config for both together. Browsers block this combo, but APIs sometimes try to force it. | `origin: '*', credentials: true` → explicit allowlist of origins. |
| **File uploads with no extension/MIME/size check, served from auth origin** | Find upload handlers, check for `multer` limits / Rails `content_type` validation / size caps. Check the serving path: are uploads on the same origin as the app, no `Content-Disposition: attachment`? | Unbounded `<input type="file">` → size + MIME allowlist + served from a separate origin or with `Content-Disposition: attachment`. |

### Serious (should fix)

| Issue | Detection hint | Bad → Good |
|---|---|---|
| **Cookies missing `Secure`, `HttpOnly`, `SameSite`** | Grep cookie set sites: `Set-Cookie`, `res.cookie`, `cookies.signed`, `session_cookie`. | `res.cookie('sid', token)``res.cookie('sid', token, { httpOnly: true, secure: true, sameSite: 'lax' })`. |
| **Missing CSRF tokens** | Stacks that don't autoconfigure CSRF (raw Node, FastAPI, Flask without `flask-wtf`). Check that state-changing POSTs require an unguessable token. | Unprotected POST `/transfer` → CSRF middleware + token in form. |
| **Missing security headers** | `Strict-Transport-Security`, `Content-Security-Policy`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `X-Frame-Options` or CSP `frame-ancestors`. Look at server config / middleware (Helmet, `secure_headers`, Django `SecurityMiddleware`). | No `Strict-Transport-Security``max-age=31536000; includeSubDomains; preload`. |
| **HTTPS not enforced** | No HSTS, no HTTP→HTTPS redirect at the edge or in the app. | Plain `http://` redirect handler missing → 308 to `https://`. |
| **Sensitive data in logs** | Logger calls and request-logging middleware passing through password fields, tokens, full card numbers, SSNs, full email bodies. Look at Rails `filter_parameters`, Express morgan tokens, Sentry `beforeSend`. | `logger.info({ password })` → redact at the middleware level. |
| **Rate limiting absent** | No throttle on login, signup, password reset, OTP/email verification, webhook endpoints, public APIs. Look for `rack-attack`, `express-rate-limit`, `slowapi`, custom counters. | Unthrottled `/login` → IP + email throttle, lockout after N failures with backoff. |
| **Login enumeration** | Different responses for "wrong email" vs "wrong password"; user-existence leaks from `/forgot-password`. | "No account with that email" → "If that email matches an account, we sent a reset link." |
| **Password reset tokens** | Tokens with no expiry, no single-use enforcement, predictable (sequential or timestamp-based), sent over insecure channel. | 24h expiry, single-use, generated with a CSPRNG. |
| **SSRF** | Server-side `fetch`/`requests.get`/`Net::HTTP` on URLs from user input without an allowlist. Especially bad if it can reach the metadata service (`169.254.169.254`) or internal hosts. | Free-form `url` param → allowlist of hosts, block private IP ranges. |
| **Path traversal** | `path.join(__dirname, userInput)`, `File.read(params[:file])`, `open(user_path)` without normalization. | `path.join(uploads, filename)``path.resolve` + check it starts with the uploads dir. |
| **Unsigned webhooks** | POST endpoints accepting from external services (Stripe, GitHub, Slack) without verifying the signature header. | Raw body accepted → verify `Stripe-Signature` / `X-Hub-Signature-256` / `X-Slack-Signature`. |
| **Catastrophic-backtracking regexes on user input** | Obvious cases: nested quantifiers on user-controlled strings (`(.*)*`, `(a+)+`). | Replace with a non-backtracking engine or a bounded regex. |
| **Default/sample admin credentials shipped** | Seeds with `[email protected] / password`, README walking through default creds. | Force a setup step that picks credentials before the app accepts traffic. |

Members read the full skill.

Join the Founding Club — every skill, field note, and drop while you're a member.