Web Security (OWASP Top 10:2025) Module 17 – Secure code review and routine
Secure code review: a repeatable method
Review a change in seven passes, trace each input to its sinks, read what a diff leaves out, and write comments with a place, a rating, a fix and a test.
What you will learn
- Review a change in seven passes, from its trust boundary to the tests that cover it
- Trace each input of a handler to its sinks and name the check between them
- Recognise high-signal smells in JavaScript and Python and use them as questions, not as findings
- Write a review comment with a place, a CWE, an ASVS requirement, a rating, a fix and a test
- Decide which checks to automate and which need a person who knows your rules
Before you start
What you’ll learn · Before you start
What you will learn
- Review a change in seven passes, from its trust boundary to the tests that cover it
- Trace each input of a handler to its sinks and name the check between them
- Recognise high-signal smells in JavaScript and Python and use them as questions, not as findings
- Write a review comment with a place, a CWE, an ASVS requirement, a rating, a fix and a test
- Decide which checks to automate and which need a person who knows your rules
Before you start
On this page
A pull request adds a short route and removes two lines, and it is approved in two minutes. The route reads a file name from the query string and sends the file. One of the removed lines held the rule that every route in the file needs a signed-in user, and nobody saw it go, because a diff shows what was typed and not what it undid.
A scanner may report the first flaw. Without your rules, none can report the second, which is a line that is no longer there. This lesson reads the change in seven passes, traces every input to its sinks, searches for smells and reads what they find, and writes each finding so that its author can fix it. Programs that ran list the passes for this change, search a route file for smells and check the findings.
Review a change in seven passes, each with one question
A review that reads a diff from top to bottom finds what the author was thinking about. A review in review pass: One reading of a change with a single question in mind, such as what it reads or who may ask. A review is several passes, because each question finds what a read from top to bottom skips. asks, one at a time, the questions the author was not asking. OWASP’s Secure Code Review Cheat Sheet separates a baseline review of a whole code base from a review of a change, and for a change it asks about the effect on controls, new attack vectors, trust boundaries and regressions. The seven passes below make that a routine, in an order that goes from the whole change to its details, shown here on the change of the opening:
| Pass | The question | What it finds in the opening’s change |
|---|---|---|
| 1 | What does it touch? | A new route, and a boundary between the browser and the server’s disk |
| 2 | What does it read? | One input, the file name in the query string |
| 3 | Where does it end? | The name goes to a path, and the path to a file sender |
| 4 | Who may ask? | The removed sign-in rule, so anybody can ask |
| 5 | What if it fails? | No error path in the new handler |
| 6 | What does it set up? | Nothing: no setting or package changed |
| 7 | Would a test notice? | No test changed, so nothing would |
Step through the passes on that change. The last step shows where the questions go:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- One change, seven questions. The change adds a short route and removes two lines. Reading it from top to bottom finds what is on the screen. The passes ask seven questions in turn, and each one can find what the others cannot.
- What does it touch?. It adds GET /download to the invoice routes and removes a line. The route reads from the query string and sends a file, so the boundary it touches is the one between the browser and the server's disk.
- What does it read?. One input is added, req.query.name on line 12. A change with no new input can still add a sink, but a new input is where a trace begins.
- Where does it end?. Line 12 puts the name into path.join, and line 13 passes the result to res.sendFile. Nothing between the two lines checks that the final path stays inside the invoices folder.
- Who may ask?. The removed line made every route in the file require a signed-in user. Now the list and the download answer anybody. The finding is in a line that is no longer there, which no search can show.
- What if it fails?. The handler has no error path. A missing file ends in whatever message the framework prints, which can name a path on the server. Failing safely is part of the change.
- What does it set up?. This change touches no settings and no packages, and that is an answer to write down. Pass 6 found nothing here, and a lockfile change in the next pull request would be read with the same question.
- Would a test notice?. No test file changed, so nothing tries to break the new route or the removed check. A review asks for a test that fails on this change and passes on its fix.
- The questions go back. The passes end in questions and findings for the author, not in a verdict. The author knows what the diff does not show, such as where else a check is applied, so the review is a conversation.
A program can do the listing part of this. The one below reads two changes in the unified diff format and prints, for each pass, what a person has to look at, with line numbers. It decides nothing, and it ends with the questions it cannot answer:
// A diff reader for the first minutes of a review. It decides nothing. For each of the seven passes it lists what a person
// has to look at in a change, with line numbers, and it ends with the questions it cannot answer itself. The two changes are
// made up, for ShopNest's invoice routes, in the unified diff format that git prints.
const CHANGE_A = `diff --git a/src/routes/invoices.js b/src/routes/invoices.js
--- a/src/routes/invoices.js
+++ b/src/routes/invoices.js
@@ -5,7 +5,10 @@
const { requireUser } = require('../auth');
const router = express.Router();
-router.use(requireUser);
-
router.get('/', async (req, res) => {
res.json(await db.invoicesFor(req.user.id));
});
+
+router.get('/download', (req, res) => {
+ const file = path.join(INVOICE_DIR, req.query.name);
+ res.sendFile(file);
+});
`;
const CHANGE_B = `diff --git a/src/routes/invoices.js b/src/routes/invoices.js
--- a/src/routes/invoices.js
+++ b/src/routes/invoices.js
@@ -6,4 +6,3 @@
const router = express.Router();
-router.use(requireUser);
router.get('/', async (req, res) => {
diff --git a/src/app.js b/src/app.js
--- a/src/app.js
+++ b/src/app.js
@@ -12,3 +12,3 @@
app.use(express.json());
-app.use('/invoices', invoicesRouter);
+app.use('/invoices', requireUser, invoicesRouter);
app.use('/orders', ordersRouter);
`;
const INPUT = /\breq\.(?:query|params|body|headers|cookies)(?:\.\w+)?/g;
const SINKS = [['path.join', /\bpath\.join\(/], ['res.sendFile', /\bres\.sendFile\(/], ['a database query', /\.query\(/], ['exec', /\bexec\(/], ['innerHTML', /\.innerHTML\b/], ['res.redirect', /\bres\.redirect\(/], ['fetch', /\bfetch\(/]];
const CHECK = /requireUser|requireAdmin|authorize|csrf|helmet|rateLimit|escape|sanitize|validate/;
const ROUTE = /router\.(get|post|put|patch|delete)\(\s*'([^']+)'/;
const CONFIG = /(^|\/)(package(-lock)?\.json|\.env[\w.]*|Dockerfile|[\w.-]*config[\w.-]*|\.github\/.*)$/;
const TEST = /(^|\/)(tests?|__tests__)\/|\.(test|spec)\.[jt]s$/;
function read(diff) {
const files = [];
let file = null;
let inHunk = false;
let oldNo = 0;
let newNo = 0;
for (const line of diff.split('\n')) {
if (line.startsWith('diff --git ')) { file = { name: '', added: [], removed: [] }; files.push(file); inHunk = false; continue; }
if (!inHunk && line.startsWith('+++ b/')) { file.name = line.slice(6); continue; }
const hunk = /^@@ -(\d+),?\d* \+(\d+),?\d* @@/.exec(line);
if (hunk) { oldNo = Number(hunk[1]); newNo = Number(hunk[2]); inHunk = true; continue; }
if (!inHunk) continue;
if (line.startsWith('+')) file.added.push({ no: newNo++, text: line.slice(1) });
else if (line.startsWith('-')) file.removed.push({ no: oldNo++, text: line.slice(1) });
else { oldNo++; newNo++; }
}
return files;
}
function sheet(title, diff) {
const files = read(diff);
const added = files.flatMap((f) => f.added.map((l) => ({ ...l, file: f.name })));
const removed = files.flatMap((f) => f.removed.map((l) => ({ ...l, file: f.name })));
const questions = [];
const routes = added.map((l) => ROUTE.exec(l.text)).filter(Boolean).map((m) => `${m[1].toUpperCase()} ${m[2]}`);
console.log(`== ${title}: ${files.length} file(s), +${added.length} -${removed.length}`);
console.log(`pass 1 change ${routes.length ? `adds ${routes.join(', ')}` : 'adds no route'}; ${files.map((f) => f.name).join(', ')}`);
const tainted = new Map();
const inputs = [];
const flows = [];
for (const l of added) {
const here = [...l.text.matchAll(INPUT)].map((m) => m[0]);
here.forEach((i) => inputs.push(`line ${l.no} ${i}`));
const via = [...tainted.keys()].filter((v) => new RegExp(`\\b${v}\\b`).test(l.text));
const sink = SINKS.find(([, re]) => re.test(l.text));
if (sink && (here.length || via.length)) flows.push({ no: l.no, what: here[0] ?? `${via[0]} (from line ${tainted.get(via[0])})`, sink: sink[0] });
const assign = /\b(?:const|let|var)\s+(\w+)\s*=/.exec(l.text);
if (assign && (here.length || via.length)) tainted.set(assign[1], l.no);
}
console.log(`pass 2 inputs ${inputs.join('; ') || 'none added'}`);
console.log(`pass 3 sinks ${flows.map((f) => `line ${f.no}: ${f.what} reaches ${f.sink}`).join('; ') || 'no input reaches a sink in the added lines'}`);
const dropped = removed.filter((l) => CHECK.test(l.text));
const checkNames = (t) => CHECK.exec(t)?.[0];
for (const l of dropped) {
const moved = added.find((a) => a.text.includes(checkNames(l.text) ?? '\0'));
console.log(`pass 4 access removes "${l.text.trim()}" (${l.file} old line ${l.no})${moved ? `; the word is added again at ${moved.file} line ${moved.no}` : ''}`);
questions.push(moved ? `Does ${moved.file} line ${moved.no} cover the routes that ${l.file} used to protect?` : `Is "${l.text.trim()}" applied somewhere else, and where?`);
}
for (const r of added.filter((a) => ROUTE.test(a.text) && !CHECK.test(a.text))) {
console.log(`pass 4 access ${ROUTE.exec(r.text)[2]} (line ${r.no}) names no check of its own`);
}
if (!dropped.length) console.log('pass 4 access no check removed');
for (const f of flows) questions.push(`Line ${f.no}: is ${f.what} checked before it reaches ${f.sink}?`);
const handlers = added.filter((a) => ROUTE.test(a.text)).length;
const withError = added.filter((a) => /catch|next\(|try\b/.test(a.text)).length;
console.log(`pass 5 errors ${handlers && !withError ? `${handlers} new handler(s), no error path in the added lines` : 'nothing to flag'}`);
console.log(`pass 6 config ${files.filter((f) => CONFIG.test(f.name)).map((f) => f.name).join(', ') || 'no configuration or dependency file changed'}`);
const tests = files.filter((f) => TEST.test(f.name)).length;
console.log(`pass 7 tests ${tests ? `${tests} test file(s) changed` : 'no test file changed'}`);
if (routes.length && !tests) questions.push('Where is the test that tries to break each new route?');
console.log(`for a person: ${questions.length} question(s)`);
questions.forEach((q, i) => console.log(` ${i + 1}. ${q}`));
}
sheet('change A, a new download route', CHANGE_A);
sheet('change B, a check moved to the app', CHANGE_B); Output
== change A, a new download route: 1 file(s), +5 -2 pass 1 change adds GET /download; src/routes/invoices.js pass 2 inputs line 12 req.query.name pass 3 sinks line 12: req.query.name reaches path.join; line 13: file (from line 12) reaches res.sendFile pass 4 access removes "router.use(requireUser);" (src/routes/invoices.js old line 7) pass 4 access /download (line 11) names no check of its own pass 5 errors 1 new handler(s), no error path in the added lines pass 6 config no configuration or dependency file changed pass 7 tests no test file changed for a person: 4 question(s) 1. Is "router.use(requireUser);" applied somewhere else, and where? 2. Line 12: is req.query.name checked before it reaches path.join? 3. Line 13: is file (from line 12) checked before it reaches res.sendFile? 4. Where is the test that tries to break each new route? == change B, a check moved to the app: 2 file(s), +1 -2 pass 1 change adds no route; src/routes/invoices.js, src/app.js pass 2 inputs none added pass 3 sinks no input reaches a sink in the added lines pass 4 access removes "router.use(requireUser);" (src/routes/invoices.js old line 7); the word is added again at src/app.js line 13 pass 5 errors nothing to flag pass 6 config no configuration or dependency file changed pass 7 tests no test file changed for a person: 1 question(s) 1. Does src/app.js line 13 cover the routes that src/routes/invoices.js used to protect?
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node diff_sheet.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Change A is the opening’s change: it adds GET /download, reads req.query.name on line 12, and the name reaches path.join
and then res.sendFile on line 13. It removes router.use(requireUser), adds a route that names no check of its own, has
no error path and no test. The sheet leaves 4 questions for a person. A reviewer who started at the top of the diff would
have met the new route first and might never have asked about the two red lines.
Key idea
A review is seven questions asked in turn of one change, and the answers are questions and findings for its author, not a verdict.
Checkpoint
Check: Seven passes, one question each
Follow every input to its sinks, and name the check between them
Passes 2 and 3 are the heart of the method. An input is any place where data from outside enters the code: request parameters, headers, cookies and bodies, uploaded files, environment variables, rows read from a database, answers from other services, and the output of a language model. A sink: A place in code where text is handed to an interpreter: a query, a page, a command, a header, an export, a log line or a template. Each sink needs a safe API, parameters or that interpreter's own encoding. More in “Injection: keep data out of code” is where text becomes an action: a query, a shell command, a file path, a page, a redirect, a fetch, a deserialiser, a template, a log line, a cryptographic call. The Cheat Sheet’s advice is to trace data from its sources through processing to its sinks and to check the controls where it crosses a trust boundary. The review writes that as a source-to-sink trace: Following one input from where it enters the code to each place where it becomes an action, naming the check on the way. A trace ends at a sink or at a check, and the finding names the line where the check is missing. of one input at a time:
| An input | A sink it must not reach unchecked |
|---|---|
| A query parameter, a header, a cookie, a body | A SQL query, a shell command, a file path, a redirect |
| An uploaded file name or its contents | A path on disk, an archive extractor, a parser |
| A row read from the database | A page or a template, since an earlier request may have put the text there |
| An answer from another service | A fetch of a further address, a deserialiser |
| A model’s output | A page, a command, a query |
Follow req.query.name from the opening’s change, hop by hop, and ask at each hop what the code assumes:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Start at the input. Line 12 reads req.query.name. The caller chooses its value, so it can be any text. The reviewer's first question is what the value is allowed to be.
- Follow it. path.join puts the name after the invoices folder. The result looks like a path inside the folder, but join only glues pieces together, and a name with parent-folder steps leads out of the folder.
- Reach the sink. res.sendFile hands the path to the file system. This is the sink, the place where text becomes an action. A trace ends at a sink or at a check.
- What is behind it. The server can send any file its process may read, which is far more than the invoices. That is why the check belongs before this call and not after.
- Name the missing checks. Two places can hold a check, the name itself and the final path. The diff has neither. The reviewer writes down both, since the second still holds when the first has a gap.
- The fix, at the right place. Resolve the name under the invoices folder, compare the result with the folder, and answer 404 when it lies outside. The check sits before the sink and covers the final path, however the name is spelled.
- Write it as a finding. The finding names line 12, CWE-22 and the ASVS requirement 5.3.2, gives the fix, and describes a test that asks for a name outside the folder and expects 404.
The trace names two places for a check, the name itself and the final path, and the diff has neither. The better check is the second. A test of the name can have a gap in what it accepts, while a check of the final path, resolved and compared with the folder, holds however the name is spelled. This is CWE-22, and ASVS 5.0.0 asks for it in requirement 5.3.2: build file paths from trusted data, or validate and sanitise strictly where a user’s file name must be used.
Checkpoint
Check: Tracing an input to its sinks
Deleted lines and code outside the diff are findings too
A diff shows additions in green and deletions in red, and readers skim the red. A removed check, a removed test or a removed
line of configuration is a finding until the author shows that the protection lives somewhere else. The second change in the
program is such a move. It removes the same requireUser line from the invoice routes, and the sheet notices that the word is
added again in src/app.js on line 13, where the router is mounted. It asks one question and not four: does that mount
cover the routes the old line protected? The reviewer has to check, since the program only matched a word.
Two gaps remain whatever the tool. A new route whose protection is registered in another file looks open in the diff and may be safe, and a route that looks protected may be mounted before the middleware. The diff cannot tell either way, so the review asks the author for the threat-model delta: What a change adds to or removes from a feature's threat model: new inputs, sinks and permissions, removed controls, and where each check lives. The author states it in the pull request, since a diff cannot show it.: the new inputs, sinks and permissions, the removed controls, and where each check lives. OWASP’s A06:2025 asks for the same habit at design time, looking in refinement sessions for changes in data flows and access control. Four more things deserve their own look in any diff:
- Configuration changes such as CORS, CSP and cookie flags, which can widen what the application accepts in one line.
- Lockfile changes, which can bring in a new install script or a package from another registry.
- Removed or skipped tests, since a test that stops running leaves its rule unguarded.
- Pull requests that touch many files, in which the one security-relevant change hides among renames.
Remove the word
Press Edit in the program above and delete the added requireUser line of change B. Run it again: the sheet now asks where
the removed check is applied, as it does for change A, because it can no longer find the word.
Checkpoint
Check: Deleted lines and what the diff leaves out
A search finds where to read, and reading decides
High-signal searches are the fastest way to the places that deserve a slow reading. The Cheat Sheet suggests sweeping for
hard-coded secrets, risky calls such as eval, exec and innerHTML, and queries built by concatenation. Each language has
its own short list, and each entry is a question:
| Language | Search for first | The question each asks |
|---|---|---|
| JavaScript | innerHTML, eval(, new Function, child_process calls, Math.random for tokens |
Does outside text reach it? Must the value be unguessable? |
| JavaScript | rejectUnauthorized: false, CORS with origin true or *, jwt.decode without verify |
Why is checking off, and where does this run? |
| JavaScript | res.redirect(req…), file calls with request data, Object.assign with req.body |
Where can it send the user or the file system? Which fields may the client set? |
| Python | subprocess with shell=True, os.system, eval, exec |
Does any part of the command come from outside? |
| Python | pickle.loads, yaml.load, verify=False, DEBUG = True |
Who made the bytes? Why is the safe mode off? |
| Python | render_template_string with concatenation, send_file with request data, f-string SQL |
Is the template or the path built from input? |
| Java, PHP, Go | Runtime.exec, ObjectInputStream; unserialize, include with request data; exec.Command with sh -c, InsecureSkipVerify |
The same questions, in the language’s own spelling |
A smell is a reason to read the lines around it, not a finding. The program runs seven of these rules over a made-up route file, prints each hit with its question, and then records what a reader decided after reading:
// A search for smells finds where to read, and the reading decides. Seven rules run over a made-up route file, and each hit
// prints the question it raises. The reader's verdict on each hit, written down after reading the lines around it, comes last.
const FILE = `const express = require('express');
const { exec } = require('node:child_process');
const https = require('node:https');
const router = express.Router();
router.get('/go', (req, res) => {
res.redirect(req.query.next);
});
router.post('/report', (req, res) => {
exec('convert logo.png -resize 50% out.png', () => res.send('ok'));
});
router.post('/reset', async (req, res) => {
const token = Math.random().toString(36).slice(2);
await saveToken(req.body.email, token);
res.send('sent');
});
router.get('/card', (req, res) => {
box.innerHTML = '<b>' + req.query.name + '</b>';
});
router.patch('/me', (req, res) => {
Object.assign(req.user, req.body);
res.json(req.user);
});
setTimeout(refresh, 1000 + Math.random() * 500);
const agent = new https.Agent({ rejectUnauthorized: false });
`;
const RULES = [
{ id: 'redirect', re: /res\.redirect\(\s*req\./, ask: 'Where can this send the browser, and is the target checked?' },
{ id: 'exec', re: /\bexec\(/, ask: 'Does any part of the command come from outside?' },
{ id: 'random', re: /Math\.random\(/, ask: 'Is the value a token or an id that must not be guessed?' },
{ id: 'inner-html', re: /\.innerHTML\s*=/, ask: 'Does outside text reach the page without encoding?' },
{ id: 'assign-body', re: /Object\.assign\([^,]+,\s*req\.body/, ask: 'Which fields may the client set?' },
{ id: 'tls-off', re: /rejectUnauthorized:\s*false/, ask: 'Why is certificate checking off, and where does this run?' },
{ id: 'jwt-decode', re: /jwt\.decode\(/, ask: 'Is the signature verified anywhere?' },
];
// The reader's verdicts by line: fix (a flaw), fine (it reads safely) or depends (the answer lies outside this file).
const READING = { 7: 'fix', 11: 'fine', 15: 'fix', 21: 'fix', 25: 'fix', 29: 'fine', 30: 'depends' };
const WHY = {
7: 'the target is the query string, unchecked',
11: 'the command is a constant string',
15: 'a reset token made by Math.random can be guessed',
21: 'the name is pasted into markup',
25: 'every field the client sends is copied onto the user',
29: 'a random delay for a retry, not a secret',
30: 'harmless in a test, serious in production: ask where it runs',
};
const lines = FILE.split('\n');
const hits = [];
lines.forEach((text, i) => {
for (const rule of RULES) if (rule.re.test(text)) hits.push({ no: i + 1, rule, text: text.trim() });
});
for (const h of hits) {
console.log(`line ${String(h.no).padStart(2)} ${h.rule.id.padEnd(11)} ${h.rule.ask}`);
console.log(` read: ${READING[h.no].padEnd(7)} ${WHY[h.no]}`);
}
const count = (verdict) => hits.filter((h) => READING[h.no] === verdict).length;
console.log(`${hits.length} hits: ${count('fix')} to fix, ${count('fine')} fine on reading, ${count('depends')} that depends on where the code runs`);
const routes = lines.filter((l) => /^router\.(get|post|patch|put|delete)\(/.test(l));
const guarded = routes.filter((l) => /requireUser|requireAdmin/.test(l));
console.log(`${routes.length} routes, ${guarded.length} name a check: no rule can report a line that is not there`); Output
line 7 redirect Where can this send the browser, and is the target checked?
read: fix the target is the query string, unchecked
line 11 exec Does any part of the command come from outside?
read: fine the command is a constant string
line 15 random Is the value a token or an id that must not be guessed?
read: fix a reset token made by Math.random can be guessed
line 21 inner-html Does outside text reach the page without encoding?
read: fix the name is pasted into markup
line 25 assign-body Which fields may the client set?
read: fix every field the client sends is copied onto the user
line 29 random Is the value a token or an id that must not be guessed?
read: fine a random delay for a retry, not a secret
line 30 tls-off Why is certificate checking off, and where does this run?
read: depends harmless in a test, serious in production: ask where it runs
7 hits: 4 to fix, 2 fine on reading, 1 that depends on where the code runs
5 routes, 0 name a check: no rule can report a line that is not there
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node smell_search.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
There are 7 hits. Reading sends 4 to the fix list: the redirect to a query parameter, the reset token from Math.random, the
name pasted into markup and the whole body copied onto the user. Two are fine: exec with a constant command, and a random
delay that is no secret. One depends on facts outside the file: certificate checking is off, which may be a test setup or
a production setting. The last line is the lesson of the section: the file has 5 routes and none names a check, and no rule
can report a line that is not there. ASVS 5.0.0 has a Level 3 requirement, 15.1.5, for documentation that highlights the
parts of an application using dangerous functionality, and these searches are one way to build that list.
Checkpoint
Check: Smells as questions
A comment that gets fixed has a place, a rating, a cause, a fix and a test
A review comment is read by someone who wants to ship. It gets fixed when it says exactly where the problem is, how bad it is and why, which weakness it is, what to change and how to prove the change. Writing it as a finding: A review result written so that it can be fixed: a place, a weakness named by a CWE and an ASVS requirement, a rating with a reason, a concrete fix and a test that fails until the fix is made. A question is a finding that is only a place and a question. with those fields makes it checkable:
- Place: the file and the line.
- Weakness: a CWE: Common Weakness Enumeration, MITRE's community-developed list of software and hardware weakness types. A CWE ID such as CWE-639 names a kind of mistake that can appear in any product. More in “Reading the OWASP Top 10:2025, CWE and ASVS” identifier and the ASVS requirement, with its version, such as
v5.0.0-5.3.2. - Rating: critical, high, medium or low, with one sentence on why, in plain words about who can do what.
- Fix: a concrete change, in code or as a pattern.
- Test: a check that fails now and passes after the fix.
- Kind: must-fix, should-fix or a question. A question is allowed to be only a place and the question.
One issue goes in one comment, so that each can be closed on its own. The Cheat Sheet’s longer write-up for a formal review adds an impact, reproduction steps, references, a status, an owner and a due date. The program below checks five findings about the opening’s change and builds the report from the ones that are ready:
"""Review comments that get fixed. A finding is a record: where it is, which weakness it is (a CWE and an ASVS requirement),
how bad it is and why, the fix, and the test that will prove the fix. This program checks five findings about the invoice
change of the lesson and builds the report from the ones that are ready. Standard library only."""
import re
FILES = {"src/routes/invoices.js": 14} # the files of the change and their length in lines
SEVERITIES = ["critical", "high", "medium", "low"]
VAGUE = re.compile(r"(?i)^(be careful|sanitize( the)? input|validate input|fix (this|it)|improve security)\b")
def problems(f):
"""What stops a finding from being sent, in words the author of the change can act on."""
out = []
if f.get("kind") not in ("must-fix", "should-fix", "question"):
out.append("kind is must-fix, should-fix or question")
if f.get("file") not in FILES:
out.append("file is not part of the change")
elif not isinstance(f.get("line"), int) or not 1 <= f["line"] <= FILES[f["file"]]:
out.append(f"line {f.get('line')} is outside the file (1 to {FILES[f['file']]})")
if len(f.get("why", "").split()) < 6:
out.append("why needs a sentence of at least six words")
if f.get("kind") == "question":
return out # a question needs a place and the question itself
if not re.fullmatch(r"CWE-\d{1,4}", f.get("cwe", "")):
out.append("cwe looks like CWE-22")
if not re.fullmatch(r"v5\.0\.0-\d{1,2}\.\d{1,2}\.\d{1,2}", f.get("asvs", "")):
out.append("asvs looks like v5.0.0-5.3.2")
if f.get("severity") not in SEVERITIES:
out.append("severity is critical, high, medium or low")
if VAGUE.match(f.get("fix", "")) or len(f.get("fix", "").split()) < 6:
out.append("fix names the change to make, in at least six words")
if len(f.get("test", "").split()) < 6:
out.append("test says what the failing check does, in at least six words")
return out
FINDINGS = [
dict(id="F1", kind="must-fix", file="src/routes/invoices.js", line=12, cwe="CWE-22", asvs="v5.0.0-5.3.2", severity="high",
why="The file name comes from the query string, so a request can name a file outside the invoices folder.",
fix="Resolve the name under INVOICE_DIR and refuse any result that does not start with that folder.",
test="A request for ../secret.txt and for a missing name answer 404, and an existing name answers 200."),
dict(id="F2", kind="must-fix", file="src/routes/invoices.js", line=7, cwe="CWE-862", asvs="v5.0.0-8.2.1", severity="high",
why="The change removes router.use(requireUser), so the list and the download answer without a signed-in user.",
fix="Put requireUser back on the router, or on each route, and refuse a request that has no user.",
test="A request with no session to / and to /download answers 401 and returns no body."),
dict(id="F3", kind="should-fix", file="src/routes/invoices.js", line=12, cwe="path traversal", asvs="", severity="bad",
why="unsafe", fix="sanitize input", test=""),
dict(id="F4", kind="must-fix", file="src/routes/invoices.js", line=99, cwe="CWE-209", asvs="v5.0.0-16.5.1", severity="medium",
why="A failed download sends the server's error text and path back to the browser.",
fix="Answer with one generic message and write the detail to the log with a request id.",
test="A request for a missing invoice returns 404 and a body without any path in it."),
dict(id="F5", kind="question", file="src/routes/invoices.js", line=7,
why="Is requireUser applied to this router in src/app.js? If it is, say so in the pull request."),
]
ready = []
for f in FINDINGS:
found = problems(f)
print(f"{f['id']} {f['kind']:10} {f['file']}:{f['line']:<3} {'ready' if not found else f'{len(found)} to fix'}")
for line in found:
print(f" - {line}")
if not found:
ready.append(f)
print(f"\n{len(ready)} of {len(FINDINGS)} findings are ready. The report, most severe first:")
for f in sorted(ready, key=lambda f: (SEVERITIES.index(f["severity"]) if "severity" in f else 9, f["line"])):
if f["kind"] == "question":
print(f" QUESTION {f['file']}:{f['line']} {f['why']}")
else:
print(f" {f['severity'].upper():8} {f['file']}:{f['line']} {f['cwe']} {f['asvs']} [{f['kind']}]")
print(f" {f['why']}")
print(f" Fix: {f['fix']}")
print(f" Test: {f['test']}") Output
F1 must-fix src/routes/invoices.js:12 ready
F2 must-fix src/routes/invoices.js:7 ready
F3 should-fix src/routes/invoices.js:12 6 to fix
- why needs a sentence of at least six words
- cwe looks like CWE-22
- asvs looks like v5.0.0-5.3.2
- severity is critical, high, medium or low
- fix names the change to make, in at least six words
- test says what the failing check does, in at least six words
F4 must-fix src/routes/invoices.js:99 1 to fix
- line 99 is outside the file (1 to 14)
F5 question src/routes/invoices.js:7 ready
3 of 5 findings are ready. The report, most severe first:
HIGH src/routes/invoices.js:7 CWE-862 v5.0.0-8.2.1 [must-fix]
The change removes router.use(requireUser), so the list and the download answer without a signed-in user.
Fix: Put requireUser back on the router, or on each route, and refuse a request that has no user.
Test: A request with no session to / and to /download answers 401 and returns no body.
HIGH src/routes/invoices.js:12 CWE-22 v5.0.0-5.3.2 [must-fix]
The file name comes from the query string, so a request can name a file outside the invoices folder.
Fix: Resolve the name under INVOICE_DIR and refuse any result that does not start with that folder.
Test: A request for ../secret.txt and for a missing name answer 404, and an existing name answers 200.
QUESTION src/routes/invoices.js:7 Is requireUser applied to this router in src/app.js? If it is, say so in the pull request.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 finding_check.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
F1, the path, and F2, the removed sign-in rule, are ready, and so is the question F5. F3 says “path traversal”, “bad” and “sanitize input”, and it has 6 problems: no CWE identifier, no ASVS requirement, no real rating, no reason, no concrete fix and no test. F4 is well written and sits on line 99 of a file of 14 lines, so it cannot be found. The report puts the two high findings first, in line order.
This looks unsafe
Wrong: “This looks unsafe, please fix.” The author does not know where, why or how, and has no way to tell when the fix is enough. Right: line 12, CWE-22, high because a request can name a file outside the folder, resolve the name under the folder and refuse results outside it, and a test that asks for a name outside the folder and expects 404.
Checkpoint
Check: Comments that get fixed
Automate what repeats, and keep people for what needs your rules
Tools and people find different things. OWASP’s A05:2025 says injection is best detected by combining source code review with automated testing, and A10:2025 lists code review or static analysis among its measures against mishandled errors. The Cheat Sheet says tools point to suspicious places and the core analysis relies on human judgment. The numbers behind that come from the security testing lesson: searching the text of 28 labelled snippets found 13 of 14 real problems with 62 % of its findings right, and reading the syntax tree found 12 of 14 with 86 % right. Answer three questions about a kind of problem and the table shows which checks can find it:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Check | Finds every instance | Noise it adds | Knows your rules |
|---|---|---|---|
| A search for known-bad calls What it reads: the text of the code | Mixed found 13 of 14 real problems in the toolbox run | Poor only 62 % of its findings were right in that run | Poor cannot tell a constant from outside data |
| Static analysis with data flow What it reads: the syntax tree and its flows | Mixed found 12 of 14 in that run, and missed an aliased call | Mixed 86 % of its findings were right in that run | Poor does not know who may do what |
| Secret and dependency scanners What it reads: files and lockfiles | Good every known secret format and listed vulnerable version | Mixed decoys and unused code raise false alarms | Poor does not know whether your code reaches the flaw |
| Tests that try to break a rule What it reads: the running code | Mixed only what your tests try | Good a failing test is always a real result | Good written from your own rules |
| A person tracing data to sinks What it reads: the diff and its callers | Mixed only what the reviewer follows in the time given | Good each finding comes with a reason | Mixed needs the author to say what lies outside the diff |
| A person checking who may do what What it reads: your roles and routes | Mixed needs a table of roles and routes to compare with | Good each finding is a row of the table that fails | Good the table is your rules |
How the answers decide
- “What does the problem look like in the code?” A check that is not there: rules out A search for known-bad calls, Static analysis with data flow and Secret and dependency scanners. A search cannot match a line that does not exist. A missing check is found by a test of your rules and by a person who asks.
- “Does finding it need your business rules or your roles?” Yes: rules out A search for known-bad calls and Secret and dependency scanners and adds a caveat to Static analysis with data flow. Roles and business rules live in your tables and your heads, and not in the text of the code.
- “What does the problem look like in the code?” A call or a string that can be searched for and “Does finding it need your business rules or your roles?” No: rules out A person checking who may do what. No rule of yours is involved, so a search, a data-flow rule or a scanner can find it without a person's table.
- “What does the problem look like in the code?” A call or a string that can be searched for and “Will it come back on every change?” Yes: adds a caveat to A person tracing data to sinks. Let a rule catch what repeats, and spend the person's time on what no rule can see.
- “Will it come back on every change?” No, it is a one-off: adds a caveat to A search for known-bad calls and Secret and dependency scanners. A one-off problem may not be worth a rule. A person reading the change is often the cheaper check.
In short: automate the boring and repeated, such as secrets, known-bad calls, dependency risk and headers, and spend the reviewer’s time on authorisation, business logic, design and the way components interact. Two habits keep a review honest. Time-box each session, and write down what was reviewed and what was not, so that the author does not read “approved” as “checked everywhere”.
Checkpoint
Check: What to automate
Practise: check a review comment
The finding check is small enough to write yourself. Your function returns the problem codes of a comment, and the sample tests include the ends of every range. They run in your browser, in JavaScript or Python:
Exercise · Easy · JavaScript, Python
Check a review comment before you send it
Write findingProblems(finding, lines) (Python: finding_problems), a smaller version of the lesson's finding check. finding is an object (Python: a dict) with kind, line, why, cwe, severity, fix and test; any key may be missing. lines is the number of lines of the file the finding is about. Return the problem codes you find, each once, sorted alphabetically, and an empty list for a finding that is ready to send.
bad-kind:kindis not'must-fix','should-fix'or'question'.bad-line:lineis not a whole number from 1 tolines. Both ends are allowed; the text'12'is not a number.short-why:whyhas fewer than 6 words. Words are separated by spaces.
A 'question' needs only those three. For the other kinds, also check:
bad-cwe:cweis notCWE-followed by one to four digits, in capitals.bad-severity:severityis not'critical','high','medium'or'low'.vague-fix:fixhas fewer than 6 words, or starts withbe careful,fix thisorfix itin any case.short-test:testhas fewer than 6 words.
The starter checks only the CWE.
JavaScript · Starter code · finding.mjs
/** The problem codes of a review comment, sorted, each once (see the prompt). */
export function findingProblems(finding, lines) {
// Starter: it checks the CWE and nothing else.
return /^CWE-\d{1,4}$/.test(finding.cwe ?? '') ? [] : ['bad-cwe'];
} The sample tests · finding.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { findingProblems } from './finding.mjs';
const ready = () => ({
kind: 'must-fix',
line: 12,
why: 'The file name comes from the query string, so a request can name another folder.',
cwe: 'CWE-22',
severity: 'high',
fix: 'Resolve the name under the invoices folder and refuse results outside it.',
test: 'A request for a name outside the folder answers 404 and a good name answers 200.',
});
test('a finding that is ready has no problem', () => {
assert.deepEqual(findingProblems(ready(), 14), []);
});
test('the kind must be one of the three', () => {
assert.deepEqual(findingProblems({ ...ready(), kind: 'nit' }, 14), ['bad-kind']);
assert.deepEqual(findingProblems({ ...ready(), kind: undefined }, 14), ['bad-kind']);
});
test('the line is a whole number from 1 to the length of the file, both ends allowed', () => {
for (const line of [1, 14]) assert.deepEqual(findingProblems({ ...ready(), line }, 14), [], String(line));
for (const line of [0, 15, -3, 3.5, '12', null, undefined]) assert.deepEqual(findingProblems({ ...ready(), line }, 14), ['bad-line'], String(line));
});
test('why needs 6 words, and 5 are not enough', () => {
assert.deepEqual(findingProblems({ ...ready(), why: 'One two three four five' }, 14), ['short-why']);
assert.deepEqual(findingProblems({ ...ready(), why: 'One two three four five six' }, 14), []);
assert.deepEqual(findingProblems({ ...ready(), why: undefined }, 14), ['short-why']);
});
test('the CWE is written in capitals with one to four digits', () => {
for (const cwe of ['cwe-22', 'CWE-', 'CWE-12345', 'CWE 22', 'path traversal', undefined]) assert.deepEqual(findingProblems({ ...ready(), cwe }, 14), ['bad-cwe'], String(cwe));
assert.deepEqual(findingProblems({ ...ready(), cwe: 'CWE-1336' }, 14), []);
});
test('severity, fix and test are checked, and a vague fix is caught by its first words', () => {
assert.deepEqual(findingProblems({ ...ready(), severity: 'urgent' }, 14), ['bad-severity']);
assert.deepEqual(findingProblems({ ...ready(), fix: 'Be careful with the folder name when you build the path' }, 14), ['vague-fix']);
assert.deepEqual(findingProblems({ ...ready(), fix: 'FIX THIS before the release goes out to every customer' }, 14), ['vague-fix']);
assert.deepEqual(findingProblems({ ...ready(), fix: 'Escape it' }, 14), ['vague-fix']);
assert.deepEqual(findingProblems({ ...ready(), test: 'It fails' }, 14), ['short-test']);
});
test('a question needs only a kind, a line and a why, and several problems are sorted', () => {
assert.deepEqual(findingProblems({ kind: 'question', line: 7, why: 'Is requireUser applied to this router in the app file?' }, 14), []);
assert.deepEqual(findingProblems({ kind: 'question', line: 70, why: 'Why?' }, 14), ['bad-line', 'short-why']);
assert.deepEqual(findingProblems({ kind: 'must-fix', line: 0, why: 'unsafe', cwe: 'x', severity: 'x', fix: 'sanitize', test: '' }, 14), ['bad-cwe', 'bad-line', 'bad-severity', 'short-test', 'short-why', 'vague-fix']);
}); Python · Starter code · finding.py
import re
def finding_problems(finding, lines):
"""The problem codes of a review comment, sorted, each once (see the prompt)."""
# Starter: it checks the CWE and nothing else.
return [] if re.fullmatch(r"CWE-\d{1,4}", finding.get("cwe") or "") else ["bad-cwe"] The sample tests · test_finding.py
from finding import finding_problems
def ready():
return {
"kind": "must-fix",
"line": 12,
"why": "The file name comes from the query string, so a request can name another folder.",
"cwe": "CWE-22",
"severity": "high",
"fix": "Resolve the name under the invoices folder and refuse results outside it.",
"test": "A request for a name outside the folder answers 404 and a good name answers 200.",
}
def test_a_finding_that_is_ready_has_no_problem():
assert finding_problems(ready(), 14) == []
def test_the_kind_must_be_one_of_the_three():
assert finding_problems({**ready(), "kind": "nit"}, 14) == ["bad-kind"]
assert finding_problems({**ready(), "kind": None}, 14) == ["bad-kind"]
def test_the_line_is_a_whole_number_from_1_to_the_length_of_the_file_both_ends_allowed():
for line in (1, 14):
assert finding_problems({**ready(), "line": line}, 14) == [], line
for line in (0, 15, -3, 3.5, "12", None):
assert finding_problems({**ready(), "line": line}, 14) == ["bad-line"], line
def test_why_needs_6_words_and_5_are_not_enough():
assert finding_problems({**ready(), "why": "One two three four five"}, 14) == ["short-why"]
assert finding_problems({**ready(), "why": "One two three four five six"}, 14) == []
assert finding_problems({**ready(), "why": None}, 14) == ["short-why"]
def test_the_cwe_is_written_in_capitals_with_one_to_four_digits():
for cwe in ("cwe-22", "CWE-", "CWE-12345", "CWE 22", "path traversal", None):
assert finding_problems({**ready(), "cwe": cwe}, 14) == ["bad-cwe"], cwe
assert finding_problems({**ready(), "cwe": "CWE-1336"}, 14) == []
def test_severity_fix_and_test_are_checked_and_a_vague_fix_is_caught_by_its_first_words():
assert finding_problems({**ready(), "severity": "urgent"}, 14) == ["bad-severity"]
assert finding_problems({**ready(), "fix": "Be careful with the folder name when you build the path"}, 14) == ["vague-fix"]
assert finding_problems({**ready(), "fix": "FIX THIS before the release goes out to every customer"}, 14) == ["vague-fix"]
assert finding_problems({**ready(), "fix": "Escape it"}, 14) == ["vague-fix"]
assert finding_problems({**ready(), "test": "It fails"}, 14) == ["short-test"]
def test_a_question_needs_only_a_kind_a_line_and_a_why_and_several_problems_are_sorted():
assert finding_problems({"kind": "question", "line": 7, "why": "Is requireUser applied to this router in the app file?"}, 14) == []
assert finding_problems({"kind": "question", "line": 70, "why": "Why?"}, 14) == ["bad-line", "short-why"]
bad = {"kind": "must-fix", "line": 0, "why": "unsafe", "cwe": "x", "severity": "x", "fix": "sanitize", "test": ""}
assert finding_problems(bad, 14) == ["bad-cwe", "bad-line", "bad-severity", "short-test", "short-why", "vague-fix"] A hint
Collect the codes in a set so that each appears once, and sort at the end. Check kind first and, when it is a question, return after the three common checks. A missing key behaves like an empty text, so read each text with a default of the empty string before you count its words, and test the line with a whole-number check before you compare it with lines.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then try the practice questions, beginning with the idea that you know least:
Practice that adapts to you
Practise this lesson
18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what does a source-to-sink trace ask about each input?
It follows one input from the place it enters the code to every place where it becomes an action, and asks two things at each hop. What can the value be, and what does the code assume about it? Is there a check between the input and the sink, and does the check cover the form the sink receives, such as the resolved path and not the typed name? A trace ends at a sink or at a check, and the finding names the line of the sink, the weakness, and the test that fails until the check exists. Rows read from a database and answers from other services are inputs too.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
A teammate's pull request adds one route and removes two lines from the same file. You have 20 minutes and no scanner results yet. How do you review it, and what do you ask the author?
-
Mid to senior
A static analysis tool reports 300 findings on the main branch and the team has stopped reading them. How do you decide what a human reviewer reads, and what do you ask of the tool?
-
Mid to staff
A reviewer approved a change that removed one middleware line, with the note "cleanup". Two weeks later an endpoint was open to anyone. What went wrong in the review, and what do you change in the process?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What are the seven passes of the lesson's secure code review of a change?
What does it touch? What does it read? Where does it end, in which sinks? Who may ask? What if it fails? What does it set up, in settings and packages? Would a test notice? Each pass is one question the author was not asking, and the answers are questions and findings, not a verdict. A review that reads a diff from top to bottom finds what the author was thinking about.
-
Why are deleted lines findings too?
A diff shows what was typed, not what it undid, and readers skim the red. A removed check, test or configuration line is a finding until the author shows that the protection lives somewhere else. In the lesson a removed line held the rule that every route needed a signed-in user, and no scanner can report a line that is no longer there.
-
What is a source-to-sink trace?
Following one input from where it enters the code (request data, uploads, environment variables, database rows, answers from other services, model output) to every sink where text becomes an action (a query, shell command, file path, page, redirect, fetch, deserialiser, template, log line, cryptographic call), asking at each hop what the code assumes and whether a check covers the form the sink receives. The better check is on the final path, resolved and compared with the folder (ASVS 5.0.0 5.3.2).
-
What is the threat-model delta a reviewer asks the author for?
The new inputs, sinks and permissions, the removed controls, and where each check lives, because a diff cannot show that a route registered in another file is protected or that a protected-looking route is mounted before its middleware. Also read configuration changes (CORS, CSP, cookie flags), lockfile changes, removed or skipped tests, and pull requests that touch many files.
-
How do high-signal searches fit into a review?
They find where to read, and reading decides. Search for hard-coded secrets, eval, exec and innerHTML, queries built by concatenation, shell=True, pickle.loads, yaml.load, verify=False, rejectUnauthorized: false and Object.assign with req.body. In the lesson 4 of 7 hits were flaws, 2 were fine and 1 depended on facts outside the file, and no rule reported that none of the 5 routes named a check.
-
What does a review finding need in order to get fixed?
A place (file and line); a weakness (a CWE and the ASVS requirement with its version, such as v5.0.0-5.3.2); a rating with one sentence of why; a concrete fix; a test that fails now and passes after the fix; and a kind (must-fix, should-fix or question). One issue goes in one comment. "This looks unsafe, please fix" gives the author no place, reason or way to tell when it is fixed.
-
What should a review automate, and what should it leave to people?
Automate the boring and repeated: secrets, known-bad calls, dependency risk and headers. Spend the reviewer's time on authorisation, business logic, design and the way components interact. Tools point to suspicious places and the core analysis relies on human judgment. Time-box each session and write down what was reviewed and what was not, so that nobody reads approved as checked everywhere.
Key takeaways
- A review in seven passes asks what the change touches, reads, ends in, allows, does when it fails, sets up and tests, and hands the author questions and findings and not a verdict.
- A trace follows each input to its sinks and names the check between them, and the check that holds is the one that covers the final form the sink receives.
- Deleted lines, moved checks and code outside the diff are findings until the author shows where the protection lives, and the threat-model delta asks for it.
- A search for smells finds where to read, and the reading decides: 4 of 7 hits were flaws, and no rule can report a missing line.
- A finding gets fixed when it has a place, a CWE and an ASVS requirement, a rating with a reason, a concrete fix and a test.
- Automate what repeats, give people what needs your rules, and write down what a review did not cover.
Terms in this lesson
- finding
- A review result written so that it can be fixed: a place, a weakness named by a CWE and an ASVS requirement, a rating with a reason, a concrete fix and a test that fails until the fix is made. A question is a finding that is only a place and a question.
- review pass
- One reading of a change with a single question in mind, such as what it reads or who may ask. A review is several passes, because each question finds what a read from top to bottom skips.
- source-to-sink trace
- Following one input from where it enters the code to each place where it becomes an action, naming the check on the way. A trace ends at a sink or at a check, and the finding names the line where the check is missing.
- threat-model delta
- What a change adds to or removes from a feature's threat model: new inputs, sinks and permissions, removed controls, and where each check lives. The author states it in the pull request, since a diff cannot show it.
References
- Secure Code Review Cheat Sheet (OWASP Foundation)
- A05:2025 Injection (OWASP Foundation)
- A06:2025 Insecure Design (OWASP Foundation)
- A10:2025 Mishandling of Exceptional Conditions (OWASP Foundation)
- ASVS 5.0.0, V5 File Handling (requirement 5.3.2) and V8 Authorization (requirement 8.2.1) (OWASP Foundation)
- ASVS 5.0.0, V15 Secure Coding and Architecture (requirement 15.1.5) (OWASP Foundation)
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (MITRE)
- CWE-862: Missing Authorization (MITRE)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress