---
title: 'The Role of Penetration Testing'
source: 'https://academia.sh/en/courses/non-functional-testing/role-of-penetration-testing'
course: 'Non-Functional Testing'
language: en
updated: '2026-08-23T14:25:17+00:00'
license: 'CC BY-SA 4.0'
---

# The Role of Penetration Testing

Measuring the point where automation ends: the share of entries a threat model can decide automatically, the coverage gap between single-step and multi-step scenarios, and how a chain of individually authorized operations can only be counted as a violation by a hand-written invariant.

All three methods looked at a rule set: static scanning at patterns, dynamic testing at
response signatures, dependency scanning at advisories. What the three share is the
assumption that what is being searched for is **named in advance**. The lending system's
most expensive defect, however, is on no list, because the defect does not live in a
single line of code but in the combination of rules.

**Penetration testing** is the name for this gap: a person reading the system's rules and
combining individually legitimate operations in a sequence the system's owner never
considered. This lesson deals not with technique but with **place** — it separates the
defect classes automation can cover from the ones that require judgment, and counts the
share that falls between them.

## Counting Coverage with a Threat Model

Asking the coverage question requires a list. A **threat model** enumerates assets, entry
points, and the threats directed at them; the list here was extracted for the lending
system, and every entry carries two fields: which source can make the decision, and how
many steps the scenario has.

**NF11 (assumption):** the threat model is complete. A threat absent from the list is
counted as nonexistent; in reality this assumption does not hold, and every coverage ratio
is relative to this list.

```js
// threat.mjs — the lending system's threat model and how coverage is spread across methods
// [threat, decision source, scenario step count]
// decision source: pattern (static), response (dynamic), advisory (dependency), judgment (human)
const THREAT = [
  ["the catalog query built by concatenation", "pattern", 1],
  ["a signing key embedded in the code", "pattern", 1],
  ["a formatter that generates code from a string", "pattern", 1],
  ["a member record returning without authentication", "response", 1],
  ["an internal path disclosed in an error response", "response", 1],
  ["a loan request accepted without authentication", "response", 1],
  ["input echoed back verbatim in the response", "response", 1],
  ["an accepted request turning into a record", "response", 2],
  ["the queue client's reported defect", "advisory", 1],
  ["the pattern compiler's reported defect", "advisory", 1],
  ["a single member derivable from the report's total", "judgment", 1],
  ["a penalty transferred through a partnership", "judgment", 4],
  ["a reader with a lapsed membership keeping their loan queue position", "judgment", 3],
  ["an extension resetting the penalty threshold", "judgment", 2],
];

const AUTOMATIC = ["pattern", "response", "advisory"];
const count = (f) => THREAT.filter(f).length;
const automatic = (t) => AUTOMATIC.includes(t[1]);

console.log(`${THREAT.length} threat entries`);
for (const k of [...AUTOMATIC, "judgment"]) {
  const n = count((t) => t[1] === k);
  console.log(`  ${k.padEnd(10)}${String(n).padStart(3)}  ${((100 * n) / THREAT.length).toFixed(0)}%`);
}
console.log(`automatically covered: ${count(automatic)}/${THREAT.length} = ${((100 * count(automatic)) / THREAT.length).toFixed(0)}%`);

for (const [name, f] of [["single-step", (t) => t[2] === 1], ["multi-step", (t) => t[2] > 1]]) {
  const group = THREAT.filter(f);
  const k = group.filter(automatic).length;
  console.log(`${name}: ${group.length} threats, ${k} automatic = ${((100 * k) / group.length).toFixed(0)}%`);
}

const manual = THREAT.filter((t) => !automatic(t));
console.log(`\n${manual.length} entries require human judgment, ${manual.reduce((s, t) => s + t[2], 0)} steps total:`);
for (const [name, , n] of manual) console.log(`  ${String(n)} steps  ${name}`);
```

```
14 threat entries
  pattern     3  21%
  response    5  36%
  advisory    2  14%
  judgment    4  29%
automatically covered: 10/14 = 71%
single-step: 10 threats, 9 automatic = 90%
multi-step: 4 threats, 1 automatic = 25%

4 entries require human judgment, 10 steps total:
  1 steps  a single member derivable from the report's total
  4 steps  a penalty transferred through a partnership
  3 steps  a reader with a lapsed membership keeping their loan queue position
  2 steps  an extension resetting the penalty threshold
```

The gap between the two ratios is this lesson's main finding. 90% of single-step threats
are settled automatically; only 25% of multi-step threats are. Automation decides **per
request**: a line, a response, a version number. The relationship between steps is never
the input to any rule.

The distinction does not end with the step count, either. The list's one single-step
judgment entry is a single request to the report endpoint; the response is flawlessly
formed, passes the authorization check, and leaks no internal detail. The defect is that
the returned total can be narrowed to a single member with a specific query — making that
call requires knowing what the data **means**.

## Individually Authorized Steps

The most expensive judgment entry was four steps. The lending system has five operations,
and each carries its own auth condition: adding a partnership is open only to members,
transferring a penalty only while a partnership exists, leaving a partnership only to a
partner. Automatic checking tests this condition at every step, and no step is out of
bounds.

```js
// chain.mjs — the violation born from combining individually authorized steps, and the chain-length threshold
const START = { member: true, partner: false, onLoan: true, penalty: 0, partnerPenalty: 0 };

// Each operation carries its own auth condition; none is out of bounds on its own.
const OPERATION = {
  "partner-add": { auth: (d) => d.member && !d.partner, effect: (d) => ({ ...d, partner: true }) },
  "partner-remove": { auth: (d) => d.partner, effect: (d) => ({ ...d, partner: false }) },
  "penalty-transfer": { auth: (d) => d.partner && d.penalty > 0, effect: (d) => ({ ...d, penalty: 0, partnerPenalty: d.partnerPenalty + d.penalty }) },
  "return": { auth: (d) => d.onLoan, effect: (d) => ({ ...d, onLoan: false }) },
  "report-lost": { auth: (d) => d.onLoan, effect: (d) => ({ ...d, onLoan: false, penalty: d.penalty + 30 }) },
};

// Two hand-written invariants. The correct one ties partnerPenalty to the partnership being open.
const INVARIANT = {
  correct: (d) => !(d.partnerPenalty > 0 && !d.partner),
  narrow: (d) => !(d.partnerPenalty > 0),
};

// Generates every authorized operation sequence up to length k.
function sequences(k) {
  const output = [];
  const walk = (d, path) => {
    if (path.length) output.push({ path, end: d });
    if (path.length === k) return;
    for (const [name, op] of Object.entries(OPERATION)) if (op.auth(d)) walk(op.effect(d), [...path, name]);
  };
  walk(START, []);
  return output;
}

console.log(`${Object.keys(OPERATION).length} operations, the auth condition is checked at every step`);
console.log(`${"k".padStart(2)}${"sequences".padStart(11)}${"steps".padStart(8)}${"auth passed".padStart(13)}${"correct violation".padStart(19)}${"narrow violation".padStart(18)}${"narrow false positive".padStart(23)}`);
for (const k of [1, 2, 3, 4, 5]) {
  const d = sequences(k);
  const steps = d.reduce((t, x) => t + x.path.length, 0);
  const correct = d.filter((x) => !INVARIANT.correct(x.end));
  const narrow = d.filter((x) => !INVARIANT.narrow(x.end));
  console.log(`${String(k).padStart(2)}${String(d.length).padStart(11)}${String(steps).padStart(8)}` +
    `${String(steps).padStart(13)}${String(correct.length).padStart(19)}${String(narrow.length).padStart(18)}` +
    `${String(narrow.length - correct.length).padStart(23)}`);
}

const first = sequences(5).filter((x) => !INVARIANT.correct(x.end)).sort((a, b) => a.path.length - b.path.length)[0];
console.log(`\nshortest violation (${first.path.length} steps): ${first.path.join(" -> ")}`);
console.log(`end state: penalty ${first.end.penalty}, penalty on the partner ${first.end.partnerPenalty}, partner ${first.end.partner}`);
```

```
5 operations, the auth condition is checked at every step
 k  sequences   steps  auth passed  correct violation  narrow violation  narrow false positive
 1          3       3            3                  0                 0                      0
 2          8      13           13                  0                 0                      0
 3         17      40           40                  0                 2                      2
 4         28      84           84                  2                 4                      2
 5         45     169          169                  2                10                      8

shortest violation (4 steps): partner-add -> report-lost -> penalty-transfer -> partner-remove
end state: penalty 0, penalty on the partner 30, partner false
```

The fourth column matches the third on every row: **the auth condition passed on every
step taken.** Not one of the hundred sixty-nine steps was out of bounds. The fifth column
rises to two at four steps: a partnership is formed, a book is reported lost, the penalty
is transferred to the partner, the partnership is closed. The result is a member who
carries a penalty but is no longer a partner.

What finds this violation is not a rule but a **hand-written invariant**. Nowhere in the
lending system was the sentence "when a partnership closes, a transferred penalty reverts
too" written down; writing it is the job of a person who knows the domain. This is exactly
where automation ends: a machine can generate chains, it cannot say what the violation is.

The sixth and seventh columns show judgment's own margin of error. The narrow invariant
says "a penalty can never be transferred" and counts legitimate transfers as violations
too: at k=4, two of the four findings are false positives; at k=5, eight of the ten are. A
poorly written invariant is no different from a poorly written scan rule — it is only more
expensive, because a person sifts through its findings.

## The Chain-Length Threshold

**NF12 (assumption):** the longest chain to examine is four steps. The threshold's source
is not a requirement but a budget; the table's second and third columns give what this
budget corresponds to.

**False pass** is a violation that stays below the threshold: at k=3 both of the two real
violations were missed, at k=4 both were caught. **False fail** is the narrow invariant
turning legitimate chains red, and it grows fast as the chain lengthens. Because the two
columns grow in the same direction, raising the threshold costs double: more sequences get
examined, and more false positives get sifted out.

The run-independent measure of the cost is the step count: 40 at k=3, 84 at k=4, 169 at
k=5. Even in a five-operation domain the count grows to nearly double each time; in a real
system the operation count is in the dozens, and raising the chain length by one
multiplies the sequences to examine many times over. This is why penetration testing is
bounded by a budget — coverage is not everything that could be found, it is everything
**up to a chosen length**.

**Who owns the decision:** these findings do not stop the release, because they are not
inside the run cycle. A penetration test finding opens a record; the record's priority
comes from the value of the asset in the threat model, and the fix is usually a rule
change — after which that rule becomes automatable. Judgment entries turn into automatic
entries over time, but the list never empties.

## Summary

- A threat model is the list required for coverage to be countable; ten of fourteen
  entries were settled by automatic methods, four required human judgment.
- The coverage gap sits in the step count: 90% of single-step threats are automatic, 25%
  of multi-step threats are. Automation decides per request; it does not look at the
  relationship between steps.
- In the five-operation lending domain, all hundred sixty-nine steps passed the auth
  condition; the violation became visible only with a hand-written invariant and a
  four-step chain.
- The caught class is a business-logic defect born from combining operations; the
  uncaught class is every rule whose invariant was never written — the machine produces
  chains, it cannot produce the definition of a violation.
- A hand-written invariant has its own false positives too: a narrowly written invariant
  emptied out eight of ten findings at k=5.
- The cost grows with chain length: 40 steps at k=3, 84 at k=4, 169 at k=5. The
  threshold's source is a budget, not a requirement, and coverage is defined by that
  budget.

## Next Step

The four lessons so far assumed a client that misuses the system. The other face of the
same question is a reader who **cannot use** it: does the lending screen work for someone
who cannot see it, cannot use a mouse, or reads with the text enlarged? This question's
criteria and their interface-side counterparts were built in the Frontend Quality course;
the next lesson's question is different, and it is this course's question: how many of
those criteria can be handed to a program, how many must be manually verified, and what is
the automatic check's own false positive and false negative rate.
