---
name: security-audit-light
description: Perform a PR-scoped security audit on a small to medium code change (1-20 files, additive endpoint, schema change, refactor). Produces a structured findings table with confidence scores, no false positives, and a clean verdict. Trigger only when the user explicitly asks for a "security audit", "security review", "vulnerability check", "is this safe to ship", or "OWASP check" of recent changes. NOT for greenfield threat modeling, full-codebase reviews, or infrastructure audits — those need heavier tooling. NOT for proactive scanning while writing code.
---

# Security Audit (Light)

PR-scoped security audit distilled from CSO's methodology. Optimized for additive endpoints, schema changes, small refactors. Single-prong — read the diff, verify each finding end-to-end, output a verdict. No subagent fan-out, no infrastructure passes, no telemetry.

## When to use

- User says "security audit", "security review", "OWASP check", "is this safe", "anything fishy?"
- Scope is the **current branch / latest uncommitted changes** (1-20 files)
- Stack is one of: PHP/Laravel, Python/Django/FastAPI, Node/Express/Nest, Ruby/Rails, Go/Gin, Java/Spring Boot, Rust/Axum

## When NOT to use

- Greenfield (no code yet) → use `systematic-debugging` or another skill
- User asks for **full codebase audit** → this skill is PR-scoped by design
- User wants **proactive scanning** while writing code → defer to `security-best-practices`
- Audit depends on infrastructure (CI/CD, deploy pipeline, secrets manager) → out of scope

## Procedure

### Step 1 — Detect stack + scope (30 seconds)

Read top-level config files to confirm framework, then **declare scope** explicitly:

| Field | Source | Example |
|---|---|---|
| Stack | `composer.json`, `package.json`, `pyproject.toml`, etc. | Laravel 12 / PHP 8.4 |
| Scope | `git diff --stat <baseline>..HEAD` | 5 files, 202 insertions |
| Branch | `git branch --show-current` | feat/GYMED-793 |
| Baseline SHA | `git rev-parse --short HEAD~1` (or merge-base) | `79d68ef` |

**State scope in the output**: "Auditing 5 files / 202 insertions on `feat/X` since `<sha>`."

### Step 2 — Attack surface census (1 minute)

Build a tiny table covering what an attacker can touch. For each row, **name the file:line** or write `none`.

```
ATTACK SURFACE CENSUS
  AuthN entry points:      N (e.g. auth:api middleware — routes/api.php:25)
  Authorization gates:     N (e.g. abort_unless isProfessional — DashboardController.php:89)
  Cross-tenant scoping:    N (e.g. sessionsForProfessionalStudents — Service.php:140)
  User-supplied inputs:    N (e.g. filter[status], pain_level, rpe)
  Output resource fields:  N (e.g. DashboardSessionPreviewResource)
  Storage mutation paths:  N (e.g. markAsReviewed, store, update)
  Pagination cursors:      N (e.g. cursorPaginate(15))
  Sensitive field loads:   N (e.g. user selects [..., email, ...])
```

This forces you to **declare coverage** before claiming "clean". If a row is `none`, you've just decided it's out of scope — make that decision visible.

### Step 3 — Pre-emit verification gate (the "1539 rule")

For every potential finding, BEFORE writing it:

1. **Quote the verbatim code that motivates it** with `file:line`. Not a paraphrase — the exact text.
2. **Trace it end-to-end**: from input (Request / user / external caller) to output (Query / Response / file). Confirm the path exists, not just the pattern.
3. **Confirm the mitigating code exists** by quoting it (e.g. the validator rule, the authorization check, the scope-bypass).

**If you cannot quote the motivating line, drop the finding's confidence to 4-5** (suppressed from main report, go to appendix only). Do not invent confidence to pass the gate.

**Framework-meta rule**: when the symbol is generated by ORM/framework metaclass (Eloquent scope, Django `Meta`, Rails `has_many`, Laravel `withoutGlobalScope`), quote the meta-construct or migration — don't expect the literal name in the class body.

### Step 4 — Confidence calibration (1-10)

| Score | Meaning | Display |
|---|---|---|
| 9-10 | Verified by reading specific code, concrete exploit demonstrated | Main report |
| 7-8 | High confidence, very likely correct | Main report |
| 5-6 | Could be FP, verify with author | Main report with caveat |
| 3-4 | Suspicious but possibly fine | **Suppressed** — appendix only |
| 1-2 | Speculation | Only if severity is P0 |

### Step 5 — Output format

**Finding format**:
```
[Px] (confidence: N/10) file:line — one-sentence description
```

Severity scale (priority = exploitability × blast radius):

| Code | Meaning |
|---|---|
| P0 | Critical, ship-blocker, immediate exploit |
| P1 | High, ship after fix |
| P2 | Medium, plan a fix |
| P3 | Low, opportunistic / hardening |
| P4+ | Cosmetic / advisory |

**Final structure**:

1. **Scope** (1 line): branch, commit baseline, files changed
2. **Stack** (1 line)
3. **Attack surface census** (small table)
4. **Trust boundaries re-verified** (table — "no new findings" rows OK)
5. **Findings** (table: `[Px] (N/10) file:line — description`) — empty table if clean
6. **Verdict**: APPROVED / APPROVED WITH NITS / BLOCKED with one-line reason

### Step 6 — Re-audit if anything changes

If the user says they shipped a fix, re-read the changed file and re-run steps 2-5 scoped to the fix. Do NOT just accept "fixed it" — verify end-to-end the same way you verified the original finding.

## Pitfalls

- **"No findings" without coverage statement**: if your attack surface census shows zero rows, you didn't audit — you just said "looks safe". Always emit the census, even if every row is "none".
- **Pattern-match FPs** (the FP class CSO calls "1539"): "field X doesn't exist" requires quoting the model body; "dict.get() might be None" requires quoting the dict init. If you find yourself writing "this could be a problem if X", stop and go read the X.
- **Reusing scope from a previous audit**: if the user asks for a NEW audit, do not reuse conclusions from a previous one without re-verifying. Each audit is its own evidence chain.
- **Severity inflation**: a missing CSRF token on an internal admin tool is not P0. Calibrate by exploitability × blast radius, not by vibes.
- **Skipping the user-supplied-input trace**: most security bugs in PRs are at the input boundary (no validation, wrong scope, wrong auth). Always trace each query/body parameter from `Request` to `Eloquent` / `QueryBuilder` / `fetch`.
- **Listing what you DIDN'T audit as if it were clean**: "no SQL injection found" is a claim. State the scope ("did not audit migrations", "did not audit infrastructure") separately.

## Verification steps

- [ ] Scope declared in opening line
- [ ] Attack surface census has ≥5 rows, each with file:line or explicit "none"
- [ ] Every finding quotes its motivating code line(s)
- [ ] Every finding has a confidence 1-10 score
- [ ] Findings at confidence ≤4 are in appendix, not main report
- [ ] Final verdict is one of: APPROVED / APPROVED WITH NITS / BLOCKED
- [ ] Findings count + verdict fit on one screen for clean audits, or fit on one page for noisy ones
- [ ] Did-not-audit scope is stated separately from clean findings

## Output template (copy-paste starter)

```markdown
## Security audit — <branch> / <baseline-sha> → <head-sha>

**Stack**: <framework> <version>
**Scope**: <N> files, <N> insertions on `<branch>` since `<sha>`
**Method**: pre-emit verification gate + 1-10 confidence

### Attack surface census
| Boundary | Where | File:line |
|---|---|---|
| AuthN | <gating middleware/group> | <file>:<line> |
| AuthZ | <gate> | <file>:<line> |
| Cross-tenant scoping | <helper> | <file>:<line> |
| User-supplied inputs | <list, e.g. filter[status], page cursor> | <file>:<line> |
| Output resource | <resource class> | <file>:<line> |
| Sensitive field loads | <list, e.g. user selects> | <file>:<line> |

### Findings
| # | Finding | Severity | Confidence | Evidence |
|---|---|---|---|---|
| — | (none) | — | — | — |

### Verdict
**APPROVED** — <one-line summary of why, e.g. "additive endpoint reuses pre-audited scope chain; filter input whitelisted; no PII leakage in response.">
```

When the audit is clean, keep the output short. Length scales with findings, not with repo size.
