Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

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:

Interactive · stage by stage Seven passes over one small change Step through the passes. Each one asks a single question of the change in the lesson's opening, and the last step shows where the questions go.
Seven passes over one small change, step 9 of 9. Showing 1 What does it touch?, 2 What does it read?, 3 Where does it end?, 4 Who may ask?, 5 What if it fails?, 6 What does it set up?, 7 Would a test notice?. In focus: 7 Would a test notice?, 1 What does it touch?.questions1 What does it touch?the change and its boundary2 What does it read?every input it adds3 Where does it end?each input to its sinks4 Who may ask?checks added and removed5 What if it fails?errors and closed doors6 What does it set up?settings and packages7 Would a test notice?the abuse casesask the author

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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, read for the seven passes JavaScript · diff/diff_sheet.mjs
// 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

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

  1. Question 1 of 2 Which question opens the seven passes?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: What does the change touch?

    Pass 1 asks what the change touches. The order runs from the whole change to its details and ends with the tests.

  2. Question 2 of 2 A new route arrives and no test file changed. Which pass reports that?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Pass 7, would a test notice?

    The diff sheet's last pass prints "no test file changed", and turns it into the question of where the test is.

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:

Interactive · step by step One input, followed to the file system Step through the trace of req.query.name. The left column is the path of the data, the right column the checks that belong on it.
One input, followed to the file system, step 7 of 7. Showing req.query.name, path.join(DIR, name), res.sendFile(file), Files on the server, A plain file name?, Inside the folder?. In focus: req.query.name, Inside the folder?.req.query.nameline 12path.join(DIR, name)line 12res.sendFile(file)line 13Files on the serverA plain file name?the first checkInside the folder?the check that holds

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. Question 1 of 2 Which line is the sink of the trace that starts at req.query.month?

    Read the code, then choose one answer.

    router.get('/report', (req, res) => {
      const month = req.query.month;
      const file = `reports/${month}.csv`;
      res.sendFile(path.resolve(file));
    });
    Show the answer: Question 1 of 2

    Answer: res.sendFile(path.resolve(file));

    The month goes into a path on line 3 and the path goes to the file sender on line 4. Nothing between them checks that the final path stays inside the reports folder.

  2. Question 2 of 2 A page shows the "about me" text that a user saved last week. Why is the text an input of the review?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It was typed by a user earlier and is read back from the database, so it reaches the page unchecked unless it is encoded now

    An input is any place where data from outside enters the code, and that includes rows that someone else's request wrote.

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

  1. Question 1 of 2 A pull request deletes the line app.use(rateLimit) and the description says cleanup. What does the review do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Treats it as a finding until the author shows where limiting happens now

    A removed check, test or configuration line is a finding until the protection is shown to live somewhere else.

  2. Question 2 of 2 The diff sheet shows a removed check added again in another file. What is left for the reviewer?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Confirm that the new place covers every route the old one protected

    This is the difference between change A and change B in the program: one question to a person instead of four.

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:

Seven rules over one route file, then the reading JavaScript · smells/smell_search.mjs
// 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

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.

Regex Tester Write one of these searches yourself and test it on a few lines of your own code, to see what it matches and what it misses.

Checkpoint

Check: Smells as questions

  1. Question 1 of 2 A search for exec( hits line 2. What does the reading decide?

    Read the code, then choose one answer.

    router.post('/report', (req, res) => {
      exec('convert logo.png -resize 50% out.png', () => res.send('ok'));
    });
    Show the answer: Question 1 of 2

    Answer: Fine on reading, because the command is a constant and nothing from outside reaches it

    In the program's run, this is one of the 2 hits that were fine on reading. The other was a random delay.

  2. Question 2 of 2 Which problem can no search for smells report?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A route that skips the sign-in check

    The program's last line says it: 5 routes and none names a check, and no rule can report a line that is not there.

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:

Five findings, checked, and the report of the ready ones Python · findings/finding_check.py
"""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

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

  1. Question 1 of 2 Which of these make a review comment checkable? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • A rating with one sentence of reason
    • The file and the line
    • A concrete fix and a test that fails until it is made
    • A CWE and an ASVS requirement

    Place, weakness, rating, fix and test are the five fields of the lesson's finding.

  2. Question 2 of 2 Which fix field is ready to send?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Resolve the name under INVOICE_DIR and refuse results outside it

    A fix names the change to make. Fewer than six words, or a vague opening, is what the check marks.

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:

Interactive · answer and compare Which check finds this problem? Answer three questions about a kind of problem. The table rules out the checks that cannot find it, and says why.
What does the problem look like in the code?
Does finding it need your business rules or your roles?
Will it come back on every change?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Six ways to check a change against what each finds, how much noise it adds and whether it knows your rules
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

  1. Question 1 of 2 Which check can find a missing authorisation rule?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A test of your authorisation matrix, or a person who asks who may call each route

    The table of the lesson rules out the search, the data-flow rule and the scanners for a check that is not there.

  2. Question 2 of 2 Besides its findings, what should a review leave in writing?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: What was reviewed and what was not

    Time-box the session and keep notes on coverage, so the next reader knows where the gaps are.

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: kind is not 'must-fix', 'should-fix' or 'question'.
  • bad-line: line is not a whole number from 1 to lines. Both ends are allowed; the text '12' is not a number.
  • short-why: why has fewer than 6 words. Words are separated by spaces.

A 'question' needs only those three. For the other kinds, also check:

  • bad-cwe: cwe is not CWE- followed by one to four digits, in capitals.
  • bad-severity: severity is not 'critical', 'high', 'medium' or 'low'.
  • vague-fix: fix has fewer than 6 words, or starts with be careful, fix this or fix it in any case.
  • short-test: test has 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.

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.

OWASP Risk Rating Calculator Rate a finding's likelihood and impact with OWASP's factors when you cannot decide between high and medium.

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.

  1. Question 1 of 18 In which pass do you list the request parameters, headers and bodies that a change reads?

    Choose one answer.

    Show the answer: Question 1 of 18

    Answer: Pass 2, what does it read?

    The diff sheet's pass 2 printed one input for change A: req.query.name on line 12.

  2. Question 2 of 18 A change adds a route that is not behind the router's sign-in rule. Which pass asks about it?

    Choose one answer.

    Show the answer: Question 2 of 18

    Answer: Pass 4, who may ask?

    The sheet's pass 4 names the removed rule and also says the new route names no check of its own.

  3. Question 3 of 18 A colleague says that passes are slower than reading the diff once. What is the best reply?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: One read finds what the author was thinking about, and each pass asks a question the author was not, so the missing check or test is found

    The opening's change was five lines long. Its second flaw was in two removed lines.

  4. Question 4 of 18 What is a sink?

    Choose one answer.

    Show the answer: Question 4 of 18

    Answer: A place where text becomes an action, such as a query, a path or a page

    A trace starts at an input and ends at a sink or at a check.

  5. Question 5 of 18 Why is it better to check the resolved path than the typed name?

    Choose one answer.

    Show the answer: Question 5 of 18

    Answer: The check covers the form the sink receives, however the name is spelled

    The trace named two places for a check. The second, after resolving, holds for any spelling.

  6. Question 6 of 18 A feature shows a language model's answer in a page. Is the answer an input of the trace?

    Choose one answer.

    Show the answer: Question 6 of 18

    Answer: Yes. Treat it like any outside text and encode it for the page

    The list of inputs in the lesson includes the output of a language model, beside rows read from a database.

  7. Question 7 of 18 Which deleted line is most likely to be a finding?

    Choose one answer.

    Show the answer: Question 7 of 18

    Answer: router.use(requireUser);

    Security-relevant deletions are findings until the author shows where the protection lives now.

  8. Question 8 of 18 What does the threat-model delta of a pull request list?

    Choose one answer.

    Show the answer: Question 8 of 18

    Answer: The new inputs, sinks and permissions, the removed controls, and where each check lives

    The delta is how the author answers the questions the diff leaves open.

  9. Question 9 of 18 A diff changes one line of CORS configuration so that any origin is allowed. How does the review treat it?

    Choose one answer.

    Show the answer: Question 9 of 18

    Answer: As a security-relevant change. Ask why and who needs it, even though it is one line

    Configuration changes, such as CORS, CSP and cookie flags, deserve their own look in any diff.

  10. Question 10 of 18 What is a smell in the lesson's sense?

    Choose one answer.

    Show the answer: Question 10 of 18

    Answer: A call or a pattern that is a reason to read the lines around it

    In the program, 7 smells led to 4 flaws, 2 fine readings and 1 that depends.

  11. Question 11 of 18 In the program's run, how many of the 7 hits did the reader send to the fix list?

    Type a number.

    Show the answer: Question 11 of 18

    Answer: 4 hits

    The redirect, the reset token, the name pasted into markup and the body copied onto the user. Two hits were fine and one depended on where the code runs.

  12. Question 12 of 18 The reading of rejectUnauthorized false was "depends". What do you ask?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: Where does this code run, only in a test setup or also in production?

    A verdict of "depends" is a question for the author, written as one.

  13. Question 13 of 18 Which CWE names the weakness on line 12 of the opening's change?

    Choose one answer.

    Show the answer: Question 13 of 18

    Answer: CWE-22, improper limitation of a pathname to a restricted directory

    F1 is CWE-22 with ASVS requirement 5.3.2, and F2 is CWE-862 with 8.2.1.

  14. Question 14 of 18 When may a review comment be only a place and a question?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: When the reviewer cannot tell from the diff whether a check exists elsewhere

    F5 in the program is a question about src/app.js, and it is ready.

  15. Question 15 of 18 A comment puts three issues in one paragraph. What goes wrong?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: The author cannot close one without the others, and the thread stalls on the hardest

    One issue goes in one comment.

  16. Question 16 of 18 What should be automated first?

    Choose one answer.

    Show the answer: Question 16 of 18

    Answer: Checks for what repeats on every change, such as secrets, known-bad calls, dependency risk and headers

    Automate the boring and repeated, and spend the reviewer's time on what only a person with your rules can see.

  17. Question 17 of 18 Why time-box a review session and keep notes?

    Choose one answer.

    Show the answer: Question 17 of 18

    Answer: So that the session ends with a record of what was and was not reviewed

    A bounded session with notes tells the next reader where the gaps are.

  18. Question 18 of 18 A tool reports 300 findings on the main branch. What is the lesson's view?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: Gate on what is new, keep a baseline for the old, and spend people's time on what no tool can see

    The security testing lesson measured precision and recall for exactly this reason.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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).

  4. 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.

  5. 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.

  6. 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.

  7. 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.