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
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
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.
// 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);
});// the test that fails on the old code: the attacker's request
test('does not return another customer invoice', async () => {
const res = await request(app).get('/invoices/1').set(auth(userB));
expect(res.status).toBe(404);
expect(res.body.invoiceNumber).toBeUndefined();
});
// and the one that proves the fix did not break the owner's path
test('returns the invoice to its owner', async () => {
const res = await request(app).get('/invoices/1').set(auth(userA));
expect(res.status).toBe(200);
});
// reverting the ownership check makes the first test fail - that is
// the property that makes it a regression test rather than a smoke test// the test that fails on the old code: the attacker's request
test('does not return another customer invoice', async () => {
const res = await request(app).get('/invoices/1').set(auth(userB));
expect(res.status).toBe(404);
expect(res.body.invoiceNumber).toBeUndefined();
});
// and the one that proves the fix did not break the owner's path
test('returns the invoice to its owner', async () => {
const res = await request(app).get('/invoices/1').set(auth(userA));
expect(res.status).toBe(200);
});
// reverting the ownership check makes the first test fail - that is
// the property that makes it a regression test rather than a smoke testOne test per fix, two assertions
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.
Related Source Code Review Guides
Remediation Guidance
The fix catalogue, organised by vulnerability class — the deep recipes this page hands off to.
Manual Code Review
Where these findings come from, and what to record while you are still in the file.
SAST and Scanning Tools
Triage at scale: baselines, gates, suppressions and the workflow around them.
Threat and Risk Assessment
How severity maps to organisational risk when a finding has to be prioritised against others.