
A code audit is a pass over the paths that change privilege, not a printout of every linter warning.
ASVS and similar catalogs are useful because they name the checks. Your audit still has to open the login, the object fetch, the admin action, and the upload. A score without those four is a slide.
The usual mistake is buying a tool, exporting a PDF, and never sitting with the handler that loads by id.
This page is how to scope an audit so it maps to ASVS-style controls, and the evidence that belongs in the report.
OWASP ASVS 5.0.0 shipped on 30 May 2025. Take a small Express app, call it invoice-app: invoices, a CSV upload, a billing webhook, a render queue, a dunning cron. The audit starts with the hatches that app exposes, not a badge.
This page is how the team that owns invoice-app reads invoice-app. Pair it with the secure coding checklist, the IDOR guide, and the injection guide.
toomuchtodo, December 2024, on Ask HN: the OWASP Code Review Guide is "a bit stale" and only "inspiration." Keep the permalink. Walk ASVS 5.0.0 instead.
Hatches first, then the chapter
A hatch is an entry. HTTP is one. A queue consumer is one. A cron that reads a bucket is one. Compliance language likes “the SDLC.” The audit starts when someone writes down every hatch invoice-app exposes and names the file that handles it.
Build hatches.json from the repo, not from memory. Routers, EventSourceMapping blocks, node-cron jobs, admin mounts, webhook paths, upload routes, and any gRPC or worker that accepts a payload from another team. If it is not in the file, it will not be walked.
{
"app": "invoice-app",
"asvs": "v5.0.0",
"hatches": [
{
"id": "http-invoice-get",
"kind": "http",
"file": "src/routes/invoice.js",
"symbol": "getInvoice",
"authn": "session",
"ownerId": "invoice-oncall"
},
{
"id": "http-invoice-patch",
"kind": "http",
"file": "src/routes/invoice.js",
"symbol": "patchInvoice",
"authn": "session",
"ownerId": "invoice-oncall"
},
{
"id": "http-admin",
"kind": "http-admin",
"file": "src/routes/admin.js",
"symbol": "adminMount",
"authn": "session+role",
"ownerId": "invoice-oncall"
},
{
"id": "upload-csv",
"kind": "upload",
"file": "src/routes/upload.js",
"symbol": "postCsv",
"authn": "session",
"ownerId": "invoice-oncall"
},
{
"id": "webhook-billing",
"kind": "webhook",
"file": "src/webhooks/billing.js",
"symbol": "billingHook",
"authn": "hmac",
"ownerId": "billing-oncall"
},
{
"id": "sqs-render",
"kind": "queue",
"file": "src/workers/render.js",
"symbol": "renderWorker",
"authn": "iam-role",
"ownerId": "invoice-oncall"
},
{
"id": "cron-dunning",
"kind": "cron",
"file": "src/jobs/dunning.js",
"symbol": "dunningJob",
"authn": "none",
"ownerId": "invoice-oncall"
}
]
}
Seven rows is a small app. A missing ownerId on a hatch is the same bug as a missing owner on a scan ticket. Do not start the ASVS map until the inventory compiles from grep.
# list_hatches.sh the inventory must come from the tree
rg -n "app\\.(get|post|put|patch|delete)\\(|router\\.(get|post|put|patch|delete)\\(" src
rg -n "createEventSourceMapping|node-cron|schedule\\(|webhooks/" src infra
rg -n "multer|upload\\.|createWriteStream|/admin" src
Map each hatch to ASVS 5.0
ASVS 5.0.0 has 17 chapters. You do not walk all 17 on every hatch. You map the ones that hatch can break. I am using the chapter titles from the 5.0.0 tree: V1 Encoding and Sanitization, V2 Validation and Business Logic, V3 Web Frontend Security, V4 API and Web Service, V5 File Handling, V6 Authentication, V7 Session Management, V8 Authorization, V9 Self-contained Tokens, V10 OAuth and OIDC.
| Hatch kind | Chapters you walk | First question |
|---|---|---|
| http | V4, V6, V7, V8 | Does getInvoice check owner? |
| http-admin | V6, V8 | Is admin a role, not a path secret? |
| upload | V5, V1 | Is the name a new id, not the client name? |
| webhook | V4, V6, V2 | Is the HMAC checked before work? |
| queue | V1, V8 | Is the body parsed, then authorized? |
| cron | V8, V2 | Can the job only touch due rows? |
Level 1 is the floor for an external-facing app. Level 2 is the usual target once you have sessions and objects. I am not inventing a 2026 certification that requires Level 3. If your auditor asked for ASVS, write the version v5.0.0 on the report and list the requirement ids you actually checked, in the v5.0.0-8.2.2 form the standard itself recommends.
What Semgrep will not see
For invoice-app in 2026 the floor is Semgrep or CodeQL on the PR, plus a secret scan, plus a lockfile advisory job. Those jobs do not read canInvoice.
Static rules catch string-built SQL, innerHTML, a hardcoded key. They will not catch “this invoiceId belongs to someone else” unless you wrote that rule. Dynamic scans that never swap the session will score GET /invoices/:id green. That gap is why the walk exists.
#.github/workflows/audit-floor.yml
# Semgrep 1.174.0 published 20 August 2026.
# Pin the image your org already uses.
- name: semgrep
run: semgrep ci --config p/owasp-top-ten --baseline-commit "$BASE"
- name: hatches-present
run: python3 tools/check_hatches.py
# tools/check_hatches.py
import json, pathlib, sys
hatches = json.loads(pathlib.Path("hatches.json").read_text())["hatches"]
def has_symbol(hatch):
text = pathlib.Path(hatch["file"]).read_text()
return hatch["symbol"] in text
missing = [h["id"] for h in hatches if not has_symbol(h)]
unowned = [h["id"] for h in hatches if not h.get("ownerId")]
if missing or unowned:
print("missing", missing, "unowned", unowned)
sys.exit(1)
If check_hatches.py fails, the audit does not start. The fallback is a red PR, not a skip. That function is the named gate.
Walk authorization on the object
ASVS V8 is the chapter most hatches fail. For each HTTP hatch, open the handler and ask whether the row load includes the caller. A UUID in the path is not the check. The IDOR page is the longer form. Here you only prove getInvoice and patchInvoice.
// src/routes/invoice.js
async function getInvoice(req, res) {
const actor = requireUser(req);
if (!actor) return res.status(401).end();
const invoice = await findInvoice(req.params.invoiceId);
if (!invoice || !canInvoice(actor, invoice)) {
return res.status(404).end();
}
return res.json({ id: invoice.id, total: invoice.total });
}
function canInvoice(actor, invoice) {
if (actor.role === "admin") return true;
return invoice.ownerId === actor.userId;
}
async function patchInvoice(req, res) {
const actor = requireUser(req);
if (!actor) return res.status(401).end();
const invoice = await findInvoice(req.params.invoiceId);
if (!invoice || !canInvoice(actor, invoice)) {
return res.status(404).end();
}
const body = InvoicePatch.parse(req.body);
await saveInvoice(invoice.id, body);
return res.status(204).end();
}
// src/schemas/invoice-patch.js
import { z } from "zod";
export const InvoicePatch = z.object({
memo: z.string().max(2000),
dueOn: z.string().date(),
}).strict();
The auditor writes two notes: getInvoice calls canInvoice, and InvoicePatch.strict() rejects ownerId and role. If either note is missing, that is a finding, not a style comment. Admin hatches use the same helper. A separate if (req.path.startsWith("/admin")) with no role is a hatch with no lock.
Queue and cron hatches still need an object rule. renderWorker must not render an invoice id that arrived on a queue another account can write. dunningJob must select rows the job is allowed to touch, not UPDATE invoices SET status with a client-supplied id from a leftover table.
Walk interpreters and files
Every hatch that reaches SQL, a shell, HTML, or a path is a V1 or V5 stop. You are looking for concatenation, not for a new class of bug. Parameterized queries on findInvoice. No exec on an upload name. A stored object key the server minted, not req.file.originalname.
// src/routes/upload.js
import { randomUUID } from "node:crypto";
import path from "node:path";
async function postCsv(req, res) {
const actor = requireUser(req);
if (!actor) return res.status(401).end();
if (!req.file || req.file.mimetype !== "text/csv") {
return res.status(400).end();
}
const storedName = `${randomUUID()}.csv`;
const dest = path.join("/var/invoice-app/uploads", storedName);
await saveBounded(dest, req.file.buffer, 1_000_000);
await recordUpload(actor.userId, storedName);
return res.status(201).json({ id: storedName });
}
Webhook hatches fail closed on a bad signature. Capture the raw bytes before JSON.parse, then verify. The billing hatch uses authn: hmac in the inventory. If the file has no verify step, the inventory lied.
// app.js, billing route only
app.use("/webhooks/billing", express.raw({ type: "application/json" }), (req, res, next) => {
req.rawBody = req.body;
next();
});
// src/webhooks/billing.js
function verifyBillingHmac(req) {
const raw = req.rawBody;
if (!Buffer.isBuffer(raw)) return false;
const sent = Buffer.from(req.get("x-billing-signature") || "", "hex");
const expect = crypto.createHmac("sha256", process.env.BILLING_SECRET).update(raw).digest();
if (sent.length !== expect.length) return false;
return crypto.timingSafeEqual(sent, expect);
}
function billingHook(req, res) {
if (!verifyBillingHmac(req)) return res.status(401).end();
const body = BillingEvent.parse(JSON.parse(req.rawBody.toString("utf8")));
enqueue(body);
return res.status(204).end();
}
Frontend hatches, if invoice-app renders HTML, pick up V3: cookie flags, CSP, and no raw HTML from a record. That walk lives on the XSS guide. This page only records the hatch and the control name.
HATCH http-invoice-get | upload-csv | webhook-billing | v PARSE InvoiceId | storedName | BillingEvent reject extra keys | v AUTHZ requireUser | hmac | iam-role canInvoice(actor, row) miss -> 401 or 404 | v SINK parameterized SELECT minted filename no shell, no innerHTML
Record a finding the loop can close
An audit note that says “improve access control” will not close. Write the hatch id, the file, the missing helper, and the test that will prove the fix. Then open a ticket the vuln playbook can run: owner, due date, retest as the CI case.
{
"ticketId": "VM-2026-0201",
"hatchId": "http-invoice-get",
"file": "src/routes/invoice.js",
"asvs": "v5.0.0-8.2.2",
"ownerId": "invoice-oncall",
"evidence": "getInvoice loads by invoiceId with no canInvoice",
"retestId": "test/get-invoice-idor.test.js",
"retestResult": "fail"
}
Compliance here means you can show the inventory, the ASVS version, the PRs that added helpers, and the tickets that closed on a named test. SOC 2 and ISO auditors ask for evidence of the control. hatches.json plus the CI job plus the closed ticket is that evidence.
Prove invoice-app yourself
You are reading your tree. You are not attacking a stranger.
- Generate or update
hatches.jsonfrom the grep commands above. Every route and worker must appear. - Run
check_hatches.py. Expect exit 0. A missing symbol is a stale inventory, not a skip. - For
http-invoice-get, confirmcanInvoicesits on the load. Add a test that uses Bob’s session on Alice’sinvoiceIdand expects 404. - For
upload-csv, confirm the disk name is minted. Grep fororiginalnameand treat a hit as a finding. - For
webhook-billing, confirm verify runs first. A 401 fixture with a bad signature belongs in CI.
rg -n "originalname|innerHTML|exec\\(|SELECT \\$\\{|canInvoice" src
node --test test/get-invoice-idor.test.js
python3 tools/check_hatches.py
When the walk is done, the leftover work is tickets, not a second methodology. The checked requirement ids and the inventory are what you show next time someone asks whether invoice-app was reviewed.
Questions we keep getting
Is a Semgrep gate a code audit?
It is the floor. It is not V8. Keep the gate. Walk the hatches that name objects, files, and interpreters. A green SAST job with no canInvoice is the failure this page is for.
Do we still need an outside review?
For a payment or identity change, yes, a second pair of eyes helps. That review still uses your inventory. Do not hand a stranger a zip and last year’s PDF and call it compliance.
Which ASVS level do we claim?
Claim the requirements you checked, with ids. “We are Level 2” with no list is a slide. v5.0.0-8.2.2 on a closed ticket is a claim an auditor can follow.



