13 min read

White-Box Testing
Intermediate
A06 A09

Triage and Remediation

A review produces findings. A finding produces a fix only if it survives the handoff — and most do not, because the ticket describes a symptom, the severity is copied from a rule label, and nobody wrote the test that would have stopped the same defect from returning through the next call site.

This page is the reviewer's output, not the fix catalogue

The deep fix recipes — parameterised queries, output encoding, deserialisation allowlists, per-class guidance with code for each language — live in Remediation Guidance, organised by vulnerability class. This page is the other half of the handoff: how a reviewer decides what matters, what to ask for, and how to prove a fix works.

From finding to fix

The gap between a review and a remediation is a document. The reviewer knows the path — source, transformations, sink, preconditions — and the developer knows the domain. A ticket that carries only the sink hands the developer the job of rediscovering the path, and the response to that is either a patch at the sink or a question, and both are slower than the write-up would have been.

The workable shape is five fields: where, what reaches it, what the attacker gets, what pattern fixes it, and the test. That is short enough to write in the file and specific enough that the fix does not need a meeting.

Triage and severity

Scanner severities describe the rule, not the finding. The same rule produces a critical against an internet-facing endpoint and an informational against an admin-only, feature-flagged path — and the queue is only useful if those are ordered correctly. Two questions decide it: who can reach it, and what does reaching it give them.

Reachability Impact Severity Response
Unauthenticated, internet Code execution, data read across tenants Critical Fix before the next deploy, or roll back the endpoint
Authenticated, any user Access to another user's data High Fix in the current release; consider a temporary control
Authenticated, privileged Escalation within the same tenant Medium Scheduled, with the class of defect fixed at once
Admin, or behind a feature flag Defence in depth only Low Batch with related work; record why it is low
No route reaches it None today Not a vulnerability Record the condition that would change that

Severity inflation is its own failure

A programme that rates everything high trains everyone to ignore the ratings. The authority that a security team has in a remediation conversation is spent on judgement — the finding that was rated low with a reason and then turned into an incident costs more credibility than ten correct mediums.

Fix patterns that hold

A fix that filters the reported payload fixes the report. A fix that removes the capability fixes the class. The table below is the short version — each row maps a defect class to the cause, the minimal structural fix, and the assertion that proves it. Deep per-class recipes, with code for each framework, are in Remediation Guidance.

Defect Root cause Minimal fix The test that proves it
SQL injection A query string is composed from data Bind parameters; allowlist identifiers The payload that broke out is rejected or stored as a literal
Command injection A shell interprets a string built from data Argument vector, fixed binary, no shell A metacharacter in the argument reaches the program unchanged
Path traversal A path is normalised, not contained Resolve, then prefix-test with a separator; realpath for links A traversal payload returns 403, not a file
Missing object authorisation The route is authenticated, the object is not checked Ownership check in the handler, on the loaded row User B receives 404 or 403 for user A's object
Output encoding Data is concatenated into markup Contextual encoding at the sink, or render as text A script payload appears as text in the response body
Insecure deserialisation A format that can name a type is parsed from data Data-only format, or an allowlist of classes A payload naming a non-allowlisted type throws before construction
Weak randomness A non-cryptographic generator produces a secret CSPRNG, and a constant-time comparison Two resets do not collide, and the value is not derivable from a timestamp
Over-permissive CORS Origin is reflected, credentials are allowed Allowlist of origins, no wildcard with credentials A foreign origin gets no Access-Control-Allow-Origin

The regression test

The test that usually gets written for a security fix asserts the happy path. It passes before the fix, which means it is a smoke test with a security-themed name. A regression test asserts the attacker's request, against the expectation that it now fails — and the only way to know it is a regression test is to revert the fix and watch it fail.

invoices.test.js
// the fix that was applied: check the owner on the loaded row
router.get('/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await Invoice.findByPk(req.params.id);
  if (!invoice) return res.sendStatus(404);
  if (invoice.customerId !== req.user.customerId) return res.sendStatus(404);
  res.json(invoice);
});

// and the test that was written for it - asserting the happy path
test('returns the invoice', async () => {
  const res = await request(app).get('/invoices/1').set(auth(userA));
  expect(res.status).toBe(200);
});
// the fix that was applied: check the owner on the loaded row
router.get('/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await Invoice.findByPk(req.params.id);
  if (!invoice) return res.sendStatus(404);
  if (invoice.customerId !== req.user.customerId) return res.sendStatus(404);
  res.json(invoice);
});

// and the test that was written for it - asserting the happy path
test('returns the invoice', async () => {
  const res = await request(app).get('/invoices/1').set(auth(userA));
  expect(res.status).toBe(200);
});

One test per fix, two assertions

The negative assertion is the security property; the positive assertion is that the fix did not break the feature. Without the second, a fix that returns 404 to everyone passes the security test and ships a broken feature — which is how a fix gets reverted, and how the vulnerability comes back.

Developer handoff

Hand off in order of consequence, not in the order the findings were written. A single high-severity finding with a complete path gets fixed in a day; twelve mediums in one ticket get triaged once and forgotten. If the report has to be batched, batch by class — "these three are the same missing ownership check" is a single change to a reviewer's list and a single change to a developer's.

Walk the first one through together. The pattern is what transfers: once a developer has seen an ownership check written against the loaded row rather than the route, they will find the remaining four themselves, and those four never reach your queue.

Measuring progress

Open-finding count is the metric that improves when you stop looking. The three that do not lie are the age of the oldest open high-severity finding, the share of findings closed with a regression test, and the ratio of findings found by a human to findings found by a scanner — a ratio that should fall as the rule set learns your codebase, and rise when a new class of defect appears.

Metric Why it works How it misleads
Age of oldest open high Cannot be improved by not scanning Falls when severity is downgraded instead of fixed
Share closed with a test Measures durability, not activity Gameable by writing a smoke test and calling it one
Human-to-scanner ratio Shows whether the rule set is learning Moves for reasons that have nothing to do with the codebase
Check Expected Where to look
Every finding names a file, a line and a reachable route A second engineer can reproduce the path from the ticket alone The ticket body
Severity is justified in one sentence Exploitability and blast radius, not the scanner default — an admin-only self-XSS is not high The severity field
The fix removes the capability, or the ticket says why it cannot Binding instead of escaping, no shell instead of sanitised shell strings The remediation suggestion
A regression test exists and fails on the old code Checked by reverting the fix locally, not by assuming The closing commit
The fix was applied at every call site, not just the reported one The same sink pattern searched across the repository before closing The verification comment

Handoff Contents

A ticket that gets fixed contains five things. A ticket missing any of them comes back as a question:

Location and path: File, line, route and the preconditions. Include whether authentication is needed and at what role — it changes the priority conversation.

The payload that demonstrates it: The request, not a description of the request. For a stored defect, the sequence of two requests that produces the effect.

The fix pattern, not the fixed line: Name the pattern ("bind parameters", "ownership check on the loaded row") so the developer can find the other four call sites themselves.

The test that proves it: A failing assertion they can paste. If you cannot write one, the finding is not yet understood well enough to file.

The deadline and the reason: A date tied to exploitability or to a release, not an arbitrary sprint boundary. A due date with no reason is a negotiation.

Closing a Finding Honestly

Four closure reasons are legitimate. The fifth — "won't fix" with no record — is how a real defect returns:

  • Fixed, and proven by a test: The only closure that needs no justification. Attach the commit and the test name.
  • Not a defect, with the control named: The framework binds it, the gateway rejects it, the value is a constant. Name the mechanism, not the file.
  • Accepted risk, with an owner and a date: Legitimate when the cost exceeds the impact. Legitimate only with a named owner and a review date, otherwise it is a silent drop.
  • Unreachable today: The code is real and the route is not. Record the condition that would make it reachable, and fail the build if that route appears.

Closing Checklist

Run this before marking anything fixed. It takes two minutes and catches the most common regression:

  • ☐ The fix addresses the cause, not the reported payload
  • ☐ The same sink pattern was searched across the repository
  • ☐ Every call site found by that search is fixed or has an explicit reason it is safe
  • ☐ The fix does not introduce an allowlist that the next feature will widen without review
  • ☐ A regression test exists and fails when the fix is reverted
  • ☐ The test asserts on the security property, not on the implementation detail
  • ☐ The test is in the suite that runs on every pull request
  • ☐ The ticket links the commit and the test
  • ☐ The scanner suppression, if any, carries a reason and a date
  • ☐ The severity was re-checked against the fix — sometimes the real impact is larger than first recorded

Programme Notes

Fixing individual findings is the visible work. These are the changes that reduce the next batch:

Why the fix gets reverted, and what to do about it

A security fix is a behaviour change pushed by someone outside the feature team, usually without a reproduction case the team recognises. When it breaks a customer flow, it is reverted, and the finding is closed as "regression from the fix". The counter is procedural: the positive assertion in the regression test, a change small enough to review, and a note in the ticket naming the behaviour that intentionally changed. If the fix cannot be that small, the finding needs a design conversation rather than a patch.

Remediation Practice

Exercises where you take a reproduced finding all the way to a merged fix, rather than stopping at detection.

OWASP Juice Shop Custom Lab medium
Pick three challenges and write the ownership-check fix for eachAdd a failing regression test per fixVerify each test fails when the fix is reverted
OWASP WebGoat Custom Lab medium
Java source available for every lessonRewrite lesson code with the fix instead of the lesson answerCompare the minimal fix with the structural one
OWASP WrongSecrets Custom Lab easy
Practise secret rotation and history rewritingUnderstand why deletion is not remediation for a committed keyWrite the detection that would have caught it