---
title: 'Dependency and Component Scanning'
source: 'https://academia.sh/en/courses/non-functional-testing/dependency-and-component-scanning'
course: 'Non-Functional Testing'
language: en
updated: '2026-08-23T14:25:17+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

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

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