---
title: 'Dependency Risk'
source: 'https://academia.sh/en/courses/backend-production/dependency-risk'
course: 'Server Security and Going to Production'
language: en
updated: '2026-08-23T07:00:25+00:00'
license: 'CC BY-SA 4.0'
---

# Dependency Risk

A version policy is a configuration decision: five policies are run over the same dependency tree and the accumulated exposure days and broken upgrades are counted; the silent outcome of a loose version range is measured by how many packages diverge between two deployments of the same source version.

Every measurement in the previous lesson stayed inside code we wrote ourselves. When the loan
service's process comes up, most of the loaded files are not lines the team wrote; the tree holds
twenty-four packages, and only eight of their names appear in a manifest file. The remaining
sixteen arrive **transitively**: each is a dependency of a dependency, no one chose it, and no one
wrote its version down.

**Scanning** these dependencies was measured in the Dependency and Component Scanning lesson; the question there
was what the tool finds. Here, what the tool finds is assumed, and the next question is asked:
what happens when a finding arrives depends on a rule. That rule is the **version policy**, and it
is a configuration decision — it is written where a manifest file sets a version constraint, and
its result shows up in a trade-off the team never sees: upgrading more often reduces accumulated
risk and raises the number of broken versions.

**No real package name, version number, or vulnerability record is used.** The tree, the release
calendar, and the record set below are produced with our own generator; the seed is visible and
the run is repeatable.

## The Tree and the Record Set

**AS13 — the tree has 24 packages: eight direct, sixteen transitive, with a 360-day horizon.**
Every package has a release interval and a break rate: how much of its releases break our code.
**AS15 — a vulnerability record takes between 0 and 90 days to reach us.** The record reaches us
not on the day the vulnerability begins to exist, but on the day it is reported; the exposure
window is counted from the day it exists to the day it closes.

```js
// tree.mjs — our own dependency tree, our own release calendar, our own record set
export const HORIZON = 360;              // days
export const SEED = 8617;
let state = SEED;
const rand = () => (state = (state * 1103515245 + 12345) % 2147483648) / 2147483648;
const pick = (arr) => arr[Math.floor(rand() * arr.length)];

// depth 1: package named in our manifest; 2 and 3: transitive, not named.
const DEPTH = [...Array(8).fill(1), ...Array(10).fill(2), ...Array(6).fill(3)];
const INTERVAL = [14, 30, 45, 60, 90];          // release interval (days)
const BREAK_RATE = [0, 0.08, 0.18, 0.35];       // fraction of releases that break us

export const packages = DEPTH.map((depth, i) => {
  const interval = pick(INTERVAL);
  const breakRate = pick(BREAK_RATE);
  // transitive package: the fix cannot be installed until the parent package cuts a new release.
  const upstreamDelay = depth === 1 ? 0 : Math.round(pick(INTERVAL) * rand());
  const releases = [{ day: 0, breaking: false }];
  for (let g = Math.round(rand() * interval); g <= HORIZON; g += interval) {
    if (g > 0) releases.push({ day: g, breaking: rand() < breakRate });
  }
  return { name: `p${String(i + 1).padStart(2, '0')}`, depth, interval, upstreamDelay, releases };
});

// A record: a vulnerability present in a package. day: day it started existing; fixedBy: the
// release that closes it; known: day the record reaches us. reachable: our code path touches that surface.
export const records = [];
for (let k = 0; k < 11; k++) {
  const p = Math.floor(rand() * packages.length);
  const day = 20 + Math.floor(rand() * (HORIZON - 80));
  const fixedBy = packages[p].releases.findIndex((y) => y.day >= day);
  if (fixedBy <= 0) continue;
  const notice = Math.round(rand() * 90);      // AS15: record delay 0-90 days
  records.push({ package: p, day, fixedBy, known: day + notice, notice, reachable: rand() < 0.55 });
}

// The constraint sits separately in each service's own manifest.
export const SERVICES = ['catalog', 'loan', 'membership', 'notification', 'penalty', 'search', 'reporting'];
export const constraintLocation = packages.map((p) => (p.depth === 1 ? 1 + Math.floor(rand() * SERVICES.length) : 0));
export const readyDay = (p, i) => packages[p].releases[i].day + packages[p].upstreamDelay;
```

## Five Policies

**AS14 — code releases happen every fourteen days.** The policies differ at exactly one point: on
which days, and for which packages, the tree changes. The fifth is not a policy but a shape of
constraint — when the version range is left loose, the tree changes on its own at release time,
without anyone touching a file.

```js
// policy.mjs — five version policies on the same tree: open days versus broken upgrades
import { packages, records, constraintLocation, readyDay, SERVICES, HORIZON, SEED } from './tree.mjs';

const DEPLOY = 14;   // AS14: release day; the loose range only changes the tree on these days

const policies = {
  'fixed': { day: () => false },
  'immediate': { day: () => true },
  'monthly': { day: (g) => g % 30 === 0 },
  'security only': { day: () => true, selective: true },
  'loose range': { day: (g) => g % DEPLOY === 0, fileless: true },
};

function run(pol) {
  const installed = packages.map(() => 0);
  const open = records.map(() => 0);
  let upgrades = 0, broken = 0, intervention = 0, brokenDay = 0, mostInDay = 0;
  const snapshot = {};
  for (let day = 1; day <= HORIZON; day++) {
    let dailyUpgrades = 0, dailyBroken = 0;
    if (pol.day(day)) {
      for (let p = 0; p < packages.length; p++) {
        // Selective policy upgrades a package only once its record has reached us.
        if (pol.selective && !records.some((k) => k.package === p && day >= k.known && installed[p] < k.fixedBy)) continue;
        let target = installed[p];
        while (target + 1 < packages[p].releases.length && readyDay(p, target + 1) <= day) target += 1;
        if (target === installed[p]) continue;
        for (let i = installed[p] + 1; i <= target; i++) if (packages[p].releases[i].breaking) dailyBroken += 1;
        dailyUpgrades += 1;
        installed[p] = target;
      }
    }
    upgrades += dailyUpgrades;
    broken += dailyBroken;
    if (dailyUpgrades > 0) intervention += 1;
    if (dailyBroken > 0) brokenDay += 1;
    mostInDay = Math.max(mostInDay, dailyBroken);
    records.forEach((k, i) => { if (day >= k.day && installed[k.package] < k.fixedBy) open[i] += 1; });
    if (day === 180 || day === 194) snapshot[day] = [...installed];
  }
  const reachable = records.map((k, i) => (k.reachable ? open[i] : 0));
  return {
    intervention, upgrades, broken, brokenDay, mostInDay,
    openDays: reachable.reduce((a, x) => a + x, 0),
    allDays: open.reduce((a, x) => a + x, 0),
    longest: Math.max(...reachable),
    fileless: pol.fileless ? upgrades : 0,
    diff: snapshot[180].filter((v, i) => v !== snapshot[194][i]).length,
  };
}

const s = (x, n) => String(x).padStart(n);
console.log(`seed ${SEED}: ${packages.length} packages, ${records.length} records, horizon ${HORIZON} days`);
console.log(`${'policy'.padEnd(17)}${s('interv.', 10)}${s('upgrades', 11)}${s('broken', 9)}`
  + `${s('broken day', 11)}${s('most', 8)}${s('open day', 10)}${s('longest', 9)}`);
const result = {};
for (const [name, pol] of Object.entries(policies)) {
  const r = (result[name] = run(pol));
  console.log(`${name.padEnd(17)}${s(r.intervention, 10)}${s(r.upgrades, 11)}${s(r.broken, 9)}`
    + `${s(r.brokenDay, 11)}${s(r.mostInDay, 8)}${s(r.openDays, 10)}${s(r.longest, 9)}`);
}

const g = result['loose range'];
console.log(`\nin the loose range, all ${g.fileless} upgrades arrived without touching a manifest file;`
  + ` the day-180 and day-194 deployments of the same source version diverge on ${g.diff} packages`);
console.log(`the same divergence is ${result.immediate.diff} packages under immediate, ${result.fixed.diff} under fixed`);

const direct = records.filter((k) => packages[k.package].depth === 1);
const delay = records.reduce((a, k) => a + packages[k.package].upstreamDelay, 0);
const notice = records.reduce((a, k) => a + k.notice, 0) / records.length;
console.log(`${direct.length} of the records are in a direct package, ${records.length - direct.length} transitive;`
  + ` ${records.filter((k) => k.reachable).length} are reachable`);
console.log(`total upstream wait for transitive records: ${delay} days; average record delay: ${notice.toFixed(1)} days`);
const rank = (field) => Object.keys(result).sort((a, b) => result[a][field] - result[b][field]).join(' < ');
console.log(`open days with the reachability filter removed: `
  + Object.entries(result).map(([name, r]) => `${name} ${r.allDays}`).join(', '));
console.log(`ranking, filtered: ${rank('openDays')}`);
console.log(`ranking, unfiltered: ${rank('allDays')}`);
console.log(`depth-1 packages: ${packages.filter((p) => p.depth === 1).length}, `
  + `total constraints in service manifests: ${constraintLocation.reduce((a, x) => a + x, 0)} `
  + `(${SERVICES.length} services); most files one package touches: ${Math.max(...constraintLocation)}`);
```

```
seed 8617: 24 packages, 11 records, horizon 360 days
policy              interv.   upgrades   broken broken day    most  open day  longest
fixed                     0          0        0          0       0       860      269
immediate               237        330       58         54       3       113       43
monthly                  12        214       58         12       8       195       65
security only            14         15       12          7       6       178       67
loose range              25        320       56         24       6       147       49

in the loose range, all 320 upgrades arrived without touching a manifest file; the day-180 and day-194 deployments of the same source version diverge on 14 packages
the same divergence is 13 packages under immediate, 0 under fixed
7 of the records are in a direct package, 4 transitive; 5 are reachable
total upstream wait for transitive records: 51 days; average record delay: 42.8 days
open days with the reachability filter removed: fixed 2181, immediate 349, monthly 520, security only 534, loose range 422
ranking, filtered: immediate < loose range < security only < monthly < fixed
ranking, unfiltered: immediate < loose range < monthly < security only < fixed
depth-1 packages: 8, total constraints in service manifests: 39 (7 services); most files one package touches: 7
```

## Reading the Trade-Off

The first row is the cost of not upgrading, and it reads zero in the broken-version column. The
tree stays as it was on day one, no release wakes anyone, no test breaks — and the reachable
records stay open for a total of **860 days**, with the longest single one at 269. This row is
this course's rule at its most expensive: a fixed tree is the quietest configuration, and its
quiet is paid for in accumulated risk.

The second row is the other extreme. Daily upgrading brings open days down from 860 to **113**,
roughly sevenfold. Its cost is 237 intervention days and 58 broken versions. The distribution of
the breaks matters: they spread across 54 separate days, with at most three landing on a single
day. Finding which upgrade a break came from is cheap in this distribution.

The third row is where intuition is wrong. Monthly batch upgrading **does not reduce** the number
of broken versions at all — still 58. Batching does not eliminate breaks, it only compresses them
into twelve days, with eight breaks landing on a single day. What is gained is intervention count
(12 instead of 237); what is lost is open days (195 instead of 113, eighty-two more) and the ease
of tracing a break to its source. Batching upgrades does not reduce the cost of breaking; it piles
it onto a single day.

The fourth row upgrades only the package that has a record, bringing intervention down to fifteen
upgrades. Broken versions drop to twelve, because most of the tree is never touched. Against that,
open days are 178 — sixty-five more than daily upgrading — and the longest single window, at 67
days, is the worst of any policy. The cause is in the line below the table: average record delay
is **42.8 days**. A policy that acts on a record has to wait for the record to arrive; a policy
that does not wait has already installed the fix. The security-only policy is cheap, and its
ceiling is not in its own hands.

## The Days No Policy Can Reach

Part of the open days sits where no policy can reach. Four of the eleven records are in a
transitive package; even once the fix ships, the upstream package cannot be installed until its
parent cuts a new release, and that wait totals **51 days**. These days are the same in all five
rows — a version constraint does not bind a package that is not in a manifest file. Combined with
the 42.8-day record delay, part of what the table shows is a product of the calendar, not the
policy.

The reachability filter carries a warning of its own. Removing it doubles or triples the numbers
(immediate goes from 113 to 349, fixed from 860 to 2181), and the two extremes stay in place — but
the two middle policies swap places. Filtered, monthly looks worse than security-only; unfiltered,
it looks better. Separating the surface our code path actually touches is therefore not a detail
but a decision that flips the ranking.

## Where the Range Sits

The last row belongs to this course's rule. A loose version range looks good in the numbers: 147
open days, 56 broken versions, 25 interventions. Yet all 320 of those upgrades arrived **without
touching a manifest file**. Its one measurable consequence is the line below the table: the
day-180 and day-194 deployments of the same source version diverge on **fourteen** of twenty-four
packages. The two deployments carry the same code, run with a different tree, and there is no
record anywhere of the difference. Rolling back the code does not roll back the tree.

Under daily upgrading, the same divergence is thirteen packages — nearly the same number. The
difference is the record of the divergence: there, every upgrade is a file change; here, none is.
Under the fixed policy, divergence is zero, and that is exactly why 860 open days get paid.

Where the constraint sits is counted in the last line: eight direct packages are written as a
total of **39 constraints** across seven services' manifest files, and a single package can appear
in as many as seven separate files at once. Responding to one record means finding all seven of
the seven files; an upgrade that finds six and misses one raises no error — it just leaves one
service on the old version.

## Summary

- Never upgrading produced zero breaks and 860 open days; the longest single window ran 269 days.
  The cost of a quiet configuration here is accumulated risk.
- Daily upgrading brought open days down to 113; its cost was 237 intervention days and 58 broken
  versions spread across 54 days.
- Monthly batch upgrading did not reduce the broken-version count (still 58); it piled them into
  twelve days, up to eight in a single day, and raised open days to 195.
- The security-only policy ran on fifteen upgrades but, because of a 42.8-day average record
  delay, pushed the longest window to 67 days; its ceiling is not in its own hands.
- Transitive packages' upstream wait is 51 days and closes under no policy; the reachability
  filter swaps the order of the two middle policies.
- In the loose range, all 320 upgrades arrived without touching a file, and two deployments of the
  same source version diverged on fourteen packages; the numbers look good while the tree has no
  record.

## Next Step

Two lessons measured two configuration decisions, and both left a question open. Who rotated a
key, when, with which version; which day the tree changed when a package was upgraded; which
deployment the fourteen diverging packages in the loose range arrived in — none of these questions
has an answer sitting inside the system. The application's own logs write the request and the
error, not the decision. The next lesson takes on this gap: it counts which events get logged, how
to verify a log has not been altered afterward, and how much personal data the log itself carries.
