Skip to content
academia.sh

Lesson 08 / 15

Dependency and Component Scanning

Searching for known vulnerabilities in the dependency tree: the share of transitive dependencies, the false positives version-range matching produces, threshold scanning over severity, and how many packages a single fix touches at once because of version ranges.

Contents

Both scans tested code this team wrote. Yet most of the lines the lending service executes were not written by this team: the catalog schema depends on a validator, the notification channel on a queue client, the log writer on a formatter, and each of these brings its own dependencies.

In this code, searching for defects is not a matter of scanning but of searching for known vulnerabilities: which defect a component carries in which version range is on record, and scanning matches these records against the installed versions. The method is cheap, but it is wrong in three places — this lesson counts them.

The Dependency Tree and the Lock File

The lending system has four direct dependencies. The lock file pins the installed versions to exact numbers; scanning looks at these versions, not at the declared ranges.

// tree.mjs — the lending system's dependency tree, lock file, and version-range check
export const ROOT = {
  "schema-validator": "^2.1.0", "queue-client": "^1.4.0",
  "log-formatter": "^3.0.0", "identity-signer": "^1.2.0",
};

// Each package version's own declared ranges.
export const REGISTRY = {
  "schema-validator": {
    "2.1.4": { "pattern-compiler": "^1.0.0", "text-normalizer": "^0.9.0" },
    "2.2.0": { "pattern-compiler": "^2.0.0", "text-normalizer": "^0.9.0" },
  },
  "queue-client": {
    "1.4.2": { "frame-decoder": "^4.2.0", "retry": "^1.1.0" },
    "1.5.0": { "frame-decoder": "^4.3.0", "retry": "^1.2.0" },
  },
  "log-formatter": { "3.0.1": { "text-normalizer": "^0.9.0", "timestamp": "^2.0.0" } },
  "identity-signer": { "1.2.0": { "digest": "^3.1.0" } },
  "pattern-compiler": { "1.0.7": {}, "2.0.0": {} },
  "text-normalizer": { "0.9.8": {} },
  "frame-decoder": { "4.2.1": {}, "4.3.0": {} },
  "retry": { "1.1.0": {}, "1.2.0": {} },
  "timestamp": { "2.0.4": {}, "2.1.0": {} },
  "digest": { "3.1.2": {} },
};

// Lock file: installed versions.
export const LOCK = {
  "schema-validator": "2.1.4", "queue-client": "1.4.2", "log-formatter": "3.0.1",
  "identity-signer": "1.2.0", "pattern-compiler": "1.0.7", "text-normalizer": "0.9.8",
  "frame-decoder": "4.2.1", "retry": "1.1.0", "timestamp": "2.0.4",
  "digest": "3.1.2",
};

export const part = (v) => v.split(".").map(Number);
export const less = (a, b) => {
  const [x, y] = [part(a), part(b)];
  for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] < y[i];
  return false;
};
// "^a.b.c": the first non-zero digit stays fixed. "<a.b.c": any version below it.
export function satisfies(version, range) {
  if (range.startsWith("<")) return less(version, range.slice(1));
  const t = part(range.slice(1)), s = part(version);
  if (less(version, range.slice(1))) return false;
  const i = t.findIndex((x) => x > 0);
  return s.slice(0, i + 1).join(".") === t.slice(0, i + 1).join(".");
}

// Walks locked versions to find every package's depth.
export function tree(root = ROOT, lock = LOCK) {
  const depth = {}, queue = Object.keys(root).map((a) => [a, 1]);
  while (queue.length) {
    const [name, d] = queue.shift();
    if (depth[name] !== undefined && depth[name] <= d) continue;
    depth[name] = d;
    for (const child of Object.keys(REGISTRY[name][lock[name]])) queue.push([child, d + 1]);
  }
  return depth;
}

Matching and False Positives

The advisory set is our own records: each advisory carries a package, an affected version range, a severity score, and the fixed version. Scanning checks whether the locked version falls inside the affected range — a version comparison a little more involved than a text comparison, and it uses no other information.

NF10 (assumption): the real risk is the flawed code actually being called in this system. A package can be in the affected range while its flawed function is never called in this system; the manually verified risk list makes this distinction, scanning cannot.

// scan.mjs — matching the advisory set against the lock file, false positives, and threshold scanning
import { LOCK, satisfies, tree } from "./tree.mjs";

const depth = tree();
const direct = Object.keys(depth).filter((a) => depth[a] === 1);
const n = Object.keys(depth).length;
console.log(`${n} packages, direct ${direct.length}, transitive ${n - direct.length}` +
  ` (${((100 * (n - direct.length)) / n).toFixed(0)}%), max depth ${Math.max(...Object.values(depth))}`);

// Our own advisory set: package, affected range, severity, fixed version.
const ADVISORY = [
  { no: "K-01", package: "pattern-compiler", affected: "<2.0.0", severity: 80, fixed: "2.0.0" },
  { no: "K-02", package: "text-normalizer", affected: "<0.9.8", severity: 60, fixed: "0.9.8" },
  { no: "K-03", package: "frame-decoder", affected: "<4.3.0", severity: 70, fixed: "4.3.0" },
  { no: "K-04", package: "queue-client", affected: "<1.5.0", severity: 90, fixed: "1.5.0" },
  { no: "K-05", package: "timestamp", affected: "<2.1.0", severity: 30, fixed: "2.1.0" },
];

// NF10: the manually verified real risk is the flawed code actually being called in this system.
const ACTUAL = ["pattern-compiler", "queue-client", "retry"];

const matches = ADVISORY.filter((k) => satisfies(LOCK[k.package], k.affected));
console.log(`${ADVISORY.length} advisories, ${matches.length} matches, ${ACTUAL.length} manually verified risks\n`);
console.log(`${"advisory".padEnd(10)}${"package".padEnd(19)}${"installed".padEnd(11)}${"affected".padEnd(11)}severity  depth  actual`);
for (const k of ADVISORY) {
  const e = satisfies(LOCK[k.package], k.affected);
  console.log(`${k.no.padEnd(10)}${k.package.padEnd(19)}${LOCK[k.package].padEnd(11)}${k.affected.padEnd(11)}` +
    `${String(k.severity).padStart(8)}${String(depth[k.package]).padStart(7)}  ${e ? (ACTUAL.includes(k.package) ? "yes" : "no") : "-"}`);
}

console.log(`\n${"threshold".padStart(9)}${"remaining".padStart(11)}${"false positive".padStart(16)}${"false negative".padStart(16)}${"cost w=5".padStart(10)}`);
for (const e of [20, 30, 50, 70, 80, 90]) {
  const k = matches.filter((b) => b.severity >= e);
  const fp = k.filter((b) => !ACTUAL.includes(b.package)).length;
  const fn = ACTUAL.length - k.filter((b) => ACTUAL.includes(b.package)).length;
  console.log(`${String(e).padStart(9)}${String(k.length).padStart(11)}${String(fp).padStart(16)}${String(fn).padStart(16)}${String(fp + 5 * fn).padStart(10)}`);
}
console.log(`actual risk that never appears in the advisory set: ${ACTUAL.filter((g) => !ADVISORY.some((k) => k.package === g)).join(", ")}`);
10 packages, direct 4, transitive 6 (60%), max depth 2
5 advisories, 4 matches, 3 manually verified risks

advisory  package            installed  affected   severity  depth  actual
K-01      pattern-compiler   1.0.7      <2.0.0           80      2  yes
K-02      text-normalizer    0.9.8      <0.9.8           60      2  -
K-03      frame-decoder      4.2.1      <4.3.0           70      2  no
K-04      queue-client       1.4.2      <1.5.0           90      1  yes
K-05      timestamp          2.0.4      <2.1.0           30      2  no

threshold  remaining  false positive  false negative  cost w=5
       20          4               2               1         7
       30          4               2               1         7
       50          3               1               1         6
       70          3               1               1         6
       80          2               0               1         5
       90          1               0               2        10
actual risk that never appears in the advisory set: retry

Three numbers are worth reading. The first is the share of transitive dependencies: six of the ten packages were declared nowhere; they came in underneath the four that were declared. Without the lock file, it would not even be known which version these six packages are at, and scanning cannot work without knowing the version.

The second is false positives: two of the four matches are not real risk. Both are correct from a version-comparison standpoint — the installed version is in the affected range — but the flawed code path does not run in this system. Version-range matching measures a package’s presence, not its use.

The third is false negative: the defect inside retry is on the manually verified risk list, but not in the advisory set. This is dependency scanning’s structural limit — it finds only reported defects, and no threshold can see a defect that has not been reported.

The threshold’s source is a measurement: threshold scanning shows that both false positives drop out at severity 80, and both of the two remaining findings turn out real; cost falls to its lowest value there. The weight again comes from NF8.

How a Fix Propagates

The two advisories that clear the threshold will be fixed. A fix looks like changing a single version number; version ranges get in the way of that.

// fix.mjs — how many packages fixing one advisory touches
import { REGISTRY, ROOT, LOCK, satisfies, less, tree } from "./tree.mjs";

const versions = (name) => Object.keys(REGISTRY[name]).sort((a, b) => (less(a, b) ? -1 : 1));
const lowestSatisfying = (name, range) => versions(name).find((v) => satisfies(v, range));
const parents = (name, lock) => Object.keys(REGISTRY).filter((u) => REGISTRY[u][lock[u]]?.[name]);

function upgrade(lock, package_, target) {
  const next = { ...lock }, touched = new Set(), blocked = [];
  const queue = [[package_, target]];
  while (queue.length) {
    const [name, s] = queue.shift();
    if (next[name] === s) continue;
    next[name] = s;
    touched.add(name);
    for (const [child, range] of Object.entries(REGISTRY[name][s]))
      if (!satisfies(next[child], range)) queue.push([child, lowestSatisfying(child, range)]);
    for (const u of parents(name, next)) {
      if (satisfies(s, REGISTRY[u][next[u]][name])) continue;
      const suitable = versions(u).find((v) => REGISTRY[u][v][name] && satisfies(s, REGISTRY[u][v][name]));
      suitable ? queue.push([u, suitable]) : blocked.push(`no suitable version for ${u}`);
    }
    if (ROOT[name] && !satisfies(s, ROOT[name])) blocked.push(`root range ${ROOT[name]} does not accept ${name}@${s}`);
  }
  return { next, touched: [...touched], blocked };
}

let combined = LOCK;
for (const [no, package_, target] of [["K-01", "pattern-compiler", "2.0.0"], ["K-04", "queue-client", "1.5.0"]]) {
  const { next, touched, blocked } = upgrade(LOCK, package_, target);
  console.log(`${no}: ${package_} ${LOCK[package_]} -> ${target}; touches ${touched.length} packages`);
  for (const a of touched) console.log(`   ${a.padEnd(23)}${LOCK[a]} -> ${next[a]}`);
  if (blocked.length) console.log(`   blocked: ${blocked.join("; ")}`);
  combined = upgrade(combined, package_, target).next;
}

const changed = Object.keys(LOCK).filter((a) => combined[a] !== LOCK[a]);
const n = Object.keys(LOCK).length;
console.log(`\nboth fixes together: ${changed.length}/${n} packages (${((100 * changed.length) / n).toFixed(0)}%) moved to a new version`);
console.log(`tree depth unchanged: ${Math.max(...Object.values(tree(ROOT, combined)))}`);
console.log(`side effect: K-03's target frame-decoder ${LOCK["frame-decoder"]} -> ${combined["frame-decoder"]}`);
K-01: pattern-compiler 1.0.7 -> 2.0.0; touches 2 packages
   pattern-compiler       1.0.7 -> 2.0.0
   schema-validator       2.1.4 -> 2.2.0
K-04: queue-client 1.4.2 -> 1.5.0; touches 3 packages
   queue-client           1.4.2 -> 1.5.0
   frame-decoder          4.2.1 -> 4.3.0
   retry                  1.1.0 -> 1.2.0

both fixes together: 5/10 packages (50%) moved to a new version
tree depth unchanged: 2
side effect: K-03's target frame-decoder 4.2.1 -> 4.3.0

The first fix propagated upward. Because pattern-compiler’s fixed version raises the major digit, the range the package above it declares no longer accepts it, so the intermediate package has to be upgraded too. The second fix propagated downward: the new version wants newer versions of its own dependencies. The two fixes together moved five of the ten packages.

This number is the scan’s real cost. The new version of five packages also brings behavior changes that have nothing to do with the security advisory; the regression testing that follows an upgrade is unavoidable, and its cost is measured not by the finding count but by the number of packages touched. The last line also shows the reverse: an advisory counted as a false positive closed for free, as the side effect of a different fix.

Who owns the decision: a match above severity 80 stops the release and opens an upgrade task; matches below it accumulate on a list. Because the advisory set can change every day, the same lock file can be green today and red tomorrow — this is the one check whose result changes without the code changing.

Summary

  • Dependency scanning matches the versions in the lock file against the affected ranges in the advisory set; in this tree, six of ten packages (60%) are transitive dependencies declared nowhere.
  • Two of the four matches turned out to be false positives: the version matched correctly, but the flawed code path is not called in this system. Version-range matching measures presence, not use.
  • The caught class is a reported defect found in the installed version; the missed class is a defect not yet reported — one of the three manually verified risks appeared in no advisory.
  • Threshold scanning gave severity 80: 0 false fails, 1 false pass. The threshold’s source is this measurement; the weight’s source is NF8.
  • The cost is the number of packages touched: the two fixes propagated up and down through the version ranges, moved five of the ten packages to a new version, and each one demands a regression test.
  • This scan is the one check whose result can change without the code ever changing; as the advisory set grows, the same lock file turns red again.

Next Step

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: a reader whose membership has lapsed being able to move a penalty record onto another member is a sequence of requests, each individually authorized, that only becomes visible when viewed together. No pattern names this, because the defect lives not in the code but in the combination of rules. The next lesson draws this boundary: on a threat model, it separates the defect classes automation can cover from the ones that require human judgment, and counts the share that falls between the two.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close