Get listed

Code audit: walk the hatches list against ASVS 5.0

A clipboard of hatch marks with one empty coral box.

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 kindChapters you walkFirst question
httpV4, V6, V7, V8Does getInvoice check owner?
http-adminV6, V8Is admin a role, not a path secret?
uploadV5, V1Is the name a new id, not the client name?
webhookV4, V6, V2Is the HMAC checked before work?
queueV1, V8Is the body parsed, then authorized?
cronV8, V2Can 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.

The inventory names the file. The walk names the helper. The sink never sees a raw id.
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.

  1. Generate or update hatches.json from the grep commands above. Every route and worker must appear.
  2. Run check_hatches.py. Expect exit 0. A missing symbol is a stale inventory, not a skip.
  3. For http-invoice-get, confirm canInvoice sits on the load. Add a test that uses Bob’s session on Alice’s invoiceId and expects 404.
  4. For upload-csv, confirm the disk name is minted. Grep for originalname and treat a hit as a finding.
  5. 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.