Skip to content
academia.sh

Lesson 13 / 17

Vulnerability Management Program

Against 884 findings arriving over 52 weeks, a weekly remediation capacity of 84 person-hours: a 13.2-hour gap leaves a 684-hour queue by year's end, four prioritization rules close between 709 and 843 items on the same set, and risk exposure ranges from 6,681 to 18,511 point-weeks. The rule's value depends on capacity: the queue empties at 101 hours/week, and the rules' order reverses at 109 hours/week.

Published Updated

In this lesson

  • Compares the incoming finding stream with remediation capacity and calculates the number of items that fall behind
  • Runs four prioritization rules on the same vulnerability set and compares risk exposure in point-weeks
  • Shows that the rule's value depends on capacity and finds the capacity at which the ranking reverses
Contents

The previous topic built a common language: controls were mapped to requirements, the requirement left without evidence was counted, and what an outside observer would see was measured. All of these measurements share one property — they are true once a year. The certification snapshot is valid the moment it is taken; the next morning a new finding drops, a patch falls behind, and a user slips back into an old habit. This topic’s five lessons measure the days between the two snapshots.

The first lesson starts with the finding itself. A scanning regime produces new items every week, the remediation team’s weekly capacity is fixed, and the two are not equal. The question asked at program scale is not “how severe is this finding”; that question was measured at the evidenced-finding level in the Security Tooling and Penetration Testing course and enters here as a single score column. The question here is: when the incoming stream is larger than capacity, which item waits, and what is the cost of waiting?

The unit of measure is point-weeks: an item’s score multiplied by the number of weeks it stays open. The number of closed items is an output measure; the total point-weeks — in this lesson, risk exposure — is what the program actually reduces, and it is meant to be low.

  • SP1. The 52-week horizon, the six asset classes, and the weekly finding stream belong to a fictional regional measurement network and are a model. No real scan runs, and no real asset is scanned.
  • SP2. The score column is an external input. How a finding is evidenced and how it is scored is not re-derived in this lesson.
  • SP3. Effort is in person-hours and does not change over the course of remediation. In reality the first fix in a class makes subsequent ones cheaper; this learning effect is not modeled.
  • SP4. Capacity is fixed per week and divisible: an item left half-finished continues where it left off the following week. The cost of context switching is ignored.
  • SP5. A closed item is not reopened, and the remediation itself does not produce a new finding.
  • SP6. The randomness generator is written inline in the lesson, and the seed is visible.
// vuln/model.mjs — MODEL of the vulnerability management program: weekly finding stream, fixed
// remediation capacity and queue in a fictional regional measurement network. No real scan
// runs; the score column is an external input.
export const SEED = 20260803, WEEKS = 52, CAPACITY = 84, AGED = 12, HIGH = 7;
const rng = (t) => { let s = t >>> 0;
  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; };

export const CLASS = [
  // name, weekly new findings, score tendency (0-10), effort tendency (person-hours)
  { name: 'field-endpoint', weekly: 6, score: 5.2, effort: 3 },
  { name: 'branch-endpoint', weekly: 3, score: 5.8, effort: 4 },
  { name: 'collector', weekly: 2, score: 6.6, effort: 9 },
  { name: 'processing-server', weekly: 2, score: 7.1, effort: 14 },
  { name: 'network-device', weekly: 1, score: 6.0, effort: 7 },
  { name: 'cloud-account', weekly: 3, score: 6.3, effort: 5 },
];

export const STREAM = (() => {
  const r = rng(SEED); const list = []; let id = 0;
  for (let w = 1; w <= WEEKS; w += 1)
    for (const c of CLASS)
      for (let k = 0; k < c.weekly; k += 1) {
        const p = Math.min(10, Math.max(1, c.score + (r() - 0.5) * 4.4));
        list.push({ id: (id += 1), arrival: w, class: c.name,
          score: +p.toFixed(1), effort: Math.max(1, Math.round(c.effort * (0.5 + r()))) });
      }
  return list;
})();
export const arriving = (w) => STREAM.filter((b) => b.arrival === w);
export const DEMAND = STREAM.reduce((a, b) => a + b.effort, 0) / WEEKS;

export const RULE = {
  'score': (b) => b.score,
  'yield': (b) => b.score / b.effort,
  'arrival': (b) => -b.arrival,
  'score+age': (b, w) => b.score + 0.25 * (w - b.arrival),
};

// Queue: each week's arrivals are added, the rule order is applied, and capacity is spent from
// the top. An item that does not fit is left partly done and continues from where it left off
// the following week.
export function run(name, capacity = CAPACITY) {
  const rule = RULE[name]; const queue = []; const closed = [];
  for (let w = 1; w <= WEEKS; w += 1) {
    for (const b of arriving(w)) queue.push({ ...b, remaining: b.effort });
    queue.sort((a, b) => rule(b, w) - rule(a, w) || a.id - b.id);
    let free = capacity;
    while (free > 0 && queue.length) {
      const b = queue[0];
      if (b.remaining <= free) { free -= b.remaining; closed.push({ ...b, closedAt: w }); queue.shift(); }
      else { b.remaining -= free; free = 0; }
    }
  }
  const openWeeks = (b) => (b.closedAt ?? WEEKS + 1) - b.arrival;
  const all = [...closed, ...queue];
  const high = closed.filter((b) => b.score >= HIGH);
  return { name, closed: closed.length, score: closed.reduce((a, b) => a + b.score, 0),
    exposure: all.reduce((a, b) => a + b.score * openWeeks(b), 0),
    queue: queue.length, hours: queue.reduce((a, b) => a + b.remaining, 0),
    highDuration: high.reduce((a, b) => a + openWeeks(b), 0) / high.length,
    aged: queue.filter((b) => WEEKS - b.arrival + 1 > AGED).length,
    highOpen: queue.filter((b) => b.score >= HIGH).length };
}

The Gap Between Flow and Capacity

// vuln/flow.mjs — the incoming finding stream, remediation capacity and the result of four
// prioritization rules on the same set
import { STREAM, CLASS, WEEKS, CAPACITY, DEMAND, AGED, HIGH, RULE, run } from './model.mjs';

const y = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join('').trimEnd());

console.log(`${WEEKS} weeks, ${STREAM.length} findings, ${CLASS.length} asset classes; ` +
  `weekly demand ${DEMAND.toFixed(1)} person-hours, capacity ${CAPACITY} person-hours`);
console.log(`gap ${(DEMAND - CAPACITY).toFixed(1)} person-hours/week; ` +
  `${STREAM.filter((b) => b.score >= HIGH).length} items scoring ${HIGH} or higher`);

const A = [-19, 8, 10, 11, 12, 13, 13];
console.log('\n1. flow per class');
y(A, 'class', 'items', 'avg score', 'avg effort', 'hours/week');
for (const c of CLASS) {
  const l = STREAM.filter((b) => b.class === c.name);
  y(A, c.name, l.length, (l.reduce((a, b) => a + b.score, 0) / l.length).toFixed(1),
    (l.reduce((a, b) => a + b.effort, 0) / l.length).toFixed(1),
    (l.reduce((a, b) => a + b.effort, 0) / WEEKS).toFixed(1));
}

console.log('\n2. four prioritization rules, same set, same capacity');
y(A, 'rule', 'closed', 'closed', 'exposure', 'backlog', `${AGED}+ weeks`,
  `open score>=${HIGH}`);
y(A, '', 'items', 'points', 'point-weeks', 'items', 'aged', 'items');
for (const k of Object.keys(RULE)) {
  const x = run(k);
  y(A, k, x.closed, x.score.toFixed(0), Math.round(x.exposure), x.queue, x.aged, x.highOpen);
}
52 weeks, 884 findings, 6 asset classes; weekly demand 97.2 person-hours, capacity 84 person-hours
gap 13.2 person-hours/week; 228 items scoring 7 or higher

1. flow per class
class                 items avg score avg effort  hours/week
field-endpoint          312       5.3        3.0        18.2
branch-endpoint         156       5.8        4.0        11.9
collector               104       6.6        8.9        17.8
processing-server       104       7.0       13.8        27.7
network-device           52       5.7        7.0         7.0
cloud-account           156       6.5        4.9        14.6

2. four prioritization rules, same set, same capacity
rule                 closed    closed   exposure     backlog    12+ weeksopen score>=7
                      items    pointspoint-weeks       items         aged        items
score                   709      4579      18219         175          122            0
yield                   843      5050       6681          41           27            6
arrival                 763      4564      18511         121            0           33
score+age               747      4593      18208         137           12            9

The first table gives the lesson’s starting condition, and reading a single line is enough: weekly demand is 97.2 person-hours, capacity is 84. The 13.2-hour gap between them is reborn every week and leaves a 684-hour queue by the end of fifty-two weeks. This is a gap that no prioritization rule can close; a rule only decides which item waits.

The class rows show where the gap comes from. Field endpoints produce the most items (312) but each one takes only 3 hours; processing servers produce three times fewer items (104) and take the largest share of weekly demand (27.7 hours). The same class also carries the highest average score. Expensive, high-scoring items piling up in the same class is the source of all the tension in the next table.

Four Rules, Two Different Answers

The second table runs the same 884 items through the same capacity in four different orders. The pure-score order closes 709 items; the order that divides score by effort closes 843. The second is ahead not only in item count but also in total points closed (5,050 against 4,579). This can look surprising, because the pure-score order by definition takes the highest-scoring item first. The reason is in the table: the highest-scoring class is also the most expensive one, and the time spent on one expensive item keeps four and a half cheap items waiting that could have been closed in the same time.

The gap in risk exposure is larger: 18,219 point-weeks against 6,681, roughly 2.7 times. The queue that runs on arrival order is the worst (18,511), and the reason shows up in the columns — under this rule there is not a single item aged over twelve weeks, but the 33 items left open score 7 or higher. A young queue does not mean low risk; it only means nobody has waited long.

The last two columns reverse the rules’ ranking. The pure-score order leaves 122 items waiting longer than twelve weeks, but the number of high-scoring items it leaves open is zero: this rule never starves a high-scoring item; instead it indefinitely postpones everything low-scoring. The rule that divides score by effort, on the other hand, leaves six high-scoring items open, and these are exactly the expensive ones. The same data yields two different priorities: a manager who watches exposure picks the yield rule; a manager who adopts the rule “no high-scoring item stays open” picks the pure-score rule. Both decisions find support in the same table.

The Rule’s Value Depends on Capacity

// vuln/capacity.mjs — as capacity changes, the value of the rule: at what point the queue
// empties and at what point the rules' order changes
import { DEMAND, RULE, run } from './model.mjs';

const AD = Object.keys(RULE);
const y = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join('').trimEnd());
const A = [-10, 8, 11, 11, 13, 11, 13];

y(A, 'capacity', 'demand', ...AD, 'lowest');
y(A, 'hours/wk', 'ratio', 'exposure', 'exposure', 'exposure', 'exposure', 'exposure');
for (let c = 84; c <= 120; c += 6) {
  const s = AD.map((k) => run(k, c));
  const m = Math.round(Math.min(...s.map((x) => x.exposure)));
  const en = AD.filter((_, i) => Math.round(s[i].exposure) === m);
  y(A, c, (c / DEMAND).toFixed(2), ...s.map((x) => Math.round(x.exposure)),
    en.length === 1 ? en[0] : 'tie');
}

const empty = (() => { for (let c = 84; c <= 160; c += 1)
  if (AD.every((k) => run(k, c).queue === 0)) return c; })();
const flip = (() => { for (let c = 84; c <= 160; c += 1) {
  const s = AD.map((k) => run(k, c).exposure);
  if (s[1] > s[0]) return c; } })();
console.log(`\nfirst capacity where the queue empties under all four rules: ${empty} hours/week ` +
  `(${(empty / DEMAND).toFixed(2)}x of demand)`);
console.log(`capacity where the rule order changes: ${flip} hours/week ` +
  `(${(flip / DEMAND).toFixed(2)}x of demand); below it yield leads, above it score leads`);
for (const c of [84, empty, flip]) {
  const s = AD.map((k) => run(k, c).exposure);
  console.log(`  ${String(c).padStart(3)} hours/week: gap between best and worst rule ` +
    `${(Math.max(...s) - Math.min(...s)).toFixed(0)} point-weeks ` +
    `(${(Math.max(...s) / Math.max(1, Math.min(...s))).toFixed(1)}x)`);
}
capacity    demand      score      yield      arrival  score+age       lowest
hours/wk     ratio   exposure   exposure     exposure   exposure     exposure
84            0.86      18219       6681        18511      18208        yield
90            0.93      10663       3671         9881       9884        yield
96            0.99       1615        831         1578       1689        yield
102           1.05        281        181          353        297        yield
108           1.11         74         55           89         74        yield
114           1.17         11         14           11         11          tie
120           1.24          0          0            0          0          tie

first capacity where the queue empties under all four rules: 101 hours/week (1.04x of demand)
capacity where the rule order changes: 109 hours/week (1.12x of demand); below it yield leads, above it score leads
   84 hours/week: gap between best and worst rule 11831 point-weeks (2.8x)
  101 hours/week: gap between best and worst rule 233 point-weeks (2.0x)
  109 hours/week: gap between best and worst rule 34 point-weeks (1.6x)

The third table holds the rule fixed and changes capacity, yielding two separate thresholds. The first is 101 hours/week: once capacity reaches 1.04 times demand, the queue empties under all four rules, meaning zero items fall behind. The second is 109 hours/week, and it is this lesson’s central proof — at that point the rules’ ranking reverses. As long as capacity is tight, the rule that divides score by effort gives the lowest exposure; once capacity passes 1.12 times demand, the pure-score order takes the lead. With abundance, no item goes hungry, so the payoff from favoring cheap items ends, and touching the high-scoring item first reduces exposure more.

The last three lines measure how valuable the rule is. At 84 hours/week there is a gap of 11,831 point-weeks between the best rule and the worst; at 101 this gap drops to 233, at 109 to 34. A rule is a tool for scarcity, and its value erodes as scarcity eases.

The same numbers make both interventions comparable. With capacity held fixed at 84, changing the rule drops exposure from 18,219 to 6,681. Keeping the rule at its best choice and raising capacity from 84 to 101 — twenty percent — drops 6,681 to 233. The second move divides exposure by roughly twenty-nine, the first by only two point seven. Arguing over the prioritization rule without writing down the capacity gap is an argument without a scale; once the two numbers are written side by side, it is clear which decision is larger.

Summary

  • Over 52 weeks, 884 findings produce a weekly demand of 97.2 person-hours against a capacity of 84 person-hours: a 13.2-hour gap leaves a 684-hour queue by year’s end, and no rule closes this gap.
  • The rule that divides score by effort closes both more items (843 against 709) and more points (5,050 against 4,579) than the pure-score order; risk exposure is 6,681 against 18,219 point-weeks.
  • The same table yields two different priorities: whoever watches exposure picks the yield rule, and whoever adopts the “no high-scoring item stays open” criterion picks the pure-score rule (0 against 6 open high-scoring items).
  • The queue that runs on arrival order has no item aged over twelve weeks, but the 33 items left open score 7 or higher: a young queue does not mean low risk.
  • The queue empties at 101 hours/week (1.04 times demand); at 109 hours/week the rules’ ranking reverses and pure score takes the lead. A rule is a tool for scarcity.
  • Raising capacity by twenty percent drops exposure from 6,681 to 233; choosing the best rule drops it from 18,219 to 6,681. The second move divides exposure by twenty-nine, the first by two point seven.

Next Step

This lesson treated remediation as a one-dimensional operation: person-hours are spent on an item and the item closes. In reality every remediation is a change, and a change touches the production environment. Closing an item fast means applying it without testing; testing, in turn, extends how long it stays open. The next lesson measures this trade-off: as the patch window narrows, the number of vulnerabilities caught before they become exploitable rises, but the same narrowing lowers test coverage and produces unplanned outages. Both curves are plotted on the same axis, and the point where they intersect is calculated — that point is the window length where speed and risk are equal.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close