Security review of a change: input handling, authz, secrets, injection, dependency risk. Each finding gets a severity, and nothing is patched silently.
What it does
Most security problems in everyday software are not clever attacks. They are an id in a web address that nobody checked belongs to you, a password left in a file, an error message that prints the inside of the system to a stranger, or a search box whose text ends up being run as a database command.
This runs a deliberate pass over a change looking for those. It starts by working out which pieces of information a stranger controls and which data is sensitive, then follows each of those pieces through the code to see where it ends up. A bundled sweep script points at the risky spots first, but only as a starting map: the most commonly missed problem, a missing permission check, looks like perfectly ordinary code and has to be found by reading.
Nothing is changed behind your back. You get a list ordered by how bad each issue is, with the exact file and line, the path the data takes from the outside world to the damage, and the specific fix. If nothing is wrong, it says so and lists what it checked instead of padding the report.
You end up with findings ranked by severity, each traced from untrusted input to impact, with a concrete fix.
When to reach for it
Use it before merging anything that touches sign-in, permissions, user input, file uploads, payments, or calls out to other services, and whenever someone asks whether a change is safe to ship.
It is a review of code as written, not a penetration test and not a substitute for one. It cannot confirm anything that only shows up at runtime, and it will say when a finding needs a live check to be certain.
---
name: security-review-pass
description: Reviews a code change for the vulnerability classes that actually appear in application code - injection, broken authorization, exposed secrets, data leakage, unsafe file handling and risky dependencies - tracing each from untrusted input to the dangerous operation and reporting it with severity, evidence and a fix. Report-only, it never silently patches. Use before merging anything touching auth, user input, data access, uploads or external calls.
---
# Security Review Pass
You review a change for the ways it could be abused, and you report what you find. You do not quietly fix things: a patch applied without discussion hides the risk from the person who owns the code, and the same mistake reappears next sprint.
Two things separate a useful review from security theater. Every finding traces a path from something an attacker controls to something that matters. And every finding you cannot trace is either dropped or clearly marked as suspected.
## Workflow
1. **Get the change and its intent.** The diff, the files, and what the change is supposed to do. A review without the intent cannot tell a deliberate public endpoint from a forgotten one.
2. **Map the trust boundaries.** Which inputs are attacker-controlled, who is authenticated at each entry point, what is public and what is internal, which data is sensitive. Everything after this depends on getting it right.
3. **Sweep for leads.** Run `scripts/risk_sweep.py` over the changed files to get a map of the places worth reading closely. Treat the output as a starting list, not findings.
4. **Trace each lead from source to sink.** Follow the untrusted value through the code to the operation it reaches. If it is validated, escaped, or parameterized on the way, there is no finding. Write the trace down; it becomes the evidence.
5. **Work the classes below** over the changed code, whether or not the sweep flagged them. The sweep finds patterns; the classes find missing checks, which have no pattern to match.
6. **Rate by exploitability and impact**, never by how hard the fix is.
7. **Report worst first**, with a one-line ship or hold at the end.
```text
Review checklist
[ ] Change intent understood, trust boundaries written down
[ ] Every new entry point checked for authentication and ownership
[ ] Every untrusted input traced to where it is used
[ ] Secrets, logs and error messages checked for exposure
[ ] New or bumped dependencies looked at
[ ] Each finding carries a source-to-sink trace, not just a pattern match
[ ] Untraceable suspicions marked "suspected" with how to confirm
[ ] Verdict stated: ship or hold
```
## The classes
**1. Injection.** Untrusted input reaching an interpreter.
- SQL built by string concatenation or interpolation. The fix is parameterized queries, not escaping.
- Shell commands assembled from input, or any subprocess call with a shell.
- HTML written from user data: raw insertion, template escaping turned off, or a client-side assignment of unescaped markup.
- Template engines rendering user-supplied templates rather than user-supplied data.
- Deserialization of attacker-controlled bytes into objects.
- NoSQL query objects built from request bodies, where an object arrives where a string was expected.
**2. Broken authorization.** The most common serious finding and the one no scanner catches.
- Missing ownership checks: can user A act on user B's object by changing an id in the URL?
- Endpoints that assume authentication because the UI only links them for signed-in users.
- Role checks performed in the front end and never on the server.
- Mass assignment: a request body writing fields the user should not control, such as `role` or `account_id`.
- Session and token handling: predictable identifiers, tokens that outlive a logout, missing expiry or signature checks.
**3. Secrets and configuration.**
- Keys, tokens and passwords in source, in a client bundle, in a container image, or in test fixtures.
- Secrets reaching logs or error responses.
- Permissive cross-origin settings, especially a wildcard origin combined with credentials.
- Debug modes, verbose error pages, or admin surfaces reachable in production.
**4. Data exposure.**
- Endpoints returning more than the caller should see, especially a whole record serialized because it was convenient.
- Sensitive values in urls, which land in logs, referrers and browser history.
- Errors that leak schema, paths, internal hostnames or stack traces to a user.
- Missing encryption for data in transit, or certificate verification disabled to make something work.
**5. Unsafe file and network operations.**
- Paths built from user input reaching the filesystem, including archive extraction that trusts entry names.
- Uploads without type, size and storage checks, or stored where they can be executed or served as active content.
- Server-side requests to urls the user supplies, which reach internal services and cloud metadata endpoints.
- Redirects to a user-supplied destination.
**6. Dependencies.** New or updated packages: known-vulnerable versions, unmaintained projects, names close to a popular package, unpinned versions, install scripts.
**7. The everyday extras.** Missing CSRF protection on state-changing forms, no rate limiting on authentication or expensive endpoints, weak or missing password hashing, predictable randomness used for tokens or resets.
## Report format
For each finding:
- **Severity**: Critical, High, Medium or Low, with the impact if exploited.
- **Location**: file and line.
- **Class**: the vulnerability type.
- **Evidence**: the trace from untrusted source to dangerous operation.
- **Fix**: the concrete remediation.
Order Critical to Low, and end with a one-line recommendation. If you found nothing, say so plainly. An honest empty report is more valuable than a padded one, because it is the one people believe next time.
## Severity guide
| Severity | Means | Typical |
| --- | --- | --- |
| Critical | Remote code execution, authentication bypass, or secret exposure with real blast radius | Command injection on a public endpoint, an admin route with no auth, a live production key in the repository |
| High | Injection, broken authorization, privilege escalation, sensitive data reaching the wrong user | An id in the URL loading another tenant's invoice, SQL built by interpolation behind a login |
| Medium | Meaningful weakening that needs conditions to exploit | Missing rate limit on login, CSRF on a real action, verbose errors leaking internals |
| Low | Defense in depth, hardening, risky patterns currently unreachable | A dangerous helper with only trusted callers today, a missing security header |
Rate by what an attacker gains. A one-line fix for an authorization hole is still High.
## Rules
- Report, never silently patch. If asked to fix afterwards, do it as a separate, visible change.
- Show the trace. A claim without a path from input to impact is a guess, and guesses are what make teams stop reading these reports.
- Do not pad. Style opinions, unreachable patterns and speculative issues belong in a code review, not a security report.
- If confirming needs runtime access you do not have, mark it "suspected" and say exactly how to confirm it.
- Never include working exploit payloads. Describe the vector precisely enough to fix and reproduce, and no further.
- Do not weaken a finding because the code is old, the deadline is close, or the author will be annoyed.
- Check what the change removed as well as what it added. Deleted checks do not show up as suspicious new code.
## Example
**Finding, High.** `routes/invoice.js:42`. Class: broken authorization.
**Evidence.**
```js
router.get('/invoice/:id', requireAuth, async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
res.json(invoice);
});
```
`requireAuth` proves someone is signed in and nothing more. `req.params.id` is fully attacker-controlled, and the query filters on it alone, so any authenticated user can read any invoice by changing the number in the URL. Invoices contain customer names, addresses and amounts.
**Fix.** Scope the query to the owner and return 404 rather than 403 on a mismatch, so the endpoint does not confirm which ids exist:
```js
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, userId: req.user.id },
});
if (!invoice) return res.status(404).end();
```
**Why High and not Critical.** It needs an authenticated account, and it reads rather than writes. If the same pattern appeared on the delete route, it would be Critical.
## Edge cases
- **The diff is huge.** Scope by risk: entry points, auth, data access and anything handling files or external calls. Say in the report what you did not review.
- **A finding sits in code the change only touched incidentally.** Report it, marked as pre-existing. Whether it blocks this merge is the team's call, not yours.
- **The framework already handles it.** Confirm the version in use actually has that protection on, and say so. "The ORM parameterizes this" is a valid non-finding worth writing down.
- **The sweep is noisy.** Expected. It matches patterns, not proof. Drop anything you cannot trace rather than reporting a maybe.
- **Secrets found in the history.** Rotation, not deletion. A key that was committed is compromised even after the commit is rewritten, and the report should say that plainly.
- **You cannot tell whether an endpoint is meant to be public.** Ask. Do not assume either way; both assumptions produce a wrong report.
- **Nothing is wrong.** Say so, list what you covered, and stop. Inventing a Low finding to look thorough costs you the next review.
## Files
- `references/examples.md` - three reviews in full: a diff with an authorization hole, one with an injection path through two files, and a clean review written honestly. Read to see how evidence and severity are worded.
- `scripts/risk_sweep.py` - run it first: `python scripts/risk_sweep.py src/` or `python scripts/risk_sweep.py --diff change.patch`. Greps for high-signal patterns and prints where to look, grouped by class. It produces leads, never findings, and it cannot see a missing check, so the class list above still has to be worked by hand.
references/examples.md
# Worked reviews
Three reviews as they were delivered: one authorization hole, one injection path that crossed two files, and one honest clean report.
## Contents
- [Review 1: an export endpoint](#review-1-an-export-endpoint)
- [Review 2: an injection path across two files](#review-2-an-injection-path-across-two-files)
- [Review 3: a clean review](#review-3-a-clean-review)
- [Patterns worth copying](#patterns-worth-copying)
---
## Review 1: an export endpoint
**The change.** "Adds CSV export for a team's activity log."
**Trust boundaries, written before reading the diff:** `teamId` comes from the URL and is fully attacker-controlled. Every caller is authenticated. Activity logs contain member email addresses and IP addresses. Teams belong to separate customers.
**The code:**
```js
router.get('/teams/:teamId/export', requireAuth, async (req, res) => {
const rows = await db.activity.findMany({
where: { teamId: req.params.teamId },
orderBy: { createdAt: 'desc' },
});
res.setHeader('Content-Disposition', `attachment; filename=${req.query.name}.csv`);
res.send(toCsv(rows));
});
```
**The report:**
```text
Change: add CSV export for team activity
Verdict: HOLD. 1 High, 1 Medium, 1 Low.
HIGH - broken authorization (cross-tenant read)
Location: routes/export.js:14
Evidence: requireAuth establishes that a session exists and nothing more.
teamId comes straight from the URL into the where clause with no check
that req.user belongs to that team. Any authenticated user can export
any team's activity log by changing the id, which returns member email
addresses and IP addresses for another customer.
Fix: scope the query to the caller's membership and return 404 on a miss,
so the endpoint does not confirm which team ids exist.
const membership = await db.member.findFirst({
where: { teamId: req.params.teamId, userId: req.user.id },
});
if (!membership) return res.status(404).end();
MEDIUM - header injection through the filename
Location: routes/export.js:18
Evidence: req.query.name is interpolated into the Content-Disposition
header with no filtering. A newline in that value ends the header and
begins another one, letting a caller add response headers.
Fix: allowlist the characters, or drop the parameter and derive the
filename from the team name and date.
LOW - unbounded result set
Location: routes/export.js:14
Evidence: no limit on the query. A large team returns everything into
memory and can be requested repeatedly. Availability rather than
disclosure, so Low, but it is one line to fix.
Fix: stream the response, or cap the range and paginate.
Recommendation: hold until the ownership check is in. The other two can
follow, but the High is a cross-customer data leak.
```
**Why the sweep did not find the High.** `risk_sweep.py` flagged the template literal in the header, which became the Medium. The authorization hole has no suspicious pattern in it at all: the code looks like every other query in the file. That is why the class list has to be worked by hand, and why broken authorization is the most commonly missed serious finding.
---
## Review 2: an injection path across two files
**The change.** "Adds a search filter to the admin reports page."
The sweep flagged one line, in a helper:
```text
== injection (1)
lib/reports.js:63 [sql built by concatenation]
const sql = `SELECT * FROM orders WHERE ${where} ORDER BY created_at DESC`;
```
Read alone, that line is not a finding. `where` could be built entirely from constants. The trace is what settles it.
**The trace, one hop at a time:**
```js
// routes/admin.js:22
const filters = parseFilters(req.query); // 1. attacker-controlled
const report = await buildReport(filters); // 2. passed on unchanged
// lib/reports.js:41
function buildReport(filters) {
const where = filters
.map(f => `${f.field} ${f.op} '${f.value}'`) // 3. values interpolated
.join(' AND ');
return run(where);
}
// lib/reports.js:63
const sql = `SELECT * FROM orders WHERE ${where} ORDER BY created_at DESC`;
run(sql); // 4. executed
```
`parseFilters` validates that `field` is in a known list and `op` is one of four comparison operators. It does not touch `value`. So three of the four parts are safe and the fourth reaches the query as raw text.
**The report:**
```text
HIGH - SQL injection through the filter value
Location: lib/reports.js:44, reached from routes/admin.js:22
Class: injection
Evidence: req.query -> parseFilters (validates field and op, not value)
-> buildReport interpolates value into a WHERE clause -> executed at
lib/reports.js:63. The value is quoted but not escaped, so a quote
character in it ends the literal and the rest is parsed as SQL. This
runs with the application's database credential, which has write access.
Mitigating: the route is behind an admin role check, so it is not
reachable anonymously. Still High: it is full database access for any
compromised or malicious admin account, and role checks have been
wrong before.
Fix: parameterize the values. Keep the existing allowlist for field and
op, since those cannot be parameters, and bind every value:
const clauses = filters.map((f, i) => `${f.field} ${f.op} $${i + 1}`);
const sql = `SELECT * FROM orders WHERE ${clauses.join(' AND ')} ...`;
run(sql, filters.map(f => f.value));
```
**Two judgment calls.** The severity stayed High despite the admin gate, because the impact is the whole database and the gate is one check away from being wrong. And the fix keeps the allowlist rather than replacing it: field names and operators cannot be bound as parameters, so an allowlist is the correct tool there. Recommending "use an ORM" would have been easier to write and much harder to act on.
---
## Review 3: a clean review
**The change.** "Adds an avatar upload to the profile page." Uploads are the highest-risk change type in most applications, so this was reviewed closely.
**The report:**
```text
Change: avatar upload on the profile page
Verdict: SHIP. No findings.
Covered:
- Upload handling: type is checked by decoding the image rather than by
trusting the extension or the declared content type. Size capped at 2MB
before the file is read into memory. Files are re-encoded to PNG on
write, which drops any embedded content.
- Storage: written to object storage under a generated identifier, not
the supplied filename, and served from a separate domain with
Content-Disposition: attachment. No user-supplied path segment reaches
the filesystem.
- Authorization: the handler writes to req.user.id and ignores any user
id in the request body, so one account cannot overwrite another's
avatar.
- Secrets: storage credentials come from the environment; nothing new in
source or in the client bundle.
- Dependencies: one added, sharp, pinned, current, widely maintained.
Not covered:
- Rate limiting on the upload endpoint is handled by the gateway config,
which is outside this diff. Worth confirming it applies to this route.
- No runtime testing. The type-checking behavior was read, not executed.
Nothing to fix.
```
**Why this report is worth writing.** The temptation with a clean change is to reply "looks fine" or to invent a Low finding so the review looks thorough. Both are worse than this. Listing what was covered lets someone else see where the gaps are, the "not covered" section names the one thing that genuinely was not checked, and the honest empty result is what makes the next report credible when it does say HOLD.
---
## Patterns worth copying
1. Write the trust boundaries down before reading the diff. Severity is decided there, not at the end.
2. Trace source to sink, one hop at a time, and put the trace in the report as the evidence.
3. A pattern match is a lead. Read what feeds it before calling it a finding.
4. Work the classes by hand for the things patterns cannot see, especially missing ownership checks.
5. Say what mitigates a finding and why the severity stayed where it did anyway.
6. Give the fix that fits the constraint. Values bind as parameters; identifiers need an allowlist.
7. When there is nothing to report, say so and list what you covered.
scripts/risk_sweep.py
#!/usr/bin/env python3
"""Print the places in a change that are worth reading closely.
This is a pattern sweep, not a scanner. It finds constructs that are often the
dangerous end of a vulnerability and tells you where they are. It cannot tell
whether the input reaching them is attacker-controlled, and it cannot see a
missing authorization check, which is the most common serious finding of all.
Every hit is a lead to trace, not a finding to report.
Usage:
python scripts/risk_sweep.py src/ # walk a directory
python scripts/risk_sweep.py app.py routes/ # several paths
python scripts/risk_sweep.py --diff change.patch # only added lines
python scripts/risk_sweep.py src/ --class secrets # one class only
Exit codes:
0 no leads found
1 leads found (review them; this does not mean the code is vulnerable)
2 nothing could be read
"""
import os
import re
import sys
SKIP_DIRS = {
".git", "node_modules", "vendor", "dist", "build", "out", "target",
"__pycache__", ".venv", "venv", ".next", "coverage", ".idea",
}
SKIP_SUFFIXES = (
".min.js", ".min.css", ".map", ".lock", ".png", ".jpg", ".jpeg", ".gif",
".pdf", ".zip", ".gz", ".woff", ".woff2", ".ttf", ".ico", ".svg",
)
MAX_BYTES = 2_000_000
MAX_LINE = 400
# class -> list of (label, compiled pattern)
PATTERNS = {
"secrets": [
("aws access key id", re.compile(r"AKIA[0-9A-Z]{16}")),
("private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("token-shaped credential", re.compile(r"\b(gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})")),
("assigned credential literal", re.compile(
r"(?i)\b(api[_-]?key|secret|passwd|password|token|private[_-]?key)\b\s*[:=]\s*[\"'][^\"']{8,}[\"']")),
("credential in a url", re.compile(r"(?i)\b[a-z]+://[^/\s:@]+:[^/\s@]+@")),
],
"injection": [
("dynamic code execution", re.compile(r"\b(eval|exec)\s*\(|new\s+Function\s*\(")),
("shell execution", re.compile(r"\b(os\.system|subprocess\.(run|call|Popen|check_output)|child_process\.(exec|execSync|spawn))\b")),
("shell=True", re.compile(r"shell\s*=\s*True")),
("sql built by concatenation", re.compile(
r"(?i)\b(select|insert|update|delete)\b[^\n]{0,120}?(\+\s*[a-zA-Z_$]|\$\{|%\s*[(s]|\.format\()")),
("sql inside an interpolated string", re.compile(
r"(?i)(f[\"'`]|`)[^\"'`]*\b(select|insert|update|delete)\b[^\"'`]*[{$]")),
("unescaped markup insertion", re.compile(r"\b(innerHTML|outerHTML|dangerouslySetInnerHTML|document\.write)\b")),
("unsafe deserialization", re.compile(r"\b(pickle\.loads?|yaml\.load\s*\((?![^)]*Safe)|unserialize)\b")),
],
"authz": [
("auth check disabled", re.compile(r"(?i)(csrf_exempt|AllowAny|permission_classes\s*=\s*\[\s*\]|authenticate\s*=\s*False)")),
("suppressed security warning", re.compile(r"(?i)#\s*nosec|eslint-disable.*security|@ts-ignore.*auth")),
("role decided in the client", re.compile(r"(?i)(isAdmin|is_admin|role)\s*(===?|==)\s*[\"'](admin|owner|superuser)[\"']")),
],
"transport": [
("certificate verification off", re.compile(r"verify\s*=\s*False|rejectUnauthorized\s*:\s*false|InsecureSkipVerify\s*:\s*true")),
("weak hash", re.compile(r"\b(md5|sha1)\s*\(")),
("non-cryptographic randomness", re.compile(r"\b(Math\.random\s*\(\)|random\.(random|randint|choice)\s*\()")),
("plain http endpoint", re.compile(r"[\"']http://(?!localhost|127\.0\.0\.1)[a-z0-9.-]+")),
],
"exposure": [
("wildcard cross-origin", re.compile(r"(?i)access-control-allow-origin[\"']?\s*[:,]\s*[\"']\*|origin\s*:\s*[\"']\*[\"']")),
("debug enabled", re.compile(r"(?i)\bdebug\s*[:=]\s*(True|true|1)\b")),
("stack trace returned", re.compile(r"(?i)(res|response)\.(send|json)\s*\([^)]*(err|error)\.(stack|message)")),
],
"files": [
("path built from a variable", re.compile(r"(os\.path\.join|path\.join)\s*\([^)]*\b(req|request|params|query|body|argv|input)\b")),
("archive extraction", re.compile(r"\b(extractall|extract_all|unzip|tar\.extract)\b")),
("server-side fetch of a variable url", re.compile(
r"\b(requests\.(get|post|put)|urlopen|fetch|axios\.(get|post))\s*\(\s*(?![\"'])[a-zA-Z_$]")),
("redirect to a variable target", re.compile(r"\b(redirect|Location)\b[^\n]{0,40}\b(req|request|params|query|body)\b")),
],
}
def iter_files(paths):
"""Yield readable text files under the given paths."""
for path in paths:
if os.path.isfile(path):
yield path
continue
if not os.path.isdir(path):
print(f"skipping {path}: not a file or directory", file=sys.stderr)
continue
for root, dirs, names in os.walk(path):
dirs[:] = [d for d in sorted(dirs) if d not in SKIP_DIRS]
for name in sorted(names):
if name.endswith(SKIP_SUFFIXES):
continue
full = os.path.join(root, name)
try:
if os.path.getsize(full) > MAX_BYTES:
continue
except OSError:
continue
yield full.replace(os.sep, "/")
def read_lines(path):
"""(line_number, text) pairs, or an empty list if the file is not text."""
try:
with open(path, "r", encoding="utf-8", errors="strict") as handle:
return list(enumerate(handle.read().split("\n"), start=1))
except (OSError, UnicodeDecodeError):
return []
def read_diff(path):
"""(display_path, line_number, text) for added lines only in a unified diff."""
try:
with open(path, "r", encoding="utf-8", errors="replace") as handle:
raw = handle.read().split("\n")
except OSError as exc:
raise ValueError(f"cannot read {path}: {exc}") from exc
out = []
current = "(unknown file)"
line_no = 0
for line in raw:
if line.startswith("+++ "):
current = line[4:].strip()
if current.startswith("b/"):
current = current[2:]
elif line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
line_no = int(match.group(1)) if match else 0
elif line.startswith("+") and not line.startswith("+++"):
out.append((current, line_no, line[1:]))
line_no += 1
elif not line.startswith("-"):
line_no += 1
return out
def scan(entries, wanted):
"""entries: (path, line_no, text). Returns {class: [(path, line, label, text)]}."""
hits = {}
for path, line_no, text in entries:
if not text.strip() or len(text) > MAX_LINE:
continue
for klass, patterns in PATTERNS.items():
if wanted and klass not in wanted:
continue
for label, pattern in patterns:
if pattern.search(text):
hits.setdefault(klass, []).append((path, line_no, label, text.strip()))
break
return hits
def main(argv):
wanted = set()
diff_path = None
paths = []
index = 0
while index < len(argv):
arg = argv[index]
if arg == "--diff":
index += 1
if index >= len(argv):
print("--diff needs a file", file=sys.stderr)
return 2
diff_path = argv[index]
elif arg == "--class":
index += 1
if index >= len(argv):
print("--class needs a name", file=sys.stderr)
return 2
wanted.add(argv[index])
elif arg.startswith("--"):
print(f"unknown flag {arg}", file=sys.stderr)
return 2
else:
paths.append(arg)
index += 1
unknown = wanted - set(PATTERNS)
if unknown:
print(f"unknown class(es): {', '.join(sorted(unknown))}", file=sys.stderr)
print(f"available: {', '.join(sorted(PATTERNS))}", file=sys.stderr)
return 2
entries = []
if diff_path:
try:
entries = read_diff(diff_path)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 2
elif paths:
for path in iter_files(paths):
entries.extend((path, n, t) for n, t in read_lines(path))
else:
print("usage: python scripts/risk_sweep.py <path> [...] | --diff <patch>", file=sys.stderr)
return 2
if not entries:
print("nothing readable to scan", file=sys.stderr)
return 2
hits = scan(entries, wanted)
total = sum(len(v) for v in hits.values())
for klass in sorted(hits):
print(f"\n== {klass} ({len(hits[klass])})")
for path, line_no, label, text in hits[klass]:
snippet = text if len(text) <= 110 else text[:107] + "..."
print(f" {path}:{line_no} [{label}]")
print(f" {snippet}")
print(f"\n{total} lead(s) across {len(hits)} class(es). Trace each one from an "
"untrusted input before reporting it, and remember this sweep cannot see "
"a missing authorization check.")
return 1 if total else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
Install it
Claude Code (this project)
Unzip the download into .claude/skills/. The folder is already named right.
Claude Code (every project)
Unzip into ~/.claude/skills/ to use it everywhere.
claude.ai
Upload the zip under Settings, then Capabilities, then Skills (paid plans).
How to call it
Just describe the task. The description line inside the skill does the matching. Or call it directly:
/security-review-passsecurity review this before I mergecheck this endpoint for vulnerabilitiesis this safe to shipcan a user see someone else's data herereview this upload handler for security problems
Works beyond Claude
Skills use the open Agent Skills format. The exact same folder works in OpenAI Codex, Gemini CLI, Cursor, GitHub Copilot and other tools that read the format.
Using a plain chat app? Paste SKILL.md into ChatGPT or Gemini as instructions. You lose auto-triggering, but the method still works.