13 min read

White-Box Testing
Intermediate
A01 A05

Manual Code Review

A scanner finds patterns. A reviewer finds the thing the pattern was standing in for — the missing ownership check, the workflow that can be entered at step three, the sanitiser applied to the wrong context. This page is the pass you make with your own eyes, and how to make it repeatable.

What this page assumes

The methodology — taint, sources, sinks, triage — is on SAST Fundamentals. This page does not restate it. It assumes you know what a sink is and covers where to stand, what to read first, and what to write down.

Review plan

A review without a plan becomes a tour of the files that looked interesting. Write the plan before opening the repository, and order it by consequence rather than by directory: what is internet-facing, what touches money or identity, what handles untrusted files. The time-box matters as much as the order — an unbounded review of a large codebase stops at 60% of the authentication module and never reaches the payment path.

A plan that holds up has five lines: scope (which repositories and branches), entry points (what can be called from outside), the highest-consequence flows (login, payment, admin, file handling), the language and framework versions in use, and what is explicitly out of scope. That last line is what stops a reviewer from being asked why they did not audit the vendored library.

Area What to check CWE
Authentication Session issuance, token validation, password reset flow, remember-me CWE-287, CWE-384
Authorisation Object-level checks on every read and write, role checks in the handler CWE-639, CWE-862
Input handling Every parameter, header, cookie and body field, including JSON and multipart CWE-20
Data access Query construction, ORM escape hatches, raw SQL in migrations and reports CWE-89
Crypto Algorithm choice, key storage, IV and nonce handling, random source CWE-327, CWE-330
Business logic Price and quantity trust, multi-step workflow state, concurrency on shared rows CWE-840, CWE-362

Architecture and trust boundaries

Every finding needs an attacker on one side of a boundary and a capability on the other. Draw the boundaries before reading the code: browser to API, API to database, API to internal service, worker to queue, admin console to everything. Then, for each crossing, ask what the receiver validates and who it trusts to have validated already. The defects cluster exactly where two teams each assumed the other had checked.

Who can call this?

Anonymous, authenticated, admin, another service. The answer decides severity before any code is read.

Who validated it?

The gateway, the framework or nobody. "The gateway validates" is a claim to verify, not a control to assume.

Who may see the result?

Authorisation is a data question. The interesting one is per-object, not per-route.

Authentication and authorisation

Broken access control has been the top entry in the OWASP Top 10 for two editions running, and in source it almost always looks like this: a handler that authenticates correctly and then fetches an object by identifier without asking who owns it. The route guard proves the caller is logged in. It says nothing about whether this invoice is theirs.

InvoiceController.java
@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {

    @GetMapping("/{id}")
    public Invoice get(@PathVariable Long id, Principal caller) {
        // authenticated, but whose invoice is it?
        return invoiceRepository.findById(id).orElseThrow();
    }
}
@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {

    @GetMapping("/{id}")
    public Invoice get(@PathVariable Long id, Principal caller) {
        // authenticated, but whose invoice is it?
        return invoiceRepository.findById(id).orElseThrow();
    }
}

The same shape appears in every framework, and the fix is the same: make ownership part of the query where you can, and an explicit check where you cannot. What does not work is a check in the template, a filter on the list endpoint, or a client-side hide — all three are bypassed by calling the API directly.

Input handling

The most common review note in the world is "sanitise your input", and it is usually wrong. Input validation is an allowlist at the point where a value enters the system — type, length, range, format. It cannot make a value safe for SQL, HTML and a shell at the same time, because those three contexts have incompatible escaping rules. Encoding belongs at the sink, chosen for that sink's context.

Search.java
// one sanitiser, three contexts, no idea which one is right
String clean = input.replace("<", "").replace(">", "");

String html  = "<div>" + clean + "</div>";
String sql   = "SELECT * FROM users WHERE name = '" + clean + "'";
String shell = "ping -c 1 " + clean;
// one sanitiser, three contexts, no idea which one is right
String clean = input.replace("<", "").replace(">", "");

String html  = "<div>" + clean + "</div>";
String sql   = "SELECT * FROM users WHERE name = '" + clean + "'";
String shell = "ping -c 1 " + clean;

Read for the inverse too: code that escapes on the way in and then stores the escaped value, which double-encodes on the way out and corrupts data. Validation is a property of the boundary; encoding is a property of the sink.

Crypto and secrets

In review, crypto findings are almost never about the mathematics. They are about four repeats: a key in the source, a hash chosen for passwords (MD5, SHA-1, or unsalted SHA-256), a cipher mode without authentication, and a random source that is not a CSPRNG. Searching for these four is faster by grep than by reading, and the hits are high-signal because production code rarely contains a hash function by accident.

Business logic

Logic flaws have no sink to grep for. They appear where the code trusts something it should recompute: a price sent by the client, a discount applied twice, a workflow step reachable without its predecessors, a balance updated without a lock. Read these flows as a state machine and ask what state the attacker can force.

Pattern What the code does What to ask
Client-supplied value Price, role or quantity arrives in the request body Is it recomputed from the database before use?
Check-then-act Balance read, compared, then updated in a second statement Is the row locked, or the update conditional?
Step skipping Each step is a separate endpoint with its own guard Can step three be called first? Is state stored server-side?
Negative quantity Quantity validated as a number, never as a positive number Does the total go down when the quantity does?
Check Expected Where to look
Every entry point in the plan has been walked at least once Controllers, message consumers, schedulers, CLI commands and file parsers each traced to the code they call The review plan, ticked off
Authorisation is enforced in the handler, not only in a route table Each object read or written has a check that the caller may touch that specific object The handler source, quoted in the finding
Encoding is applied at the sink with the correct context HTML escaping for HTML, parameter binding for SQL, argument arrays for exec — not one sanitiser reused for all three The call site, next to the sink it protects
Findings carry a reproduction path A request or input that reaches the sink, plus the line numbers of source and sink, so a second person can repeat it The ticket body

Evidence Collection

A manual finding has to survive a developer saying "show me". Record these while you are still in the file:

Source and sink with line numbers: Quote both, from the same commit. A path quoted across two branches is not a path.

The request that reaches it: Method, route, parameter, and the authentication state required. If it needs an admin, say so — that changes severity.

What the control was supposed to be: The comment, the framework feature or the route annotation that looks like a check but is not on the path.

Bedrock Questions Before Filing

The four questions that eliminate most manual-review false positives:

  • Can an unprivileged user reach it?: A sink behind an authenticated admin route is a defence-in-depth finding, not a critical one. Grade it for the audience that can reach it.
  • Is there a filter earlier in the chain?: Spring interceptors, Express middleware, Rails before_action and API gateways all sit between the route and the handler. Read the chain, not the handler alone.
  • Does the framework already handle it?: Auto-escaping templates, ORM parameter binding and CSRF tokens are controls. A finding that ignores one is a finding the developer will reject.
  • Is the code on the path at all?: Feature flags, dead branches and test-only wiring are the most common reason a real defect is not a real risk today. Record it as latent, with the switch named.

Manual Review Checklist

Authentication

  • ☐ Session tokens are random, rotated on privilege change, and invalidated on logout.
  • ☐ Password reset tokens are single-use, expiring and bound to the account they were issued for.
  • ☐ MFA state is checked on the server for every request that matters, not only at login.

Authorisation

  • ☐ Every object access checks the object, not just the route.
  • ☐ Role and permission checks live in the handler or the service, not only in the client.
  • ☐ Administrative functions are separated by path and by check, not by a hidden menu item.

Input and output

  • ☐ One validation point per parameter, allowlist-based, on the server.
  • ☐ Output encoding is context-specific: HTML, attribute, URL, JavaScript, SQL, shell.
  • ☐ File uploads check type by content, size by policy and store outside the web root.

Crypto and secrets

  • ☐ No key, password or token in source, committed config or image layers.
  • ☐ AEAD or a correct encrypt-then-MAC construction; no ECB, no static IV.
  • ☐ Randomness comes from a CSPRNG for anything security-relevant.

Business logic

  • ☐ Prices, quantities and totals are recomputed server-side from trusted data.
  • ☐ Multi-step workflows cannot be entered at step three.
  • ☐ Shared rows are locked or versioned where concurrent updates change money or access.

Fixing What You Found

A reviewer who hands back "sanitise your input" has handed back nothing. Each finding needs a destination:

Reading a large codebase without reading all of it

Start from the routes file and the OpenAPI document, not from the front door. Both give you the external surface in one place, and both are usually wrong in ways that are themselves findings — a route that exists in code and not in the spec is a route nobody reviews again. Then follow only the paths that touch identity, money or files. Everything else gets a scanner and a skim.

Why reviewers miss the same bug twice

Because the second reviewer reads the diff, not the path. If the first review filed a finding against a helper, later patches to its callers are invisible to a review scoped to the changed lines. Keep a list of open findings and re-walk each path when anything on it changes — that is the only technique that reliably catches a fix that was applied to one of five call sites.

Manual Review Practice Targets

Applications small enough to read end to end, with known defects you can find by hand before checking the answer.

OWASP Juice Shop Custom Lab medium
Read the Express routes for missing ownership checksFollow a known challenge backwards to its handlerBusiness-logic flows in the basket API
OWASP NodeGoat Custom Lab easy
Eight documented vulnerability classesSmall enough to review completelyContrast with the scanner output
OWASP WrongSecrets Custom Lab easy
Secrets in source, config and historyPer-challenge hints in the repoGit history as a review target

Reporting findings

The review is only as good as what survives it. Two reviewers looking at the same defect produce different tickets, and the difference is whether the ticket carries a path. Source line, sink line, request, preconditions, and the control that was assumed to be there — that is the whole of it. The rest of the lifecycle, from severity to the regression test that closes the finding, is on Triage and Remediation.