Skip to content
academia.sh

Lesson 09 / 15

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.

Contents

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.

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close