---
title: 'Endpoint and System Hardening'
source: 'https://academia.sh/en/courses/endpoint-security'
language: en
updated: '2026-09-26T03:02:50+00:00'
license: 'CC BY-SA 4.0'
lessons: 16
outcomes:
    - 'Inventories foothold points by counting the items that run automatically'
    - 'Separates the points each control closes from the points it only makes visible'
    - 'Extracts suspicious activity from endpoint logs and counts the source gap'
---

# Endpoint and System Hardening

How many points allow a foothold once code has run on the endpoint: the point that removing an unnecessary service closes, the window the patch cycle and emergency patching leave open, the security benchmark items that cannot be applied, the legitimate work application allowlists block, isolating suspicious code in a sandbox, what full disk encryption protects and does not protect under physical access, how malware types spread and persist, the false positive and miss balance of signature and behavior-based detection, endpoint detection and response telemetry, host-based filtering, how living off the land lowers signature-based detection, persistence mechanisms, event and authentication logs, process creation logs, and log integrity.

## Hardening

Counting the items that run automatically on the endpoint: the foothold point a default install leaves behind, the point that removing an unnecessary item closes, the window a patch leaves open, the benchmark items that cannot be applied, the legitimate work an application allowlist blocks, the decision to run in a sandbox, and the item full disk encryption protects under physical access.

### Hardening Principles

Source: https://academia.sh/en/courses/endpoint-security/hardening-principles

The course sets its unit of measure: the 84 foothold points a default install leaves across four machine classes are counted, clearing unnecessary items closes 40 of them, 44 are only written to a baseline, and the legitimate-work cost of closing them comes out to 41 manual steps.

The previous course tied identity to a root: who a key belongs to now rests on a named chain. Even
if the entire chain is built correctly, that root sits on top of a machine, and if the machine is
compromised the chain keeps working in the attacker's hands.

This course starts from there, and it opens with an assumption different from the previous four
courses: **code has already run once on the endpoint**. What gets counted is no longer whether entry
is blocked, but from how many points persistence is possible after entry. Code that has run once is
a one-time event if it does not run again by itself when the machine restarts or the user logs on;
for it to run again, an item has to be added to the machine's own automatic-run mechanism. Every item
in that mechanism is a **foothold point**, and it can be counted. This lesson sets the course's unit
of measure.

#### Foothold Point and Installed Item

An inventory item is a foothold point if nobody has to do anything for it to run. Five types meet
this definition: a **boot entry**, a **service definition**, a **scheduled task**, a **logon
script**, and a **configuration hook**. What they share is not their form but their function — each
carries a **definition**, and whoever can write that definition makes their own chosen thing run the
next time the machine boots or the next time a session opens.

An installed package does not meet this definition. A binary sitting on disk does not run by itself;
for it to run, either a user has to invoke it or one of the five types above has to name it. The
distinction narrows and sharpens the measurement: not the whole inventory, only the automatically run
part of it is counted. In this course, **how** a foothold point is defined — which file gets what
written to it — is never written down anywhere; what is counted is that the type exists, and how
many there are.

The measurement is set up across the four machine classes of the **regional measurement network**:
field team laptops, branch endpoints, meter collector machines, and central processing servers.

- **SH1.** The thirty-three-item inventory and the four classes are a **model**; no real machine is
  scanned, no real removal is performed.
- **SH2.** Only the five automatic types count toward the foothold-point tally; installed packages
  are counted separately.
- **SH3.** The "required" column is treated as derived from a job description and is fixed in the
  model. In reality this column is filled in by an inventory pass and changes as roles change.
- **SH4.** Removal cost is the number of legitimate tasks that fall to a manual step when the item is
  removed; it was written in by hand.
- **SH5.** Removal is total: an item either exists or it does not. The distinction between disabling
  and removing is not modeled; the latter also deletes the definition, the former leaves it in place.
- **SH6.** The field for who can write the definition carries a single value and shows the broadest
  privilege.

```js
// harden/inventory.mjs — the item inventory a default install leaves behind IS A MODEL.
// No real machine is scanned, no real removal is performed; the numbers derive from this file.
export const CLASS = ['field-laptop', 'branch-endpoint', 'collector', 'processing-server'];
// Five automatically run item types count as a foothold point; 'package' does not.
export const POINT_TYPE = ['boot', 'service', 'task', 'logon', 'hook'];
// row: name | type | installed mask | required mask | who can write the definition | removal cost
// mask is four characters, in CLASS order; 'k' present, '.' absent.
// cost: the number of legitimate tasks that fall to a manual step when the item is removed.
export const ITEM = `
  boot-manager-entry           boot    kkkk kkkk administrator       0
  recovery-boot-entry          boot    kkkk .... administrator       2
  network-boot                 boot    .kkk ..k. administrator       0
  remote-management-service    service kkkk .kkk administrator       0
  print-spooler                service kk.. .k.. administrator       0
  discovery-broadcast          service kkkk .... administrator       1
  remote-desktop                service kk.. .k.. administrator       0
  metrics-collector-agent      service kkkk kkkk administrator       0
  log-forwarder-agent          service kkkk kkkk administrator       0
  patch-agent                   service kkkk kkkk administrator       0
  helper-tool-service           service kkkk .... local-administrator 0
  cloud-sync-service            service k... .... user                2
  meter-reading-service         service ..k. ..k. administrator       0
  job-queue-service             service ...k ...k administrator       0
  update-check                  task    kkkk kkkk administrator       0
  disk-maintenance               task    kkkk kkkk administrator       0
  telemetry-upload               task    kkkk .... administrator       0
  helper-tool-update             task    kkkk .... local-administrator 1
  user-task-folder                task    kk.. .... user                3
  backup-trigger                  task    ..kk ..kk administrator       0
  logon-script                    logon   kk.. kk.. administrator       0
  user-startup-folder             logon   kk.. .... user                3
  shell-config-file               logon   kkkk k.k. user                2
  package-manager-hook            hook    kkkk ...k administrator       1
  driver-load-hook                 hook    kkkk kk.. administrator       0
  auth-plugin                      hook    kkkk .kkk administrator       0
  shell-plugin-directory           hook    kk.. .... user                2
  document-editor                  package kk.. kk.. -                   0
  archive-tool                     package kkkk kkkk -                   0
  script-runtime                   package kkkk ..kk -                   1
  database-client                  package ..kk ..kk -                   0
  diagnostic-toolkit               package kkkk .... -                   3
  field-measurement-app            package k... k... -                   0`
  .trim().split('\n').map((r) => {
    const [name, type, installed, required, writer, cost] = r.trim().split(/\s+/);
    return { name, type, installed, required, writer, cost: Number(cost),
      point: POINT_TYPE.includes(type) };
  });

export const isInstalled = (k, i) => k.installed[i] === 'k';
export const isRequired = (k, i) => k.required[i] === 'k';
// Items installed in a class, not required, and therefore removable.
export const surplus = (i) => ITEM.filter((k) => isInstalled(k, i) && !isRequired(k, i));
export const points = (i) => ITEM.filter((k) => k.point && isInstalled(k, i));
export const remaining = (i) => points(i).filter((k) => isRequired(k, i));
export const measure = (i) => ({
  installed: ITEM.filter((k) => isInstalled(k, i)).length,
  point: points(i).length,
  closed: surplus(i).filter((k) => k.point).length,
  remaining: remaining(i).length,
  cost: surplus(i).filter((k) => k.point).reduce((a, k) => a + k.cost, 0),
});
```

#### Counting the Inventory

```js
// harden/count.mjs — foothold points, closed points, and removal cost across four machine classes
import { CLASS, POINT_TYPE, ITEM, measure, isInstalled, isRequired, points, remaining, surplus } from './inventory.mjs';

const row = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join('').trimEnd());
const total = (f) => CLASS.reduce((a, _, i) => a + f(i), 0);

console.log(`${ITEM.length} inventory items; ${ITEM.filter((k) => k.point).length} of them ` +
  `automatically run (${POINT_TYPE.join(', ')})`);

const A = [-20, 11, 7, 9, 11, 8];
console.log('\n1. count per class');
row(A, 'class', 'installed', 'point', 'closed', 'remaining', 'cost');
for (const [i, s] of CLASS.entries()) {
  const o = measure(i);
  row(A, s, o.installed, o.point, o.closed, o.remaining, o.cost);
}
row(A, 'total', total((i) => measure(i).installed), total((i) => measure(i).point),
  total((i) => measure(i).closed), total((i) => measure(i).remaining), total((i) => measure(i).cost));

const B = [-8, 7, 9, 11, 8];
console.log('\n2. count per type, sum of four classes');
row(B, 'type', 'point', 'closed', 'remaining', 'cost');
for (const t of POINT_TYPE)
  row(B, t, total((i) => points(i).filter((k) => k.type === t).length),
    total((i) => surplus(i).filter((k) => k.point && k.type === t).length),
    total((i) => remaining(i).filter((k) => k.type === t).length),
    total((i) => surplus(i).filter((k) => k.point && k.type === t).reduce((a, k) => a + k.cost, 0)));

console.log('\n3. point diverging by class (required in one class, surplus in another)');
const diverging = ITEM.filter((k) => k.point && CLASS.some((_, i) => isInstalled(k, i) && isRequired(k, i))
  && CLASS.some((_, i) => isInstalled(k, i) && !isRequired(k, i)));
for (const k of diverging) row([-30, -9, -6], k.name, k.type, k.required);

const nonAdmin = (i) => points(i).filter((k) => k.writer !== 'administrator');
console.log(`\npoints needing a separate decision       ${diverging.length} items, ` +
  `${total((i) => diverging.filter((k) => isInstalled(k, i)).length)} decisions`);
console.log(`points definable without an administrator ${total((i) => nonAdmin(i).length)} decisions, ` +
  `closed ${total((i) => nonAdmin(i).filter((k) => !isRequired(k, i)).length)}, ` +
  `remaining ${total((i) => nonAdmin(i).filter((k) => isRequired(k, i)).length)}`);
console.log(`removable package                          ${total((i) => surplus(i).filter((k) => !k.point).length)} decisions, ` +
  `cost ${total((i) => surplus(i).filter((k) => !k.point).reduce((a, k) => a + k.cost, 0))}`);
console.log(`total point decisions ${total((i) => measure(i).point)}: ` +
  `closed ${total((i) => measure(i).closed)}, ` +
  `written to baseline only ${total((i) => measure(i).remaining)}`);
```

```
33 inventory items; 27 of them automatically run (boot, service, task, logon, hook)

1. count per class
class                 installed  point   closed  remaining    cost
field-laptop                 28     23       14          9      15
branch-endpoint              27     23       11         12      15
collector                    23     19        7         12       5
processing-server            23     19        8         11       6
total                       101     84       40         44      41

2. count per type, sum of four classes
type      point   closed  remaining    cost
boot         11        6          5       8
service      31       12         19       6
task         20       10         10      10
logon         8        4          4      10
hook         14        8          6       7

3. point diverging by class (required in one class, surplus in another)
network-boot                  boot     ..k.
remote-management-service     service  .kkk
print-spooler                 service  .k..
remote-desktop                service  .k..
shell-config-file             logon    k.k.
package-manager-hook          hook     ...k
driver-load-hook              hook     kk..
auth-plugin                   hook     .kkk

points needing a separate decision       8 items, 27 decisions
points definable without an administrator 19 decisions, closed 17, remaining 2
removable package                          6 decisions, cost 14
total point decisions 84: closed 40, written to baseline only 44
```

#### The Closed Point and the Recorded Point

The total row gives the course's two columns at once. Across four classes there are 84 foothold-point
decisions; clearing unnecessary items **closes 40 of them** and **leaves 44 open**. The only thing
that can be done for the 44 open points is to write their definitions to a baseline and compare
later deviations against that line. This is not a closure; a recorded point still runs when it is
used, it is only noticed when the comparison is made.

Keeping the two columns separate is this course's rule. The sentence "it was hardened" does not by
itself say which column something was written to. A closed point cannot be used again; a recorded
point can be used, and it is noticed only when someone reads the record.

The model treats an item as either present or absent (SH5), and that collapses two separate
operations into a single number in reality. **Disabling** a service leaves its definition in place
and only flips the run flag off; **removing** it also deletes the definition. For counting purposes
the difference is this: a disabled item does not belong in the closed column, it belongs in the
recorded column, because whoever can write the definition can flip the flag back on. When the
difference is measured, the number 40 drops in most inventories — items that cannot be removed but
can be disabled get mistaken for closed. This lesson's count assumes removal actually happened; if
it did not happen at an endpoint, that item moves to the second column.

The class rows show a reversed distribution. The field laptop carries the most items (28 installed,
23 points) and is also the class cleaned the most: 14 points close, 9 remain. On the collector
machine, only 7 of 19 points close, because whatever is installed is nearly always required. So
cleanup pays off the most on a machine built for many purposes with no single job, and on a machine
with a single job the room to gain is already narrow.

The type table shows where the remaining side concentrates. Of thirty-one service definitions, only
12 close; 19 services remain, and that is the largest piece of the 44 remaining points. The metrics
collector, the log forwarder, the patch agent, and the auth plugin cannot be removed, because the
machine's manageability depends on them. Hardening's limit shows up here: the items that make an
endpoint manageable are also the ways to persist on that endpoint.

#### Who Can Write It Matters More Than How Many There Are

A separate row flags 19 of the 84 points: points definable without administrator privilege. The user
task folder, the user startup folder, the shell plugin directory, the shell config file, the cloud
sync service, and two helper-tool items that ship with local-administrator privilege belong to this
group. Cleanup **closes 17 of them**, leaving **2**.

The smallness of the number does not shrink the importance of the result. A foothold point that
requires administrator privilege is an item usable by someone who has already obtained that
privilege; a point that does not require administrator privilege is open to code running in an
ordinary user session. The difference between the two items is not in the count, it is in which step
has to happen before it becomes usable. The two remaining items — the shell config file on the field
laptop and on the collector machine — cannot be removed, because in both classes the work itself
runs through the shell.

#### Items That Diverge by Class

The third block separates eight items: each is required in one class, surplus in another. The remote
management service is mandatory in three classes and unnecessary on the field laptop. The print
spooler is only required at the branch endpoint. Network boot only on the collector machine. The
package manager hook only on the processing server.

These eight items produce 27 decisions, and a single corporate "removal list" cannot resolve them.
If the list removes all eight items, management access closes in three classes; if it removes none
of them, an unnecessary point stays open on the field laptop. The correct approach is to decide per
class, and that is exactly the maintenance cost: four lists, 84 decisions, and 8 boundary items that
have to be revisited with every role change.

#### The Cost of Closing

The cost column shows 41 manual steps, and its distribution is also reversed: the two classes with
the most points closed pay the highest cost (15 and 15), the two classes with the least closed pay
the lowest (5 and 6). The type table shows a sharper ratio: logon scripts run 10 cost per 4 closed
points, service definitions run 6 cost per 12 closed points. Closing items users install themselves
costs roughly five times as much as closing service definitions — because the user installed that
item to get a job done, and once the item closes, the job falls to management.

The cost becomes concrete once items are read by name. The recovery boot entry is surplus in all
four classes, and removing it requires portable media for on-site repair — 8 manual steps across the
four classes. The user task folder and the startup folder are how users set up their own scheduling;
once closed, every request turns into a management task.

The package side is counted separately: 6 removal decisions and 14 manual steps. The diagnostic
toolkit is surplus in all four classes, but troubleshooting is done with it; removing it means a
reinstall on every incident. This package is not a foothold point — it does not run by itself — but
as long as it sits on disk it serves a point that could call it. The same item will be counted again
in the application allowlist lesson.

#### Summary

- A foothold point is an item that requires nobody to do anything for it to run: a boot entry, a
  service definition, a scheduled task, a logon script, and a configuration hook. An installed
  package does not belong to this set and is counted separately.
- Of 101 installed-item decisions across four machine classes, 84 are foothold points; clearing
  unnecessary items closes 40 of them and leaves 44 open. The only thing that can be done for what
  stays open is to write it to a baseline — that is not a closure, it is making it visible.
- Service definitions are the largest piece of the remaining side: 19 of 31 points cannot be
  removed, because the items that make an endpoint manageable are also the ways to persist there.
- Of 19 points definable without administrator privilege, 17 close and 2 remain. Who can write a
  point carries more decision weight than how many there are.
- Eight items diverge by class and produce 27 decisions; a single corporate list resolves them
  either as unnecessary work in one class or as incomplete closure in another.
- The cost of closing is 41 manual steps and is highest in the classes cleaned the most; the cost
  per closed point in logon scripts is roughly five times that of service definitions.

#### Next Step

The remaining 44 points cannot be closed, because the work requires them. But the components
carrying these points age where they stand: once a flaw is announced in the binary behind a service
definition, that point, because it cannot be removed, stays usable until a patch is applied. The
next lesson's measure is therefore the **window**: the days between announcement and application,
the number of points that stay open in that window, and the product of the two. An emergency patch
shortens the window, but how many test steps it skips and how many outages it produces are counted
separately. What a patch actually closes is also a separate question: a patch does not remove a
foothold point, it only closes one known path to reaching that point.

### Patch Management

Source: https://academia.sh/en/courses/endpoint-security/patch-management

The patch's measure is taken as the window: the days between announcement and application for 12 announcements across a fleet of 102 machines, the point-day equivalent of the 1038 foothold points left open in that window, the 939 unplanned outages the emergency path produces by skipping 36 of 48 test items, and the 12 points that never close under any regime.

The previous lesson left 44 foothold points unclosed across four machine classes: the work itself
requires them. A point that cannot be closed does not age where it stands, but the component that
defines it does. When a flaw is announced in the binary behind a service definition, that point —
because it cannot be removed — stays reachable by a known path until the patch is applied.

This lesson's measure is the **window**: the interval between the day an announcement is published
and the day the patch is applied. On its own, the window is only a span of time; the weight it
carries emerges once it is multiplied by the number of points left open in that interval. The unit
is the **point-day**.

#### What a Patch Closes

This lesson begins with a distinction, because without it every number that follows gets written
to the wrong column. A patch does not **remove** a foothold point. After the patch is applied, the
service definition stays in place, the scheduled task stays in place, the boot entry stays in
place. The only thing that changes is that a **known flaw** allowing the point's definition to be
written disappears.

For the course's two columns, this means: the number of points a patch closes is zero. What a
patch reduces is the **duration** for which the point is reachable. The previous lesson's pruning
actually closed 40 points; a patch closes none of them, it only determines how long the remaining
44 stay open. Neither job substitutes for the other, and neither makes the other unnecessary.

The post-install exposed surface and the four parts of the patch window were measured in the
**Server, Network, and Hardening** course; they are not repeated here. This lesson's measure is
the foothold point, not the server item, and the count is taken across the fleet.

- **SH7.** The fleet of 102 machines, four classes, and 12 announcements are a **model**; no real
  patch is downloaded, no real fleet is scanned. The remaining-point count per class (9, 12, 12,
  11) comes from the previous lesson.
- **SH8.** Each foothold point's definition is counted as belonging to a single component. In
  reality a point can depend on more than one component; that would only increase the counts, not
  change their direction.
- **SH9.** There is no randomness: the announcement days, the components, and the calendar are
  fixed in the model.
- **SH10.** Flawed patches and rollbacks are not modeled; both were measured in the previous
  course and are taken here as input.
- **SH11.** The maintenance calendar has a fixed interval. The number of machines that miss a turn
  because they are offline is fixed per class, and those machines are patched on the next turn.
- **SH12.** The emergency path applies in 1.5 days and skips three of the four test items;
  machines that miss their turn also miss the emergency path.

```js
// patch/model.mjs — the window IS A MODEL: the announcement day, the application day, and the
// open foothold point in that interval, in a constructed fleet. No real patch is downloaded, no real fleet is scanned.
// The remaining-point count per class (9, 12, 12, 11) comes from the previous lesson.
export const CLASS = [
  // name, machines, calendar interval (days), calendar offset, machines that miss a turn because they stay offline
  { name: 'field-laptop',      machines: 60, calendar: 21, offset: 6, missed: 9 },
  { name: 'branch-endpoint',   machines: 24, calendar: 14, offset: 3, missed: 1 },
  { name: 'collector',         machines: 12, calendar: 7,  offset: 2, missed: 0 },
  { name: 'processing-server', machines: 6,  calendar: 30, offset: 9, missed: 0 },
];
// Each component carries the definition for how many foothold points per class; heavy: the machine restarts.
export const COMPONENT = {
  kernel:                 { points: [2, 2, 2, 2], heavy: true },
  'system-library':       { points: [1, 1, 1, 1], heavy: true },
  'remote-management':    { points: [0, 1, 1, 1], heavy: false },
  'monitoring-agent':     { points: [2, 2, 2, 2], heavy: false },
  'patch-agent':          { points: [2, 2, 2, 2], heavy: false },
  'identity-plugin':      { points: [0, 1, 1, 1], heavy: false },
  shell:                  { points: [2, 2, 2, 1], heavy: false },
  'business-application': { points: [0, 1, 1, 1], heavy: false },
};
export const ANNOUNCEMENT = [                       // [day, component, emergency?]
  [4.0, 'system-library', false], [13.5, 'kernel', true], [21.0, 'shell', false],
  [28.5, 'monitoring-agent', false], [34.0, 'remote-management', true], [42.5, 'system-library', false],
  [51.5, 'patch-agent', false], [58.5, 'kernel', false], [66.0, 'business-application', false],
  [74.5, 'identity-plugin', true], [88.0, 'shell', false], [97.5, 'system-library', true],
];
export const HORIZON = 120, EMERGENCY_DURATION = 1.5, TEST_ITEM = 4, EMERGENCY_SKIPPED = 3;
// Unpatchable item: the business application cannot be upgraded because of the collection service's version compatibility.
export const UNPATCHABLE = [['business-application', 'collector']];
export const patchable = (b, i) => !UNPATCHABLE.some(([x, y]) => x === b && y === CLASS[i].name);
export const calendarDay = (i, g) => {
  const s = CLASS[i];
  return Math.ceil((g - s.offset) / s.calendar) * s.calendar + s.offset;
};
export const TOTAL_POINTS = CLASS.reduce((a, s, i) =>
  a + s.machines * Object.values(COMPONENT).reduce((b, c) => b + c.points[i], 0), 0);

// The window and point-days of an announcement in a class. regime: 'calendar' | 'mixed' | 'all-emergency'
export function window(d, i, regime) {
  const [g, b, emergency] = d, n = COMPONENT[b].points[i], s = CLASS[i];
  const empty = { points: 0, pointDays: 0, openPointDays: 0, outage: 0, openRemaining: 0 };
  if (n === 0) return empty;
  if (!patchable(b, i))
    return { ...empty, points: n * s.machines, openPointDays: n * s.machines * (HORIZON - g),
      openRemaining: n * s.machines };
  const emergencyPath = regime === 'all-emergency' || (regime === 'mixed' && emergency);
  const first = emergencyPath ? g + EMERGENCY_DURATION : calendarDay(i, g);
  const next = calendarDay(i, first + 0.001);          // machines that miss the first turn
  const onTime = s.machines - s.missed;
  return { ...empty, points: n * s.machines,
    pointDays: n * (onTime * (first - g) + s.missed * (next - g)),
    outage: emergencyPath ? onTime : 0 };
}

export function scan(regime) {
  let pointDays = 0, weight = 0, full = 0, service = 0, open = 0, openDays = 0, skipped = 0;
  for (const d of ANNOUNCEMENT) {
    const emergencyPath = regime === 'all-emergency' || (regime === 'mixed' && d[2]);
    if (emergencyPath) skipped += EMERGENCY_SKIPPED;
    for (let i = 0; i < CLASS.length; i += 1) {
      const p = window(d, i, regime);
      pointDays += p.pointDays; openDays += p.openPointDays; open += p.openRemaining;
      if (p.openRemaining === 0) weight += p.points;
      if (COMPONENT[d[1]].heavy) full += p.outage; else service += p.outage;
    }
  }
  return { avgWindow: pointDays / weight, pointDays, full, service, open, openDays, skipped };
}
```

#### Window per Announcement

```js
// patch/measure.mjs — the window, the open point in the window, and the cost of an emergency patch
import { CLASS, COMPONENT, ANNOUNCEMENT, HORIZON, EMERGENCY_DURATION, TEST_ITEM, TOTAL_POINTS,
  UNPATCHABLE, window, calendarDay, scan } from './model.mjs';

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

console.log(`${CLASS.reduce((a, s) => a + s.machines, 0)} machines, ${TOTAL_POINTS} foothold points, ` +
  `${ANNOUNCEMENT.length} announcements over ${HORIZON} days (${ANNOUNCEMENT.filter((d) => d[2]).length} marked emergency)`);

const A = [-24, 6, 8, 8, 11, 9, 12, 12];
console.log('\n1. window (days) and open point per announcement, calendar regime');
row(A, 'announcement', 'day', 'field', 'branch', 'collector', 'server', 'open point', 'point-days');
for (const d of ANNOUNCEMENT) {
  const p = CLASS.map((_, i) => window(d, i, 'calendar'));
  row(A, d[1], f1(d[0]), ...CLASS.map((s, i) => (COMPONENT[d[1]].points[i] === 0 ? '-'
    : f1(calendarDay(i, d[0]) - d[0]))),
  p.reduce((a, x) => a + x.points, 0),
  p.reduce((a, x) => a + x.pointDays + x.openPointDays, 0));
}

const B = [-16, 14, 13, 10, 13, 14, 12];
console.log('\n2. three regimes: window, point-days, and the cost of an emergency patch');
row(B, 'regime', 'avg window', 'point-days', 'skipped', 'full outage', 'service out.', 'open point');
for (const regime of ['calendar', 'mixed', 'all-emergency']) {
  const t = scan(regime);
  row(B, regime, f1(t.avgWindow), t.pointDays, `${t.skipped}/${TEST_ITEM * ANNOUNCEMENT.length}`,
    t.full, t.service, t.open);
}

console.log("\n3. the patch's effect on point count");
const t = scan('mixed');
row([-50, 12], 'foothold points before the patch', TOTAL_POINTS);
row([-50, 12], 'points the patch closes', 0);
row([-50, 12], 'foothold points after the patch', TOTAL_POINTS);
row([-50, 12], 'point-days closed by the known path', t.pointDays);
for (const [b, cls] of UNPATCHABLE) {
  const i = CLASS.findIndex((s) => s.name === cls), d = ANNOUNCEMENT.find((x) => x[1] === b);
  row([-50, 12], `unpatchable ${b}/${cls}`,
    `${COMPONENT[b].points[i] * CLASS[i].machines} points x ${f1(HORIZON - d[0])} days`);
}
row([-50, 12], 'average window shortened by the emergency path',
  f1(scan('calendar').avgWindow - scan('all-emergency').avgWindow));
row([-50, 12], "emergency path's application time", `${f1(EMERGENCY_DURATION)} days`);
```

```
102 machines, 1038 foothold points, 12 announcements over 120 days (4 marked emergency)

1. window (days) and open point per announcement, calendar regime
announcement               day   field  branch  collector   server  open point  point-days
system-library             4.0     2.0    13.0        5.0      5.0         102         725
kernel                    13.5    13.5     3.5        2.5     25.5         204        2560
shell                     21.0     6.0    10.0        2.0     18.0         198        1762
monitoring-agent          28.5    19.5     2.5        1.5     10.5         204        3028
remote-management         34.0       -    11.0        3.0      5.0          42         344
system-library            42.5     5.5     2.5        1.5     26.5         102         770
patch-agent               51.5    17.5     7.5        6.5     17.5         204        3232
kernel                    58.5    10.5     0.5        6.5     10.5         204        1972
business-application      66.0       -     7.0        6.0      3.0          42         848
identity-plugin           74.5       -    12.5        4.5     24.5          42         515
shell                     88.0     2.0    13.0        5.0     11.0         198        1456
system-library            97.5    13.5     3.5        2.5      1.5         102        1136

2. three regimes: window, point-days, and the cost of an emergency patch
regime              avg window   point-days   skipped  full outage  service out.  open point
calendar                  10.8        17700      0/48            0             0          12
mixed                      8.6      14080.5     12/48          184            82          12
all-emergency              2.3         3801     36/48          460           479          12

3. the patch's effect on point count
foothold points before the patch                          1038
points the patch closes                                      0
foothold points after the patch                           1038
point-days closed by the known path                    14080.5
unpatchable business-application/collector        12 points x 54.0 days
average window shortened by the emergency path             8.5
emergency path's application time                     1.5 days
```

#### The Window Is a Product of the Calendar, Not the Component

The first table's rows show the same announcement as four separate windows. For the shell
announcement on day twenty-one, the window is 6 days on the field laptop, 10 on the branch
endpoint, 2 on the collector, and 18 on the processing server. The difference does not come from
the component; it comes from where the announcement falls on the maintenance calendar. On the
collector, whose calendar is seven days, even the longest window never exceeds 6.5 days; on the
processing server, whose calendar is thirty days, it can reach 26.5.

This means the question "how fast is a patch applied" cannot be answered with a single number.
The server class has the sparsest calendar, and there is a reason for it: a restart means a
business disruption, which requires change approval. That same reason stretches the window out to
thirty days.

The point-days column reorders the announcements by weight, and the ordering does not follow the
day count. The patch-agent announcement on day fifty-one carries 3232 point-days; the
system-library announcement on day forty-two carries 770, because it falls into very short
windows across all four classes. Part of the difference comes from the number of points affected
(204 versus 102), part from calendar alignment. Even if two announcements are both flagged as
"same severity," the weight they carry differs by a factor of four, and the difference is visible
only once the point-days are computed.

#### Three Regimes and the Cost of the Emergency Path

The second table compares three regimes: everything by calendar, only the emergency-flagged
announcements by the emergency path, and everything by the emergency path. The average window
drops from 10.8 days to 8.6, then to 2.3. Point-days fall from 17,700 to 3801 — close to a fifth.

The same rows carry three cost columns. Of the 48 test items (twelve announcements, four items
each), none is skipped under the calendar regime; 12 are skipped under the mixed regime, and 36
under the regime where everything goes by the emergency path. The skipped items are the
compatibility test, the rollback rehearsal, and the user notification; the pilot group is
preserved under every regime.

The outage columns carry the actual cost. Under the calendar regime, restarts coincide with the
maintenance window, so unplanned outages are zero. Under the mixed regime, 184 machines see an
unplanned full restart and 82 a service outage. When everything is applied by the emergency path,
those numbers become 460 and 479: 939 unplanned outages in 120 days, more than nine per machine in
a fleet of one hundred two. This is what an 8.5-day window gain costs, and whoever makes the
decision needs to read both numbers side by side.

One number in the third block caps this cost: the emergency path applies in 1.5 days, but the
average window stays at 2.3. The 0.8-day gap comes from nine field laptops and one branch endpoint
that miss their turn because they stay offline. These ten machines also miss the emergency path
and wait for the next calendar day. The floor on shortening the window is not the patch's
readiness time — it is the fleet's **reachability**.

#### The Point That Never Closes

The third block ties the lesson's distinction to a number. The foothold point count across the
fleet is 1038 before the patch, 1038 after. The number of points the patch closes is zero. What
closes is 14,080.5 point-days: the total duration for which the points were reachable by a known
path.

The 12 in the last column stays the same across all three regimes. The business application on
the collector machines cannot be upgraded because of a version-compatibility issue; for 54 days
after the announcement, 12 points stay open, and no change of calendar closes them. For this item,
the concept of a window loses its meaning, because there is no day on which the window closes.

The remedy for an unpatchable item is not in the patch — it is in the previous lesson's column: if
a point cannot be closed, it should at least be made **visible**. The definition of the 12 points
is written to the baseline and compared against for change. This does not remove the flaw; it
makes the moment the flaw is used recordable. That is exactly the difference between the two
columns, and for this item there is nothing but the second column.

#### Summary

- A patch does not remove a foothold point: the point count across the fleet is 1038 both before
  and after the patch. What a patch reduces is the duration for which the point is reachable by a
  known path; the number of points it closes is zero.
- The window is a product of the calendar, not the component: the same announcement stays open 2
  days on a seven-day calendar and 18 on a thirty-day calendar. A single "average patch time"
  hides this difference.
- Point-days reorder the announcements: an announcement affecting 204 points carries 3232, another
  affecting 102 points carries 770. Same severity does not mean same weight.
- The emergency path cuts the average window from 10.8 days to 2.3; in exchange, 36 of 48 test
  items are skipped and 939 unplanned outages are produced over 120 days.
- The floor on the window is set by the fleet's reachability, not the patch's readiness time: the
  emergency path applies in 1.5 days, the average stays at 2.3, and the gap comes from ten offline
  machines.
- The unpatchable item leaves 12 points open under all three regimes and stays open for 54 days;
  its only remedy is the second column — not closing, but recording to the baseline and comparing.

#### Next Step

Two jobs have been done so far: unnecessary items were pruned, and the known flaws in what
remained were closed. The list both jobs rest on was written by hand across these two lessons —
someone decided which item was necessary and which calendar was appropriate, and the reasoning
behind those decisions was never written down. That gap is the next lesson's subject:
**security benchmark guides** collect the same decisions item by item and propose a baseline.
The measure is not how many items the guide contains, but what those items actually do in
this environment: how many items are noncompliant with the endpoints' baseline, how many are
**never applicable** in this environment, and how many block legitimate work when applied. When
the three are not counted separately, only a single compliance percentage remains, and that
percentage says nothing about how many points actually close.

### Configuration Benchmarks

Source: https://academia.sh/en/courses/endpoint-security/configuration-benchmarks

The benchmark guide's 26 items are applied to four machine classes' baseline: 11 of 104 decisions are inapplicable in this environment, 42 are noncompliant, the compliance ratio gives two different numbers with two different denominators, and applying every item narrows the write authority of 588 points while dropping 48 legitimate tasks to a manual step.

In the previous two lessons, someone decided which item was necessary and which calendar was
appropriate; the reasoning behind those decisions was never written down anywhere.
**Security benchmark guides** exist to fill that gap: they collect the same decisions item by
item and propose a baseline. The guide itself is an external source, it does not know the
environment, and its items are not written based on what a specific endpoint does.

This lesson's measure is not how many items the guide contains, but what those items do in this
environment. When three distinct outcomes are collapsed into a single ratio, the decision-carrying
information is lost.

#### Three Distinct Outcomes

When a guide item is applied to a machine class, one of three outcomes results. **Compliant**:
the baseline already satisfies the item. **Noncompliant**: the baseline does not satisfy the item
and could. **Inapplicable**: the item does not make sense in this environment or makes the
machine's job impossible. The third outcome differs from the first two; a screen-lock timeout is
not a deficiency on a machine with no screen — it is that the guide was not written for that
machine.

A fourth axis layers on top of these: whether **legitimate work stops** when the item is applied.
An item being applicable does not mean it is free.

This lesson's point column must also be read carefully. Guide items do not remove a foothold
point — that work was done in the first lesson, and the remaining points stay because the job
requires them. What a guide item does is narrow the **set of subjects who can write** that
point's definition. The three lessons' measures line up this way: the first closed the point
itself, the second shortened the **duration** for which the point is reachable by a known path,
the third narrows the **set of subjects** who can write to the point. The number of closed points
is zero here too.

- **SH13.** The 26-item guide, four machine classes, and baseline are a **model**. The guide's
  name is not written; items are written at a type level, and no real audit is run.
- **SH14.** An item is either applicable or inapplicable in a class; partial application is not
  modeled.
- **SH15.** Compliance status is treated as read from the baseline and is fixed in the model.
- **SH16.** The point column is the number of points per machine whose write authority narrows to
  the administrator when the item is applied.
- **SH17.** Cost is the number of legitimate tasks that fall to a manual step when the item is
  applied; written per class.
- **SH18.** Machine counts come from the previous lesson: 60, 24, 12, and 6.

```js
// benchmark/benchmark.mjs — applying a security benchmark to the baseline IS A MODEL.
// The guide's name is not written; items are written at a type level. No real audit is run.
export const CLASS = [
  { name: 'field-laptop', machines: 60 }, { name: 'branch-endpoint', machines: 24 },
  { name: 'collector', machines: 12 }, { name: 'processing-server', machines: 6 },
];
// row: item | write-authority-narrowing points (per machine) | applicability | compliance | cost
// applicability: u = applicable, x = inapplicable in this environment
// compliance: v = baseline already compliant, a = noncompliant, - = none, because inapplicable
// cost: number of legitimate tasks falling to a manual step when the item is applied (digit per class)
export const CONTROL = `
  unnecessary-service-disabling                  2 uuuu vvvv 0000
  autorun-restriction                            1 uuuu avvv 2000
  scheduled-task-write-restriction               1 uuuu aavv 1100
  logon-script-write-restriction                 1 uuxu av-v 2001
  driver-signature-requirement                   1 uuuu vvaa 0011
  boot-order-lock                                1 uuuu vvaa 0000
  local-administrator-membership-restriction     1 uuuu aavv 3200
  remote-management-restriction                  1 uuuu vvva 0001
  software-source-restriction                    1 uuuu aaav 4210
  script-runtime-restriction                     1 uuxx aa-- 3200
  plugin-directory-lock                          1 uuxx av-- 1000
  management-tool-access-restriction             1 xuuu -vaa 0011
  default-account-disablement                    0 uuuu vvvv 0000
  password-length                                0 uuuu vvvv 0000
  account-lockout-threshold                      0 uuuu vvva 0001
  session-timeout                                0 uuuu avvv 1000
  screen-lock-timeout                            0 uuxx aa-- 2100
  host-firewall-default                          0 uuuu aavv 1100
  inbound-connection-restriction                 0 uuuu vvaa 0022
  share-disabling                                0 uuuu avvv 1000
  log-retention-period                           0 uuuu aaav 0000
  process-creation-logging                       0 uuuu aaaa 0000
  log-centralization                             0 uxuu a-vv 0000
  disk-encryption-requirement                    0 uuuu avva 0000
  removable-media-restriction                    0 uuxu aa-v 3200
  automatic-update-requirement                   0 uxuu v-aa 0011`
  .trim().split('\n').map((r) => {
    const [name, points, applicability, compliance, cost] = r.trim().split(/\s+/);
    return { name, points: Number(points), applicability, compliance, cost };
  });

export const applicable = (m, i) => m.applicability[i] === 'u';
export const compliant = (m, i) => m.compliance[i] === 'v';
export const noncompliant = (m, i) => m.compliance[i] === 'a';
export const costOf = (m, i) => Number(m.cost[i]);
// A scenario: which noncompliant items are applied. 'none' | 'free' | 'all'
export const applied = (m, i, scenario) => noncompliant(m, i)
  && (scenario === 'all' || (scenario === 'free' && costOf(m, i) === 0));

export function scan(scenario) {
  let cell = 0, inapplicable = 0, alreadyCompliant = 0, narrowed = 0, stopped = 0, exception = 0;
  for (const m of CONTROL)
    for (const [i, s] of CLASS.entries()) {
      cell += 1;
      if (!applicable(m, i)) { inapplicable += 1; continue; }
      if (compliant(m, i)) { alreadyCompliant += 1; continue; }
      if (applied(m, i, scenario)) { narrowed += m.points * s.machines; stopped += costOf(m, i); }
      else exception += 1;
    }
  const compliantCount = alreadyCompliant + (cell - inapplicable - alreadyCompliant - exception);
  return { cell, inapplicable, alreadyCompliant, narrowed, stopped, exception,
    rawPercent: (100 * compliantCount) / cell,
    applicablePercent: (100 * compliantCount) / (cell - inapplicable) };
}
```

#### Applying Items to the Baseline

```js
// benchmark/measure.mjs — noncompliant items are separated from inapplicable items; compliance percent and narrowed points
import { CLASS, CONTROL, applicable, compliant, noncompliant, costOf, scan } from './benchmark.mjs';

const row = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join('').trimEnd());
const f1 = (x) => x.toFixed(1);
const pointItems = CONTROL.filter((m) => m.points > 0);

console.log(`${CONTROL.length} guide items x ${CLASS.length} machine classes = ` +
  `${CONTROL.length * CLASS.length} decisions; ${pointItems.length} items touch a foothold point`);

const A = [-18, 7, 14, 11, 14, 12, 17, 8];
console.log('\n1. item status per class');
row(A, 'class', 'items', 'inapplicable', 'compliant', 'noncompliant', 'raw %', 'applicable %', 'cost');
for (const [i, s] of CLASS.entries()) {
  const ux = CONTROL.filter((m) => !applicable(m, i)).length;
  const uv = CONTROL.filter((m) => compliant(m, i)).length;
  const ua = CONTROL.filter((m) => noncompliant(m, i)).length;
  row(A, s.name, CONTROL.length, ux, uv, ua, f1((100 * uv) / CONTROL.length),
    f1((100 * uv) / (CONTROL.length - ux)),
    CONTROL.filter((m) => noncompliant(m, i)).reduce((a, m) => a + costOf(m, i), 0));
}

const B = [-46, 8, 14, 14, 17, 8];
console.log('\n2. items that touch a foothold point');
row(B, 'item', 'points', 'inapplicable', 'noncompliant', 'narrowed points', 'cost');
for (const m of pointItems)
  row(B, m.name, m.points, CLASS.filter((_, i) => !applicable(m, i)).length,
    CLASS.filter((_, i) => noncompliant(m, i)).length,
    CLASS.reduce((a, s, i) => a + (noncompliant(m, i) ? m.points * s.machines : 0), 0),
    CLASS.reduce((a, _, i) => a + (noncompliant(m, i) ? costOf(m, i) : 0), 0));

const C = [-24, 12, 18, 17, 14, 12];
console.log('\n3. three scenarios');
row(C, 'scenario', 'raw %', 'applicable %', 'narrowed points', 'stopped work', 'exceptions');
for (const [name, scenario] of [['none applied', 'none'], ['free ones applied', 'free'],
  ['all applied', 'all']]) {
  const t = scan(scenario);
  row(C, name, f1(t.rawPercent), f1(t.applicablePercent), t.narrowed, t.stopped, t.exception);
}
const t0 = scan('none'), t1 = scan('free'), t2 = scan('all');
console.log(`\ninapplicable decisions          ${t0.inapplicable} / ${t0.cell}`);
console.log(`items that close no point       ${CONTROL.length - pointItems.length} / ${CONTROL.length}`);
console.log(`compliance ${f1(t0.rawPercent)}% -> ${f1(t2.rawPercent)}% gain  ` +
  `${t2.narrowed} points, ${t2.stopped} manual steps`);
console.log(`share of free steps             ${t1.narrowed}/${t2.narrowed} points, ` +
  `${t1.stopped}/${t2.stopped} manual steps`);
```

```
26 guide items x 4 machine classes = 104 decisions; 12 items touch a foothold point

1. item status per class
class               items  inapplicable  compliant  noncompliant       raw %     applicable %    cost
field-laptop           26             1          9            16        34.6             36.0      24
branch-endpoint        26             2         15             9        57.7             62.5      11
collector              26             5         13             8        50.0             61.9       6
processing-server      26             3         14             9        53.8             60.9       7

2. items that touch a foothold point
item                                            points  inapplicable  noncompliant  narrowed points    cost
unnecessary-service-disabling                        2             0             0                0       0
autorun-restriction                                  1             0             1               60       2
scheduled-task-write-restriction                     1             0             2               84       2
logon-script-write-restriction                       1             1             1               60       2
driver-signature-requirement                         1             0             2               18       2
boot-order-lock                                      1             0             2               18       0
local-administrator-membership-restriction           1             0             2               84       5
remote-management-restriction                        1             0             1                6       1
software-source-restriction                          1             0             3               96       7
script-runtime-restriction                           1             2             2               84       5
plugin-directory-lock                                1             2             1               60       1
management-tool-access-restriction                   1             1             2               18       2

3. three scenarios
scenario                       raw %      applicable %  narrowed points  stopped work  exceptions
none applied                    49.0              54.8                0             0          42
free ones applied               60.6              67.7               18             0          30
all applied                     89.4             100.0              588            48           0

inapplicable decisions          11 / 104
items that close no point       14 / 26
compliance 49.0% -> 89.4% gain  588 points, 48 manual steps
share of free steps             18/588 points, 0/48 manual steps
```

#### Two Compliance Percentages

The first table's last two percentage columns produce two different numbers from the same
baseline. **Raw compliance** divides the compliant items by the guide's total item count.
**Applicable compliance** divides the same numerator by a denominator reduced after removing the
inapplicable items. The second number is always larger than the first, and it is usually the one
written in reports.

The collector-machine row shows where the difference comes from. Raw compliance is 50.0 percent;
applicable compliance is 61.9 percent. The entire 11.9-point gap comes from five items dropping
out of the denominator — screen-lock timeout on a machine with no screen, script-runtime
restriction on a machine whose job is done via script, removable-media restriction because meter
data is collected via removable media. Marking these five items inapplicable is correct. What is
wrong is that a correct decision looks like an **improvement**: no point narrowed, no
configuration changed, and the percentage rose 11.9 points.

The third table gives the limit of this. When **all** noncompliant items are applied, applicable
compliance rises to 100 percent, while raw compliance stays at 89.4 percent. Eleven decisions can
never be compliant, because the item is not meaningful in that environment. "100 percent
compliance" is therefore an unmeasurable goal; the measurable goal is **naming the inapplicable
items explicitly**.

The field laptop is the class with the lowest of both numbers (34.6 and 36.0) and the highest cost
(24 manual steps). Reading the two numbers together shows why it is low: most of the sixteen
noncompliant items restrict items the user installed themselves, and applying them stops
legitimate work. Noncompliance here is not negligence — it is an unpaid cost.

#### Items That Do not Close a Point

Fourteen of the twenty-six items touch no foothold point at all. Password length, session
timeout, log retention period, process-creation logging, and inbound-connection restriction are
real controls, valuable for other things; they add no number to this course's measure. Yet they
carry the same weight as the other items in the compliance ratio's denominator.

The result: there is no fixed relationship between the compliance ratio and the number of narrowed
points. Going from 49.0 percent to 89.4 percent narrows 588 points' write authority, but much of
that same 40-point gain comes from items that never touch the point column. The reverse is also
possible: a team that applies only non-point-closing items raises the ratio while making zero
progress in the point column.

The `unnecessary-service-disabling` row in the second table is the other end of the same
relationship. It narrows 2 points per machine, the strongest item, and it is already
**compliant** in all four classes — because that work was exactly what got done in the first
lesson. The guide adds nothing here; it confirms. The guide's contribution can only be measured in
the noncompliant column.

#### Exception List and Maintenance

The last two rows give the tradeoff facing whoever makes the decision. Applying the zero-cost
noncompliant items raises compliance to 60.6 percent with no legitimate work stopped — and narrows
only 18 of 588 points, 3 percent. Nearly all the narrowing comes from items with a cost.
Software-source restriction alone narrows 96 points and brings 7 manual steps;
local-administrator-membership restriction narrows 84 points and 5 steps. What is cheap does very
little; nothing that does real work is cheap.

Every unapplied noncompliant decision is an **exception item**, and an exception is not a one-time
task. 30 exception items remain in the free-of-cost scenario, 42 when nothing is applied. Each
item needs a reason, an owner, and a re-evaluation date; every new version of the guide remaps
items and re-tests the inapplicable markers. The 11 inapplicable decisions sit on the same list,
because when a machine's job changes, what was inapplicable can become applicable.

This is the true measure of maintenance: 53 of 104 decisions — 42 noncompliant plus 11
inapplicable — form a continuously maintained list. This is the number that shows applying the
guide is far more expensive than reading it.

The boundary between the two markers must therefore be protected. "Inapplicable" states an
environmental fact and shrinks the denominator; "not applied" states a decision and stays in the
denominator. The only difference between them is one letter, and the same person who writes that
letter also reports the ratio. Marking a high-cost item as inapplicable finishes the work and
raises the ratio at the same time; the result is a table with an empty noncompliant column, a high
raw-compliance ratio, and zero narrowed points. What distinguishes this from a lie is that every
inapplicable marking states, alongside it, **which environmental condition** made the item
meaningless. A machine with no screen is a testable justification; "the restriction makes our job
harder" is a cost statement and belongs in the noncompliant column.

#### Summary

- When a guide item is applied to a class, one of three outcomes results: compliant, noncompliant,
  inapplicable. 51 of 104 decisions are compliant, 42 noncompliant, 11 inapplicable in this
  environment.
- Two compliance ratios give two different numbers from the same baseline: raw 50.0 on the
  collector machine, applicable 61.9. The 11.9-point gap is earned with zero configuration change,
  purely by shrinking the denominator.
- Even if every item is applied, raw compliance stays at 89.4 percent; 11 decisions can never be
  compliant. The measurable goal is not 100 percent compliance — it is naming the inapplicable items
  explicitly.
- A guide item does not close a foothold point; it narrows the set of subjects who can write its
  definition: zero points closed, 588 points narrowed. The three lessons' measures are, in
  sequence, the point itself, the duration it stays open, and the set of subjects who can write to
  it.
- Fourteen of the twenty-six items touch no point at all but carry the same weight as the others
  in the compliance ratio; the strongest item is already compliant in all four classes, because
  that work was done in the first lesson.
- Zero-cost noncompliant items narrow only 18 of 588 points; 97 percent of the narrowing comes at
  the cost of 48 manual steps. Every unapplied decision is an exception item, and 53 items require
  ongoing maintenance.

#### Next Step

This lesson's most expensive item was the software-source restriction: it narrows 96 points, drops
7 legitimate tasks to a manual step, and is already compliant in one class. Behind the item is a
decision mentioned in a single line — deciding in advance what software can run on the endpoint.
That is the next lesson's topic. An **application allowlist** is not a configuration item but a
decision made at every execution attempt, and its accuracy can only be measured against a real
execution set: how many legitimate items the list blocks, how many items it drops to manual
approval, and what a signature-based rule misses differently from a path-based rule.

### Application Allowlists

Source: https://academia.sh/en/courses/endpoint-security/application-allow-lists

The allowlist is evaluated against a 25-item run set: the path-based and signature-based rule miss the same number but different items, the combination form raises the wrong block from 1 to 7 items, and 972 of the fleet's 1038 foothold points become unable to run unapproved code while 66 stay open.

The previous lesson's most expensive item was the software-source restriction: it narrowed the
write authority of 96 points and dropped seven legitimate tasks to a manual step. Behind the item
is a decision mentioned in a single line — deciding in advance what software can run on the
endpoint. An **application allowlist** turns that decision from a configuration item into a
decision made at every execution attempt.

A configuration item is applied once and its compliance is asked about. An allowlist is
re-evaluated on every execution, and its accuracy can only be measured against a **real execution
set**: the items that ran on the endpoints over a period are checked against the list one by one,
and two kinds of error are counted — a blocked legitimate item and an illegitimate item that gets
through.

#### Two Kinds of Rule

Allowlist rules are written along two axes. A **path-based rule** looks at which directory class
the executed file sits in: the system directory and the program directory are allowed; the user
directory, the temp directory, a network share, and removable media are not. A **signature-based
rule** looks at who signed the file: the operating system vendor, the business software vendor,
and an internal signature are allowed; unsigned and unknown-signature items are not.

The two rules can be combined. In an **or** form, an item passing a single criterion is enough; in
an **and** form, it must pass both. The same two rules produce very different results with two
different combinations, and the difference is visible only once measured against a set.

- **SH19.** The twenty-five-item run set, the period, and the run counts are a **model**; no
  execution is blocked on a real endpoint.
- **SH20.** Illegitimate items are identified only by type, path class, and signature class; what
  they do and how they arrived are not written.
- **SH21.** The path class has six values, the signature class five. In reality, subdirectory and
  certificate detail grow the item count without changing the rules' behavior relative to each
  other.
- **SH22.** The decision is binary: pass or block. An alert-only, non-blocking mode is not
  modeled.
- **SH23.** For script items, the decision is made on the interpreter that runs them; with script
  inspection on, the script itself is evaluated by the same rule.
- **SH24.** Script inspection being inapplicable in two classes comes from the previous lesson: in
  those two classes, the work itself runs through a script.

```js
// allowlist/allowlist.mjs — evaluating an application allowlist against a run set IS A MODEL.
// No real endpoint execution is blocked; items are written at a type level.
export const PATH_ALLOW = ['system', 'program'];
export const SIGNATURE_ALLOW = ['os-vendor', 'business-vendor', 'internal'];
// row: name | type | path class | signature class | legitimate? | machines it ran on | runs in the period
export const ITEM = `
  metric-agent                    binary system       os-vendor       e  102  91800
  log-forwarder                   binary system       os-vendor       e  102   3060
  patch-agent                     binary program       business-vendor e  102    408
  driver-updater                  binary program       os-vendor       e  102    204
  archive-tool                    binary program       business-vendor e  102   1224
  document-editor                 binary program       business-vendor e   84   5040
  field-measurement-application   binary program       business-vendor e   60   7200
  database-client                 binary program       business-vendor e   18    540
  internal-report-tool            binary program       unsigned        e   24    480
  maintenance-script               script system       internal        e  102   3060
  diagnostic-tool                 binary temp          internal        e   30     90
  new-version-installer           binary temp          business-vendor e   40     40
  internal-helper-tool            binary user          unsigned        e   18    360
  meter-reading-tool              binary removable     business-vendor e   12    720
  field-setup-script               script user         unsigned        e   60    180
  quarterly-report-script          script network-share internal        e   24     96
  unknown-binary-1                binary temp          unsigned        h    4     11
  unknown-binary-2                binary user          unsigned        h    7     23
  unknown-binary-3                binary network-share unknown         h    2      4
  unknown-binary-4                binary removable     unknown         h    1      1
  interpreted-item-1               script user         unsigned        h    6     19
  interpreted-item-2               script temp         unsigned        h    3      8
  signed-tool-misuse              binary program       business-vendor h    2      5
  unsigned-in-allowed-path        binary program       unsigned        h    5     14
  signed-item-in-user-path        binary user          business-vendor h    3      9`
  .trim().split('\n').map((r) => {
    const [name, type, path, signature, m, machines, runs] = r.trim().split(/\s+/);
    return { name, type, path, signature, legitimate: m === 'e', machines: Number(machines), runs: Number(runs) };
  });

export const RULE = {
  path: { rows: PATH_ALLOW.length, passes: (k) => PATH_ALLOW.includes(k.path) },
  signature: { rows: SIGNATURE_ALLOW.length, passes: (k) => SIGNATURE_ALLOW.includes(k.signature) },
  'path-or-signature': { rows: PATH_ALLOW.length + SIGNATURE_ALLOW.length,
    passes: (k) => PATH_ALLOW.includes(k.path) || SIGNATURE_ALLOW.includes(k.signature) },
  'path-and-signature': { rows: PATH_ALLOW.length + SIGNATURE_ALLOW.length,
    passes: (k) => PATH_ALLOW.includes(k.path) && SIGNATURE_ALLOW.includes(k.signature) },
};
// Script items: the decision is made on the interpreter that runs them, not the script itself.
// With script inspection on, the script itself is evaluated by the same rule.
export const passes = (k, rule, scriptInspection) =>
  (k.type === 'script' && !scriptInspection ? true : RULE[rule].passes(k));

export function measure(rule, scriptInspection) {
  const blocked = ITEM.filter((k) => k.legitimate && !passes(k, rule, scriptInspection));
  const evaded = ITEM.filter((k) => !k.legitimate && passes(k, rule, scriptInspection));
  const sum = (l, field) => l.reduce((a, k) => a + k[field], 0);
  return { blockedItems: blocked.length, blockedMachines: sum(blocked, 'machines'),
    blockedRuns: sum(blocked, 'runs'), evadedItems: evaded.length,
    evadedMachines: sum(evaded, 'machines'), evadedRuns: sum(evaded, 'runs'),
    rows: RULE[rule].rows + (scriptInspection ? 1 : 0) + blocked.length };
}

// How many of the fleet's foothold points become unusable with the allowlist.
// A point that can carry a script does not close, because it can call an allowed interpreter.
export const CLASS = [
  { name: 'field-laptop', machines: 60, remaining: 9, scriptable: 3, scriptInspection: true },
  { name: 'branch-endpoint', machines: 24, remaining: 12, scriptable: 3, scriptInspection: true },
  { name: 'collector', machines: 12, remaining: 12, scriptable: 4, scriptInspection: false },
  { name: 'processing-server', machines: 6, remaining: 11, scriptable: 3, scriptInspection: false },
];
export const pointMeasure = (s) => ({
  points: s.remaining * s.machines,
  closed: (s.scriptInspection ? s.remaining : s.remaining - s.scriptable) * s.machines,
  open: (s.scriptInspection ? 0 : s.scriptable) * s.machines,
});
```

#### Four Rule Sets, the Same Set

```js
// allowlist/measure.mjs — four rule sets evaluated against the same run set
import { ITEM, RULE, CLASS, passes, measure, pointMeasure } from './allowlist.mjs';

const row = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join('').trimEnd());
const sum = (f, field) => ITEM.filter(f).reduce((a, k) => a + k[field], 0);

console.log(`${ITEM.length} run items, ${sum(() => true, 'runs')} runs; ` +
  `${ITEM.filter((k) => !k.legitimate).length} items not legitimate, ` +
  `${ITEM.filter((k) => k.type === 'script').length} items are scripts`);

const A = [-20, 7, 12, 18, 11, 17, 13, 8];
console.log('\n1. rule sets (script inspection off / on)');
row(A, 'rule', 'script', 'blocked i.', 'blocked machines', 'evaded i.', 'evaded machines', 'evaded runs', 'rows');
for (const rule of Object.keys(RULE))
  for (const si of [false, true]) {
    const o = measure(rule, si);
    row(A, rule, si ? 'yes' : 'no', o.blockedItems, o.blockedMachines, o.evadedItems,
      o.evadedMachines, o.evadedRuns, o.rows);
  }

const B = [-32, 8, 13, 17, 12, 9];
console.log('\n2. items given a wrong decision under the path-or-signature rule (script inspection on)');
row(B, 'item', 'type', 'path', 'signature', 'legitimate', 'result');
for (const k of ITEM) {
  const g = passes(k, 'path-or-signature', true);
  if (g === k.legitimate) continue;                    // correctly decided items are skipped
  row(B, k.name, k.type, k.path, k.signature, k.legitimate ? 'yes' : 'no', g ? 'passed' : 'blocked');
}

const C = [-18, 8, 10, 8, 19];
console.log('\n3. the allowlist\'s effect on foothold points');
row(C, 'class', 'points', 'closed', 'open', 'script inspection');
let t = { points: 0, closed: 0, open: 0 };
for (const s of CLASS) {
  const o = pointMeasure(s);
  for (const a of Object.keys(t)) t[a] += o[a];
  row(C, s.name, o.points, o.closed, o.open, s.scriptInspection ? 'yes' : 'no');
}
row(C, 'total', t.points, t.closed, t.open, '');
console.log('\nselected rule set: path-or-signature');
for (const si of [false, true]) {
  const v = measure('path-or-signature', si);
  console.log(`  script inspection ${si ? 'yes' : 'no'}: blocked ${v.blockedItems} items / ` +
    `${v.blockedMachines} machines / ${v.blockedRuns} runs; evaded ${v.evadedItems} items / ` +
    `${v.evadedMachines} machines; ${v.rows} rows`);
}
console.log(`  classes where script inspection applies: ` +
  `${CLASS.filter((s) => s.scriptInspection).length}/${CLASS.length}`);
```

```
25 run items, 114596 runs; 9 items not legitimate, 5 items are scripts

1. rule sets (script inspection off / on)
rule                 script  blocked i.  blocked machines  evaded i.  evaded machines  evaded runs    rows
path                     no           4               100          4               16           46       6
path                    yes           6               184          2                7           19       9
signature                no           2                42          4               14           41       5
signature               yes           3               102          2                5           14       7
path-or-signature        no           1                18          5               19           55       6
path-or-signature       yes           2                78          3               10           28       8
path-and-signature       no           5               124          3               11           32      10
path-and-signature      yes           7               208          1                2            5      13

2. items given a wrong decision under the path-or-signature rule (script inspection on)
item                                type         path        signature  legitimate   result
internal-helper-tool              binary         user         unsigned         yes  blocked
field-setup-script                script         user         unsigned         yes  blocked
signed-tool-misuse                binary      program  business-vendor          no   passed
unsigned-in-allowed-path          binary      program         unsigned          no   passed
signed-item-in-user-path          binary         user  business-vendor          no   passed

3. the allowlist's effect on foothold points
class               points    closed    open  script inspection
field-laptop           540       540       0                yes
branch-endpoint        288       288       0                yes
collector              144        96      48                 no
processing-server       66        48      18                 no
total                 1038       972      66

selected rule set: path-or-signature
  script inspection no: blocked 1 items / 18 machines / 360 runs; evaded 5 items / 19 machines; 6 rows
  script inspection yes: blocked 2 items / 78 machines / 540 runs; evaded 3 items / 10 machines; 8 rows
  classes where script inspection applies: 2/4
```

#### Same Number, Different Items

With script inspection off, the path-based rule misses 4 illegitimate items; the signature-based
rule also misses 4. The two numbers are equal, the items are not. The path rule lets through an
unsigned item sitting in an allowed directory — the moment a writable subdirectory exists inside
an allowed path class, the rule covers that subdirectory too. The signature rule lets through an
item signed with a recognized signature sitting in the user directory — a signature says who
produced the file, not where it was placed.

This means the question "which rule type is better" has no answer. The question to ask is which
items they miss, and the two lists do not cover each other. Combining the two rules with **and**
brings the evaded items down from 4 to 3; because it requires passing both criteria, items that
pass only one of them are stopped.

The cost of the combination form is readable in the same rows. The **or** form brings the wrong
block down to 1 item and 18 machines but raises evaded items to 5. The **and** form brings evaded
down to 3 while raising the wrong block to 5 items and 124 machines. The same two rules, the same
five rows, produce a fivefold difference in wrong blocks with two different combinations. The rule
count never tells this decision; the combination form does.

#### An Item Both Rules Let Through

The second table lists the five items given a wrong decision under the selected rule set. The
third row is this lesson's boundary: the misuse of a tool that sits in an allowed directory and is
signed with a recognized signature. This item passes every rule set including
`path-and-signature`, and script inspection does not stop it either.

The reason is not a gap in the rule; it is the question itself. The allowlist answers "can this
binary run"; it does not look at "what is this binary doing right now." An approved tool being
invoked for an unapproved task is an item the allowlist cannot see by definition. Its count is
small — two machines, five runs — but it stays the same across every rule set, and a stricter list
does not reduce it. Measuring this item belongs to the next topic, controls that watch behavior.

#### Blocked Legitimate Work

The strictest configuration — `path-and-signature` plus script inspection — brings evaded items
down to 1 and evaded runs to 5. In the same row, the wrong block rises to 7 items and 208
machines. On a fleet of one hundred two machines, 208 machine-items means some machines run into
more than one block.

Reading the blocked items by name, each has a justification. The diagnostic tool runs from the
temp directory because it is downloaded there during an incident. The new-version installer is in
the temp directory by definition. The meter-reading tool runs from removable media because there is
no network in the field. The internal report tool is unsigned because no signing process was ever
set up. The field-setup script sits in the user directory and is unsigned. None of these is a
mistake; all of them are real work that falls outside the order the list assumes.

Every blocked item produces either an exception line or a manual approval on every run. The rows
column counts this: 6 rows in the loosest configuration, 13 in the strictest. Maintenance cost
does not come from the list itself but from the exceptions, and every new tool, every new version,
and every new field procedure reopens this list.

#### The Interpreted Item and the Foothold Point

Five items are scripts, and their decision is made on the interpreter that runs them. With script
inspection off, every rule set lets these five items through, because the interpreter is an
allowed binary and that is what the list is asked about. When script inspection is turned on,
evaded items drop under every rule set, but not by the same ratio: by half (4 to 2) under the
`path` and `signature` rules, by a third (3 to 1) under the `and` rule.

The third table gives this a counterpart in foothold points, and for the first time since the
first lesson, a large number lands in the closed column. 972 of the fleet's 1038 points become
unable to run unapproved code: a startup entry, a service definition, or a scheduled task stays in
place, but what it calls does not run if it does not pass the list.

The 66 points that stay open come from two classes. Script inspection cannot be applied on the
collector machine and the processing server, because — as measured in the previous lesson — the
work itself runs through a script in those two classes. In these classes, a point that can carry a
script can bypass the list by calling an allowed interpreter. The number is small (6 percent of
1038) and lands exactly where the list cannot be applied. The rule for a point that cannot be closed
applies here too: the 66 points are at least written to the baseline, and whether they change is
compared.

#### Summary

- An allowlist is not a configuration item but a decision made on every execution; its accuracy is
  measured only against a real execution set. 9 of twenty-five items are not legitimate, 5 are
  scripts.
- The path-based and signature-based rules miss the same number of items (4) but not the same
  items: one lets through an unsigned item in an allowed directory, the other a signed item in the
  user directory.
- The combination form carries more of the decision than the rule count: the same five rows
  wrongly block 1 item and miss 5 in the **or** form, and wrongly block 5 items and miss 3 in the
  **and** form.
- The strictest configuration brings evaded items down to 1, at the cost of blocking 7 legitimate
  items on 208 machines and raising the exception rows from 6 to 13. Every blocked item has a
  valid justification.
- The misuse of a tool sitting in an allowed directory and signed with a recognized signature
  passes every rule set; the allowlist looks at what can run, not at what it is doing.
- The allowlist makes 972 of the fleet's 1038 foothold points unable to run unapproved code; 66
  stay open in the two classes where script inspection does not apply and can only be written to
  the baseline.

#### Next Step

This lesson decided whether an item could run by looking at path and signature. Neither criterion
ever asks what the item **does**, and that left exactly the item that evades both. The only way to
learn what an item does is to run it — but running it on the endpoint defeats the purpose of the
decision itself. The next lesson's topic is the answer to this bind: the **sandbox** is the
decision to run a suspicious item somewhere isolated from the production environment and observe
its behavior. What gets measured is not the strength of the isolation — that was measured in a
previous course and is taken here as an input — it is the run decision itself: how many suspicious
items actually get run in isolation, how many behaviors show up in the observation window, how
many behaviors never surface, and how many items the decision falls to manual review for.

### Sandbox Usage

Source: https://academia.sh/en/courses/endpoint-security/sandbox-usage

The sandbox is measured as a run decision: 15 of 18 suspicious items can run in isolation, four configurations raise observed behavior from 23 to 33, 5 items cannot be decided in any configuration, wrong allow and wrong block never change in any configuration, and closed foothold points are zero on every row.

The previous lesson decided whether an item could run by looking at path and signature; neither
criterion ever asked what the item **does**, and that left exactly the items it missed. The only
way to learn what an item does is to run it — but running it on the endpoint defeats the purpose
of the decision itself.

The **sandbox** is the answer to this bind: a suspicious item is run somewhere isolated from the
production environment and its behavior is observed. How strong the isolation is was measured in
the **Information Technology and Systems Fundamentals** course and is taken as an input here. What is measured is not
the isolation, it is the decision itself.

#### Not a Control, a Decision

This lesson's result can be written in the first line: the sandbox closes no foothold point. A
startup entry, a service definition, or a scheduled task is in the same state after the sandbox is
set up as before. The only thing the sandbox produces is a **decision**, and that decision becomes
input to another control — the allowlist. It is written to only the second of the course's two
columns, the visibility column, and this lesson is that column's purest example.

The decision has three values. **Block**: a decisive behavior was seen in observation. **Allow**:
every behavior of the item was seen and none was decisive. **Uncertain**: the item ran but at
least one behavior was not seen, so its absence does not mean it does not exist. The third value is
this lesson's real subject; a two-valued decision table folds uncertain into allow and the
measurement breaks.

When a suspicious item is run, it may try to reach out over the network; the sandbox's controlled
network must never reach a real target, and examining an item belonging to another organization
without **written authorization and a defined scope** is both illegal and unprofessional.

- **SH25.** The eighteen-item queue, eight behavior classes, and four configurations are a
  **model**; no real item is run. Suspicious items are identified only by type and observed
  behavior class; what they are and how they arrived is not written.
- **SH26.** The strength of the isolation is not measured here; it is taken as an input.
- **SH27.** A behavior becoming visible is treated as depending on a single environment condition.
  In reality a behavior may need more than one condition; this grows the unseen column.
- **SH28.** Whether an item can run at all depends on a single required condition; if the
  condition is not provided, the item is never run.
- **SH29.** The observation-window and manual-review durations are written by hand; there is no
  randomness.
- **SH30.** The decisive flag belongs to the behavior, not the item. The same behavior can also be
  seen in a legitimate item, and the model marked it that way.

```js
// sandbox/queue.mjs — the run decision in a sandbox IS A MODEL. No real item is run;
// suspicious items are identified only by type and observed behavior class.
// A behavior becoming visible depends on the sandbox providing its environment condition.
export const BEHAVIOR = {
  'file-write': '-', 'process-creation': '-', 'persistence-definition': '-',
  'network-connection': 'network', 'credential-read': 'domain-identity',
  'scheduled-trigger': 'long-duration', 'user-action': 'user-interaction',
  'device-access': 'native-hardware',
};
// Configuration: environment conditions it provides, observation window (min), whether it needs an operator.
export const CONFIG = {
  isolated: { provides: [], window: 5, operator: false },
  'controlled-network': { provides: ['network'], window: 5, operator: false },
  'long-window': { provides: ['network', 'long-duration'], window: 60, operator: false },
  interactive: { provides: ['network', 'long-duration', 'user-interaction'], window: 60, operator: true },
};
export const MANUAL_MIN = 25;                 // manual review time per uncertain item
// row: name | type | condition required to run | actual | behaviors ('*' = decisive)
export const ITEM = `
  diagnostic-tool             binary -               legitimate  file-write,process-creation
  new-version-installer       binary -               legitimate  file-write,network-connection,*persistence-definition
  internal-helper-tool        binary -               legitimate  file-write,process-creation,network-connection
  meter-reading-tool          binary native-hardware  legitimate  device-access,file-write
  field-setup-script          script -               legitimate  file-write,*persistence-definition,user-action
  quarterly-report-script     script -               legitimate  file-write,network-connection
  internal-report-tool        binary -               legitimate  file-write,network-connection
  new-document-template       document -             legitimate  file-write,user-action
  vendor-archive               archive -             legitimate  file-write,process-creation
  domain-management-script    script domain-identity  legitimate  network-connection,*credential-read
  suspicious-item-1           binary -               malicious   file-write,process-creation,*persistence-definition
  suspicious-item-2           script -               malicious   file-write,network-connection,*credential-read
  suspicious-item-3           binary -               malicious   file-write,*scheduled-trigger
  suspicious-item-4           binary -               malicious   process-creation,*credential-read
  suspicious-item-5           document -             malicious   file-write,*user-action
  suspicious-item-6           binary -               malicious   file-write,process-creation
  suspicious-item-7           archive -              malicious   file-write,*network-connection
  suspicious-item-8           binary native-hardware  malicious   file-write,*device-access`
  .trim().split('\n').map((r) => {
    const [name, type, required, actual, beh] = r.trim().split(/\s+/);
    return { name, type, required, malicious: actual === 'malicious',
      behavior: beh.split(',').map((d) => ({ name: d.replace('*', ''), decisive: d.startsWith('*') })) };
  });

export const condition = (d) => BEHAVIOR[d.name];
export const visible = (d, c) => condition(d) === '-' || CONFIG[c].provides.includes(condition(d));
export const runs = (k, c) => k.required === '-' || CONFIG[c].provides.includes(k.required);

// A single item's decision in a configuration: block | allow | uncertain | no-run
export function decide(k, c) {
  if (!runs(k, c)) return 'no-run';
  const seen = k.behavior.filter((d) => visible(d, c));
  if (seen.some((d) => d.decisive)) return 'block';
  return seen.length === k.behavior.length ? 'allow' : 'uncertain';
}

export function scan(c) {
  const s = { ran: 0, didNotRun: 0, seen: 0, unseen: 0,
    block: 0, allow: 0, uncertain: 0, wrongAllow: 0, wrongBlock: 0 };
  for (const k of ITEM) {
    const d = decide(k, c);
    if (d === 'no-run') { s.didNotRun += 1; continue; }
    s.ran += 1;
    s.seen += k.behavior.filter((b) => visible(b, c)).length;
    s.unseen += k.behavior.filter((b) => !visible(b, c)).length;
    s[d] += 1;
    if (d === 'allow' && k.malicious) s.wrongAllow += 1;
    if (d === 'block' && !k.malicious) s.wrongBlock += 1;
  }
  s.observeMin = s.ran * CONFIG[c].window;
  s.operatorMin = CONFIG[c].operator ? s.observeMin : 0;
  s.manualMin = (s.uncertain + s.didNotRun) * MANUAL_MIN;
  return s;
}
```

#### Four Configurations, the Same Queue

```js
// sandbox/measure.mjs — four sandbox configurations evaluated against the same suspicious-item queue
import { BEHAVIOR, CONFIG, ITEM, MANUAL_MIN, condition, visible, runs, decide, scan } from './queue.mjs';

const row = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join('').trimEnd());
const neverProvided = [...new Set(Object.values(BEHAVIOR))]
  .filter((c) => c !== '-' && !Object.values(CONFIG).some((v) => v.provides.includes(c)));

console.log(`${ITEM.length} suspicious items, ${ITEM.filter((k) => k.malicious).length} malicious; ` +
  `${Object.keys(BEHAVIOR).length} behavior classes, ${Object.keys(CONFIG).length} configurations`);
console.log(`condition never provided by any configuration: ${neverProvided.join(', ')}`);

const A = [-21, 9, 12, 10, 12, 9, 7, 11, 13, 14];
console.log('\n1. result per configuration');
row(A, 'configuration', 'ran', 'no-run', 'seen', 'unseen', 'block', 'allow',
  'uncertain', 'wrong allow', 'wrong block');
for (const c of Object.keys(CONFIG)) {
  const s = scan(c);
  row(A, c, s.ran, s.didNotRun, s.seen, s.unseen, s.block, s.allow, s.uncertain,
    s.wrongAllow, s.wrongBlock);
}

const B = [-27, 12, 14, 21, 14, 14];
console.log('\n2. decision per item');
row(B, 'item', 'actual', ...Object.keys(CONFIG));
for (const k of ITEM)
  row(B, k.name, k.malicious ? 'malicious' : 'legitimate', ...Object.keys(CONFIG).map((c) => decide(k, c)));

const C = [-21, 9, 13, 14, 12, 11, 15];
console.log('\n3. decision time (min) and closed foothold points');
row(C, 'configuration', 'window', 'observe min', 'operator min', 'manual min', 'total min', 'closed points');
for (const c of Object.keys(CONFIG)) {
  const s = scan(c);
  row(C, c, CONFIG[c].window, s.observeMin, s.operatorMin, s.manualMin,
    s.observeMin + s.operatorMin + s.manualMin, 0);
}
const u = scan('long-window'), e = scan('interactive');
console.log(`\nitems falling to manual review (long-window)   ${u.uncertain + u.didNotRun} / ${ITEM.length}`);
console.log(`undecided in every configuration                ${e.uncertain + e.didNotRun} / ${ITEM.length}`);
console.log(`  malicious items unstoppable in the best config  ` +
  `${ITEM.filter((k) => k.malicious && decide(k, 'interactive') !== 'block').length}`);
console.log(`wrong allow / wrong block in every configuration ${e.wrongAllow} / ${e.wrongBlock}`);
console.log(`manual review time                               ${MANUAL_MIN} min/item`);
```

```
18 suspicious items, 8 malicious; 8 behavior classes, 4 configurations
condition never provided by any configuration: domain-identity, native-hardware

1. result per configuration
configuration              ran      no-run      seen      unseen    block  allow  uncertain  wrong allow   wrong block
isolated                    15           3        23          12        3      3          9            1             2
controlled-network          15           3        29           6        4      6          5            1             2
long-window                 15           3        30           5        5      6          4            1             2
interactive                 15           3        33           2        6      7          2            1             2

2. decision per item
item                             actual      isolated   controlled-network   long-window   interactive
diagnostic-tool              legitimate         allow                allow         allow         allow
new-version-installer        legitimate         block                block         block         block
internal-helper-tool         legitimate     uncertain                allow         allow         allow
meter-reading-tool           legitimate        no-run               no-run        no-run        no-run
field-setup-script           legitimate         block                block         block         block
quarterly-report-script      legitimate     uncertain                allow         allow         allow
internal-report-tool         legitimate     uncertain                allow         allow         allow
new-document-template        legitimate     uncertain            uncertain     uncertain         allow
vendor-archive               legitimate         allow                allow         allow         allow
domain-management-script     legitimate        no-run               no-run        no-run        no-run
suspicious-item-1             malicious         block                block         block         block
suspicious-item-2             malicious     uncertain            uncertain     uncertain     uncertain
suspicious-item-3             malicious     uncertain            uncertain         block         block
suspicious-item-4             malicious     uncertain            uncertain     uncertain     uncertain
suspicious-item-5             malicious     uncertain            uncertain     uncertain         block
suspicious-item-6             malicious         allow                allow         allow         allow
suspicious-item-7             malicious     uncertain                block         block         block
suspicious-item-8             malicious        no-run               no-run        no-run        no-run

3. decision time (min) and closed foothold points
configuration           window  observe min  operator min  manual min  total min  closed points
isolated                     5           75             0         300        375              0
controlled-network           5           75             0         200        275              0
long-window                 60          900             0         175       1075              0
interactive                 60          900           900         125       1925              0

items falling to manual review (long-window)   7 / 18
undecided in every configuration                5 / 18
  malicious items unstoppable in the best config  4
wrong allow / wrong block in every configuration 1 / 2
manual review time                               25 min/item
```

#### Observed and Unobserved Behavior

15 of eighteen items can be run, 3 cannot be run at all. Two of the ones that cannot run are items
requiring native hardware — the meter-reading tool and a suspicious binary — and one is a
management script requiring a domain identity. The sandbox says nothing about these three items; a
sixth of the queue falls to manual review before any observation happens at all.

Loosening the isolation increases observed behavior: from 23 to 33, unobserved from 12 to 2. But
what is being loosened is exactly the isolation itself. Adding a controlled network makes six more
behaviors visible; extending the observation window from five minutes to sixty adds only one
behavior; operator interaction adds three behaviors. The gains are not equal to each other, and
neither are the costs.

Two conditions are never provided in any configuration: **domain identity** and **native
hardware**. The first cannot be provided because giving the sandbox a real domain identity
eliminates the isolation — everything reachable with that identity enters the sandbox. The second
cannot be provided because the hardware is not there. These two conditions are not a
configuration gap; they are the method's limit: as long as the sandbox is not the production
environment itself, behavior tied to the production environment stays invisible, and the moment it
is the production environment, it stops being a sandbox.

#### The Item Left Undecided

The second table shows how decisions change from configuration to configuration. In the isolated
configuration, 9 of the 15 running items stay uncertain; in the most capable configuration that
number drops to 2. But three values stay the same: wrong allow at 1, wrong block at 2, and items
that cannot run at 3.

**Wrong allow** is a single item, and it does not get fixed in any configuration. All of that
item's behaviors are visible in observation — file write and process creation — and neither is
decisive. Extending the observation window, adding the network, or seating an operator changes
nothing for this item, because what is missing is not observation, it is the distinction itself. If
an item's sandbox behavior cannot be told apart from a legitimate item's, the sandbox cannot produce
a decision about it.

**Wrong block** is two items, and the reason is the same, reversed. The new-version installer and
the field setup script both write a persistence definition — the very thing this course measures,
a foothold-point definition. A decisive behavior is seen and the decision comes out "block." Both
are legitimate work. The sandbox sees the behavior, not **who did it under what authority**;
writing a persistence definition during installation is an installer's job.

Five items cannot be decided in any configuration, and **three are malicious**. Three come from
items that cannot be run, two from items with a behavior requiring domain identity. Of the queue's
eight malicious items, only four are stopped by the sandbox; of the remaining four, three get no
answer from the sandbox at all — the question is handed to manual review — and the fourth passes
with a wrong **allow** decision in all four configurations and never falls to review.

#### The Cost of the Decision

The third table gives the cost, and it is not one-directional. Observation time is 75 minutes in
the isolated configuration, 900 in the long window. Manual review time goes the other way: from
300 minutes down to 125. Added together, the cheapest configuration turns out to be **controlled
network** — 275 minutes — and the most expensive is interactive, 1925 minutes. The gap is exactly
sevenfold, and in return uncertain items drop from 5 to 2 while wrong decisions do not change at
all.

This is the number that determines where the decision stops. Adding a controlled network to a
five-minute window raises observed behavior from 23 to 29 and **lowers** total time from 375 to
275, because fewer items fall to manual review. Extending the window to sixty minutes, on the
other hand, gains a single behavior and quadruples the time. The cheap step is adding the network;
the expensive one is extending the duration.

There is another cost the table does not show: the sandbox itself needs maintenance. For observation
to work, the environment has to resemble production endpoints — the same software set, the same
versions, the same configuration — and the more it resembles them, the less isolation there is.
Every time the endpoints' baseline changes, the sandbox image has to be updated too; when it
is not, an item may show no behavior at all because it does not recognize the environment it is
running in, and the decision silently drifts to "allow." Add to this the inventory of the machines
that run the sandbox itself: they are endpoints too, and they go into this course's first-lesson
count.

The last column is zero in every row. No matter how many more behaviors one configuration reveals
over another, the number of closed foothold points does not change. The decision the sandbox
produces either adds a row to the allowlist or it does not; what closes the point is that row, not
the sandbox.

#### Summary

- The sandbox is not a control but a run decision: closed foothold points are zero across all four
  configurations. The decision it produces becomes input to the allowlist, and the point closes
  there.
- The decision is three-valued; if uncertain is not kept separate, the measurement breaks. In the
  isolated configuration, 9 of the 15 running items are uncertain; in the most capable
  configuration, 2.
- Two environment conditions can never be provided in any configuration — domain identity and
  native hardware. Providing the first eliminates the isolation; this is not a gap, it is the
  method's limit.
- Wrong allow (1) and wrong block (2) never change in any configuration. Wrong block happens
  because a legitimate installer also writes a persistence definition: the sandbox sees the
  behavior, not the authority.
- Only four of the eight malicious items are stopped by the sandbox; three cannot be decided and go
  to manual review, and one passes with a wrong allow decision in all four configurations.
- Time runs in two directions: adding a controlled network lowers total time from 375 minutes to
  275; extending the window to sixty minutes gains one behavior and pushes time to 1075 minutes.

#### Next Step

Everything counted up to here held while the machine was on and an operating system was running.
The inventory was pulled from inside that system, the allowlist audited that system's run
decisions, the sandbox ran an item on a copy of that system. Every one of them shares the same
assumption: the only way to reach the machine is through the running system.

The next lesson removes that assumption. When the disk is pulled from the machine and mounted
somewhere else, the running system drops out of the picture, and every control drops with it: the
allowlist performs no evaluation, the hardening baseline is not read, no log is kept. What gets
measured is this: how many items can be read with physical access on an unencrypted and an
encrypted disk, which foothold-point class full disk encryption closes, and, item by item, what
encryption **does not** protect while the machine is on, a session is open, or the screen is
locked. The topic closes with this measurement.

### Disk Encryption

Source: https://academia.sh/en/courses/endpoint-security/disk-encryption

The item readable via physical access is compared on an unencrypted and an encrypted disk: 15 of 18 items are protected while the machine is off, encryption's contribution is zero in the other three states, 936 of the fleet's 1038 point definitions close against the offline write path, and the cost is 204 key copies plus 14,112 minutes a year of manual intervention.

The previous five lessons shared one assumption: the only way to reach the machine is through the
running system. The inventory was pulled from inside that system, the patch was applied to that
system's components, the benchmark items changed that system's configuration, the allowlist
audited that system's run decisions, and the sandbox ran an item on a copy of that system.

That assumption drops when the disk is pulled from the machine and mounted somewhere else. The
running system drops out of the picture, and every control drops with it: the allowlist performs
no evaluation, the baseline is not read, no log is kept. What is left is only the bytes on the disk.
This lesson's measure is how many of those bytes can be read, and how many can be written.

#### What Encryption Moves Out of Reach

**Full disk encryption** ties the disk's contents to a key. Until the key is presented, the volume
cannot be decrypted; until the volume is decrypted, no item on it can be read and none can be
written. The two verbs matter separately. The read side is a familiar subject: credentials,
business data, and logs are protected. What actually changes for this course's measure is the
**write** side.

Offline write is a foothold path seen for the first time in this course. When the disk is pulled
and mounted on another machine, or the machine is booted from an external medium, a service
definition or a scheduled task definition can be changed directly as a file. The allowlist does not
work on this path, because nothing is being run; a file is being written, and the machine runs it
itself on its next boot.

How the key is stored and rotated is not measured here; key management is a separate subject.
Measuring the boot chain was done in the **Identity, Access and Cryptography** course and is
taken as input here: the protective value of a key tied to the machine depends on that chain
staying intact.

- **SH31.** The eighteen-item disk content, four machine states, and two encryption settings are a
  **model**; no real disk is read, no real encryption is set up.
- **SH32.** Items are written at the class level; file path and format are not modeled.
- **SH33.** A volume is either decrypted or it is not; there is no partial encryption or file-level
  encryption.
- **SH34.** Offline write is only counted as possible while the machine is off. Pulling the disk
  from a running machine means shutting the machine down, and that is a transition to the off
  state.
- **SH35.** In sleep, the key sits in memory; the model treats this state as decrypted.
- **SH36.** The machine counts, remaining points, and restart counts per class come from earlier
  lessons.

```js
// disk/model.mjs — the item readable via physical access IS A MODEL. No real disk is read,
// no real encryption is set up; items are written at the class level.
// row: item | class | inside the encrypted volume | who can read it in a logged-in session | points it carries
export const ITEM = `
  boot-partition-configuration  boot          outside  administrator 1
  boot-signature-record         boot          outside  administrator 0
  recovery-key-hint             identity      outside  administrator 0
  service-definitions           definition    inside   administrator 4
  scheduled-task-definitions    definition    inside   administrator 2
  session-scripts                definition    inside   user          1
  configuration-hooks            definition    inside   administrator 1
  system-configuration           configuration inside   administrator 0
  credential-store                identity      inside   user          0
  saved-session-token             identity      inside   user          0
  client-cache                    data          inside   user          0
  local-logs                      record        inside   administrator 0
  page-file                       memory        inside   administrator 0
  hibernation-image               memory        inside   administrator 0
  temp-file-directory              data          inside   user          0
  meter-reading-data               data          inside   user          0
  local-backup-directory           data          inside   administrator 0
  removable-media-mapping          data          inside   user          0`
  .trim().split('\n').map((r) => {
    const [name, cls, where, reads, points] = r.trim().split(/\s+/);
    return { name, class: cls, inside: where === 'inside', reads, points: Number(points) };
  });
// Machine state: is the volume unlocked, is there an offline write path (disk removal / external boot)
export const STATE = {
  off: { unlocked: false, offlineWrite: true },
  sleep: { unlocked: true, offlineWrite: false },
  'locked-screen': { unlocked: true, offlineWrite: false },
  'logged-in-session': { unlocked: true, offlineWrite: false },
};
export const POINTS = ITEM.reduce((a, k) => a + k.points, 0);
export const OUTSIDE_POINTS = ITEM.filter((k) => !k.inside).reduce((a, k) => a + k.points, 0);

// Items readable and points writable via physical access in a given state and encryption setting.
export function measure(state, encrypted) {
  const d = STATE[state];
  const readable = !encrypted || d.unlocked;              // is the volume's content readable
  const readItems = ITEM.filter((k) => readable || !k.inside);
  const points = d.offlineWrite
    ? ITEM.filter((k) => readable || !k.inside).reduce((a, k) => a + k.points, 0) : 0;
  return { read: readItems.length, points,
    blocked: ITEM.length - readItems.length };            // items encryption blocks
}

// Fleet: points remaining per class (from earlier lessons) and points sitting outside the volume.
// preAuth: is pre-boot authentication required; if not, the key is tied to the machine.
export const CLASS = [
  { name: 'field-laptop', machines: 60, remaining: 9, outside: 1, restarts: 14, preAuth: true, lost: 5 },
  { name: 'branch-endpoint', machines: 24, remaining: 12, outside: 1, restarts: 14, preAuth: true, lost: 1 },
  { name: 'collector', machines: 12, remaining: 12, outside: 1, restarts: 26, preAuth: false, lost: 0 },
  { name: 'processing-server', machines: 6, remaining: 11, outside: 1, restarts: 12, preAuth: false, lost: 0 },
];
export const KEY_COPY = 2;                     // recovery key copies per machine
export const MANUAL_MIN = 12;                  // manual intervention on an unattended restart (min)
```

#### Physical Access Across Four States

```js
// disk/measure.mjs — items readable, points closed, and cost, unencrypted vs. encrypted disk
import { ITEM, STATE, POINTS, OUTSIDE_POINTS, CLASS, KEY_COPY, MANUAL_MIN, measure } from './model.mjs';

const row = (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(`${ITEM.length} disk items, ${ITEM.filter((k) => !k.inside).length} outside the encrypted ` +
  `volume; ${POINTS} foothold-point definitions per machine, ${OUTSIDE_POINTS} outside`);

const A = [-20, 18, 20, 16, 18, 22];
console.log('\n1. items readable and points writable via physical access');
row(A, 'state', 'unencrypted read', 'unencrypted points', 'encrypted read', 'encrypted points', 'encryption blocks');
for (const d of Object.keys(STATE)) {
  const a = measure(d, false), b = measure(d, true);
  row(A, d, a.read, a.points, b.read, b.points, b.blocked);
}

const B = [-30, -14, 10, 8, 12, 22];
console.log('\n2. items outside the volume and points they carry');
row(B, 'item', 'class', 'location', 'points', 'when off', 'in session');
for (const k of ITEM.filter((x) => !x.inside || x.points > 0))
  row(B, k.name, k.class, k.inside ? 'inside' : 'outside', k.points,
    k.inside ? 'protected' : 'readable', `${k.reads} reads`);

const C = [-19, 11, 10, 15, 13, 15, 14];
console.log('\n3. fleet: points closed and the cost of encryption');
row(C, 'class', 'machines', 'points', 'closed points', 'open points', 'manual min/yr', 'lost devices');
const t = { points: 0, closed: 0, open: 0, min: 0, lost: 0 };
for (const s of CLASS) {
  const points = s.remaining * s.machines, open = s.outside * s.machines;
  const min = s.preAuth ? s.machines * s.restarts * MANUAL_MIN : 0;
  t.points += points; t.closed += points - open; t.open += open; t.min += min; t.lost += s.lost;
  row(C, s.name, s.machines, points, points - open, open, min, s.lost);
}
row(C, 'total', CLASS.reduce((a, s) => a + s.machines, 0), t.points, t.closed, t.open, t.min, t.lost);

const m = CLASS.reduce((a, s) => a + s.machines, 0);
console.log(`\nrecovery key copies               ${m * KEY_COPY} (${KEY_COPY} per machine)`);
console.log(`items readable on a lost device    unencrypted ${t.lost * ITEM.length}, ` +
  `encrypted ${t.lost * ITEM.filter((k) => !k.inside).length}`);
console.log(`items a user can read in a logged-in session ` +
  `${ITEM.filter((k) => k.reads === 'user').length} / ${ITEM.length}`);
console.log(`states encryption does not protect  ` +
  `${Object.keys(STATE).filter((d) => measure(d, true).blocked === 0).length} / ${Object.keys(STATE).length}`);
```

```
18 disk items, 3 outside the encrypted volume; 9 foothold-point definitions per machine, 1 outside

1. items readable and points writable via physical access
state                 unencrypted read  unencrypted points  encrypted read  encrypted points     encryption blocks
off                                 18                   9               3                 1                    15
sleep                               18                   0              18                 0                     0
locked-screen                       18                   0              18                 0                     0
logged-in-session                   18                   0              18                 0                     0

2. items outside the volume and points they carry
item                          class           location  points    when off            in session
boot-partition-configuration  boot             outside       1    readable   administrator reads
boot-signature-record         boot             outside       0    readable   administrator reads
recovery-key-hint             identity         outside       0    readable   administrator reads
service-definitions           definition        inside       4   protected   administrator reads
scheduled-task-definitions    definition        inside       2   protected   administrator reads
session-scripts               definition        inside       1   protected            user reads
configuration-hooks           definition        inside       1   protected   administrator reads

3. fleet: points closed and the cost of encryption
class                 machines    points  closed points  open points  manual min/yr  lost devices
field-laptop                60       540            480           60          10080             5
branch-endpoint             24       288            264           24           4032             1
collector                   12       144            132           12              0             0
processing-server            6        66             60            6              0             0
total                      102      1038            936          102          14112             6

recovery key copies               204 (2 per machine)
items readable on a lost device    unencrypted 108, encrypted 18
items a user can read in a logged-in session 7 / 18
states encryption does not protect  3 / 4
```

#### The Point Closed and the Path It Is Closed Against

The fleet table writes 936 into this course's closed column: 936 of the 1038 point definitions sit
inside the encrypted volume and cannot be written offline. The remaining 102 are the boot-partition
entries present one per machine; a partition that has to be read before the volume is decrypted
cannot be encrypted.

Placed next to the number without the path's name, it reads wrong. Encryption closes these 936
points **against the offline write path**. The same 936 points are wide open to anyone who can run
code on the machine while it is running; neither encryption nor the key does anything there. The 40
points closed in the course's first lesson were genuinely removed and never came back on any path;
the closing here belongs to a single path.

This is the shared conclusion of six lessons. A closing number is comparable only once the path
it is closed against is written down: removal closes against every path, patching closes a known
defect, a benchmark item narrows the set of subjects that can write, the allowlist refuses to run,
encryption stops offline writing. All five can be described with the sentence "the point closed,"
and all five are different things.

#### Three States Encryption Does Not Protect

The first table's last column is zero in three of four states. While the machine is asleep, the
screen is locked, and a session is open, encryption contributes nothing, because the volume is
decrypted. Eighteen of eighteen items are readable, and what protects them is the screen lock and
the operating system's access control.

This is not a shortcoming of encryption, it is its definition: a machine cannot run without its
volume decrypted. But the measurement's conclusion is clear — for the entire time an endpoint
spends **running**, full disk encryption's protective value is zero. Field laptops spend most of
the day asleep, and in that state the key sits in memory.

There is a direct corollary. What raises encryption's protective value is not a stronger algorithm,
it is the time the machine spends **in the off state**. How many items are readable when a device
is lost depends on which state the device is in at that moment: 3 if it is off, 18 if it is asleep.
So the number worth measuring is not the encryption setting, it is the time from sleep to shutdown —
how many minutes before the key in the machine's memory is wiped. Shortening that duration
lengthens the time spent off, at the cost of the user waiting through a reboot on every return. Put
the two numbers side by side and the decision becomes measurable: how many minutes of waiting in
exchange for how many hours off.

One of the bottom rows gives a second limit: in a logged-in session, 7 of 18 items are readable
with ordinary user privileges. The credential store, the saved session token, the client cache,
the temp file directory, and the meter-reading data are among them. Code running with a user's own
privileges at one endpoint reads these seven items, and disk encryption sees none of that reading.

The second table shows a third limit. Three items sit outside the encrypted volume, and one is the
recovery key hint — the protection scheme's own recovery path sits outside the area it protects.
The others are the boot-partition configuration and the boot signature record; both have to be
readable before the volume is decrypted, so neither can be encrypted.

#### Cost

Encryption's cost gathers in three items. First, the **recovery key**: 204 copies for 102
machines. Every copy sits somewhere, and whoever can read that place can decrypt that machine's
disk. The number is directly proportional to the machine count and cannot be reduced — a machine
whose key is lost cannot be recovered; it goes, along with the business data on its disk. Cutting
the copy count to one halves the number of places that can read it and raises the risk of
unrecoverability by the same proportion; the two numbers work in opposite directions.

Second, **unattended restart**. In the two classes requiring pre-boot authentication, every remote
restart requires someone at the machine: 14,112 minutes a year, roughly 235 hours. This directly
conflicts with the second lesson, where the thing that shortened the window was an unattended
patch. On the collector and the processing server this cost is zero, because the key is tied to
the machine and the machine unlocks its own disk. The tradeoff is not zero either: on a machine that
unlocks its own disk, encryption protects only against the disk being **removed**, not against the
machine itself being booted. The protection holding depends on the boot chain staying intact.

Third, it shows up in lost devices: 6 devices are lost a year. Without encryption, 108
item-classes become readable; with encryption, 18. This is the single largest gain in the table,
and it shows the loss is distributed by device class — all six of the losses come from the two
portable classes.

#### Summary

- Full disk encryption closes reading and writing together; what changes this course's measure is
  the write side. Offline write is a foothold path the allowlist never sees at all.
- While the machine is off, 15 of 18 disk items are protected; in the other three states — sleep,
  locked screen, logged-in session — encryption's contribution is zero, because the volume is
  decrypted.
- 936 of the fleet's 1038 point definitions close against offline writing, 102 remain in the boot
  partition. A closing number is comparable only once the path it is closed against is written
  down.
- In a logged-in session, 7 of 18 items are readable with ordinary user privileges; one of the
  three items outside the encrypted volume is the protection scheme's own recovery hint.
- The cost is 204 recovery key copies and, in the two classes requiring pre-boot authentication,
  14,112 minutes a year of manual intervention — a direct conflict with the second lesson's
  unattended patching.
- In the two classes where the key is tied to the machine, manual intervention is zero and
  protection holds only against the disk being removed; items readable on a lost device drop from
  108 to 18 for the 6 lost a year.

#### Next Step

The topic closes with six measurements. Of the 84 point decisions the default setup left, 40 were
removed and 44 were written to the baseline; patching brought the time points stayed open down
from 10.8 days to 2.3; the benchmark narrowed the writing-subject set for 588 points; the allowlist
made 972 points unable to run unapproved code; the sandbox closed no point at all, it only produced
a decision; encryption closed 936 points against offline writing. The item count at the endpoint
was narrowed, and foothold points were closed.

Underneath all of this measurement sits one untested assumption: **that nothing is running**.
Every number was taken on the assumption that no malicious code exists on the endpoint, or that it
has not run yet. Once code does run, how many of the closed points actually stop it, how many only
slow it down, and what can be done through the ones that are not closed — none of that was asked.
The next topic, **Endpoint Protection**, opens with that question: malicious software types are
classified by their propagation and persistence behavior, signature-based and behavior-based
detection are run against the same event set, and what each layer misses is counted separately.

## Endpoint Protection

How many foothold points the protection layers close once code has run on the endpoint, and how many they only make visible: the spread and persistence behavior of malware types, the false positive and miss balance of signature and behavior-based detection over the same event set, the conflict between the visibility scope and the retention window of endpoint detection and response telemetry, how end-to-end traffic the perimeter rule never sees is closed by host-based filtering, how abuse of built-in and signed binaries lowers signature-based detection, and the split of the persistence mechanism inventory between what each audit closes and what it only logs.

### Malware Types

Source: https://academia.sh/en/courses/endpoint-security/malware-types

Ten types are classified by spread mode and the foothold point each uses: the number of points per type, the two types that never establish persistence, the number of types using each point type alongside the legitimate item count at that same point, and the number of types that stop fully, stop partially, or stay unaffected when point types are closed cheapest first.

The previous topic narrowed the number of items on the endpoint and closed the automatically run
points one by one. That whole measurement carried a silent assumption: nothing had run on the
endpoint yet. This lesson lifts that assumption and asks the reverse question — once code has run,
how much of it do the closed points stop?

The answer does not depend on **what** the running thing is, but on **how it spreads and where it
takes hold**. So the classification is not done by family name but by two measurable behaviors:
spread mode and the type of foothold point used. Three numbers are measured: how many foothold
points a type uses, how many types use a point type, and how many legitimate items closing that
point takes down together with it. No infection method is written in this lesson; what is counted
is the item the infection leaves behind.

- **EP1.** The ten types, eight point types, and legitimate item counts on the endpoint belong to
  the field laptop baseline of a fictional regional measurement network. No real sample is run;
  what is measured is the model built with `node`.
- **EP2.** Type names are behavior definitions. Family name, campaign name, and sample name never
  appear in the model; two different samples that show the same behavior are a single row in the
  model.
- **EP3.** A type either uses a point type or it does not. In reality the same type picks a
  different point depending on the environment; in the model the choice is fixed, which makes the
  point count look smaller than it is.
- **EP4.** The legitimate item counts are taken from a single endpoint's baseline. These numbers
  are larger on servers and smaller on branch endpoints; the direction of the ratios does not
  change.
- **EP5.** "Closing" means blocking new items from being added to a point type. Because existing
  legitimate items must go through the same control as well, the cost column counts those items.
- **EP6.** Detection is absent from this lesson. Closed points are counted, not seen points;
  visibility is the subject of the next two lessons.

#### Two Axes: Spread and Persistence

The model holds four things for each type: the behavior definition, the spread mode, the point
types it uses, and the outcome it returns. The spread mode tells **how** the code arrives at the
endpoint; the point list tells **where** it writes once it has arrived. The two axes are
independent, and conflating them is a common measurement error: a type that spreads fast can have
weak persistence, and a type that never spreads can hold onto the most points.

```js
// types.mjs — malware types, their spread mode, and the foothold points they use; model
// [type name, spread mode, point types used, outcome]
export const TYPE = [
  ['network-spreading component', 'automatic network', ['service-definition', 'scheduled-task'], 'spread'],
  ['user-opened component', 'user opening', ['startup-entry', 'login-script'], 'remote access'],
  ['data-encrypting component', 'secondary deployment', [], 'availability loss'],
  ['keystroke and screen logger', 'user opening', ['login-script', 'load-path-hook', 'event-subscription'], 'confidentiality loss'],
  ['remote-command listener', 'secondary deployment', ['service-definition', 'event-subscription'], 'sustained access'],
  ['centrally managed agent', 'automatic network', ['startup-entry', 'scheduled-task', 'service-definition'], 'resource takeover'],
  ['compute-draining component', 'secondary deployment', ['scheduled-task'], 'resource drain'],
  ['memory-resident loader', 'user opening', [], 'second-stage retrieval'],
  ['binary-infecting component', 'file attachment', ['legitimate-binary-attach'], 'spread'],
  ['removable-media component', 'removable media', ['media-autorun'], 'spread'],
];
// point type -> number of legitimate items on one endpoint (fictional field laptop baseline)
export const LEGIT = {
  'startup-entry': 14, 'service-definition': 63, 'scheduled-task': 41, 'login-script': 6,
  'load-path-hook': 3, 'event-subscription': 2, 'legitimate-binary-attach': 0,
  'media-autorun': 1,
};
export const POINT = Object.keys(LEGIT);
export const usedBy = (n) => TYPE.filter((t) => t[2].includes(n));
export const status = (t, closed) => t[2].length === 0 ? 'no point'
  : t[2].every((n) => closed.includes(n)) ? 'fully stopped'
  : t[2].some((n) => closed.includes(n)) ? 'partially stopped' : 'unaffected';
```

The eight point types are the classes of automatically run items inventoried in the previous
topic: the entry that runs at startup, the service definition, the scheduled task, the user login
script, the library load-path hook, the persistent event subscription, attaching to a legitimate
binary, and the autorun entry on removable media. The last two stand apart from the rest:
attaching to a file does not produce a new entry on the endpoint, and the removable media entry
sits on the inserted media, not on the endpoint.

#### Points per Type, Types per Point

```js
// run1.mjs — classifies types by spread and persistence behavior, counts point types
import { TYPE, POINT, LEGIT, usedBy } from './types.mjs';

const noPersistence = TYPE.filter((t) => t[2].length === 0);
console.log(`${TYPE.length} types, ${POINT.length} point types; types with no persistence: ${noPersistence.length}\n`);
console.log(`${'type'.padEnd(38)} ${'spread'.padStart(20)} ${'point'.padStart(5)} ${'outcome'.padStart(22)}`);
for (const t of TYPE)
  console.log(`${t[0].padEnd(38)} ${t[1].padStart(20)} ${String(t[2].length).padStart(5)} ${t[3].padStart(22)}`);

const spreadModes = [...new Set(TYPE.map((t) => t[1]))];
console.log(`\n${'spread mode'.padEnd(20)} ${'type'.padStart(4)} ${'total points'.padStart(12)} ${'per type'.padStart(10)}`);
for (const s of spreadModes) {
  const g = TYPE.filter((t) => t[1] === s), n = g.reduce((a, t) => a + t[2].length, 0);
  console.log(`${s.padEnd(20)} ${String(g.length).padStart(4)} ${String(n).padStart(12)} ${(n / g.length).toFixed(1).padStart(10)}`);
}

console.log(`\n${'point type'.padEnd(26)} ${'using types'.padStart(12)} ${'legitimate items'.padStart(18)}`);
for (const n of POINT)
  console.log(`${n.padEnd(26)} ${String(usedBy(n).length).padStart(12)} ${String(LEGIT[n]).padStart(18)}`);
console.log(`${'total'.padEnd(26)} ${String(TYPE.reduce((a, t) => a + t[2].length, 0)).padStart(12)} ` +
  `${String(Object.values(LEGIT).reduce((a, b) => a + b, 0)).padStart(18)}`);
```

```
10 types, 8 point types; types with no persistence: 2

type                                                 spread point                outcome
network-spreading component               automatic network     2                 spread
user-opened component                          user opening     2          remote access
data-encrypting component              secondary deployment     0      availability loss
keystroke and screen logger                    user opening     3   confidentiality loss
remote-command listener                secondary deployment     2       sustained access
centrally managed agent                   automatic network     3      resource takeover
compute-draining component             secondary deployment     1         resource drain
memory-resident loader                         user opening     0 second-stage retrieval
binary-infecting component                  file attachment     1                 spread
removable-media component                   removable media     1                 spread

spread mode          type total points   per type
automatic network       2            5        2.5
user opening            3            5        1.7
secondary deployment    3            3        1.0
file attachment         1            1        1.0
removable media         1            1        1.0

point type                  using types   legitimate items
startup-entry                         2                 14
service-definition                    3                 63
scheduled-task                        3                 41
login-script                          2                  6
load-path-hook                        1                  3
event-subscription                    2                  2
legitimate-binary-attach              1                  0
media-autorun                         1                  1
total                                15                130
```

The first number is that two types never establish persistence, and there are two separate
reasons for it. The data-encrypting component finishes its job in a single run; it has no need to
run after a restart, because the damage it does sits on disk. The memory-resident loader completes
its task once it has fetched the second stage. Both disappear on restart, and for both that is not
a loss. A defense that relies on closing persistence points does nothing to these two types — none
of the fifteen counted points belong to them.

The spread table gives the second difference. The two types that spread automatically over the
network use 2.5 points per type; the three types that arrive through secondary deployment use only
1.0. Persistence widens as spread becomes more automatic, because a component that spreads
automatically does not know which endpoint it lands on, and writing to more than one point raises
the odds of staying there. A manually deployed component knows its target; a single correct point
is enough.

The third table shows the measurement's real tension. How often a point type is used and how many
legitimate items sit at that point move **in the same direction**. The two point types used by the
most types are the service definition and the scheduled task — three types each — and 104 of the
endpoint's legitimate items, more than three-quarters of the total 130, sit at exactly these two
points. Malicious code does not pick these points at random; it picks the place the operating
system and legitimate software both use for routine work. Standing inside the crowd is itself a
defense — the crowd is the defense.

#### Closing Order and Three Outcomes

```js
// run2.mjs — closes point types cheapest first, counts stopped and unaffected types
import { TYPE, POINT, LEGIT, status } from './types.mjs';

const order = [...POINT].sort((a, b) => LEGIT[a] - LEGIT[b]);
const counts = (closed) => ['fully stopped', 'partially stopped', 'unaffected', 'no point']
  .map((d) => TYPE.filter((t) => status(t, closed) === d).length);

console.log(`${'closed point type'.padEnd(26)} ${'cost'.padStart(5)} ${'full'.padStart(4)} ${'part'.padStart(5)} ` +
  `${'unaffected'.padStart(10)} ${'no point'.padStart(8)}`);
const closed = [];
for (const n of [null, ...order]) {
  if (n) closed.push(n);
  const [full, partial, unaffected, none] = counts(closed);
  const cost = closed.reduce((a, k) => a + LEGIT[k], 0);
  console.log(`${(n ?? 'none').padEnd(26)} ${String(cost).padStart(5)} ${String(full).padStart(4)} ` +
    `${String(partial).padStart(5)} ${String(unaffected).padStart(10)} ${String(none).padStart(8)}`);
}

const cheap = order.slice(0, 5);
console.log(`\ncheap five point types closed (cost ${cheap.reduce((a, k) => a + LEGIT[k], 0)} legitimate items):`);
for (const t of TYPE)
  console.log(`  ${t[0].padEnd(38)} ${status(t, cheap).padStart(17)} ` +
    `${t[2].filter((n) => !cheap.includes(n)).join(', ') || '-'}`);
```

```
closed point type           cost full  part unaffected no point
none                           0    0     0          8        2
legitimate-binary-attach       0    1     0          7        2
media-autorun                  1    2     0          6        2
event-subscription             3    2     2          4        2
load-path-hook                 6    2     2          4        2
login-script                  12    3     2          3        2
startup-entry                 26    4     2          2        2
scheduled-task                67    5     3          0        2
service-definition           130    8     0          0        2

cheap five point types closed (cost 12 legitimate items):
  network-spreading component                   unaffected service-definition, scheduled-task
  user-opened component                  partially stopped startup-entry
  data-encrypting component                       no point -
  keystroke and screen logger                fully stopped -
  remote-command listener                partially stopped service-definition
  centrally managed agent                       unaffected startup-entry, scheduled-task, service-definition
  compute-draining component                    unaffected scheduled-task
  memory-resident loader                          no point -
  binary-infecting component                 fully stopped -
  removable-media component                  fully stopped -
```

The closing order starts from the cheap end, and the curve is not flat from start to finish. The
first three steps stop two types fully and two types partially, at a total cost of three
legitimate items. The fourth step — the library load-path hook — spends three more items and
**changes no column**: only one type uses that point, and that type's other two points are already
open. A step that has a cost and returns zero in exchange shows exactly where the assumption that
adding a layer is free breaks down.

The last two steps reverse the trend. Closing the scheduled task costs 41 legitimate items, closing
the service definition costs 63; together they raise the cost from 26 to 130 and, in exchange,
carry the fully-stopped type count from four to eight. That is buying four more types at the cost
of reviewing **all** of the endpoint's legitimate autorun items. At this point the measurement
offers a decision, not a recommendation: are four types worth having a hundred and thirty items
approved one by one on every endpoint, or are these two point types too crowded to close and better
left to a different defense?

The list below spells out the status of the cheap five one by one and shows why the two columns
are kept separate. Three types stop fully; two stop partially, meaning they can still run but their
point choice drops to one; three stay unaffected; and two have no point to close. At a cost of
twelve legitimate items, three of the ten types stopped. For the remaining seven types, closing
points is a finished defense — the only question left is whether they are seen **while running**.

#### The Order Itself Is a Decision

Closing cheapest first is not the only option. There is a second ordering that feels more
intuitive: close the point used by the most types first. The two orderings cover the same eight
points and end up in the same place; where they diverge is the point **in between** where you stop.

```js
// run3.mjs — two closing orders: cheapest first and most types affected first
import { TYPE, POINT, LEGIT, usedBy, status } from './types.mjs';

const cheap = [...POINT].sort((a, b) => LEGIT[a] - LEGIT[b]);
const wide = [...POINT].sort((a, b) => usedBy(b).length - usedBy(a).length || LEGIT[a] - LEGIT[b]);
const full = (k) => TYPE.filter((t) => status(t, k) === 'fully stopped').length;
function curve(order) {
  const k = [], point = {};
  for (const n of order) {
    k.push(n);
    const d = full(k);
    if (point[d] === undefined) point[d] = { step: k.length, cost: k.reduce((a, x) => a + LEGIT[x], 0) };
  }
  return point;
}
const [a, b] = [curve(cheap), curve(wide)];
console.log(`cheap order: ${cheap.join(' > ')}`);
console.log(`wide order : ${wide.join(' > ')}\n`);
console.log(`${'target fully stopped'.padEnd(20)} ${'cheap step'.padStart(10)} ${'cheap cost'.padStart(10)} ` +
  `${'wide step'.padStart(10)} ${'wide cost'.padStart(11)}`);
for (let d = 1; d <= 8; d++) {
  if (!a[d] && !b[d]) continue;
  const f = (x) => x ? [String(x.step), String(x.cost)] : ['-', '-'];
  const [aa, ab] = f(a[d]), [ba, bb] = f(b[d]);
  console.log(`${String(d).padEnd(20)} ${aa.padStart(10)} ${ab.padStart(10)} ${ba.padStart(10)} ${bb.padStart(11)}`);
}
```

```
cheap order: legitimate-binary-attach > media-autorun > event-subscription > load-path-hook > login-script > startup-entry > scheduled-task > service-definition
wide order : scheduled-task > service-definition > event-subscription > login-script > startup-entry > legitimate-binary-attach > media-autorun > load-path-hook

target fully stopped cheap step cheap cost  wide step   wide cost
1                             1          0          1          41
2                             2          1          2         104
3                             5         12          3         106
4                             6         26          -           -
5                             7         67          5         126
6                             -          -          6         126
7                             -          -          7         127
8                             8        130          8         130
```

The two curves diverge at every target, and the cheap ordering stays ahead from start to finish.
Stopping one type fully costs zero legitimate items in the cheap ordering and forty-one in the wide
ordering. Reaching three types costs twelve in the cheap ordering and one hundred six in the wide
ordering — a difference of close to ninefold. Why comes from the alignment in the third table: the
points used by the most types are also the most crowded legitimate points, and closing them alone
stops no type **fully**, because those types have other points as well. The wide ordering spends
two expensive steps and gains only two types.

The wide ordering's gap at four types has the same cause: going from the third step to the fifth,
the count jumps from three to five, and four never appears. The ordering does not produce a smooth
curve; it produces jumps that depend on which type uses which set of points. In row eight, both
reach the same place, one hundred thirty items — once you want to close everything, the ordering no
longer matters. The ordering only matters **if you stop partway**, and defenses almost always stop
partway.

#### Summary

- Ten types were classified by spread mode and foothold point; family name was never used. A total
  of 15 point uses spread across 8 point types.
- Two types never establish persistence: one finishes its job in a single run, the other fetches
  the second stage and stops. A defense that relies on closing points does nothing to these two.
- Automatic network spread uses 2.5 points per type, secondary deployment uses 1.0: persistence
  widens as spread becomes more automatic, because the target is not known in advance.
- The two most-used point types (service definition, scheduled task) hold 104 of the endpoint's 130
  legitimate items. Malicious code picks the crowded point; the crowd makes closing it expensive.
- At a cost of 12 legitimate items, 3 types stop fully and 2 stop partially; 3 types stay
  unaffected. Closing every point stops 8 types at a cost of 130 items, and 2 types still remain
  outside.
- One step has a cost and changes no column (the load-path hook, 3 items). The ordering itself is
  also a decision: stopping 3 types costs 12 items in the cheap ordering and 106 in the ordering
  that starts with the point affecting the most types. Both reach 130 once all eight types are
  covered; the ordering only matters if you stop partway.

#### Next Step

The seven types this lesson could not close and the two types that never establish persistence
point to the same place: something must be seen and stopped the moment it runs. The layer that
takes this on can ask two separate questions — "have I seen this item before" and "what is this
item doing." The two questions answer differently over the same event set, and each carries two
kinds of error. That is the next lesson's question: over the same run set, how many bad items do
signature-based and behavior-based detection catch, how many do they miss, how many legitimate
items do they flag by mistake, and why do these two errors move in opposite directions when the
decision threshold shifts.

### Anti-Malware Software

Source: https://academia.sh/en/courses/endpoint-security/anti-malware-software

Twenty execution items are evaluated in the same set with two detection types: the items signature-based detection catches, misses, and falsely flags; the threshold scan of the behavior score derived from six indicators; how the missed count falls and the false positive count rises as the threshold drops; and the foothold point closed in block mode together with the legitimate work it cuts, versus the same point only made visible in alert mode.

The previous lesson could not stop seven of the ten types by closing points, and two of them had no
point to close in the first place. The layer that stops those seven asks a different question: once
something runs on the endpoint, **how is it known** that this item is a problem? There are two
separate answers, and both can be measured over the same execution set.

The first answer relies on recognition: I have seen this item before and flagged it as a problem.
The second relies on behavior: what this item does resembles what problem items do. The first has
no single setting; the second has a threshold, and this lesson scans that threshold end to end.
Three numbers are written together for every run: how many bad items were caught, how many slipped
through, how many legitimate items were flagged by mistake. Detection on the network side was
measured in another course, and the threshold was not modeled there; what is measured here is
exactly the threshold itself.

- **EP7.** Twenty execution items, six indicators, and their weights belong to one field laptop
  endpoint of a fictional regional measurement network. No real item is run; what is measured is the
  model built with `node`.
- **EP8.** The true class of each item is known in the model. In reality this class is only known
  after investigation; it is assumed known here so that the missed count and the false positive
  count can be counted separately.
- **EP9.** The behavior score is the sum of indicator weights, and the weights are fixed. In real
  systems weights are learned and drift over time; a fixed weight simplifies the curve's shape, it
  does not change the opposite direction of the two errors.
- **EP10.** The signature was modeled with three values: exact match, broad match, and no match. A
  broad match means a rule that looks at a packaging format, and it can also touch a legitimate
  item.
- **EP11.** An item either runs or is blocked; partial blocking, quarantine, and rollback were not
  modeled.
- **EP12.** The foothold point column comes from the previous lesson's inventory: it is the number
  of points the item writes to. It has no relation to the score; it is carried as a separate column.

#### Score Derived from Indicators

The behavior score is not a number assigned by hand; it is the sum of the weights of six
indicators. The weights say how distinguishing an indicator is: writing to another process's memory
and adding an item to an automatically run point carry the highest weight, running at an unusual
hour the lowest. None of the indicators is a problem on its own; legitimate administration tools do
the same things, and that is where the model's tension comes from.

```js
// events.mjs — execution events on the endpoint, indicator weights, and two detection types; model
export const INDICATOR = {
  'writes to a foothold point': 3, 'writes to another process': 3, 'unsigned binary': 2,
  'bulk file modification': 2, 'sustained external connection': 2, 'runs outside user hours': 1,
};
const B = Object.keys(INDICATOR);
const [N, S, I, D, G, Z] = B;
// [item, true class, signature status, indicators, foothold points written]
export const EVENT = [
  ['network-spreading component', 'bad', 'exact', [N, I, G, Z], 2],
  ['remote-command listener', 'bad', 'none', [N, S, G], 2],
  ['data-encrypting component', 'bad', 'exact', [I, D, G, Z], 0],
  ['keystroke and screen logger', 'bad', 'none', [N, S, Z], 3],
  ['memory-resident loader', 'bad', 'none', [I, G], 0],
  ['living-off-the-land step', 'bad', 'none', [G, Z], 0],
  ['endpoint management agent', 'legit', 'none', [N, S, G], 2],
  ['patch deployment agent', 'legit', 'none', [N, D, Z], 1],
  ['backup tool', 'legit', 'none', [D, G, Z], 0],
  ['inventory scan tool', 'legit', 'none', [N, G], 1],
  ['enterprise installer package', 'legit', 'broad', [N, I], 1],
  ['remote support client', 'legit', 'none', [S, G], 0],
  ['field measurement uploader', 'legit', 'none', [I, G, Z], 0],
  ['compiler output directory', 'legit', 'none', [I, D], 0],
  ['scheduled report script', 'legit', 'none', [N, Z], 1],
  ['log collector', 'legit', 'none', [G, Z], 0],
  ['disk encryption helper', 'legit', 'none', [D, Z], 0],
  ['archiving tool', 'legit', 'none', [D], 0],
  ['browser', 'legit', 'none', [G], 0],
  ['document editor', 'legit', 'none', [], 0],
];
export const score = (o) => o[3].reduce((a, b) => a + INDICATOR[b], 0);
export const MAX_SCORE = Object.values(INDICATOR).reduce((a, b) => a + b, 0);
export const signatureAlerts = (o) => o[2] !== 'none';
export const behaviorAlerts = (o, threshold) => score(o) >= threshold;
export function count(alerts) {
  const bad = EVENT.filter((o) => o[1] === 'bad');
  const caught = bad.filter(alerts), falsePositive = EVENT.filter((o) => o[1] === 'legit' && alerts(o));
  return { caught, missed: bad.filter((o) => !alerts(o)), falsePositive,
    point: caught.reduce((a, o) => a + o[4], 0), legitPoint: falsePositive.reduce((a, o) => a + o[4], 0) };
}
```

#### The Signature's Three Numbers

```js
// run1.mjs — event set, indicator-derived behavior score, and signature-based detection's three numbers
import { EVENT, INDICATOR, MAX_SCORE, score, signatureAlerts, count } from './events.mjs';

console.log(`${EVENT.length} execution items, ${EVENT.filter((o) => o[1] === 'bad').length} bad; ` +
  `${Object.keys(INDICATOR).length} indicators, max score ${MAX_SCORE}\n`);
console.log(`${'indicator'.padEnd(30)} ${'weight'.padStart(7)} ${'bad'.padStart(12)} ${'legit'.padStart(13)}`);
for (const [b, a] of Object.entries(INDICATOR)) {
  const g = EVENT.filter((o) => o[3].includes(b));
  console.log(`${b.padEnd(30)} ${String(a).padStart(7)} ${String(g.filter((o) => o[1] === 'bad').length).padStart(12)} ` +
    `${String(g.filter((o) => o[1] === 'legit').length).padStart(13)}`);
}
console.log(`\n${'item'.padEnd(30)} ${'class'.padStart(5)} ${'sig'.padStart(5)} ${'score'.padStart(5)} ${'point'.padStart(5)}`);
for (const o of [...EVENT].sort((a, b) => score(b) - score(a)))
  console.log(`${o[0].padEnd(30)} ${o[1].padStart(5)} ${o[2].padStart(5)} ${String(score(o)).padStart(5)} ${String(o[4]).padStart(5)}`);
const r = count(signatureAlerts);
console.log(`\nsignature-based detection: caught ${r.caught.length}/${r.caught.length + r.missed.length}, ` +
  `missed ${r.missed.length}, false positive ${r.falsePositive.length}`);
console.log(`  missed: ${r.missed.map((o) => o[0]).join('; ')}`);
console.log(`  false positive: ${r.falsePositive.map((o) => `${o[0]} (${o[2]} signature)`).join('; ')}`);
```

```
20 execution items, 6 bad; 6 indicators, max score 13

indicator                       weight          bad         legit
writes to a foothold point           3            3             5
writes to another process            3            2             2
unsigned binary                      2            3             3
bulk file modification               2            1             5
sustained external connection        2            5             7
runs outside user hours              1            4             6

item                           class   sig score point
network-spreading component      bad exact     8     2
remote-command listener          bad  none     8     2
endpoint management agent      legit  none     8     2
data-encrypting component        bad exact     7     0
keystroke and screen logger      bad  none     7     3
patch deployment agent         legit  none     6     1
backup tool                    legit  none     5     0
inventory scan tool            legit  none     5     1
enterprise installer package   legit broad     5     1
remote support client          legit  none     5     0
field measurement uploader     legit  none     5     0
memory-resident loader           bad  none     4     0
compiler output directory      legit  none     4     0
scheduled report script        legit  none     4     1
living-off-the-land step         bad  none     3     0
log collector                  legit  none     3     0
disk encryption helper         legit  none     3     0
archiving tool                 legit  none     2     0
browser                        legit  none     2     0
document editor                legit  none     0     0

signature-based detection: caught 2/6, missed 4, false positive 1
  missed: remote-command listener; keystroke and screen logger; memory-resident loader; living-off-the-land step
  false positive: enterprise installer package (broad signature)
```

The indicator table gives the first result, and it is not encouraging at all: four of the six
indicators appear **more** often in legitimate items than in bad items, and two appear in exactly
the same number; none stands out on the bad side. Sustained external connection appears in five bad
and seven legitimate items; writes to a foothold point in three bad and five legitimate items. This
is not a modeling flaw but a fact of endpoints: the number of legitimate programs running on an
endpoint is far larger than the number of bad items, so no indicator stands out on the bad side by
raw count.

The sorted list shows the result of this in a single line. One of the three highest-scoring items is
legitimate and sits at the **same** place as two bad items, with a score of eight: the endpoint
management agent writes to automatically run points, injects code into other processes, and holds a
sustained outbound connection. No behavior-based threshold can separate this item from the other
two, because there is no number to separate them with.

Signature-based detection's three numbers are at the bottom of the list. It catches two of the six
bad items, misses four, and flags one legitimate item. What the missed items have in common is that
they were never seen before; the legitimate item it flags happens to touch a broad signature. Zero
false positives is the frequently cited advantage of signature-based detection, and it is not true
even in the model: a single broadly written rule ends that advantage.

#### Moving the Threshold

```js
// run2.mjs — behavior threshold scan; the opposite direction of the two errors and the cost of combined detection
import { EVENT, score, signatureAlerts, behaviorAlerts, count } from './events.mjs';

const BAD = EVENT.filter((o) => o[1] === 'bad').length;
console.log(`${'threshold'.padStart(9)} ${'caught'.padStart(9)} ${'missed'.padStart(6)} ${'false'.padStart(6)} ` +
  `${'comb. caught'.padStart(13)} ${'combined false'.padStart(15)} ${'total false'.padStart(13)}`);
const result = [];
for (let e = 1; e <= 10; e++) {
  const d = count((o) => behaviorAlerts(o, e));
  const b = count((o) => signatureAlerts(o) || behaviorAlerts(o, e));
  result.push({ e, d, b });
  console.log(`${String(e).padStart(9)} ${`${d.caught.length}/${BAD}`.padStart(9)} ${String(d.missed.length).padStart(6)} ` +
    `${String(d.falsePositive.length).padStart(6)} ${`${b.caught.length}/${BAD}`.padStart(13)} ${String(b.falsePositive.length).padStart(15)} ` +
    `${String(b.missed.length + b.falsePositive.length).padStart(13)}`);
}
const min = result.reduce((a, s) => (s.b.missed.length + s.b.falsePositive.length < a.b.missed.length + a.b.falsePositive.length ? s : a));
const full = result.filter((s) => s.b.missed.length === 0);
console.log(`\nthreshold minimizing total false: ${min.e} (missed ${min.b.missed.length} + false ${min.b.falsePositive.length})`);
console.log(`thresholds that never miss: ${full.map((s) => `${s.e} (false ${s.b.falsePositive.length})`).join(', ')}`);
console.log(`zero-false-positive threshold: ${result.filter((s) => s.b.falsePositive.length === 0).map((s) => `${s.e} (missed ${s.b.missed.length})`).join(', ') || 'none'}`);

console.log(`\n${'threshold'.padStart(9)} ${'closed points'.padStart(13)} ${'legit work lost'.padStart(16)} ` +
  `${'seen points'.padStart(13)} ${'work lost'.padStart(10)} ${'manual review alerts'.padStart(20)}`);
for (const { e, b } of result.filter((s) => [3, 6, 8, 9].includes(s.e)))
  console.log(`${String(e).padStart(9)} ${String(b.point).padStart(13)} ${String(b.falsePositive.length).padStart(16)} ` +
    `${String(b.point).padStart(13)} ${'0'.padStart(10)} ${String(b.caught.length + b.falsePositive.length).padStart(20)}`);
const six = count((o) => signatureAlerts(o) || behaviorAlerts(o, 6));
console.log(`\nthreshold 6 missed bad items: ${six.missed.map((o) => `${o[0]} (score ${score(o)})`).join('; ')}`);
console.log(`threshold 6 legit work lost : ${six.falsePositive.map((o) => `${o[0]} (score ${score(o)})`).join('; ')}`);
```

```
threshold    caught missed  false  comb. caught  combined false   total false
        1       6/6      0     13           6/6              13            13
        2       6/6      0     13           6/6              13            13
        3       6/6      0     11           6/6              11            11
        4       5/6      1      9           5/6               9            10
        5       4/6      2      7           4/6               7             9
        6       4/6      2      2           4/6               3             5
        7       4/6      2      1           4/6               2             4
        8       2/6      4      1           3/6               2             5
        9       0/6      6      0           2/6               1             5
       10       0/6      6      0           2/6               1             5

threshold minimizing total false: 7 (missed 2 + false 2)
thresholds that never miss: 1 (false 13), 2 (false 13), 3 (false 11)
zero-false-positive threshold: none

threshold closed points  legit work lost   seen points  work lost manual review alerts
        3             7               11             7          0                   17
        6             7                3             7          0                    7
        8             4                2             4          0                    5
        9             2                1             2          0                    3

threshold 6 missed bad items: memory-resident loader (score 4); living-off-the-land step (score 3)
threshold 6 legit work lost : endpoint management agent (score 8); patch deployment agent (score 6); enterprise installer package (score 5)
```

The scan gives the opposite direction of two columns row by row. At threshold three, all six of the
six bad items are caught, and eleven legitimate items are flagged along with them. At threshold
nine, the false positive count drops to zero and behavior-based detection catches no bad items at
all. Every row in between is a trade-off, and the trade-off ratio is not constant: going from five
to six drops the false positive count from seven to two while never changing the caught count — this
is the steepest point of the curve. Going from seven to eight, by contrast, drops the caught count
from four to two without lowering the false positive count at all.

Combining signature with behavior is read in the difference between the third and fourth columns.
There is a gain at high thresholds: at threshold nine, behavior catches nothing while combined
detection catches two items. At low thresholds the signature contributes nothing, it only raises the
false positive count by one. The two detection types complement each other for this reason: one
works on the known, the other on the unknown, and as the known set shrinks the load the threshold
carries grows.

The bottom table separates the course's two columns. At threshold six, four bad items are caught
and, in block mode, seven foothold points close; in exchange three legitimate jobs are cut, and
among those cut are the patch deployment agent and the endpoint management agent — the very
mechanisms that protect the endpoints. In alert mode the same seven points are only made
**visible**, no legitimate job is cut, and in exchange seven items wait for manual review. The
closed-point count and the visible-point count are numerically the same; what makes the difference
is that in one the point disappears, in the other it is only written to a record.

Lowering the threshold to three shows a measurable trap in this table. The caught bad item count
rises from four to six, but the closed foothold point count stays at seven, because the two newly
added items have no persistence point. Cut legitimate work rises from three to eleven. A setting
that decides by caught item count counts this as an improvement; a setting that decides by foothold
point sees no gain at all.

The scores of the two missed items open a separate question when looked at from the bottom of the
list. The memory-resident loader sits below three legitimate items with a score of four; the
living-off-the-land step sits at the same place as two legitimate items with a score of three. A
threshold that would catch both also catches every legitimate item with a higher score; this is not
a problem that a threshold setting can solve. The item with the lower score is not doing less work —
it is doing work that **leaves fewer indicators**, because every tool it uses is already standing on
the endpoint and is signed. This distinction will be measured by number not in the next lesson but
in the fifth.

#### Summary

- 4 of the 6 indicators appear more often in legitimate items than in bad items, and 2 appear in
  exactly the same number. Since the count of legitimate software on an endpoint is far larger, no
  indicator stands out on the bad side by raw count.
- One of the 3 highest-scoring items is legitimate and shares the same score (8) as 2 bad items: the
  endpoint management agent. No behavior threshold can separate these three from each other.
- Signature-based detection catches 2 of the 6 bad items, misses 4, and flags 1 legitimate item
  because of a single broadly written rule. "Signatures produce no false positives" is wrong even in
  the model.
- At threshold 3, 6/6 are caught and 11 legitimate items are cut; at threshold 9, false positives
  are zero and behavior catches nothing. The threshold that minimizes total error is 7 (2 missed +
  2 false); no threshold zeroes both errors at once.
- At threshold 6, 7 foothold points close and 3 legitimate jobs are cut, including the patch
  deployment agent and the endpoint management agent. In alert mode the same 7 points are only made
  visible, cut work is 0, manual review alerts are 7.
- Lowering the threshold from 6 to 3 raises the caught count from 4 to 6 but leaves the closed point
  count at 7: the two newly caught items have no persistence point. Cut legitimate work rises from
  3 to 11.

#### Next Step

Alert mode was this lesson's cheapest option: no legitimate job was cut. Its cost looked at the time
like a queue of seven items, but its real cost lies elsewhere. For an item to be merely visible,
someone must have **recorded** it, and not everything on the endpoint is recorded. That was also
what the two items missed at threshold six had in common: neither of them wrote anything to disk.
The next lesson's question is this: how many of the foothold points does endpoint telemetry see, how
many never appear in any source at all, and how much does the volume of collected data shorten the
retention window.

### Endpoint Detection and Response

Source: https://academia.sh/en/courses/endpoint-security/endpoint-detection-and-response

Seven telemetry sources are measured against eight foothold point types: which point appears in which source, the two point types that appear in no source, the two sources that see no point type, how the visible point count stalls at six as sources are added in order of point gain per volume, how the daily volume shrinks the retention window from 6,657 days to 19.5 days, and the detections that do not fit inside the window.

The previous lesson's alert mode cut no legitimate work, and so it looked cheap. For an item to be
visible, a record of it must remain, and that record does not exist on its own: it must be collected
on the endpoint, moved somewhere, and retained until someone looks at it. This lesson's subject is
the layer that produces those records.

Three numbers are measured. The first is coverage: how many of the eight foothold point types appear
in at least one telemetry source, and how many appear in none. The second is volume: how many bytes
each source produces per day. The third is the window: given a fixed retention budget, how many days
back the collected data reaches. The three are not independent — the only way to grow coverage is to
add sources, and every source shortens the window. Which of the course's two columns this layer is
written into also becomes clear in this lesson: the number of points it closes is zero.

- **EP13.** Seven sources, event rates, event sizes, and the retention budget belong to a fictional
  regional measurement network; the 432 endpoints consist of four classes. What is measured is the
  model built with `node`.
- **EP14.** Event rates are a fixed average per endpoint. In reality servers produce far more events
  than field laptops, and the rate fluctuates through the day; the average does not change the
  direction of the window calculation.
- **EP15.** A point type is either visible in a source or it is not. Partial visibility — seeing only
  that the point ran, not that it was written — was not modeled; this makes coverage look better
  than it is.
- **EP16.** Compression, sampling, and moving data to a cold tier were not modeled. All three extend
  the window and all three have their own cost; the shape of the curve does not change.
- **EP17.** Detection delays are a fictional list: the number of days that pass before past events
  are noticed. The real distribution has a long tail.
- **EP18.** The telemetry agent running on the endpoint is itself software and adds an item to the
  inventory; this item is counted, the agent's own security is not measured.

#### Sources and What They See

The model consists of two tables. The first holds each telemetry source's daily event count per
endpoint and byte size per event; the second holds which foothold point type leaves a trace in which
source. The second table encodes a technical fact: a point type becomes visible only when the action
that **writes** it or **runs** it is recorded.

```js
// telemetry.mjs — endpoint telemetry sources, visibility matrix, and retention budget; model
export const ENDPOINTS = 432;                // fictional fleet: field laptop, branch endpoint, aggregator, server
export const BUDGET = 120 * 1024 ** 3;       // retention budget, bytes (fictional)
// source -> [events/endpoint/day, bytes/event]
export const SOURCE = {
  'configuration change log': [140, 320],
  'script block log': [260, 4096],
  'module load log': [9800, 384],
  'file modification log': [31000, 256],
  'process creation log': [2400, 512],
  'network connection log': [5600, 224],
  'logon log': [18, 448],
};
// foothold point type -> sources that see it
export const VISIBILITY = {
  'startup-entry': ['configuration change log', 'process creation log'],
  'service-definition': ['configuration change log', 'process creation log'],
  'scheduled-task': ['configuration change log', 'process creation log'],
  'login-script': ['script block log', 'process creation log', 'file modification log'],
  'load-path-hook': ['module load log', 'file modification log'],
  'legitimate-binary-attach': ['file modification log'],
  'event-subscription': [],
  'media-autorun': [],
};
export const POINT = Object.keys(VISIBILITY);
export const daily = (k) => SOURCE[k][0] * SOURCE[k][1] * ENDPOINTS;
export const visible = (set) => POINT.filter((n) => VISIBILITY[n].some((k) => set.includes(k)));
export const window = (set) => BUDGET / set.reduce((a, k) => a + daily(k), 0);
export const toGiB = (b) => (b / 1024 ** 3).toFixed(2);
// fictional: days that pass before past events are noticed
export const DELAY = [3, 7, 12, 18, 21, 26, 34, 45, 60, 90];
```

```js
// run1.mjs — source volumes and the visibility matrix: which point appears in which source
import { SOURCE, VISIBILITY, POINT, ENDPOINTS, BUDGET, daily, toGiB } from './telemetry.mjs';

const sources = Object.keys(SOURCE);
console.log(`${ENDPOINTS} endpoints, ${sources.length} telemetry sources, ${POINT.length} foothold point types; ` +
  `retention budget ${toGiB(BUDGET)} GiB\n`);
console.log(`${'source'.padEnd(31)} ${'evt/ep/day'.padStart(11)} ${'bytes/evt'.padStart(9)} ` +
  `${'GiB/day'.padStart(8)} ${'points seen'.padStart(13)}`);
for (const k of sources)
  console.log(`${k.padEnd(31)} ${String(SOURCE[k][0]).padStart(11)} ${String(SOURCE[k][1]).padStart(9)} ` +
    `${toGiB(daily(k)).padStart(8)} ${String(POINT.filter((n) => VISIBILITY[n].includes(k)).length).padStart(13)}`);
const total = sources.reduce((a, k) => a + daily(k), 0);
console.log(`${'total'.padEnd(31)} ${''.padStart(11)} ${''.padStart(9)} ${toGiB(total).padStart(8)}`);

console.log(`\n${'foothold point type'.padEnd(26)} ${'count'.padStart(6)}  sources that see it`);
for (const n of POINT)
  console.log(`${n.padEnd(26)} ${String(VISIBILITY[n].length).padStart(6)}  ${VISIBILITY[n].join(', ') || '-'}`);
const dark = POINT.filter((n) => VISIBILITY[n].length === 0);
console.log(`\npoint types seen by no source: ${dark.length}/${POINT.length} (${dark.join(', ')})`);
console.log(`sources that see no point type: ` +
  sources.filter((k) => !POINT.some((n) => VISIBILITY[n].includes(k))).join(', '));
```

```
432 endpoints, 7 telemetry sources, 8 foothold point types; retention budget 120.00 GiB

source                           evt/ep/day bytes/evt  GiB/day   points seen
configuration change log                140       320     0.02             3
script block log                        260      4096     0.43             1
module load log                        9800       384     1.51             1
file modification log                 31000       256     3.19             3
process creation log                   2400       512     0.49             4
network connection log                 5600       224     0.50             0
logon log                                18       448     0.00             0
total                                                     6.16

foothold point type         count  sources that see it
startup-entry                   2  configuration change log, process creation log
service-definition              2  configuration change log, process creation log
scheduled-task                  2  configuration change log, process creation log
login-script                    3  script block log, process creation log, file modification log
load-path-hook                  2  module load log, file modification log
legitimate-binary-attach        1  file modification log
event-subscription              0  -
media-autorun                   0  -

point types seen by no source: 2/8 (event-subscription, media-autorun)
sources that see no point type: network connection log, logon log
```

Two numbers stand out immediately, and both surprise anyone expecting the opposite. Two of the eight
point types appear in **no** source at all. Persistent event subscription and removable media's
autorun entry fall outside telemetry; the first because it is written into the management
framework's own record space, the second because it sits on the attached medium, not the endpoint.
These two point types were the cheapest to close in the previous lesson — and when they are not
closed, there is no visibility standing in to take their place either.

The second number runs the other way: two sources see no foothold point at all. The network
connection log produces half a GiB a day and makes not a single point visible. This does not mean
the source is unnecessary; once an item is caught, only that source says where it connected and
which endpoints it talked to. But by the foothold-point measure its contribution is zero, and it
still takes its share of the budget.

The volume column gives the third surprising number. The process creation log sees the most points,
four, and the configuration change log sees three; the second is one of the two cheapest sources,
and the two together sit just above half a GiB a day. The most expensive source, the file
modification log, also sees three point types but produces 3.19 GiB a day; two of the three it sees
are already seen by other sources. There is no clean proportion between coverage and volume.

#### As Coverage Grows, the Window Shrinks

```js
// run2.mjs — adds sources by point-gain-per-volume ratio; the window and the detections that fall outside it
import { SOURCE, POINT, DELAY, daily, visible, window, toGiB } from './telemetry.mjs';

const remaining = Object.keys(SOURCE), chosen = [];
console.log(`${'source added'.padEnd(31)} ${'new points'.padStart(10)} ${'visible'.padStart(7)} ` +
  `${'GiB/day'.padStart(7)} ${'window days'.padStart(11)} ${'window detections'.padStart(18)}`);
while (remaining.length) {
  let best = null;
  for (const k of remaining) {
    const gain = visible([...chosen, k]).length - visible(chosen).length;
    const ratio = gain / daily(k);
    if (!best || ratio > best.ratio || (ratio === best.ratio && daily(k) < daily(best.k))) best = { k, gain, ratio };
  }
  chosen.push(best.k);
  remaining.splice(remaining.indexOf(best.k), 1);
  const w = window(chosen), volume = chosen.reduce((a, k) => a + daily(k), 0);
  console.log(`${best.k.padEnd(31)} ${String(best.gain).padStart(10)} ${`${visible(chosen).length}/${POINT.length}`.padStart(7)} ` +
    `${toGiB(volume).padStart(7)} ${w.toFixed(1).padStart(11)} ${`${DELAY.filter((g) => g <= w).length}/${DELAY.length}`.padStart(18)}`);
}
const four = Object.keys(SOURCE).slice(0, 4);
console.log(`\nfour-source setup: visible ${visible(four).length}/${POINT.length}, ` +
  `window ${window(four).toFixed(1)} days, detections in window ${DELAY.filter((g) => g <= window(four)).length}/${DELAY.length}`);
const all = Object.keys(SOURCE);
console.log(`seven-source setup: visible ${visible(all).length}/${POINT.length}, ` +
  `window ${window(all).toFixed(1)} days, detections in window ${DELAY.filter((g) => g <= window(all)).length}/${DELAY.length}`);
console.log(`\nbudget needed for a 21-day window with seven sources: ` +
  `${toGiB(21 * all.reduce((a, k) => a + daily(k), 0))} GiB (` +
  `${(21 * all.reduce((a, k) => a + daily(k), 0) / (120 * 1024 ** 3)).toFixed(2)}x the current budget)`);
console.log(`foothold points closed: 0 — this layer does not close points, it makes them visible`);
console.log(`points the telemetry agent adds to the endpoint: 2 (service-definition, load-path-hook)`);
```

```
source added                    new points visible GiB/day window days  window detections
configuration change log                 3     3/8    0.02      6657.6              10/10
script block log                         1     4/8    0.45       268.8              10/10
module load log                          1     5/8    1.96        61.2               9/10
file modification log                    1     6/8    5.15        23.3               5/10
logon log                                0     6/8    5.16        23.3               5/10
process creation log                     0     6/8    5.65        21.2               5/10
network connection log                   0     6/8    6.16        19.5               4/10

four-source setup: visible 6/8, window 23.3 days, detections in window 5/10
seven-source setup: visible 6/8, window 19.5 days, detections in window 4/10

budget needed for a 21-day window with seven sources: 129.27 GiB (1.08x the current budget)
foothold points closed: 0 — this layer does not close points, it makes them visible
points the telemetry agent adds to the endpoint: 2 (service-definition, load-path-hook)
```

The add order was set by point gain per byte, and the curve exhausts itself in four steps. The
configuration change log alone makes three point types visible and costs 0.02 GiB a day — a window
of six thousand six hundred fifty-seven days, meaning the budget is practically unlimited. After the
fourth step, no source brings a new point: coverage stays at six, and the remaining three sources add
only volume. The window drops from twenty-three days to nineteen and a half.

These last three rows are not a list of waste, and reading them that way misuses the measurement. The
process creation log is the source that sees the most point types; the reason it comes late in the
order is that all four points it sees are already seen by cheaper sources. But once an item is
noticed, only that source says **what started what**; the context to be measured in the fifth lesson
is exactly this. The network connection log and the logon log belong to the same class. The
foothold-point measure does not show these three sources' value; what it shows is that these three
sources cannot be defended on **coverage** grounds.

The bottom lines give the lesson's real tension. When all seven sources are collected together, the
window is nineteen and a half days; four of the ten fictional detection delays fit inside this
window. For an event noticed after twenty-one days, the endpoint's record has already been deleted —
detection worked, there is no evidence. The same coverage is achieved with four sources and a
twenty-three-day window, and five of the ten detections stay inside it. In this model, collecting
less with the same budget means seeing more.

The last three lines record this layer's place in the course's two columns. The number of foothold
points it closes is zero; it makes six of the eight point types **visible** and never sees two of
them at all. On top of that, it needs a service definition and a load-path hook on the endpoint for
its own agent: it adds two points to the inventory. The visibility layer's cost is not only storage —
it is two items of the very thing it measures.

#### Depth of Visibility

The coverage column says whether a point is visible or not; it does not say how many sources it is
visible from. The difference matters: a point tied to a single source becomes invisible when that
source goes silent, and silencing a source is a single configuration item on the endpoint.

```js
// run3.mjs — depth of visibility: how many sources see a point type, how many points depend on a single source
import { SOURCE, VISIBILITY, POINT, visible, window } from './telemetry.mjs';

const FOUR = Object.keys(SOURCE).slice(0, 4), ALL = Object.keys(SOURCE);
const depth = (set, n) => VISIBILITY[n].filter((k) => set.includes(k)).length;
console.log(`${'foothold point type'.padEnd(26)} ${'4 sources'.padStart(13)} ${'7 sources'.padStart(13)}`);
for (const n of POINT)
  console.log(`${n.padEnd(26)} ${String(depth(FOUR, n)).padStart(13)} ${String(depth(ALL, n)).padStart(13)}`);
for (const [label, set] of [['four sources', FOUR], ['seven sources', ALL]]) {
  const g = visible(set);
  console.log(`\n${label}: visible ${g.length}/${POINT.length}, window ${window(set).toFixed(1)} days`);
  console.log(`  points on a single source : ${g.filter((n) => depth(set, n) === 1).length}`);
  console.log(`  points on two or more     : ${g.filter((n) => depth(set, n) >= 2).length}`);
  const one = g.filter((n) => depth(set, n) === 1)
    .map((n) => VISIBILITY[n].find((k) => set.includes(k)));
  const critical = [...new Set(one)].map((k) => [k, one.filter((x) => x === k).length])
    .sort((a, b) => b[1] - a[1])[0];
  console.log(`  source that darkens the most points if silenced: ${critical[0]} (${critical[1]} points)`);
}
```

```
foothold point type            4 sources     7 sources
startup-entry                          1             2
service-definition                     1             2
scheduled-task                         1             2
login-script                           2             3
load-path-hook                         2             2
legitimate-binary-attach               1             1
event-subscription                     0             0
media-autorun                          0             0

four sources: visible 6/8, window 23.3 days
  points on a single source : 4
  points on two or more     : 2
  source that darkens the most points if silenced: configuration change log (3 points)

seven sources: visible 6/8, window 19.5 days
  points on a single source : 1
  points on two or more     : 5
  source that darkens the most points if silenced: file modification log (1 points)
```

The two setups give the same coverage with different depths. In the four-source setup, four of the
six visible points depend on a single source, and three of them on the same source: when the
configuration change log is silenced, startup entry, service definition, and scheduled task go dark
all at once. In the seven-source setup, the number of points tied to a single source drops to one.
The three extra sources brought nothing in the coverage column; in the depth column they raise three
points from one source to two, and one point to three.

This changes how the previous table is read. "A source that brings no new point" and "a source that
is worthless" are not the same thing. The three measures contradict each other: coverage saturates at
the fourth source, depth peaks at the seventh, and the window shortens with every addition. No setup
in the model makes all three best at once. The decision is made by writing down how much of which
measure will be given up — and that writing is this very table.

#### Summary

- 2 of the 8 foothold point types appear in no telemetry source (persistent event subscription,
  removable media entry). These two were the cheapest points to close in the previous lesson.
- 2 sources see no point type (network connection log, logon log) and together cost 0.50 GiB a day.
  Their value lies in response context, not coverage.
- When sorted by point gain per byte, coverage reaches 6/8 at 4 sources and stalls; the remaining 3
  sources raise the daily volume from 5.15 GiB to 6.16 GiB without bringing a new point.
- With a 120 GiB budget, the window is 23.3 days with 4 sources and 19.5 days with 7. The 21-day
  fictional detection delay falls outside the window in the second case: detection works, no
  evidence is found. Holding the same window with 7 sources needs 1.08 times the budget.
- The number of points this layer closes is 0; it makes 6 points visible and adds 2 points to the
  endpoint for its own agent. Visibility is not closing, and its cost is not only storage.

#### Next Step

This lesson's telemetry recorded what happened on the endpoint, but only one source saw the
relationship between endpoints, and that source could not be defended on coverage grounds. The
connection between endpoints itself was measured somewhere else: the box standing at the network's
edge could count the traffic crossing between zones. There is a set that measurement cannot see — the
connection between two endpoints sitting in the same zone never crosses the edge at all. The next
lesson's question is this: how many connection paths exist between endpoints on the same network, how
many of them does the perimeter rule never see, and how many of those paths does a filter sitting on
the host side close while cutting how much legitimate work along with them.

### Host-Based Firewall and Prevention

Source: https://academia.sh/en/courses/endpoint-security/host-based-firewall

In a 432-endpoint model, 729,252 connection paths are counted and the 290,772 intra-zone paths the zone-boundary rule never sees at all are separated out: the paths a twelve-item host-based allowlist closes, the peer-to-peer traffic it has to leave open, the two legitimate flows it cuts because they were never written into the list, the way a denied path lands in the record, and the number of endpoints reachable in a single hop from a compromised endpoint.

The previous lesson measured the telemetry on the endpoint and showed that the one source that saw
the connection between endpoints could not be defended on coverage grounds. This lesson counts that
connection itself. The box standing at the network's edge was measured in another course, and there
the traffic between zones was counted; that measurement had a structural blind spot: the connection
between two endpoints in the same zone never crosses the boundary at all, so no rule at the boundary
either sees or evaluates it.

Three numbers are measured. The first is how many connection paths exist inside a zone — this is the
set the perimeter rule never sees at all. The second is how many of them a filter sitting on the host
side closes. The third is the cost of closing: how many legitimate flows have to be written into the
list, how many unwritten flows get cut, and which paths cannot be closed. No attack step is written in
this lesson; what is counted is the path itself.

- **EP19.** 432 endpoints, four endpoint classes, eighteen listening-service definitions, and twelve
  legitimate flows belong to a fictional regional measurement network. What is measured is the model
  built with `node`.
- **EP20.** The path definition is fixed: a path is the combination of a directed endpoint pair and a
  service listening at the destination. Reaching the same service by two different routes is a single
  path in the model.
- **EP21.** All endpoints in a class listen on the same services. In reality endpoints drift, and
  drift grows the path count; the fixed class makes the number look smaller than it is.
- **EP22.** The allowlist was written at the endpoint **class** level. A list written to endpoint
  addresses closes more paths and multiplies the maintenance item by the endpoint count.
- **EP23.** The host-side rule set was assumed to be unchangeable on the endpoint; if a privilege
  exists that can remove the rule, this measurement is invalid.
- **EP24.** The rule at the zone boundary evaluates only inter-zone paths. This is the boundary's
  definition; the paths that rule closes are taken here as a given input.

#### The Set the Boundary Never Sees

```js
// network.mjs — endpoint classes, listening services, and legitimate internal flows; model, not a real network
// class -> [zone, endpoint count, listening services]
export const CLASS = {
  'field laptop': ['field', 120, ['remote support', 'file sharing', 'printer discovery']],
  'branch endpoint': ['branch', 240, ['remote support', 'file sharing', 'printer discovery', 'local cache']],
  'aggregator': ['aggregator', 60, ['remote support', 'metric ingestion', 'status query', 'file sharing', 'management interface']],
  'central server': ['central', 12, ['remote support', 'metric ingestion', 'status query', 'database', 'management interface', 'reporting interface']],
};
export const NAME = Object.keys(CLASS);
export const zone = (s) => CLASS[s][0];
export const count = (s) => CLASS[s][1];
export const services = (s) => CLASS[s][2];
// [source class, destination class, service] — legitimate flows written into the list
export const ALLOWED = [
  ['aggregator', 'central server', 'metric ingestion'],
  ['central server', 'aggregator', 'status query'],
  ['field laptop', 'central server', 'reporting interface'],
  ['branch endpoint', 'central server', 'reporting interface'],
  ['central server', 'field laptop', 'remote support'],
  ['central server', 'branch endpoint', 'remote support'],
  ['central server', 'aggregator', 'remote support'],
  ['branch endpoint', 'branch endpoint', 'local cache'],
  ['branch endpoint', 'branch endpoint', 'printer discovery'],
  ['field laptop', 'field laptop', 'file sharing'],
  ['aggregator', 'aggregator', 'file sharing'],
  ['central server', 'central server', 'database'],
];
// legitimate flows not written into the list, discovered later
export const FORGOTTEN = [
  ['branch endpoint', 'field laptop', 'file sharing'],
  ['field laptop', 'aggregator', 'status query'],
];
export const pair = (a, b) => (a === b ? count(a) * (count(a) - 1) : count(a) * count(b));
export function paths() {
  const l = [];
  for (const a of NAME) for (const b of NAME) for (const h of services(b)) l.push([a, b, h, pair(a, b)]);
  return l;
}
export const isInternal = (y) => zone(y[0]) === zone(y[1]);
export const isAllowed = (y, list) => list.some((i) => i[0] === y[0] && i[1] === y[1] && i[2] === y[2]);
export const sum = (l) => l.reduce((a, y) => a + y[3], 0);
```

```js
// run1.mjs — intra-zone and inter-zone path count: what the perimeter rule sees and never sees
import { CLASS, NAME, count, services, paths, isInternal, sum } from './network.mjs';

const Y = paths(), N = NAME.reduce((a, s) => a + count(s), 0);
console.log(`${N} endpoints, ${NAME.length} endpoint classes, ${NAME.reduce((a, s) => a + services(s).length, 0)} listening-service definitions\n`);
console.log(`${'endpoint class'.padEnd(16)} ${'zone'.padStart(11)} ${'eps'.padStart(4)} ${'svcs'.padStart(6)} ` +
  `${'intra-zone'.padStart(13)} ${'inter-zone'.padStart(14)}`);
for (const s of NAME) {
  const target = Y.filter((y) => y[1] === s);
  console.log(`${s.padEnd(16)} ${CLASS[s][0].padStart(11)} ${String(count(s)).padStart(4)} ${String(services(s).length).padStart(6)} ` +
    `${String(sum(target.filter(isInternal))).padStart(13)} ${String(sum(target.filter((y) => !isInternal(y)))).padStart(14)}`);
}
const [inside, outside] = [sum(Y.filter(isInternal)), sum(Y.filter((y) => !isInternal(y)))];
console.log(`${'total'.padEnd(16)} ${''.padStart(11)} ${String(N).padStart(4)} ${''.padStart(6)} ` +
  `${String(inside).padStart(13)} ${String(outside).padStart(14)}`);
console.log(`\ntotal paths                    : ${inside + outside}`);
console.log(`paths the perimeter sees       : ${outside} (${(outside * 100 / (inside + outside)).toFixed(1)}%)`);
console.log(`paths the perimeter never sees : ${inside} (${(inside * 100 / (inside + outside)).toFixed(1)}%)`);
```

```
432 endpoints, 4 endpoint classes, 18 listening-service definitions

endpoint class          zone  eps   svcs    intra-zone     inter-zone
field laptop           field  120      3         42840         112320
branch endpoint       branch  240      4        229440         184320
aggregator        aggregator   60      5         17700         111600
central server       central   12      6           792          30240
total                         432               290772         438480

total paths                    : 729252
paths the perimeter sees       : 438480 (60.1%)
paths the perimeter never sees : 290772 (39.9%)
```

Of the seven hundred twenty-nine thousand two hundred fifty-two total paths, two hundred ninety
thousand seven hundred seventy-two stay inside a zone: thirty-nine point nine percent. No rule at the
boundary evaluates this set — it is not denied, not allowed, not logged. The boundary rule cannot even
be said to "allow" these paths; it never sees them at all.

The distribution is not one-directional either. Two hundred twenty-nine thousand four hundred forty
of the intra-zone paths come from the branch zone; the reason lies in the path definition itself,
because the path count grows with the square of the endpoint count. The central zone listens on the
most services and has only seven hundred ninety-two intra-zone paths.

#### What the Allowlist Closes and Cuts

```js
// run2.mjs — host-based default-deny: closed paths, remaining paths, and cut legitimate work
import { NAME, ALLOWED, FORGOTTEN, count, paths, isInternal, isAllowed, sum } from './network.mjs';

const Y = paths(), IN = Y.filter(isInternal), OUT = Y.filter((y) => !isInternal(y));
const setup = (list) => ({
  intra: sum(IN.filter((y) => isAllowed(y, list))), inter: sum(OUT.filter((y) => isAllowed(y, list))),
  cut: FORGOTTEN.filter((u) => !list.some((i) => i.join() === u.join())),
});
console.log(`${'setup'.padEnd(30)} ${'intra-zone left'.padStart(15)} ${'inter-zone left'.padStart(16)} ` +
  `${'total left'.padStart(12)} ${'cut legit flows'.padStart(18)}`);
for (const [name, list] of [['no host filter', Y.map((y) => [y[0], y[1], y[2]])],
  ['allowlist (12 items)', ALLOWED], ['+ two forgotten flows added', [...ALLOWED, ...FORGOTTEN]]]) {
  const r = setup(list);
  console.log(`${name.padEnd(30)} ${String(r.intra).padStart(15)} ${String(r.inter).padStart(16)} ` +
    `${String(r.intra + r.inter).padStart(12)} ${String(r.cut.length).padStart(18)}`);
}
const r = setup(ALLOWED), t = sum(IN), d = sum(OUT);
console.log(`\nintra-zone paths closed  : ${t - r.intra}/${t} (${((t - r.intra) * 100 / t).toFixed(1)}%)`);
console.log(`inter-zone paths closed  : ${d - r.inter}/${d} (${((d - r.inter) * 100 / d).toFixed(1)}%)`);
console.log(`paths closed in the set the perimeter never sees: ${t - r.intra}`);
console.log(`denied path types that land in the record: ${IN.filter((y) => !isAllowed(y, ALLOWED)).length} intra + ` +
  `${OUT.filter((y) => !isAllowed(y, ALLOWED)).length} inter = ${Y.filter((y) => !isAllowed(y, ALLOWED)).length}`);
console.log(`\nintra-zone flows left open by the allowlist:`);
for (const y of IN.filter((y) => isAllowed(y, ALLOWED)))
  console.log(`  ${`${y[0]} -> ${y[1]}`.padEnd(36)} ${y[2].padEnd(20)} ${String(y[3]).padStart(6)} paths`);
console.log(`\nlegitimate flows cut because they were not written into the list:`);
for (const u of setup(ALLOWED).cut) {
  const y = Y.find((y) => y[0] === u[0] && y[1] === u[1] && y[2] === u[2]);
  console.log(`  ${`${u[0]} -> ${u[1]}`.padEnd(36)} ${u[2].padEnd(20)} ${String(y[3]).padStart(6)} paths`);
}

console.log(`\n${'breached class'.padEnd(16)} ${'no filter'.padStart(9)} ${'with allowlist'.padStart(15)} ${'closed'.padStart(7)}`);
for (const a of NAME) {
  const targets = NAME.filter((b) => ALLOWED.some((i) => i[0] === a && i[1] === b));
  const reached = targets.reduce((t, b) => t + (a === b ? count(b) - 1 : count(b)), 0);
  const N = NAME.reduce((t, s) => t + count(s), 0) - 1;
  console.log(`${a.padEnd(16)} ${String(N).padStart(9)} ${String(reached).padStart(15)} ${String(N - reached).padStart(7)}`);
}
```

```
setup                          intra-zone left  inter-zone left   total left    cut legit flows
no host filter                          290772           438480       729252                  0
allowlist (12 items)                    132672            10800       143472                  2
+ two forgotten flows added             132672            46800       179472                  0

intra-zone paths closed  : 158100/290772 (54.4%)
inter-zone paths closed  : 427680/438480 (97.5%)
paths closed in the set the perimeter never sees: 158100
denied path types that land in the record: 13 intra + 47 inter = 60

intra-zone flows left open by the allowlist:
  field laptop -> field laptop         file sharing          14280 paths
  branch endpoint -> branch endpoint   printer discovery     57360 paths
  branch endpoint -> branch endpoint   local cache           57360 paths
  aggregator -> aggregator             file sharing           3540 paths
  central server -> central server     database                132 paths

legitimate flows cut because they were not written into the list:
  branch endpoint -> field laptop      file sharing          28800 paths
  field laptop -> aggregator           status query           7200 paths

breached class   no filter  with allowlist  closed
field laptop           431             131     300
branch endpoint        431             251     180
aggregator             431              71     360
central server         431             431       0
```

A twelve-item list closes fifty-four point four percent of the intra-zone paths. Where the remaining
one hundred thirty-two thousand six hundred seventy-two come from is written in the list below and
sums to a single line: the printer discovery and local cache flows that branch endpoints open to each
other, one hundred fourteen thousand seven hundred twenty paths together. These are legitimate flows,
and when they are removed from the list, two hundred forty endpoints stop seeing each other. In a
zone with legitimate peer-to-peer traffic, there is a ceiling on the paths host-based filtering can
close, and what sets that ceiling is not the attack — it is the work itself.

The inter-zone column shows the opposite: ninety-seven point five percent closes. The difference
comes from how few legitimate flows exist between zones — seven items. If the same list behaves this
differently across the two sets, the value of host-based filtering is not in **replacing** the
boundary rule, but in doing something in the set the boundary never looks at at all: one hundred
fifty-eight thousand one hundred paths close without the boundary rule changing at all.

The third row gives the shape of the cost. When the allowlist was written, two legitimate flows were
not in the list and were cut: file sharing from a branch endpoint to a field laptop, and a status
query from a field laptop to an aggregator. When the two are added, the intra-zone remainder does not
change at all — both are inter-zone — but the inter-zone remainder rises from ten thousand eight
hundred to forty-six thousand eight hundred. Adding two items quadruples the open inter-zone path
count. Every item written into the list behaves this way, and this is the real cost of maintenance:
the twelve-item list is not a one-time job, it is an inventory that keeps growing.

The sixty denied path types, meanwhile, are written into the second column, visibility. The filter on
the host side is the one layer in this lesson that both closes and records in the same move: the
connection attempt it denies produces a record, and that record is added to the previous lesson's
telemetry sources. The closed path and the path that lands in the record are the same set here — one
of the rare places in the course where these two columns overlap.

The last table gives the lesson's harshest number. The number of endpoints reachable in a single hop
from a compromised aggregator drops from four hundred thirty-one to seventy-one, and from a field
laptop to one hundred thirty-one. From a compromised central server, four hundred thirty-one endpoints
remain reachable and the closed count is zero. The reason is plain: the remote support flow is
written from the center to all three of the other endpoint classes, and if that flow is removed there
is no such thing as remote support left. The management path itself cannot be closed; a path that
cannot be closed must at least be made visible.

#### How Much Each Item Opens

List items are not equal. Starting from an empty list and adding items one at a time shows,
separately, how many paths each item opens and how many legitimate flows it saves in return.

```js
// run3.mjs — paths opened as the list grows: how many paths each item opens, how many legitimate flows it saves
import { ALLOWED, FORGOTTEN, paths, isInternal, isAllowed, sum } from './network.mjs';

const Y = paths(), FULL = [...ALLOWED, ...FORGOTTEN];
console.log(`${'item'.padEnd(4)} ${'added flow'.padEnd(58)} ${'new paths'.padStart(10)} ` +
  `${'remaining'.padStart(10)} ${'still cut'.padStart(12)}`);
for (let n = 0; n <= FULL.length; n++) {
  const list = FULL.slice(0, n);
  const remaining = sum(Y.filter((y) => isAllowed(y, list)));
  const added = n === 0 ? 0 : remaining - sum(Y.filter((y) => isAllowed(y, FULL.slice(0, n - 1))));
  const label = n === 0 ? '(empty list: everything is denied)' : `${FULL[n - 1][0]} -> ${FULL[n - 1][1]} / ${FULL[n - 1][2]}`;
  console.log(`${String(n).padEnd(4)} ${label.padEnd(58)} ${String(added).padStart(10)} ${String(remaining).padStart(10)} ` +
    `${String(FULL.length - n).padStart(12)}`);
}
const firstThree = sum(Y.filter((y) => isAllowed(y, FULL.slice(0, 3))));
console.log(`\nfirst 3 items open ${(firstThree * 100 / sum(Y)).toFixed(2)}% of the total paths`);
const in_ = FULL.filter((i) => isInternal([i[0], i[1]]));
console.log(`intra-zone item count: ${in_.length}/${FULL.length}; paths they open: ` +
  `${sum(Y.filter((y) => isAllowed(y, in_)))} (${(sum(Y.filter((y) => isAllowed(y, in_))) * 100 / sum(Y.filter((y) => isAllowed(y, FULL)))).toFixed(1)}% of the remainder)`);
```

```
item added flow                                                  new paths  remaining    still cut
0    (empty list: everything is denied)                                  0          0           14
1    aggregator -> central server / metric ingestion                   720        720           13
2    central server -> aggregator / status query                       720       1440           12
3    field laptop -> central server / reporting interface             1440       2880           11
4    branch endpoint -> central server / reporting interface          2880       5760           10
5    central server -> field laptop / remote support                  1440       7200            9
6    central server -> branch endpoint / remote support               2880      10080            8
7    central server -> aggregator / remote support                     720      10800            7
8    branch endpoint -> branch endpoint / local cache                57360      68160            6
9    branch endpoint -> branch endpoint / printer discovery          57360     125520            5
10   field laptop -> field laptop / file sharing                     14280     139800            4
11   aggregator -> aggregator / file sharing                          3540     143340            3
12   central server -> central server / database                       132     143472            2
13   branch endpoint -> field laptop / file sharing                  28800     172272            1
14   field laptop -> aggregator / status query                        7200     179472            0

first 3 items open 0.39% of the total paths
intra-zone item count: 5/14; paths they open: 132672 (73.9% of the remainder)
```

The curve splits into two regions. The first seven items — all of the inter-zone flows — open ten
thousand eight hundred paths in total; half of the fourteen items produce six percent of the
remaining paths. The eighth and ninth items alone open one hundred fourteen thousand seven hundred
twenty paths. Two items open roughly twice as much as the other twelve combined, and both can be
defended with the same sentence: branch endpoints need to see each other.

A list measured by item count is therefore misleading. Five of the fourteen items are intra-zone, and
they open seventy-three point nine percent of the remaining paths. Reviewing an allowlist is not
counting items — it is writing down, next to each item, the path it opens.

#### Summary

- The 432-endpoint model has 729,252 connection paths; 290,772 of them (39.9%) are intra-zone, and
  the boundary rule neither denies nor logs this set — it never sees it at all.
- 79% of the intra-zone paths come from a single zone (branch): the path count grows with the square
  of the endpoint count and linearly with the service count.
- The 12-item host-based allowlist closes 54.4% of the intra-zone paths (158,100 paths) and 97.5% of
  the inter-zone paths. What makes the difference is the legitimate peer-to-peer traffic inside a
  zone: the printer discovery and local cache flows between branch endpoints alone leave 114,720
  paths open.
- The cost is two items: 2 legitimate flows not written into the list were cut; adding them raised
  the open inter-zone paths from 10,800 to 46,800. The list is not a one-time job — it is an
  inventory that grows the surface with every addition.
- The 60 denied path types land in the record in the same move: the host-based filter is the rare
  layer where the closing column and the visibility column overlap.
- The number of endpoints reachable in a single hop from a compromised endpoint drops from 431 to 71
  for an aggregator and to 131 for a field laptop; it stays at 431 for a central server, and the
  closed count is zero — the remote support flow cannot be removed.

#### Next Step

This lesson's allowlist rested on an assumption: that whatever wants to use a closed path has a tool
**of its own**. The legitimate flows written into the list were already open, and the tools that
carried out those flows already sat on the endpoint. If the tool that carries the remote support flow
is itself run with malicious intent, no rule is violated; no path closes, no denial record is
produced. The next lesson's question is this: how many of the built-in and signed tools on the
endpoint can do the same work, why can a signature-based rule not tell them apart, and how many can
behavioral context — parent process, command pattern, timing — tell apart.

### Living Off the Land

Source: https://academia.sh/en/courses/endpoint-security/living-off-the-land

The overlap of fourteen built-in, signed tools across seven capability classes is counted: an average of 3.4 tools carrying the same capability, three signature rules flagging zero of twenty-four runs, and three context dimensions — parent process, command pattern, timing — evaluated singly, combined, and intersected; five of six suspicious runs are separated out, one is identical in four dimensions to a legitimate run.

The previous lesson's allowlist closed the paths it could close and named the paths it could not:
the remote support flow could not be removed. The tool that carries out that flow sits on the
endpoint, signed and recorded in the inventory's baseline. This lesson counts what happens on the
detection side when that tool — and thirteen others like it — is run with malicious intent.

Three things are measured. The first is overlap: how many separate built-in tools can do the same
job. The second is how many items the signature-based rule flags among these runs. The third is how
many suspicious runs the behavioral context — parent process, command pattern, and timing — can
separate, and how many legitimate runs it flags in return. No command, parameter, or step sequence
is written in this lesson; the runs are modeled at the level of type and count. Trying these
techniques on a system without written authorization and a defined scope is both illegal and
unprofessional.

- **EP25.** Fourteen tools, seven capability classes, and twenty-four runs belong to one endpoint of
  the fictional regional measurement network. What is measured is the model built with `node`.
- **EP26.** All of the tools are built-in, validly signed, and recorded in the baseline. This is the
  lesson's definition, not its assumption: a tool not in the inventory was already measured in
  another lesson.
- **EP27.** The true class of each run is known in the model. In reality it is known only after
  investigation; it is assumed known here so that the separated and the false positive can be
  counted separately.
- **EP28.** Context was modeled with three discrete dimensions. In reality each dimension has a
  continuous value and needs a threshold; the discrete value shrinks the rule count, it does not
  change the direction of the combinations.
- **EP29.** The command pattern classes are a definition of **form**, not content; no example
  command was written.
- **EP30.** A run either matches a rule or it does not; frequency, recurrence, and correlation with
  other runs on the same endpoint were not modeled. All three increase separating power, and all
  three need more data.

#### How Many Tools Can Do the Same Job

```js
// tools.mjs — built-in and signed tool inventory on the endpoint, capabilities, and run record; model
// tool type -> capability classes it carries (all assumed built-in and signed)
export const TOOL = {
  'script interpreter': ['code interpretation', 'process launch', 'network data fetch'],
  'shell interpreter': ['code interpretation', 'process launch'],
  'file transfer tool': ['network data fetch'],
  'package manager': ['network data fetch', 'process launch'],
  'installer engine': ['network data fetch', 'process launch'],
  'certificate helper': ['network data fetch'],
  'remote management client': ['remote connection', 'process launch'],
  'management script interface': ['remote connection', 'process launch', 'code interpretation'],
  'archiver': ['archiving'],
  'backup helper': ['archiving', 'scheduling'],
  'scheduler interface': ['scheduling', 'process launch'],
  'system information tool': ['inventory read'],
  'configuration query tool': ['inventory read'],
  'log query tool': ['inventory read'],
};
export const NAME = Object.keys(TOOL);
export const CAPABILITY = [...new Set(Object.values(TOOL).flat())];
export const carries = (y) => NAME.filter((a) => TOOL[a].includes(y));
// signature-based rule set: each rule looks at one field of a run
export const SIGNATURE_RULE = [
  ['unknown file identity', (k) => k.identity === 'unknown'],
  ['unsigned binary', (k) => k.signature === 'none'],
  ['item not in baseline', (k) => k.baseline === false],
];
// run: [tool, parent process class, command pattern, time slot, true class]
export const RUN = [
  ['script interpreter', 'scheduler', 'ordinary', 'maintenance window', 'legitimate'],
  ['script interpreter', 'management agent', 'ordinary', 'business hours', 'legitimate'],
  ['shell interpreter', 'scheduler', 'ordinary', 'off hours', 'legitimate'],
  ['archiver', 'scheduler', 'ordinary', 'maintenance window', 'legitimate'],
  ['file transfer tool', 'management agent', 'ordinary', 'business hours', 'legitimate'],
  ['file transfer tool', 'scheduler', 'externally sourced parameter', 'maintenance window', 'legitimate'],
  ['remote management client', 'user session', 'ordinary', 'business hours', 'legitimate'],
  ['remote management client', 'remote session', 'ordinary', 'business hours', 'legitimate'],
  ['scheduler interface', 'management agent', 'ordinary', 'business hours', 'legitimate'],
  ['certificate helper', 'service', 'ordinary', 'business hours', 'legitimate'],
  ['system information tool', 'management agent', 'ordinary', 'business hours', 'legitimate'],
  ['configuration query tool', 'user session', 'ordinary', 'business hours', 'legitimate'],
  ['log query tool', 'management agent', 'long single line', 'off hours', 'legitimate'],
  ['backup helper', 'scheduler', 'ordinary', 'off hours', 'legitimate'],
  ['installer engine', 'user session', 'ordinary', 'business hours', 'legitimate'],
  ['package manager', 'management agent', 'ordinary', 'maintenance window', 'legitimate'],
  ['management script interface', 'service', 'ordinary', 'business hours', 'legitimate'],
  ['shell interpreter', 'document application', 'ordinary', 'business hours', 'legitimate'],
  ['script interpreter', 'document application', 'encoded input', 'business hours', 'suspicious'],
  ['shell interpreter', 'remote session', 'long single line', 'off hours', 'suspicious'],
  ['file transfer tool', 'user session', 'externally sourced parameter', 'off hours', 'suspicious'],
  ['archiver', 'service', 'ordinary', 'off hours', 'suspicious'],
  ['remote management client', 'user session', 'ordinary', 'business hours', 'suspicious'],
  ['scheduler interface', 'remote session', 'ordinary', 'business hours', 'suspicious'],
];
export const record = (k) => ({ tool: k[0], identity: 'known', signature: 'valid', baseline: true });
export const CONTEXT = [
  ['parent process', (k) => ['document application', 'remote session'].includes(k[1])],
  ['command pattern', (k) => ['encoded input', 'externally sourced parameter'].includes(k[2])],
  ['timing', (k) => k[3] === 'off hours' && k[1] !== 'scheduler'],
];
```

```js
// run1.mjs — tool count carrying the same capability, and items the signature-based rule set flags
import { TOOL, NAME, CAPABILITY, RUN, SIGNATURE_RULE, carries, record } from './tools.mjs';

console.log(`${NAME.length} built-in tools, ${CAPABILITY.length} capability classes, ${RUN.length} run records\n`);
console.log(`${'capability class'.padEnd(24)} ${'tools'.padStart(12)} ${'left if one blocked'.padStart(23)}`);
for (const y of CAPABILITY)
  console.log(`${y.padEnd(24)} ${String(carries(y).length).padStart(12)} ${String(carries(y).length - 1).padStart(23)}`);
const many = CAPABILITY.filter((y) => carries(y).length > 1);
console.log(`\ncapabilities tied to one tool: ${CAPABILITY.length - many.length}/${CAPABILITY.length}`);
console.log(`capabilities with multiple tools: ${many.length}/${CAPABILITY.length}, average ` +
  `${(many.reduce((a, y) => a + carries(y).length, 0) / many.length).toFixed(1)} tools`);

const suspicious = RUN.filter((k) => k[4] === 'suspicious');
console.log(`\n${'signature rule'.padEnd(26)} ${'flagged runs'.padStart(18)} ${'of suspicious'.padStart(16)}`);
for (const [name, f] of SIGNATURE_RULE) {
  const flagged = RUN.filter((k) => f(record(k)));
  console.log(`${name.padEnd(26)} ${String(flagged.length).padStart(18)} ` +
    `${`${flagged.filter((k) => k[4] === 'suspicious').length}/${suspicious.length}`.padStart(16)}`);
}
console.log(`\ntool count used by suspicious runs: ${new Set(suspicious.map((k) => k[0])).size}`);
console.log(`how many of these tools have a valid signature and a baseline record: ` +
  `${[...new Set(suspicious.map((k) => k[0]))].filter((a) => NAME.includes(a)).length}/${new Set(suspicious.map((k) => k[0])).size}`);
```

```
14 built-in tools, 7 capability classes, 24 run records

capability class                tools     left if one blocked
code interpretation                 3                       2
process launch                      7                       6
network data fetch                  5                       4
remote connection                   2                       1
archiving                           2                       1
scheduling                          2                       1
inventory read                      3                       2

capabilities tied to one tool: 0/7
capabilities with multiple tools: 7/7, average 3.4 tools

signature rule                   flagged runs    of suspicious
unknown file identity                       0              0/6
unsigned binary                             0              0/6
item not in baseline                        0              0/6

tool count used by suspicious runs: 6
how many of these tools have a valid signature and a baseline record: 6/6
```

The first table gives, in numbers, why one defensive move does not work. None of the seven
capability classes is tied to a single tool; an average of 3.4 tools carry the same capability. Seven
tools can launch a process; five can fetch data from the network. Removing a tool from the
application allowlist does not remove the capability, it only reduces the option count by one — and
if the removed tool is used for legitimate work, it stops that work too. This is the measure of the
overlap: blocking has a gain of one and a cost of one.

The second table gives the result the lesson is named for. Three signature-based rules flag zero of
the twenty-four runs and catch zero of the six suspicious runs. This is not because the rules are
badly written, but because the field they look at carries the same value in every one of these runs:
file identity known, signature valid, item recorded in the baseline. All six of the tools used by the
suspicious runs pass all three criteria. Signature-based detection does not give a wrong answer; it
**gives no answer at all**, because the question it asks is not discriminating here.

#### Three Context Dimensions

A question that could be discriminating looks not at the tool but at the run: what launched this
tool, how was it invoked, when did it run. All three dimensions can already be recorded on the
endpoint; the separating power of all three is measured separately and together.

```js
// run2.mjs — three context dimensions singly, combined, and intersected; the run that cannot be separated
import { RUN, CONTEXT } from './tools.mjs';

const S = RUN.filter((k) => k[4] === 'suspicious'), M = RUN.filter((k) => k[4] === 'legitimate');
const matches = (k) => CONTEXT.filter(([, f]) => f(k)).map(([a]) => a);
const measure = (rule) => [S.filter(rule).length, M.filter(rule).length];
console.log(`${'context set'.padEnd(44)} ${'separated suspicious'.padStart(21)} ${'false positive'.padStart(14)}`);
for (let m = 1; m < 8; m++) {
  const chosen = CONTEXT.filter((_, i) => m & (1 << i));
  const [a, y] = measure((k) => chosen.some(([, f]) => f(k)));
  console.log(`${chosen.map(([name]) => name).join(' or ').padEnd(44)} ${`${a}/${S.length}`.padStart(21)} ${`${y}/${M.length}`.padStart(14)}`);
}
for (const n of [2, 3]) {
  const [a, y] = measure((k) => matches(k).length >= n);
  console.log(`${`at least ${n} dimensions at once`.padEnd(44)} ${`${a}/${S.length}`.padStart(21)} ${`${y}/${M.length}`.padStart(14)}`);
}

console.log(`\n${'run'.padEnd(30)} ${'parent process'.padStart(22)} ${'pattern'.padStart(29)} ${'time'.padStart(15)}  matching dimensions`);
for (const k of S)
  console.log(`${k[0].padEnd(30)} ${k[1].padStart(22)} ${k[2].padStart(29)} ${k[3].padStart(15)}  ${matches(k).join(', ') || '-'}`);

const key = (k) => k.slice(0, 4).join('|');
const twin = S.filter((s) => M.some((m) => key(m) === key(s)));
console.log(`\nsuspicious runs identical to a legitimate run in four dimensions: ${twin.length}/${S.length}`);
for (const s of twin) console.log(`  ${s[0]} / ${s[1]} / ${s[2]} / ${s[3]}`);
console.log(`suspicious runs matching no context dimension: ${S.filter((k) => matches(k).length === 0).length}/${S.length}`);
```

```
context set                                   separated suspicious false positive
parent process                                                 3/6           2/18
command pattern                                                2/6           1/18
parent process or command pattern                              4/6           3/18
timing                                                         3/6           1/18
parent process or timing                                       5/6           3/18
command pattern or timing                                      4/6           2/18
parent process or command pattern or timing                    5/6           4/18
at least 2 dimensions at once                                  3/6           0/18
at least 3 dimensions at once                                  0/6           0/18

run                                    parent process                       pattern            time  matching dimensions
script interpreter               document application                 encoded input  business hours  parent process, command pattern
shell interpreter                      remote session              long single line       off hours  parent process, timing
file transfer tool                       user session  externally sourced parameter       off hours  command pattern, timing
archiver                                      service                      ordinary       off hours  timing
remote management client                 user session                      ordinary  business hours  -
scheduler interface                    remote session                      ordinary  business hours  parent process

suspicious runs identical to a legitimate run in four dimensions: 1/6
  remote management client / user session / ordinary / business hours
suspicious runs matching no context dimension: 1/6
```

Single dimensions are weak: the best one separates three of the six suspicious runs. Combinations do
better, but they do not add up cleanly. Parent process together with timing separates five of them
and flags three legitimate runs. Adding the third dimension — command pattern — **does not change
the separated count at all** and raises the false positives from three to four. Command pattern
alone separated two runs; both were runs the other two dimensions already caught. If adding a layer
closes zero additional paths, that layer counts as unmeasured, and this rule holds on the detection
side here too.

The intersection rows show the gain in the opposite direction. When at least two dimensions are
required to match at once, the separated count drops to three, but the false positives go to zero:
none of the eighteen legitimate runs match two dimensions at once. This cuts the number of alerts to
review to three, and all three of them are real. The choice here has the same shape as in earlier
lessons — five separated and four wrong, or three separated and zero wrong.

The list below names the run that cannot be separated, and the reason is measurable: a remote
management client running from a user session with an ordinary pattern during business hours is
identical, in four dimensions, to a legitimate run in the list. There is no field left to separate it
by. The only way to separate this run in the model is to add a fourth dimension — the endpoint it
connects to, the volume of data carried, the other work the same user did that day — and each fourth
dimension means a new telemetry source, which means a new cut into the window measured in the third
lesson.

#### Blocking's Two Columns

Blocking instead of separating is also an option: removing the tools that appear in suspicious runs
from the application allowlist. The gain and the cost of this move can be counted on the same model.

```js
// run3.mjs — the gain and cost of blocking a tool: stopped runs and the remaining alternative tool
import { TOOL, RUN, carries } from './tools.mjs';

const used = [...new Set(RUN.map((k) => k[0]))];
console.log(`${'blocked tool'.padEnd(30)} ${'stopped legit'.padStart(13)} ${'stopped suspicious'.padStart(19)} ` +
  `${'other tool, same capability'.padStart(27)}`);
let totalLegit = 0, totalSuspicious = 0;
for (const a of used) {
  const k = RUN.filter((x) => x[0] === a);
  const [m, s] = [k.filter((x) => x[4] === 'legitimate').length, k.filter((x) => x[4] === 'suspicious').length];
  const alternative = new Set(TOOL[a].flatMap((y) => carries(y)).filter((x) => x !== a));
  if (s > 0) { totalLegit += m; totalSuspicious += s; }
  console.log(`${a.padEnd(30)} ${String(m).padStart(13)} ${String(s).padStart(19)} ${String(alternative.size).padStart(27)}`);
}
const suspiciousTools = used.filter((a) => RUN.some((x) => x[0] === a && x[4] === 'suspicious'));
const transferable = suspiciousTools.filter((a) => TOOL[a].some((y) => carries(y).length > 1));
console.log(`\ntools used by suspicious runs: ${suspiciousTools.length}`);
console.log(`cost of blocking all of them: ${totalLegit} legitimate runs stop, ${totalSuspicious} suspicious runs stop`);
console.log(`of these tools, how many can hand their capability to another tool: ${transferable.length}/${suspiciousTools.length}`);
```

```
blocked tool                   stopped legit  stopped suspicious other tool, same capability
script interpreter                         2                   1                           8
shell interpreter                          2                   1                           6
archiver                                   1                   1                           1
file transfer tool                         2                   1                           4
remote management client                   2                   1                           6
scheduler interface                        1                   1                           7
certificate helper                         1                   0                           4
system information tool                    1                   0                           2
configuration query tool                   1                   0                           2
log query tool                             1                   0                           2
backup helper                              1                   0                           2
installer engine                           1                   0                           8
package manager                            1                   0                           8
management script interface                1                   0                           6

tools used by suspicious runs: 6
cost of blocking all of them: 10 legitimate runs stop, 6 suspicious runs stop
of these tools, how many can hand their capability to another tool: 6/6
```

Blocking all six tools stops six suspicious runs and stops ten legitimate runs along with them: the
ratio is greater than one, meaning more than one legitimate job is cut for every run gained. The
column on the right, however, shows that the gain is not lasting. Other tools carrying the
capabilities of every blocked tool still sit on the endpoint — eight for the script interpreter,
seven for the scheduler interface, one in the narrowest case, the archiver. All six of the six tools
can hand their capability to another tool.

Reading this table separates the course's two columns once more. Blocking really does close
something here, but what it closes is the **tool**, not the capability; as long as the capability
stays in the inventory, no path is closed. Separating, on the other hand, closes nothing — it only
makes visible — and for the five suspicious runs it makes visible, it also flags four legitimate
runs.

#### Summary

- None of the 7 capability classes is tied to a single tool; an average of 3.4 built-in tools carry
  the same capability. 7 tools can launch a process. Blocking a tool does not remove the capability,
  it reduces the option by one and stops a legitimate job.
- 3 signature-based rules flag 0 of the 24 runs. All 6 of the tools used by suspicious runs have a
  known identity, a valid signature, and a baseline record; the rule does not give a wrong answer, it
  gives no answer.
- The best single context dimension separates 3 of 6 suspicious runs. Parent process together with
  timing separates 5 of them and produces 3 false positives.
- Adding a third dimension raises false positives from 3 to 4 without changing the separated count: a
  layer that closes zero paths counts as unmeasured here too.
- When at least 2 dimensions are required to match at once, the separated count drops to 3 and the
  false positives go to 0. None of the 18 legitimate runs match two dimensions at once.
- 1 suspicious run is identical to a legitimate run in four dimensions and matches no context
  dimension. Separating it requires a fourth dimension — a new telemetry source.

#### Next Step

This lesson gave, in numbers, the limit of separating the misuse of a tool, but what it measured
were individual runs. A run is transient: it ends and leaves only a record behind. What persists is
the item that run writes on the endpoint — the foothold points counted in the first lesson. The topic
therefore returns to where it began. The next lesson's question is this: how many items are the
persistence point types on the endpoint as an inventory, how many of them do five separate audits
close, how many do they only log, and how many points remain that no audit either closes or sees.

### Persistence Mechanisms

Source: https://academia.sh/en/courses/endpoint-security/persistence-mechanisms

Ten persistence point types are counted as an inventory and six controls run across four endpoint classes: how many types each control closes, how many it only makes visible, which control cannot be applied in which endpoint class; the number of types closed in all four classes is zero, the number of types no control touches is one, and the cost of closing is 36 blocked legitimate items plus 52 manual-review items.

The previous lesson measured runs and showed that a run is transient: it ends and leaves only a
record behind. What persists is the item that run writes on the endpoint. The topic therefore
returns to where it began — the first lesson counted the points the types used; this lesson
counts those points as an inventory on the endpoint and runs six controls across them.

The three columns measured are the course's three columns. How many point types there are; how
many each control **closes**, how many it only makes **visible**; what closing costs in
legitimate work. A fourth distinction is added to this: a control cannot be applied in every
endpoint class. No persistence-establishment method was written in this lesson; what is counted
is the point itself and the control over it.

- **EP31.** Ten point types, legitimate item counts, six controls, and their applicability belong
  to a fictional regional measurement network. What is measured is the model built with `node`.
- **EP32.** A control's effect on a point type is one of three values: closes, makes visible,
  does not touch. Partial closing was not modeled; this makes the closed column look better than
  it is.
- **EP33.** Applicability is at the endpoint **class** level. In reality, some endpoints in the
  same class do not carry the control; drift measurement was the previous topic's subject and is
  taken here as input.
- **EP34.** Cost was counted with two items: blocked legitimate items and work that falls to
  manual review. Ongoing maintenance load was not counted; had it been, the cost of
  visibility-granting controls would have grown.
- **EP35.** A control that closes a point can also log the same point; in the model, closing is
  counted first. The two columns are therefore disjoint: a type is either closed or only visible.

#### Inventory and Six Controls

```js
// inventory.mjs — persistence point types, endpoint classes, and control effects; model
// point type -> legitimate item count on one endpoint
export const LEGIT = {
  'startup-entry': 14, 'service-definition': 63, 'scheduled-task': 41, 'login-script': 6,
  'load-path-hook': 3, 'event-subscription': 2, 'legitimate-binary-attach': 0,
  'media-autorun': 1, 'application-extension': 27, 'boot-entry': 4,
};
export const POINT = Object.keys(LEGIT);
export const ENDPOINTS = { 'field laptop': 120, 'branch endpoint': 240, 'aggregator': 60, 'central server': 12 };
export const CLASS = Object.keys(ENDPOINTS);
// control -> [effect map, applicable endpoint classes, blocked legitimate items, work that falls to manual review]
export const CONTROL = {
  'write-permission restriction': [
    { 'login-script': 'closes', 'application-extension': 'closes', 'startup-entry': 'closes' },
    ['branch endpoint', 'aggregator', 'central server'], 8, 4],
  'application allowlist': [
    { 'startup-entry': 'closes', 'service-definition': 'closes', 'scheduled-task': 'closes',
      'media-autorun': 'closes', 'login-script': 'visible' },
    ['branch endpoint', 'aggregator'], 22, 31],
  'signed code requirement': [
    { 'legitimate-binary-attach': 'closes', 'load-path-hook': 'closes', 'application-extension': 'visible' },
    ['field laptop', 'branch endpoint', 'aggregator'], 6, 3],
  'secure boot chain': [
    { 'boot-entry': 'closes' },
    ['field laptop', 'branch endpoint', 'central server'], 0, 2],
  'baseline comparison': [
    { 'startup-entry': 'visible', 'service-definition': 'visible', 'scheduled-task': 'visible',
      'login-script': 'visible', 'load-path-hook': 'visible', 'application-extension': 'visible',
      'media-autorun': 'visible' },
    ['field laptop', 'branch endpoint', 'aggregator', 'central server'], 0, 12],
  'process creation log': [
    { 'startup-entry': 'visible', 'service-definition': 'visible', 'scheduled-task': 'visible',
      'login-script': 'visible' },
    ['field laptop', 'branch endpoint', 'aggregator', 'central server'], 0, 0],
};
export const CONTROL_NAME = Object.keys(CONTROL);
export const applies = (d, s) => CONTROL[d][1].includes(s);
export function status(type, cls) {
  const effects = CONTROL_NAME.filter((d) => applies(d, cls)).map((d) => CONTROL[d][0][type]).filter(Boolean);
  return effects.includes('closes') ? 'closed' : effects.includes('visible') ? 'visible' : 'dark';
}
export const size = (s) => ENDPOINTS[s];
export const TOTAL_ENDPOINTS = Object.values(ENDPOINTS).reduce((a, b) => a + b, 0);
```

```js
// run1.mjs — point type inventory and the types each control closes / makes visible
import { LEGIT, POINT, CONTROL, CONTROL_NAME, CLASS, TOTAL_ENDPOINTS } from './inventory.mjs';

const items = Object.values(LEGIT).reduce((a, b) => a + b, 0);
console.log(`${POINT.length} persistence point types, ${items} legitimate items per endpoint, ${TOTAL_ENDPOINTS} endpoints\n`);
console.log(`${'point type'.padEnd(26)} ${'legitimate items'.padStart(17)} ${'closing controls'.padStart(16)} ${'visible controls'.padStart(16)}`);
for (const t of POINT) {
  const k = CONTROL_NAME.filter((d) => CONTROL[d][0][t] === 'closes'), g = CONTROL_NAME.filter((d) => CONTROL[d][0][t] === 'visible');
  console.log(`${t.padEnd(26)} ${String(LEGIT[t]).padStart(17)} ${String(k.length).padStart(16)} ${String(g.length).padStart(16)}`);
}
console.log(`\n${'control'.padEnd(30)} ${'closes'.padStart(7)} ${'makes visible'.padStart(13)} ${'endpoint classes'.padStart(16)} ` +
  `${'blocked items'.padStart(16)} ${'manual review'.padStart(13)}`);
for (const d of CONTROL_NAME) {
  const e = CONTROL[d][0];
  console.log(`${d.padEnd(30)} ${String(Object.values(e).filter((x) => x === 'closes').length).padStart(7)} ` +
    `${String(Object.values(e).filter((x) => x === 'visible').length).padStart(13)} ` +
    `${`${CONTROL[d][1].length}/${CLASS.length}`.padStart(16)} ${String(CONTROL[d][2]).padStart(16)} ${String(CONTROL[d][3]).padStart(13)}`);
}
const none = POINT.filter((t) => !CONTROL_NAME.some((d) => CONTROL[d][0][t]));
console.log(`\npoint types no control touches: ${none.length}/${POINT.length} (${none.join(', ') || '-'})`);
console.log(`total blocked legitimate items: ${CONTROL_NAME.reduce((a, d) => a + CONTROL[d][2], 0)}; ` +
  `work that falls to manual review: ${CONTROL_NAME.reduce((a, d) => a + CONTROL[d][3], 0)}`);
```

```
10 persistence point types, 161 legitimate items per endpoint, 432 endpoints

point type                  legitimate items closing controls visible controls
startup-entry                             14                2                2
service-definition                        63                1                2
scheduled-task                            41                1                2
login-script                               6                1                3
load-path-hook                             3                1                1
event-subscription                         2                0                0
legitimate-binary-attach                   0                1                0
media-autorun                              1                1                1
application-extension                     27                1                2
boot-entry                                 4                1                0

control                         closes makes visible endpoint classes    blocked items manual review
write-permission restriction         3             0              3/4                8             4
application allowlist                4             1              2/4               22            31
signed code requirement              2             1              3/4                6             3
secure boot chain                    1             0              3/4                0             2
baseline comparison                  0             7              4/4                0            12
process creation log                 0             4              4/4                0             0

point types no control touches: 1/10 (event-subscription)
total blocked legitimate items: 36; work that falls to manual review: 52
```

The lower table separates the two control families with a clean line. The four closing controls
close ten point types in total and block thirty-six legitimate items in return. The two
visibility controls close no type, make eleven types visible, and block not a single legitimate
item. Their costs sit in different places: the closing controls' cost is in blocked items, the
visibility controls' is in work that falls to manual review — and twelve of the total
fifty-two-item manual-review load comes from a single visibility control.

The applicability column supplies the third distinction, and the closing controls are all weak
here. Two controls are applicable in all four endpoint classes, and both of them give
only visibility. None of the closing controls is applicable in all four classes; the
narrowest, application allowlist, is applicable in only two. There is a tension between closing
and spreading: the more a control closes, the fewer endpoints it can be applied in.

Two rows in the upper table also stand out. No control touches the persistent event subscription
— neither a closing one nor a visibility one. One control closes the boot entry, but none makes
it visible: on an endpoint where closing cannot be applied, that type stays entirely dark. The
two most crowded point types, service definition and scheduled task, are each closed by one
control and made visible by two.

#### Three Columns by Endpoint Class

```js
// run2.mjs — closed / only-visible / dark point type count per endpoint class
import { POINT, LEGIT, CLASS, CONTROL_NAME, CONTROL, status, size, TOTAL_ENDPOINTS } from './inventory.mjs';

console.log(`${'point type'.padEnd(26)} ${CLASS.map((s) => s.padStart(16)).join(' ')}`);
for (const t of POINT)
  console.log(`${t.padEnd(26)} ${CLASS.map((s) => status(t, s).padStart(16)).join(' ')}`);
console.log(`\n${'endpoint class'.padEnd(15)} ${'endpoints'.padStart(9)} ${'applied controls'.padStart(17)} ${'closed'.padStart(6)} ` +
  `${'only visible'.padStart(14)} ${'dark'.padStart(8)} ${'dark items'.padStart(14)}`);
const count = (s, d) => POINT.filter((t) => status(t, s) === d);
for (const s of CLASS)
  console.log(`${s.padEnd(15)} ${String(size(s)).padStart(9)} ${String(CONTROL_NAME.filter((d) => CONTROL[d][1].includes(s)).length).padStart(17)} ` +
    `${String(count(s, 'closed').length).padStart(6)} ${String(count(s, 'visible').length).padStart(14)} ` +
    `${String(count(s, 'dark').length).padStart(8)} ${String(count(s, 'dark').reduce((a, t) => a + LEGIT[t], 0)).padStart(14)}`);
const weight = (d) => CLASS.reduce((a, s) => a + size(s) * count(s, d).length, 0) / TOTAL_ENDPOINTS;
console.log(`${'fleet average'.padEnd(15)} ${String(TOTAL_ENDPOINTS).padStart(9)} ${''.padStart(17)} ` +
  `${weight('closed').toFixed(1).padStart(6)} ${weight('visible').toFixed(1).padStart(14)} ${weight('dark').toFixed(1).padStart(8)}`);

const everywhere = POINT.filter((t) => CLASS.every((s) => status(t, s) === 'closed'));
const neverClosed = POINT.filter((t) => CLASS.every((s) => status(t, s) !== 'closed'));
console.log(`\npoint types closed in all four endpoint classes: ${everywhere.length}/${POINT.length} (${everywhere.join(', ') || '-'})`);
console.log(`point types closed in no endpoint class        : ${neverClosed.length}/${POINT.length} (${neverClosed.join(', ')})`);
const darkest = CLASS.map((s) => [s, count(s, 'dark').length]).sort((a, b) => b[1] - a[1])[0];
console.log(`endpoint class carrying the most dark point types : ${darkest[0]} (${darkest[1]} types, ${size(darkest[0])} endpoints)`);
```

```
point type                     field laptop  branch endpoint       aggregator   central server
startup-entry                       visible           closed           closed           closed
service-definition                  visible           closed           closed          visible
scheduled-task                      visible           closed           closed          visible
login-script                        visible           closed           closed           closed
load-path-hook                       closed           closed           closed          visible
event-subscription                     dark             dark             dark             dark
legitimate-binary-attach             closed           closed           closed             dark
media-autorun                       visible           closed           closed          visible
application-extension               visible           closed           closed           closed
boot-entry                           closed           closed             dark           closed

endpoint class  endpoints  applied controls closed   only visible     dark     dark items
field laptop          120                 4      3              6        1              2
branch endpoint       240                 6      9              0        1              2
aggregator             60                 5      8              0        2              6
central server         12                 4      4              4        2              2
fleet average         432                      7.1            1.8      1.2

point types closed in all four endpoint classes: 0/10 (-)
point types closed in no endpoint class        : 1/10 (event-subscription)
endpoint class carrying the most dark point types : aggregator (2 types, 60 endpoints)
```

Zero is this table's most important number. None of the ten point types is closed in all four
endpoint classes. The fleet average is seven point one closed, one point eight
only-visible, and one point two dark types; but the average summarizes this table wrongly,
because the distribution splits sharply by class. Nine of the ten types are closed on branch
endpoints, three on field laptops. The same inventory, the same control list, different
applicability.

The field laptop column shows where closing gets stuck. Four controls are applicable, and six
point types stay only visible. On these endpoints, the diversity of legitimate work makes
application allowlist and write-permission restriction inapplicable; in return, these endpoints
log the six types that cannot be closed. The measurement here gives a consequence, not a choice:
where closing cannot be applied, the only thing left is visibility, and on these endpoints
visibility carries all six types.

The dark column, in turn, counts the types that are neither closed nor made visible. In type
count, two classes run neck and neck — aggregator and central server, both with two types — but
the legitimate items left in the dark are highest at the aggregator. Two types are dark at
aggregators: the persistent event subscription and the boot entry. The reason for the second is
hardware — the secure boot chain is not applicable in this class, and no control makes that type
visible. Six legitimate items sit in these two types, and no control looks at them. Central
servers also have two dark types; that looks like a small number for twelve endpoints, but these
twelve endpoints carry the most valuable data the course measures.

#### Cost at Fleet Scale

Cost counted per endpoint does not carry a decision; the number that carries the decision is that
cost multiplied by the number of endpoints the control is applied on. The same model can also be
run at fleet scale.

```js
// run3.mjs — fleet-scale cost: covered endpoints, blocked items, and review load per control
import { POINT, LEGIT, CLASS, CONTROL_NAME, CONTROL, status, size, TOTAL_ENDPOINTS } from './inventory.mjs';

console.log(`${'control'.padEnd(30)} ${'covered endpoints'.padStart(17)} ${'blocked items'.padStart(16)} ` +
  `${'review items'.padStart(12)} ${'closed pairs'.padStart(18)}`);
let [blocked, review] = [0, 0];
for (const d of CONTROL_NAME) {
  const n = CONTROL[d][1].reduce((a, s) => a + size(s), 0);
  const closed = Object.entries(CONTROL[d][0]).filter(([, v]) => v === 'closes').length;
  blocked += n * CONTROL[d][2]; review += n * CONTROL[d][3];
  console.log(`${d.padEnd(30)} ${String(n).padStart(17)} ${String(n * CONTROL[d][2]).padStart(16)} ` +
    `${String(n * CONTROL[d][3]).padStart(12)} ${String(n * closed).padStart(18)}`);
}
console.log(`${'total'.padEnd(30)} ${String(TOTAL_ENDPOINTS).padStart(17)} ${String(blocked).padStart(16)} ${String(review).padStart(12)}`);

const pair = (d) => CLASS.reduce((a, s) => a + size(s) * POINT.filter((t) => status(t, s) === d).length, 0);
const itemPair = (d) => CLASS.reduce((a, s) =>
  a + size(s) * POINT.filter((t) => status(t, s) === d).reduce((x, t) => x + LEGIT[t], 0), 0);
console.log(`\n${'column'.padEnd(16)} ${'point-endpoint pairs'.padStart(21)} ${'share'.padStart(6)} ${'legitimate items'.padStart(17)}`);
const totalPairs = TOTAL_ENDPOINTS * POINT.length;
for (const d of ['closed', 'visible', 'dark'])
  console.log(`${d.padEnd(16)} ${String(pair(d)).padStart(21)} ${`${(pair(d) * 100 / totalPairs).toFixed(1)}%`.padStart(6)} ` +
    `${String(itemPair(d)).padStart(17)}`);
console.log(`${'total'.padEnd(16)} ${String(totalPairs).padStart(21)}`);
```

```
control                        covered endpoints    blocked items review items       closed pairs
write-permission restriction                 312             2496         1248                936
application allowlist                        300             6600         9300               1200
signed code requirement                      420             2520         1260                840
secure boot chain                            372                0          744                372
baseline comparison                          432                0         5184                  0
process creation log                         432                0            0                  0
total                                        432            11616        17736

column            point-endpoint pairs  share  legitimate items
closed                            3048  70.6%             48912
visible                            768  17.8%             19536
dark                               504  11.7%              1104
total                             4320
```

The ranking changes at fleet scale. Application allowlist was the control that closed the most
types per endpoint; at fleet scale it is also the most expensive: six thousand six hundred
blocked items and nine thousand three hundred review items. Secure boot chain, by contrast,
closes three hundred seventy-two point-endpoint pairs with zero blocked items. The difference
between the two controls is the legitimate-item density of the point type they close: one
touches the most crowded points on the endpoint, the other touches a point that is nearly empty.

The lower table gives the topic's closing number. Four hundred thirty-two endpoints and ten point
types make four thousand three hundred twenty point-endpoint pairs; of these, seventy point six
percent are closed, seventeen point eight percent are only visible, and eleven point seven
percent are dark. The legitimate items carried by the dark pairs are only eleven hundred four —
meaning the points left in the dark are the sparsest points on the endpoint. This can read as
good news, but the reverse is also true: a sparse point is the easiest point to notice when an
item is added to it — if a record is watching. The definition of the dark column is exactly the
absence of such a record.

#### Summary

- 10 persistence point types hold 161 legitimate items per endpoint. 4 closing controls close 10
  types in total and block 36 legitimate items; 2 visibility controls close no type, make 11
  types visible, and block 0 items.
- None of the closing controls is applicable in all 4 of the 4 endpoint classes; the two controls
  applicable in all four classes give only visibility. The more a control closes, the fewer
  endpoints it applies to.
- The number of point types closed in all 4 of the 4 endpoint classes is **0/10**. 9 types are
  closed on branch endpoints, 3 on field laptops; the same inventory, different applicability.
- No control touches the persistent event subscription: neither a closing one nor a visibility
  one. One control closes the boot entry, none makes it visible — on an endpoint where closing
  cannot be applied, it stays entirely dark.
- The dark type count is equally highest in two classes (aggregator and central server, both
  with 2 types); the legitimate items left in the dark, however, are highest at the aggregator —
  6 items, versus 2 at the central server. The fleet average is 7.1 closed, 1.8 only-visible,
  1.2 dark.
- The total cost of closing is 36 blocked legitimate items and 52 manual-review items; of the 52,
  31 come from a single control (application allowlist) and 12 from a single visibility control.

#### Next Step

This topic counted the layers of protection one by one across six lessons and wrote down what
each layer misses: that closing persistence points could not stop seven of ten types, that there is
no threshold that zeroes out both signature and behavior errors at once, that telemetry never saw
two of eight points, that the host-based filter could not close legitimate peer-to-peer traffic,
that living-off-the-land tools never showed up for a signature rule, and that no point is closed
in all four endpoint classes at once. Each lesson left a missed set behind, and the
same sentence was written every time: a point that cannot be closed must at least be made
visible.

But visibility rested on an assumption, and that assumption was never tested. When a point was
said to be "logged," no one asked where that record sits, who can read it, how long it stays,
and — most important — what proves it has not been tampered with. Only a record can show that
what was missed was missed; if that record itself can be missed, nothing is left to measure. The
next topic, **System Logs**, starts with this question: which sources hold the records on the
endpoint, which of those sources sit somewhere someone standing at the endpoint can reach, and
with what evidence a set of logs is said to be untampered with.

## System Logs

Which foothold point each endpoint log source sees, the events a threshold choice misses and the ones it raises needlessly in failed login patterns, the process tree extracted from process creation logs, and detecting a chain break in a tampered log set.

### Event Logs

Source: https://academia.sh/en/courses/endpoint-security/event-logs

In a fictional endpoint fleet, twelve foothold point types are measured against seven log sources: under the default configuration, 932 of 3,078 (machine, type) pairs are visible by name and 1,092 are in no source at all; once every source is opened, the visible-by-name count rises to 2,356, 250 pairs stay blind, and the cost is 10.2 million extra records a day plus 5 configuration items per role profile.

The previous lesson counted persistence mechanisms type by type and recorded what each
protection layer misses. The only thing that can show a missed item was missed is a record: if
the protection layer could not stop it, all that is left is what it saw. This topic measures
those records, and the first question is the plainest one — when a foothold is set up on the
endpoint, which source writes it down.

This topic's unit of measure is **visibility**, and its two columns must not be conflated. A
record either writes the item **by name** (which scheduled task, which service definition, which
login script) or only shows that something happened: a session opened, a process started, a
configuration changed. The second kind starts an investigation, it does not answer it. The
measurement below is a **model** and the regional measurement network is **fictional**; source
type is used, not product name.

- **SL1.** The endpoint fleet is fictional: 84 field laptops, 46 branch endpoints, 120 meter
  collector machines, 18 central processing servers. Platform families are referred to by their
  security model, as in the Information Technology and Systems Fundamentals course: the family
  with discrete user and group permissions, the family with access control lists, the family
  relying on a signed app store.
- **SL2.** There are twelve foothold point types, and not every type exists in every family; the
  family with access control lists has no counterpart for three of them.
- **SL3.** A source sees a type **fully** (writes the item by name), **indirectly** (the event is
  recorded, the item stays unnamed), or not at all.
- **SL4.** Two sources are closed by default: the process creation log and the scheduler log.
  Turning them on requires a configuration item and brings volume with it. Log volumes are
  per-machine averages and are fictional.
- **SL5.** The application audit log exists only on machines that run the application; the
  management plane audit log only on centrally managed machines. Both were measured in earlier
  courses; here they are just sources.

#### What the Sources See

```js
// source.mjs — coverage between log sources and foothold point types. MODEL.
// The regional measurement network is FICTIONAL; no real organization, machine, or product name is used.

// Foothold point type -> which platform families it exists in.
// a: family with discrete user and group permissions, e: family with access control lists,
// i: family relying on a signed app store.
export const POINT = {
  "startup entry": "aei", "system service definition": "aei", "user service definition": "ai",
  "scheduled task": "aei", "login script": "aei", "desktop autostart entry": "aei",
  "package install hook": "ai", "kernel module": "aei", "bootloader boot entry": "ai",
  "management agent plugin": "aei", "in-app scheduled job": "aei",
  "firmware startup entry": "aei",
};

// Source: open by default?, log records/machine (fictional), configuration items to turn it on,
// type it sees -> "full" (writes the item by name) or "indirect" (event visible, item unnamed).
export const SOURCE = {
  "system event log": { open: true, volume: 4200, item: 0, sees: {
    "system service definition": "full", "kernel module": "full",
    "startup entry": "indirect", "management agent plugin": "indirect" } },
  "authentication log": { open: true, volume: 1900, item: 0, sees: {
    "login script": "indirect", "desktop autostart entry": "indirect" } },
  "process creation log": { open: false, volume: 38000, item: 3, sees: {
    "startup entry": "full", "system service definition": "full",
    "user service definition": "full", "scheduled task": "full", "login script": "full",
    "desktop autostart entry": "full", "package install hook": "full",
    "in-app scheduled job": "full", "bootloader boot entry": "indirect",
    "kernel module": "indirect", "management agent plugin": "indirect" } },
  "package and installation log": { open: true, volume: 40, item: 0, sees: {
    "package install hook": "full", "kernel module": "indirect" } },
  "scheduler log": { open: false, volume: 320, item: 2, sees: {
    "scheduled task": "full" } },
  "application audit log": { open: true, volume: 2400, item: 0, sees: {
    "in-app scheduled job": "full" } },
  "management plane audit log": { open: true, volume: 60, item: 0, sees: {
    "management agent plugin": "full", "firmware startup entry": "full" } },
};

// Machine group: family, machine count, sources present on it (fictional inventory).
export const GROUP = [
  ["field laptop", "i", 84, ["system event log", "authentication log",
    "process creation log", "package and installation log", "scheduler log"]],
  ["branch endpoint", "e", 46, ["system event log", "authentication log",
    "process creation log", "package and installation log", "scheduler log"]],
  ["meter collector", "a", 120, ["system event log", "authentication log",
    "process creation log", "package and installation log", "scheduler log",
    "application audit log"]],
  ["central processing", "a", 18, Object.keys(SOURCE)],
];

// For one type in one group: the best among open sources is "full", "indirect", or "none".
export const visibility = (type, sources, allOpen) => {
  let best = "none";
  for (const s of sources) {
    if (!allOpen && !SOURCE[s].open) continue;
    const g = SOURCE[s].sees[type];
    if (g === "full") return "full";
    if (g === "indirect") best = "indirect";
  }
  return best;
};
export const types = (family) => Object.keys(POINT).filter((t) => POINT[t].includes(family));

const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j])
  : String(v).padEnd(B[0]))).join("");
const line = row([33, 12, 8, 11, 9, 16]);

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(`model: ${Object.keys(POINT).length} foothold point types, ` +
    `${Object.keys(SOURCE).length} log sources, ${GROUP.length} machine groups (fictional)`);
  console.log(line(["log source", "default", "full", "indirect", "blind",
    "rec./day/mach."]));
  for (const [name, k] of Object.entries(SOURCE)) {
    const d = Object.values(k.sees);
    console.log(line([name, k.open ? "open" : "closed", d.filter((x) => x === "full").length,
      d.filter((x) => x === "indirect").length,
      Object.keys(POINT).length - d.length, k.volume]));
  }
  const byDefault = Object.keys(POINT).filter((t) =>
    visibility(t, Object.keys(SOURCE), false) === "none");
  const withAll = Object.keys(POINT).filter((t) =>
    visibility(t, Object.keys(SOURCE), true) !== "full");
  console.log(`\ntypes no source writes by name even with all sources open: ` +
    `${withAll.length} (${withAll.join(", ")})`);
  console.log(`types left with no trace under the default open sources: ` +
    `${byDefault.length} (${byDefault.join(", ")})`);
}
```

```
model: 12 foothold point types, 7 log sources, 4 machine groups (fictional)
log source                            default    full   indirect    blind  rec./day/mach.
system event log                         open       2          2        8            4200
authentication log                       open       0          2       10            1900
process creation log                   closed       8          3        1           38000
package and installation log             open       1          1       10              40
scheduler log                          closed       1          0       11             320
application audit log                    open       1          0       11            2400
management plane audit log               open       2          0       10              60

types no source writes by name even with all sources open: 1 (bootloader boot entry)
types left with no trace under the default open sources: 3 (user service definition, scheduled task, bootloader boot entry)
```

The first thing that stands out in the table is that none of the three sources open by default on
every machine is broad. The system event log writes two of the twelve types by name, the
authentication log writes **none** of them — that source's job is not tracking items, it is
tracking sessions, and it sees only two types indirectly. The package and installation log writes
one type. The default configuration's coverage is these three rows.

The only source that writes eight types by name is the one that is **closed**. The process
creation log is broad because what foothold points have in common is that they eventually **run**
something; the process that installs the item and the job the item runs both create a process.
The same row carries the cost too: 38,000 records per machine per day, more than four times the
total of the other six sources combined.

The last two rows rescue three types. The in-app scheduled job appears only in the application's
own audit log; the management agent plugin and the firmware startup entry appear only in the
management plane audit log. These are types that cannot be seen **from inside** the endpoint:
firmware runs before the operating system, and the agent plugin is pushed from a central point
and leaves no installation record on the machine.

The last row shows the measurement's limit. Even with every source open, one type is never
written by name in any source: the bootloader boot entry is visible only indirectly — the process
creation log has processes that were started under it, but the entry itself is never
named anywhere.

#### Coverage by Machine Group

```js
// coverage.mjs — coverage per machine group: default configuration vs. everything open.
import { POINT, SOURCE, GROUP, visibility, types } from "./source.mjs";

const count = (group, allOpen) => {
  const [, family, , sources] = group;
  const d = { full: 0, indirect: 0, none: 0, blindSpots: [] };
  for (const t of types(family)) {
    const g = visibility(t, sources, allOpen);
    d[g === "none" ? "none" : g]++;
    if (g === "none") d.blindSpots.push(t);
  }
  return d;
};
const extra = (group) => group[3].filter((s) => !SOURCE[s].open)
  .reduce((a, s) => ({ item: a.item + SOURCE[s].item, volume: a.volume + SOURCE[s].volume }),
    { item: 0, volume: 0 });

const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j])
  : String(v).padEnd(B[0]))).join("");
const line = row([20, 7, 10, 7, 7, 10, 7, 7, 11, 8, 13]);

console.log(line(["machine group", "family", "machines", "types", "full", "indirect", "none",
  "full+", "indirect+", "none+", "extra items"]));
let pairs = 0, fullDefault = 0, fullAll = 0, noneDefault = 0, noneAll = 0, dailyExtra = 0;
for (const g of GROUP) {
  const v = count(g, false), h = count(g, true), e = extra(g), n = g[2];
  pairs += n * types(g[1]).length;
  fullDefault += n * v.full; fullAll += n * h.full; noneDefault += n * v.none;
  noneAll += n * h.none;
  dailyExtra += n * e.volume;
  console.log(line([g[0], g[1], n, types(g[1]).length, v.full, v.indirect, v.none,
    h.full, h.indirect, h.none, e.item]));
}
const machines = GROUP.reduce((a, g) => a + g[2], 0);
console.log(`\nfleet: ${machines} machines, ${pairs} (machine, type) pairs`);
console.log(`default configuration: ${fullDefault} pairs visible by name, ${noneDefault} ` +
  `pairs in no source`);
console.log(`all sources open: ${fullAll} pairs visible by name, ${noneAll} pairs in no source`);
for (const g of GROUP) {
  const h = count(g, true);
  console.log(`${g[0].padEnd(20)} blind spots: ${h.blindSpots.join(", ") || "-"}`);
}
const dailyRecords = GROUP.reduce((a, g) => a + g[2] * g[3]
  .filter((s) => SOURCE[s].open).reduce((sum, s) => sum + SOURCE[s].volume, 0), 0);
console.log(`\ndaily records: default ${dailyRecords.toLocaleString("en-US")}, ` +
  `all open ${(dailyRecords + dailyExtra).toLocaleString("en-US")} ` +
  `(${((dailyRecords + dailyExtra) / dailyRecords).toFixed(1)}x)`);
console.log(`gain: +${fullAll - fullDefault} pairs become visible by name; cost is ` +
  `${GROUP.reduce((a, g) => a + extra(g).item, 0)} configuration items ` +
  `(${extra(GROUP[0]).item} per role profile) and ` +
  `${dailyExtra.toLocaleString("en-US")} extra records per day`);
console.log(`even with all sources open, ${noneAll} pairs stay blind; all are the same type ` +
  `and correspond to one of ${Object.keys(POINT).length} types`);
```

```
machine group        family  machines  types   full  indirect   none  full+  indirect+   none+  extra items
field laptop              i        84     12      3         4      5      9          2       1            5
branch endpoint           e        46      9      2         4      3      7          1       1            5
meter collector           a       120     12      4         4      4      9          2       1            5
central processing        a        18     12      6         3      3     11          1       0            5

fleet: 268 machines, 3078 (machine, type) pairs
default configuration: 932 pairs visible by name, 1092 pairs in no source
all sources open: 2356 pairs visible by name, 250 pairs in no source
field laptop         blind spots: firmware startup entry
branch endpoint      blind spots: firmware startup entry
meter collector      blind spots: firmware startup entry
central processing   blind spots: -

daily records: default 1,977,800, all open 12,247,560 (6.2x)
gain: +1424 pairs become visible by name; cost is 20 configuration items (5 per role profile) and 10,269,760 extra records per day
even with all sources open, 250 pairs stay blind; all are the same type and correspond to one of 12 types
```

At fleet scale the question is: how many of the 3,078 (machine, type) pairs are visible by name.
Under the default configuration the answer is **932**, less than a third; 1,092 pairs are not
visible in any source at all. This second number is the lesson's main finding, because a coverage
gap does not produce an alert: a closed source is silent, and the inventory's "logs are being
collected" line is still true.

The group rows show where the difference comes from. Central processing servers see six of the
twelve types by name under the default configuration, field laptops see three. The difference is
the **presence** of two sources; the application audit log and the management plane audit log are
sources that have no counterpart on a laptop. The weakest visibility at the endpoint sits on the
organization's most scattered and most physically unprotected machines.

The branch endpoint row calls for reading a distinction. In that group the type count is nine,
not twelve, and the number of invisible pairs is lower for that reason — three types **do
not exist** in that family; they are not closed, they never existed. If a coverage table's
percentage calculation does not separate these two situations, the narrowest platform reads as
the best-protected platform. What should be counted is not a ratio, it is the number of blind
spots left.

The indirect visibility column carries a cost too, and that cost does not show up in the table.
When an investigation starts from an indirect record, it has to go to the machine and look for
the answer: the record says a session was opened, not which script ran in that session. A record
that names the item by name is answered with a query; an indirect record is answered with a site
visit.

Opening the two closed sources makes 1,424 pairs visible by name. The cost has two parts: 5
configuration items per role profile — 20 across the fleet, because the setting is written per
profile, not per machine — and 10,269,760 extra records a day. Total volume rises from 1.98
million to 12.25 million, a **6.2x** increase. This line also sets up the question for this
topic's later lessons: volume is not just a number on its own, it means retention time and search
cost.

The remaining 250 pairs cannot be closed. All of them are the same type — the firmware startup
entry — and on the 250 machines that carry no management plane audit log, no source sees it.
Making this type visible is not a logging setting, it is a management-architecture decision: the
endpoints need to be connected to a centrally managed plane. Making an unclosable point **at
least visible** is this course's rule, and here even that cannot be done; the only option left is
comparing that type from the outside with a periodic integrity measurement.

#### Summary

- The three sources open by default on every machine write three of the twelve foothold point
  types by name; the only source that writes eight types by name is the process creation log,
  which is **closed** by default.
- A record falls into one of three states: **full** visibility, which writes the item by name;
  **indirect** visibility, which shows the event without naming the item; and a blind spot, where
  no record exists at all. The authentication log sees no type fully, and sees two indirectly.
- Across the fleet, 932 of 3,078 (machine, type) pairs are visible by name under the default
  configuration and 1,092 are in no source at all; once the two closed sources are opened, the
  visible-by-name count rises to 2,356 and the blind count falls to 250.
- The cost is 5 configuration items per role profile and 10.2 million extra records a day; total
  volume rises to 6.2x.
- All of the remaining 250 blind pairs are the firmware startup entry and cannot be seen from
  inside the endpoint; the bootloader boot entry, meanwhile, is never named in any source — it
  leaves only an indirect trace.

#### Next Step

In this lesson's coverage table, the authentication log looks like the weakest source because it
does not write a single type by name. But that source is looking at a different question: not the
item being installed, but the account being taken over. The next lesson takes that log on its own
and runs a form of experiment on a real fictional record set: it parses failed-login patterns to
ask how many events a chosen threshold misses, how many times it raises an alert needlessly, and
why a threshold counted per account goes completely silent.

### Authentication Logs

Source: https://academia.sh/en/courses/endpoint-security/authentication-logs

A one-day, 1,168-line fictional authentication log set is parsed and six failure clusters are measured against threshold rules: the per-account threshold misses 206 of 216 campaign events, produces 5 false positives, and blocks 9 legitimate logins; opening the window to 24 hours catches both campaigns but locks out 70 accounts and cuts off 49 legitimate logins. Because the spray never makes more than 2 attempts per account, it is invisible at any threshold; a rule that counts along the zone axis separates it out, because the largest legitimate bulk failure stays at 62 accounts.

In the previous lesson's coverage table, the authentication log did not write a single one of the
twelve foothold point types by name. That source's job is not tracking items: a foothold point
has to have a session opened before it can be set up, and the authentication log sees exactly
that step. The number of points it closes is zero; what it makes visible is the step **before**
the points.

This lesson takes that log on its own. The question is not how to write an alert rule, but what a
chosen threshold misses and how much legitimate work it stops in exchange. The log set below is a
**model** and the regional measurement network is **fictional**; account names, machine names, and
zone names are made up. **Attempting passwords against a set of accounts without written
authorization and a defined scope is both illegal and outside professional bounds**; what is
measured here is not the attempt itself, but the record it leaves behind.

- **SL6.** The log set covers one day: 96 personnel accounts, 14 service identities, five network
  zones. The lines are text; fields are extracted with a regular expression.
- **SL7.** The cluster label the model knows is **not written** to the log line. The parser cannot
  see it; the rules only have the fields present in the line.
- **SL8.** There are six clusters. Four are legitimate: a typo, a forgotten password, a service
  account with a stale credential, and a server-side authentication setting change. Two are
  campaigns: a targeted attempt and a spray.
- **SL9.** Rule 1 counts failures per account, rule 2 counts **distinct accounts** per zone. Both
  use a sliding window; for a given key, only the first crossing produces an alert.
- **SL10.** The threshold is tied to lockout: an account that crosses the threshold cannot log in
  for the rest of the day. The blocked-login count follows from this assumption.

#### Parsing the Log

```js
// login.mjs — a fictional authentication log is generated and parsed. THIS IS A MODEL.
// There is no real organization, real account, or real address; zone names are fictional.
// The generator is written by hand; the seed is visible below.
export const SEED = 20260314;
const generator = (t) => { let s = t >>> 0;
  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; };
const r = generator(SEED);
const int = (n) => Math.floor(r() * n);
const clock = (s) => [s / 3600, (s / 60) % 60, s % 60]
  .map((v) => String(Math.floor(v)).padStart(2, "0")).join(":");

export const ACCOUNT = Array.from({ length: 96 }, (_, i) => `sh-${101 + i}`);
export const SERVICE = Array.from({ length: 14 }, (_, i) => `sv-${201 + i}`);
export const ZONE = ["branch-net", "field-vpn", "collector-net", "management-net", "external-access"];

// Raw record: [seconds, machine, result, account, zone, label]. The label is the ground truth
// the model knows and is NOT WRITTEN to the log line; parsing cannot see it.
const raw = [];
const add = (s, mac, result, account, zone, label) =>
  raw.push([s, mac, result, account, zone, label]);

// 1) Normal login at shift start; a fraction has a typo.
for (const [i, h] of ACCOUNT.entries()) {
  const z = ZONE[i % 3], mac = `ep-${String(1 + (i % 268)).padStart(3, "0")}`;
  let t = 25200 + int(7200);                       // between 07:00 and 09:00
  if (r() < 0.22) for (let k = 0, n = 1 + int(2); k < n; k++)
    add(t += 20 + int(40), mac, "failed", h, z, "typo");
  add(t + 15, mac, "succeeded", h, z, "typo");
  for (let k = 0, n = int(3); k < n; k++)
    add(36000 + int(28800), mac, "succeeded", h, z, "typo");
}
// 2) Three accounts that forgot their password: heavy failures, then a reset and login.
for (const h of [ACCOUNT[7], ACCOUNT[41], ACCOUNT[70]]) {
  let t = 27000 + int(9000);
  const n = 9 + int(8);
  for (let k = 0; k < n; k++)
    add(t += 30 + int(90), "ep-114", "failed", h, "field-vpn", "forgotten-password");
  add(t + 2400, "ep-114", "succeeded", h, "field-vpn", "forgotten-password");
}
// 3) Two service accounts with stale credentials: steady retries all day.
for (const [j, h] of [SERVICE[3], SERVICE[9]].entries())
  for (let t = 600 + j * 130; t < 86400; t += 300)
    add(t, "ep-207", "failed", h, "collector-net", "stale-service-credential");
// 4) A targeted attempt against one account: the same account for three hours.
for (let k = 0, t = 68400; k < 40; k++)
  add(t += 240 + int(60), "ep-018", "failed", ACCOUNT[92], "external-access", "targeted-attempt");
// 5) Spray: two attempts against each of 88 accounts, a fifty-minute window.
for (const [j, h] of ACCOUNT.slice(0, 88).entries()) {
  add(48000 + j * 17, "ep-018", "failed", h, "external-access", "spray");
  add(49500 + j * 17, "ep-018", "failed", h, "external-access", "spray");
}
add(51100, "ep-018", "succeeded", ACCOUNT[63], "external-access", "spray");
// 6) Server-side authentication setting change: 62 branch accounts fail once, then log in.
for (const [j, h] of ACCOUNT.slice(20, 82).entries()) {
  add(36000 + j * 19, "ep-061", "failed", h, "branch-net", "config-change");
  add(36300 + j * 19, "ep-061", "succeeded", h, "branch-net", "config-change");
}

raw.sort((a, b) => a[0] - b[0]);
export const LINE = raw.map(([s, mac, result, account, zone]) =>
  `2026-03-14T${clock(s)} ${mac} authentication result=${result} account=${account} ` +
  `zone=${zone} method=password`);
export const LABEL = raw.map((k) => k[5]);

// Parsing: the log line is text, its fields are extracted with a regular expression.
const PATTERN = /^(\S+)T(\S+) (\S+) authentication result=(\w+) account=(\S+) zone=(\S+) /;
export const parse = (line) => {
  const m = PATTERN.exec(line);
  if (!m) return null;
  const [, , ti, machine, result, account, zone] = m;
  const [h, d, s] = ti.split(":").map(Number);
  return { t: h * 3600 + d * 60 + s, machine, result, account, zone };
};
export const EVENT = LINE.map(parse);

if (import.meta.url === `file://${process.argv[1]}`) {
  console.log(`model (seed ${SEED}): ${LINE.length} log lines, ` +
    `${ACCOUNT.length} personnel and ${SERVICE.length} service accounts (fictional)`);
  for (const i of [0, 1]) console.log(LINE[i]);
  const broken = EVENT.filter((o) => o === null).length;
  const fail = EVENT.filter((o) => o.result === "failed");
  console.log(`\nunparsable lines ${broken}; failed ${fail.length}, ` +
    `succeeded ${EVENT.length - fail.length}`);
  const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j])
    : String(v).padEnd(B[0]))).join("");
  const line1 = row([26, 12, 10, 10, 17]);
  console.log(line1(["cluster (ground truth)", "failed", "accounts", "zones",
    "max per account"]));
  for (const e of [...new Set(LABEL)]) {
    const l = LINE.map((_, i) => i).filter((i) => LABEL[i] === e &&
      EVENT[i].result === "failed");
    const h = {};
    for (const i of l) h[EVENT[i].account] = (h[EVENT[i].account] || 0) + 1;
    console.log(line1([e, l.length, Object.keys(h).length,
      new Set(l.map((i) => EVENT[i].zone)).size, Math.max(0, ...Object.values(h))]));
  }
}
```

```
model (seed 20260314): 1168 log lines, 96 personnel and 14 service accounts (fictional)
2026-03-14T00:10:00 ep-207 authentication result=failed account=sv-204 zone=collector-net method=password
2026-03-14T00:12:10 ep-207 authentication result=failed account=sv-210 zone=collector-net method=password

unparsable lines 0; failed 916, succeeded 252
cluster (ground truth)          failed  accounts     zones  max per account
stale-service-credential           572         2         1              286
typo                                29        19         3                2
forgotten-password                  37         3         1               14
config-change                       62        62         1                1
spray                              176        88         1                2
targeted-attempt                    40         1         1               40
```

The cluster table puts the whole tension of the lesson on a single screen. **572 of the day's 916
failed logins come from a single problem**: two service accounts with stale credentials retry
every five minutes and produce 286 failures per account. That number is seven times the 40
failures the deepest campaign leaves on a single account, and it is entirely legitimate.

The shapes of the two campaigns in the bottom rows are opposites. The targeted attempt leaves 40
failures on a single account — deep and narrow. The spray spreads across 88 accounts but makes
**only two attempts per account** — shallow and wide. The fourth row shows why the second shape is
hard to separate: a server-side setting change produced one failure each on 62 accounts, and its
shape resembles a spray.

#### Threshold Per Account

```js
// threshold.mjs — threshold choice: missed events, needless alerts, and legitimate work lockout stops.
import { EVENT, LABEL, LINE } from "./login.mjs";

const CAMPAIGN = ["targeted-attempt", "spray"];
const failures = [];
EVENT.forEach((o, i) => { if (o.result === "failed")
  failures.push({ ...o, label: LABEL[i], id: failures.length }); });
const succeeded = EVENT.map((o, i) => ({ ...o, label: LABEL[i] }))
  .filter((o) => o.result === "succeeded");
const campaignEvents = failures.filter((o) => CAMPAIGN.includes(o.label));

// For one key (account or zone) in a sliding window: the moment it first crosses the threshold, and the events in that window.
function crossing(key, measure, threshold, window) {
  const first = new Map();
  for (const [i, o] of failures.entries()) {
    const k = key(o);
    if (first.has(k)) continue;
    const w = failures.slice(0, i + 1).filter((x) => key(x) === k && x.t > o.t - window);
    if (measure(w) >= threshold) first.set(k, { t: o.t, w });
  }
  return first;
}
const count = (w) => w.length;
const distinctAccounts = (w) => new Set(w.map((x) => x.account)).size;

// Evaluation looks at the alert's WINDOW: does it contain a campaign event.
function evaluate(alerts) {
  const covered = new Set();
  let empty = 0;
  for (const [, v] of alerts) {
    const c = v.w.filter((o) => CAMPAIGN.includes(o.label));
    if (c.length === 0) empty++;
    for (const o of c) covered.add(o.id);
  }
  const caught = new Set(campaignEvents.filter((o) => covered.has(o.id))
    .map((o) => o.label));
  return { caught: caught.size, missed: campaignEvents.length - covered.size, empty };
}

const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j])
  : String(v).padEnd(B[0]))).join("");
const line1 = row([10, 11, 12, 12, 15, 12, 19]);
const line2 = row([11, 8, 12, 15, 12, 19]);

console.log(`${LINE.length} lines, ${failures.length} failed logins; two campaigns ` +
  `(${CAMPAIGN.join(", ")}) produced ${campaignEvents.length} events in total`);
console.log("\nrule 1 — failure count per account");
console.log(line1(["window", "threshold", "locked out", "caught", "missed events",
  "false pos.", "blocked logins"]));
for (const [name, window] of [["1 hour", 3600], ["24 hours", 86400]])
  for (const threshold of [3, 5, 10, 20]) {
    const alerts = crossing((o) => o.account, count, threshold, window);
    const d = evaluate(alerts);
    const blocked = [...alerts].reduce((a, [k, v]) => a + succeeded
      .filter((b) => b.account === k && b.t >= v.t && b.label !== "spray").length, 0);
    console.log(line1([name, threshold, alerts.size, `${d.caught}/2`, d.missed, d.empty, blocked]));
  }

console.log("\nrule 2 — count of DISTINCT accounts per zone (1-hour window)");
console.log(line2(["threshold", "alerts", "caught", "missed events", "false pos.", "blocked accounts"]));
for (const threshold of [10, 20, 40, 70, 90]) {
  const alerts = crossing((o) => o.zone, distinctAccounts, threshold, 3600);
  const d = evaluate(alerts);
  const blockedAccounts = new Set([...alerts].filter(([, v]) => !v.w.some((o) =>
    CAMPAIGN.includes(o.label))).flatMap(([, v]) => v.w.map((o) => o.account))).size;
  console.log(line2([threshold, alerts.size, `${d.caught}/2`, d.missed, d.empty, blockedAccounts]));
}

const biggestLegit = Math.max(...failures.map((o, i) => new Set(failures.slice(0, i + 1)
  .filter((x) => x.zone === o.zone && x.t > o.t - 3600 && !CAMPAIGN.includes(x.label))
  .map((x) => x.account)).size));
const spray = failures.filter((o) => o.label === "spray");
const perAccount = [...new Set(spray.map((o) => o.account))]
  .map((h) => spray.filter((o) => o.account === h).length);
console.log(`\nspray: ${new Set(spray.map((o) => o.account)).size} accounts, at most ` +
  `${Math.max(...perAccount)} attempts per account, 1 successful login`);
console.log(`largest LEGITIMATE bulk failure: ${biggestLegit} distinct accounts in one hour`);
```

```
1168 lines, 916 failed logins; two campaigns (targeted-attempt, spray) produced 216 events in total

rule 1 — failure count per account
window      threshold  locked out      caught  missed events  false pos.     blocked logins
1 hour              3           6         1/2            213           5                  9
1 hour              5           6         1/2            211           5                  9
1 hour             10           6         1/2            206           5                  9
1 hour             20           0         0/2            216           0                  0
24 hours            3          70         2/2            104          11                 49
24 hours            5          12         2/2            199           5                 16
24 hours           10           6         1/2            206           5                  9
24 hours           20           3         1/2            196           2                  0

rule 2 — count of DISTINCT accounts per zone (1-hour window)
threshold    alerts      caught  missed events  false pos.   blocked accounts
10                2         1/2            206           1                 10
20                2         1/2            196           1                 20
40                2         1/2            176           1                 40
70                1         1/2            146           0                  0
90                0         0/2            216           0                  0

spray: 88 accounts, at most 2 attempts per account, 1 successful login
largest LEGITIMATE bulk failure: 62 distinct accounts in one hour
```

The first table's first three rows try three different thresholds and **all three give the same
result**: six accounts get locked out, one of the two campaigns is caught, and between 206 and
213 of the 216 campaign events get missed. There is no measurable detection difference among
thresholds set at three, five, and ten; all three catch the same five legitimate accounts, because
the only things that cross ten failures in a one-hour window are stale service credentials, users
who forgot their password, and the targeted attempt.

The table's `false pos.` column counts alerts whose window contains no campaign event — that is,
**false positives**: at every threshold, five alerts come from a legitimate cause, and if lockout
is on, nine legitimate logins are stopped. Two are service accounts; what stops when they get
locked out is not a user, it is a meter-collection job. Pulling the threshold up to twenty
zeroes out detection along with the false positives: zero campaigns caught, 216 events missed.

Opening the window to twenty-four hours changes the table but does not solve the problem. At a
threshold of three, both campaigns get caught and missed events drop to 104; the cost is **70
locked-out accounts**, 11 false positives, and 49 blocked legitimate logins. That means everyone
who mistypes their password once during the day cannot log back in until evening. That is the
bill for buying sensitivity from the window.

How the catch happens matters too: the spray becomes visible only because its two attempts per
account **add up** with a legitimate failure elsewhere in the day. The rule does not see it as a
spray; it sees an account that has accumulated three failures, and on accounts with no legitimate
failure at all, it still does not see it.

#### Counting the Same Event Along the Zone Axis

A rule that counts per account can never see the spray on its own at any threshold, because it is
measuring the wrong quantity: the campaign stays at two attempts per account, and **two** is below
the level of a legitimate typo. Lowering the threshold does not help here — it only turns typos
into alerts. What needs to change is not the threshold, it is the **key** being counted.

The second table reads the same log by counting distinct accounts per zone. At thresholds of ten,
twenty, and forty, two alerts come out, and one is a false positive: the server-side setting
change produced one failure each on 62 branch accounts, and this rule sees it as a campaign. In a
rule tied to zone blocking, the cost reads directly — at a threshold of forty, 40 legitimate
accounts get blocked.

At a threshold of seventy, only one alert remains, the false positive drops to zero, and the spray
is separated out. This row looks promising, but the last output line writes the limit: **the
largest legitimate bulk failure is 62 accounts in one hour.** The rule can only separate out
attempts that spread wider than that without producing a false positive. If the same campaign had
spread across 50 accounts, it could not have been separated at any threshold; and at a threshold
of ninety, even this campaign is missed and all 216 events become invisible.

Translated into this course's measure, the table says: the authentication log **closes no
foothold point at all**; it only makes visible the step before a point is set up, and that
visibility depends on the threshold. The one successful login the spray produced is sitting right
there in the record too, but a failure threshold never looks at successful logins at all.

#### Summary

- 572 of the day's 916 failed logins come from a single legitimate problem: two service accounts
  with stale credentials produce 286 failures per account, seven times the 40 failures the deepest
  campaign leaves on a single account.
- A rule that counts per account locks out the same six accounts in a one-hour window at
  thresholds of three, five, and ten — none at a threshold of twenty; at a threshold of ten, 206
  of 216 campaign events get missed, 5 alerts are false positives, and 9 legitimate logins are
  blocked.
- Opening the window to 24 hours catches both campaigns and brings missed events down to 104; the
  cost is 70 locked-out accounts, 11 false positives, and 49 blocked legitimate logins.
- Because the spray makes at most 2 attempts per account, it is invisible at any account-axis
  threshold; the number two is below the level of a legitimate typo, and lowering the threshold
  only turns typos into alerts.
- A rule that counts distinct accounts per zone separates the spray out without a false positive
  at a threshold of 70; the floor for that is the size of the largest legitimate bulk failure — 62
  accounts in one hour.

#### Next Step

Both rules looked at a single field: whether the login failed, on which account, in which zone.
What happens after a session opens is not in this log; the one successful login the spray produced
cannot be told apart from the other 251 successful logins. The next lesson looks inside that
session and opens the source that wrote eight types by name on its own in the first lesson's
table: the process creation log. The measure there is parent-process context — how many items
look suspicious when the same command list is read as a flat sequence, how many get separated out
when it is read as a tree, and what the record's volume is.

### Command-Line Auditing

Source: https://academia.sh/en/courses/endpoint-security/command-line-auditing

In a 5,110-record process creation sample, a rule that looks only at the child process name flags 1,702 records; parent-process context brings the same set down to 40 records and 9 pairs, and a new-and-sparse criterion separates out all five suspicious items along with one legitimate addition. Three of the five items have no trace in any other source. Across the fleet the record runs to 10.18 million lines and 4.55 GiB a day; keeping only non-baseline pairs divides the volume by 128 but leaves three of four investigation questions unanswered.

The previous lesson measured the authentication log on its own and showed that it cannot see what
happens after a session opens. In the first lesson's coverage table there was a source that wrote
eight of the twelve foothold point types by name on its own, and it was off by default: the
process creation log. This lesson turns that record on and asks two questions together — what
the record can separate out, and how much space it takes.

The key to separating things out is a single field: the **parent process**. If a process creation
record carries only the name of the running item, what you have is a flat list; if it also carries
the parent process identity, the same records turn into a **process tree**, and the question stops
being "did this tool run" and becomes "what ran this tool." The measurement below is a **model**,
the regional measurement network is **fictional**, and the process names are **type** names, not
products. Suspicious items are referred to only as **pairs**; no command, script, or usage pattern
is written down.

- **SL11.** The sample is 5,110 lines from one day's process creation log and covers 8
  machines. Fleet volume scales from the first lesson's assumption of 38,000 records per machine.
- **SL12.** The baseline is 16 (parent, child) pairs seen during a learning period. If a pair is
  not in the baseline, it counts as a "new pair."
- **SL13.** Rule 1 looks only at the child process's name and treats three names as noteworthy.
  Rule 2 looks at whether the (parent, child) pair is in the baseline. Neither rule sees the label
  field.
- **SL14.** What the model knows to be true: four new pairs are legitimate additions, five new
  pairs are suspicious. Some of the suspicious items have a trace in another source; that trace is
  read from the first lesson's coverage table.
- **SL15.** The record size is 480 bytes, and the retention comparison is made over 90 days.

#### Flat List and Process Tree

```js
// process.mjs — process creation log sample and two rules: name list vs. parent-process pair.
// THIS IS A MODEL; process names are TYPE names, not products. The rules only read the parent/child/machine fields;
// the label field is the model's ground truth and is not given to the rules.
export const SEED = 20260401;
const generator = (t) => { let s = t >>> 0;
  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; };
const r = generator(SEED);

// Baseline: (parent, child) pair seen during the learning period, and its daily count.
export const BASELINE = [
  ["session-manager", "shell", 620], ["shell", "metrics-client", 840],
  ["shell", "archiver", 180], ["shell", "network-transfer", 210],
  ["scheduler", "script-interpreter", 460], ["scheduler", "backup", 120],
  ["management-agent", "package-manager", 90], ["management-agent", "script-interpreter", 260],
  ["service-manager", "metrics-collector", 40], ["metrics-collector", "metrics-client", 1300],
  ["document-handler", "print-helper", 70], ["mail-client", "document-handler", 150],
  ["scanner", "document-handler", 130], ["package-manager", "install-hook", 210],
  ["backup", "archiver", 260], ["install-hook", "shell", 130],
];
// Pair not in the baseline: four legitimate additions, five suspicious items.
// The "trace" field: the OTHER source that sees the item's effect (the first lesson's coverage table), or null if none.
export const NEW = [
  ["management-agent", "network-transfer", 14, "legitimate-addition", null],
  ["scheduler", "archiver", 6, "legitimate-addition", null],
  ["shell", "package-manager", 9, "legitimate-addition", "package and installation log"],
  ["backup", "network-transfer", 2, "legitimate-addition", null],
  ["document-handler", "script-interpreter", 2, "suspicious", null],
  ["mail-client", "shell", 1, "suspicious", null],
  ["metrics-collector", "archiver", 3, "suspicious", null],
  ["print-helper", "shell", 1, "suspicious", "scheduler log"],
  ["scanner", "script-interpreter", 2, "suspicious", "system event log"],
];
export const MACHINE = 8;

const records = [];
const add = (parent, child, label) => records.push({
  t: Math.floor(r() * 86400), machine: `ep-${String(1 + Math.floor(r() * MACHINE))
    .padStart(3, "0")}`, parent, child, label });
for (const [parent, child, n] of BASELINE) for (let i = 0; i < n; i++) add(parent, child, "baseline");
for (const [parent, child, n, kind] of NEW) for (let i = 0; i < n; i++) add(parent, child, kind);
records.sort((a, b) => a.t - b.t);
export const RECORD = records;

// Rule 1: looks only at the child process's name.
export const INTEREST = ["shell", "script-interpreter", "network-transfer"];
// Rule 2: is the (parent, child) pair in the baseline.
const baselineSet = new Set(BASELINE.map(([p, c]) => `${p}>${c}`));
export const pair = (k) => `${k.parent}>${k.child}`;
export const newPair = (k) => !baselineSet.has(pair(k));
export const suspiciousPair = new Set(NEW.filter((y) => y[3] === "suspicious")
  .map(([p, c]) => `${p}>${c}`));

if (import.meta.url === `file://${process.argv[1]}`) {
  const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j])
    : String(v).padEnd(B[0]))).join("");
  const line1 = row([42, 8, 10, 18, 30]);
  console.log(`model (seed ${SEED}): ${RECORD.length} process creation log sample, ` +
    `${MACHINE} machines, ${new Set(RECORD.map(pair)).size} distinct (parent, child) pairs (fictional)`);
  const byName = RECORD.filter((k) => INTEREST.includes(k.child));
  const tree = RECORD.filter(newPair);
  console.log(`rule 1 (child process name only): ${byName.length} records flagged, ` +
    `${new Set(byName.map(pair)).size} distinct pairs`);
  console.log(`rule 2 (non-baseline pair): ${tree.length} records, ` +
    `${new Set(tree.map(pair)).size} distinct pairs`);
  console.log("\n" + line1(["non-baseline pair", "record", "machine", "rule 1 sees it",
    "trace in another source"]));
  for (const [p, c, , kind, trace] of NEW) {
    const l = RECORD.filter((k) => pair(k) === `${p}>${c}`);
    console.log(line1([`${p} > ${c}`, l.length, new Set(l.map((k) => k.machine)).size,
      INTEREST.includes(c) ? "yes" : "no", trace ?? "none"]));
  }
  const sparse = [...new Set(tree.map(pair))]
    .filter((c) => tree.filter((k) => pair(k) === c).length <= 3);
  console.log(`\nnew AND sparse (3 records or fewer) pairs: ${sparse.length}; of these, ` +
    `suspicious items: ${sparse.filter((c) => suspiciousPair.has(c)).length}, ` +
    `legitimate additions: ${sparse.filter((c) => !suspiciousPair.has(c)).length}`);
  console.log(`${NEW.filter((y) => y[3] === "suspicious" && y[4]).length} of the five ` +
    `suspicious items have a trace in another source; ` +
    `${NEW.filter((y) => y[3] === "suspicious" && !y[4]).length} appear only in the process ` +
    `creation log`);
}
```

```
model (seed 20260401): 5110 process creation log sample, 8 machines, 25 distinct (parent, child) pairs (fictional)
rule 1 (child process name only): 1702 records flagged, 11 distinct pairs
rule 2 (non-baseline pair): 40 records, 9 distinct pairs

non-baseline pair                           record   machine    rule 1 sees it       trace in another source
management-agent > network-transfer             14         6               yes                          none
scheduler > archiver                             6         5                no                          none
shell > package-manager                          9         6                no  package and installation log
backup > network-transfer                        2         2               yes                          none
document-handler > script-interpreter            2         1               yes                          none
mail-client > shell                              1         1               yes                          none
metrics-collector > archiver                     3         3                no                          none
print-helper > shell                             1         1               yes                 scheduler log
scanner > script-interpreter                     2         2               yes              system event log

new AND sparse (3 records or fewer) pairs: 6; of these, suspicious items: 5, legitimate additions: 1
2 of the five suspicious items have a trace in another source; 3 appear only in the process creation log
```

The ratio between the two rules' output is the lesson's first number. The rule that looks only at
the child process's name flags **1,702** of 5,110 records — a third of them. That is not because
the flagged names are genuinely suspicious, but because the organization uses these tools
legitimately all day: the shell starts from the session manager 620 times, the script interpreter
starts from the scheduler 460 times, the network transfer tool starts from the shell 210 times. As
an alert list, this number is useless.

When the same record set is read together with the parent process, 1,702 records drop to **40**,
and eleven pairs drop to nine. The only thing that changes is the number of fields in the
criterion: two instead of one. What the process tree provides is the ability to count not that a
tool ran, but that it ran **from an unexpected parent**.

The table shows these nine pairs split into two groups. Four are legitimate additions — a new
deployment step, a new backup job, an administrator doing a manual install. Five are suspicious,
and what they share is their shape: an interpreter or a shell under a process that handles
documents, receives mail, or scans; an archiver under a service that touches external data. None
of these individually contains any forbidden command; what is suspicious is the **location**.

The last two lines write two separate limits. The new-and-sparse criterion gives six pairs; five
are suspicious items, one is a legitimate addition — so this criterion separates out all five
items and produces one needless review in exchange. The second limit is heavier: **three of the
five suspicious items have no trace in any other source at all**. If the record is off, nothing is
left for those three items. One of the remaining two appears only in the scheduler log, and that
source, too, is off by default in the first lesson's table.

This criterion's cost to legitimate work does not sit in the table as a fourth column, but it can
be counted. In one day, nine new pairs came out, and **four** of them are legitimate additions
that should be added to the baseline: a new deployment tool, a new backup job, an administrator's
manual install. Roughly half the alerts are not a threat but a maintenance item, and each one
needs a person to look at it and decide. The baseline is not a file written once and left alone;
every software change in the organization ages it. If that cost goes unpaid, the criterion
degrades on its own — accumulated legitimate additions crowd the list, and after a while no one
looks at nine rows.

#### The Record's Volume and the Retention Decision

```js
// volume.mjs — the volume of three retention policies, the suspicious items each keeps, and the questions each answers.
import { RECORD, INTEREST, newPair, pair, suspiciousPair } from "./process.mjs";

const FLEET = 268, DAILY = 38000, BYTES = 480;    // per-machine record count from the previous lesson
const POLICY = [
  ["keep everything", () => true],
  ["name of interest only", (k) => INTEREST.includes(k.child)],
  ["non-baseline pair only", newPair],
];
// Questions are answered on the full set; if the filtered set gives the same answer, it is "yes".
const QUESTION = [
  ["what else did document-handler run",
    (l) => new Set(l.filter((k) => k.parent === "document-handler").map((k) => k.child)).size],
  ["archiver pair's first machine",
    (l) => l.filter((k) => pair(k) === "metrics-collector>archiver")
      .sort((a, b) => a.t - b.t)[0]?.machine ?? "-"],
  ["how many times did metrics-client run",
    (l) => l.filter((k) => k.child === "metrics-client").length],
  ["distinct parents that ran the shell",
    (l) => new Set(l.filter((k) => k.child === "shell").map((k) => k.parent)).size],
];
const fullAnswer = QUESTION.map(([, f]) => f(RECORD));

const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j])
  : String(v).padEnd(B[0]))).join("");
const line1 = row([25, 10, 9, 15, 14, 13, 11]);
const toGiB = (n) => (n * BYTES) / 1024 ** 3;

console.log(`fleet ${FLEET} machines x ${DAILY} records/day = ` +
  `${(FLEET * DAILY).toLocaleString("en-US")} records/day, record ${BYTES} bytes`);
console.log(line1(["policy", "sample", "share", "fleet rec/day", "GiB/day", "90-day GiB",
  "suspicious"]));
const answers = [];
for (const [name, filter] of POLICY) {
  const l = RECORD.filter(filter);
  const share = l.length / RECORD.length;
  const s = new Set(l.filter((k) => suspiciousPair.has(pair(k))).map(pair)).size;
  answers.push(QUESTION.map(([, f], i) => f(l) === fullAnswer[i]));
  console.log(line1([name, l.length, `${(100 * share).toFixed(1)}%`,
    Math.round(FLEET * DAILY * share).toLocaleString("en-US"),
    toGiB(FLEET * DAILY * share).toFixed(2), Math.round(toGiB(FLEET * DAILY * share) * 90),
    `${s}/${suspiciousPair.size}`]));
}
const line2 = row([38, 15, 23, 24]);
console.log("\n" + line2(["investigation question", ...POLICY.map((p) => p[0])]));
for (const [i, [name]] of QUESTION.entries())
  console.log(line2([name, ...answers.map((y) => (y[i] ? "yes" : "no"))]));
console.log(POLICY.map((p, j) => `${p[0]}: ` +
  `${answers[j].filter(Boolean).length}/${QUESTION.length}`).join("; "));
const fullSize = toGiB(FLEET * DAILY), narrowSize = toGiB(FLEET * DAILY *
  RECORD.filter(newPair).length / RECORD.length);
console.log(`\nkeeping only non-baseline pairs divides the volume by ` +
  `${Math.round(fullSize / narrowSize)} and still keeps all five suspicious items; the cost is that three of four investigation questions go unanswered`);
```

```
fleet 268 machines x 38000 records/day = 10,184,000 records/day, record 480 bytes
policy                       sample    share  fleet rec/day       GiB/day   90-day GiB suspicious
keep everything                5110   100.0%     10,184,000          4.55          410        5/5
name of interest only          1702    33.3%      3,392,009          1.52          136        4/5
non-baseline pair only           40     0.8%         79,718          0.04            3        5/5

investigation question                keep everything  name of interest only  non-baseline pair only
what else did document-handler run                yes                     no                      no
archiver pair's first machine                     yes                     no                     yes
how many times did metrics-client run             yes                     no                      no
distinct parents that ran the shell               yes                    yes                      no
keep everything: 4/4; name of interest only: 1/4; non-baseline pair only: 1/4

keeping only non-baseline pairs divides the volume by 128 and still keeps all five suspicious items; the cost is that three of four investigation questions go unanswered
```

The volume row explains why this record is off by default. The fleet produces 10.18 million
process creation log entries a day; that is 4.55 GiB a day and 410 GiB over ninety days. The number is
not unaffordable on its own, but it is large next to the sum of the other six sources from the
first lesson, and this record has to be turned on machine by machine.

The middle row shows what narrowing retention by name gives you: volume drops to a third, and
**four** of the five suspicious items remain. The item that is lost is an archiver whose name is
not on the interest list — narrowing by name means deleting exactly the tool that was not written
into the list. The bottom row uses the pair criterion: volume divides by 128, and all five items
remain.

The table below measures what this cheapness costs. Four investigation questions get answered on
the full set; the policy that keeps only non-baseline pairs answers just one. It cannot answer the
rest, because what it deletes is the **expected** records: what else a parent process ran that
day, how many times an item ran, and how many distinct parents launched a tool all sit only inside
the expected records.

Together, these two tables give the record's real decision. Non-baseline pairs are enough for
detection, and they are very cheap; **investigation** needs the full record, and that costs 410
GiB over ninety days. These are not the same decision: producing an alert and being able to ask
questions after an alert are priced separately, and narrowing by name is the worst option on both
counts — it pays a third of the volume, answers one of four questions, and loses one of five
items.

#### Summary

- A rule that looks only at the child process name flags 1,702 of 5,110 records; reading the same
  set with parent-process context brings it down to 40 records and 9 pairs.
- Four of the nine non-baseline pairs are legitimate additions, five are suspicious; what is
  suspicious is not the command but the **location** — which parent process an item runs under.
- The new-and-sparse criterion gives six pairs: all five suspicious items are among them, at the
  cost of one needless review.
- Three of the five suspicious items have no trace in any other source; one appears only in the
  scheduler log, which is also off by default.
- The fleet produces 10.18 million records and 4.55 GiB a day; keeping only non-baseline pairs
  divides the volume by 128 and keeps all five items, but leaves three of four investigation
  questions unanswered.

#### Next Step

All three lessons in this topic carried the same silent assumption: the record sits there, and it
is correct. But every record measured here is produced on the local disk of the very machine the
item is running on. Once code has run on the endpoint, those records sit where it can reach them.
The last lesson tests that assumption: it detects, with a hash chain, how many records in a
tampered log set were deleted and how many were altered, names the case the chain cannot see, and
measures the window centralization closes.

### Log Integrity

Source: https://academia.sh/en/courses/endpoint-security/log-integrity

A 1,200-record log chain is tested against four tampering forms: deletion is caught at sequence 744 with an 11-sequence gap, a digest-refreshed change is caught at sequence 839, only the sequence gap remains once the chain is recomputed, and local verification says clean once the sequence is reissued too. The central copy shows 0 of 13 tampered records with daily transfer, 11 with hourly transfer, and 13 with five-second transfer; across the fleet the window never closes at any interval, because 130 machines stay offline.

The previous lesson turned on the process creation log and showed that parent-process context
brings 1,702 flags down to 40. All three lessons in this topic carried the same silent assumption:
the record sits there, and it is correct. But every record counted here is produced on the local
disk of the very machine the item is running on, and this course's opening assumption granted code
the ability to run on the endpoint once. The record sits exactly where that code can reach it.

This lesson tests that assumption. A **hash chain** shows that the record is internally
consistent: each record's digest covers its sequence number, the previous digest, and the body, so
removing or changing a record in the middle breaks the chain. The question to ask is what this
works **against**. The measurement is a **model**, the regional measurement network is
**fictional**, and the tampering's **method is not written down**, only its result is modeled.
**Operating on records in a system that belongs to someone else — deleting, changing, moving —
without written authorization and a defined scope is both illegal and unprofessional.**

- **SL16.** One day's log on one endpoint is 1,200 records. Each record carries a sequence number,
  a body, and a digest; the digest is computed from the sequence number, the previous digest, and
  the body using `node:crypto`. The structure is the same as the chained digest in the Backend
  Production course; the question being measured is different.
- **SL17.** The tampering instant is second 61,550, and the tampered window starts at second
  54,000. 11 records are deleted from the window, and 2 records' bodies change.
- **SL18.** Four tampering forms are separated: **A** deletion, without touching the chain; **B**
  change, refreshing only that record's digest; **C** deletion, recomputing the chain; **D**
  deletion, reissuing the sequence numbers too.
- **SL19.** The central collector keeps a copy of the record, and the comparison is made through
  the record's unchanging item identity. A record that has not reached the center cannot be
  compared.
- **SL20.** The fleet has four groups, and field laptops are offline 23,400 seconds a day. The
  overhead per transfer is 8 KiB; all figures are fictional.

#### The Four Forms the Chain Sees

```js
// chain.mjs — the log chain produced on the endpoint, four tampering forms, and local verification.
// THIS IS A MODEL; the tampering METHOD is not written, only its result is modeled.
import { createHash } from "node:crypto";

export const SEED = 20260515, N = 1200, T = 61550;   // tampering instant (seconds)
const generator = (t) => { let s = t >>> 0;
  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; };
const r = generator(SEED);
const SOURCE = ["process-creation", "authentication", "system-event", "scheduler"];

export const digest = (seq, prev, body) =>
  createHash("sha256").update(`${seq}|${prev}|${body}`).digest("hex").slice(0, 16);

// Chain: each record's digest is computed from its sequence number, the previous digest, and the body.
export function chain(records) {
  let prev = "start";
  return records.map((k) => { prev = digest(k.seq, prev, k.body); return { ...k, digest: prev }; });
}
const times = Array.from({ length: N }, () => Math.floor(r() * 86400)).sort((a, b) => a - b);
export const LOG = chain(times.map((t, i) => ({
  seq: i + 1, t,
  body: JSON.stringify({ t, source: SOURCE[Math.floor(r() * 4)], item: 100 + i }),
})));

// Tampering window: the last activity window. Part of the records in this window are deleted,
// the last two records are changed.
const tamperWindow = LOG.filter((k) => k.t >= 54000 && k.t <= T);
export const DELETED = tamperWindow.filter((_, i) => i % 9 === 0).slice(0, 12).map((k) => k.seq);
export const CHANGED = tamperWindow.filter((k) => !DELETED.includes(k.seq)).slice(-2)
  .map((k) => k.seq);
// The change shifts the record's timestamp; the item identity does not change.
const corrupt = (k) => JSON.stringify({ ...JSON.parse(k.body), t: JSON.parse(k.body).t + 900 });

// A: deleted, chain untouched. B: changed, only that record's digest refreshed.
// C: deleted, chain recomputed. D: deleted, sequence reissued too.
export const FORM = {
  A: () => LOG.filter((k) => !DELETED.includes(k.seq)),
  B: () => LOG.map((k) => (CHANGED.includes(k.seq)
    ? { ...k, body: corrupt(k), digest: digest(k.seq, LOG[k.seq - 2].digest, corrupt(k)) } : k)),
  C: () => chain(LOG.filter((k) => !DELETED.includes(k.seq))
    .map((k) => (CHANGED.includes(k.seq) ? { ...k, body: corrupt(k) } : k))),
  D: () => chain(LOG.filter((k) => !DELETED.includes(k.seq))
    .map((k, i) => ({ ...k, seq: i + 1,
      body: CHANGED.includes(k.seq) ? corrupt(k) : k.body }))),
};

// Local verification: the chain is run from the start, the sequence of the first mismatch is returned (0 if none).
export function verify(records) {
  let prev = "start";
  for (const k of records) {
    prev = digest(k.seq, prev, k.body);
    if (prev !== k.digest) return k.seq;
  }
  return 0;
}
// Sequence gaps: the only local trace deleted records can leave.
export const gap = (records) => records.reduce((a, k, i) =>
  (i && k.seq !== records[i - 1].seq + 1 ? a + k.seq - records[i - 1].seq - 1 : a), 0);

if (import.meta.url === `file://${process.argv[1]}`) {
  const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j])
    : String(v).padEnd(B[0]))).join("");
  const line = row([42, 8, 16, 14, 15]);
  console.log(`model (seed ${SEED}): ${N}-record log chain, tampering instant second ${T}`);
  console.log(`tampered window: ${DELETED.length} deleted, ${CHANGED.length} changed records ` +
    `(seq ${DELETED[0]}-${DELETED.at(-1)} and ${CHANGED.join(", ")})`);
  console.log("\n" + line(["tampering form", "records", "first mismatch", "seq gap",
    "local result"]));
  for (const [name, note] of [["A", "record deleted, chain untouched"],
    ["B", "record changed, digest refreshed"],
    ["C", "record deleted, chain recomputed"],
    ["D", "deleted, sequence and chain reissued"]]) {
    const l = FORM[name]();
    const u = verify(l), b = gap(l);
    console.log(line([`${name}: ${note}`, l.length, u || "-", b || "-",
      u || b ? "tampered" : "clean"]));
  }
}
```

```
model (seed 20260515): 1200-record log chain, tampering instant second 61550
tampered window: 11 deleted, 2 changed records (seq 743-833 and 838, 839)

tampering form                             records  first mismatch       seq gap   local result
A: record deleted, chain untouched            1189             744            11       tampered
B: record changed, digest refreshed           1200             839             -       tampered
C: record deleted, chain recomputed           1189               -            11       tampered
D: deleted, sequence and chain reissued       1189               -             -          clean
```

The four rows build a staircase. In the first form the records have been deleted and the chain has
not been touched; two independent signals come out at once — verification stops at sequence 744,
and 11 sequence gaps are counted. In the second, two adjacent records' bodies have changed and only
those two records' digests have been recomputed; the mismatch shows up not at the first changed
record but at **the one after it** — at sequence 839 — because that record's digest depends on the
previous record's old digest. This is exactly what the chain buys you: making one record consistent
requires rewriting every record after it too.

In the third form the whole chain has been recomputed, and verification finds no mismatch at all.
One trace remains: the 11-sequence gap. In the fourth form the sequence numbers have been reissued
too, and that trace disappears as well — local verification says **clean**. Eleven records have
been deleted, two records' timestamps have been shifted, and there is nothing on the machine that
shows it.

The result comes down to one sentence: a chain produced on the same machine and verified on the
same machine is not proof of integrity against whoever holds that machine, only proof of
**consistency**. Its real contribution is that it makes partial tampering impossible: whoever
tampers with it will either not touch it at all, or will have to reproduce the entire record.

#### The Central Copy and the Transfer Window

```js
// center.mjs — comparison with the central copy and the fleet cost of the transfer window.
import { LOG, FORM, DELETED, CHANGED, T } from "./chain.mjs";

const local = FORM.D();                              // the set local verification calls clean
const item = (k) => JSON.parse(k.body).item;          // the record's unchanging identity
const INTERVAL = [["once a day", 86400], ["4 hours", 14400], ["hourly", 3600],
  ["5 minutes", 300], ["5 seconds", 5]];

const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j])
  : String(v).padEnd(B[0]))).join("");
const line1 = row([13, 11, 13, 20, 19, 15]);
const line2 = row([20, 10, 11, 14, 16, 17, 13]);

console.log(`tampering instant second ${T}; ${DELETED.length} deleted and ${CHANGED.length} ` +
  `changed records exist, the local chain shows none of them`);
console.log(line1(["interval", "at center", "in window", "deletions detected",
  "changes detected", "invisible"]));
for (const [name, w] of INTERVAL) {
  const lastTransfer = Math.floor(T / w) * w;
  const center = LOG.filter((k) => k.t <= lastTransfer);
  const localItem = new Map(local.map((k) => [item(k), k.body]));
  const del = center.filter((k) => !localItem.has(item(k))).length;
  const chg = center.filter((k) => localItem.has(item(k)) &&
    localItem.get(item(k)) !== k.body).length;
  console.log(line1([name, center.length, LOG.length - center.length,
    `${del}/${DELETED.length}`, `${chg}/${CHANGED.length}`,
    DELETED.length + CHANGED.length - del - chg]));
}

// Fleet: the cost of shortening the transfer interval and the window that cannot be closed.
const GROUP = [["field laptop", 84, 41000, 23400], ["branch endpoint", 46, 39000, 3600],
  ["meter collector", 120, 44000, 0], ["central processing", 18, 52000, 0]];
const OVERHEAD = 8192;                                // overhead per transfer (bytes)
console.log("\n" + line2(["transfer interval", "transfers", "fleet/day", "overhead MiB",
  "field window", "records at risk", "unclosed"]));
for (const [name, w] of INTERVAL) {
  const transfers = Math.round(86400 / w);
  const fleet = GROUP.reduce((a, g) => a + g[1] * transfers, 0);
  const risk = GROUP.reduce((a, g) => a + g[1] * g[2] * Math.max(w, g[3]) / 86400, 0);
  const unclosed = GROUP.filter((g) => g[3] > w).reduce((a, g) => a + g[1], 0);
  console.log(line2([name, transfers, fleet.toLocaleString("en-US"), Math.round(fleet * OVERHEAD / 1048576),
    `${Math.max(w, GROUP[0][3])} s`, Math.round(risk).toLocaleString("en-US"), unclosed]));
}
console.log(`\nshortening the interval from once a day to 5 minutes multiplies the transfer ` +
  `count by 288; on field laptops the effective window never drops below ${GROUP[0][3]} seconds`);
```

```
tampering instant second 61550; 11 deleted and 2 changed records exist, the local chain shows none of them
interval       at center    in window  deletions detected   changes detected      invisible
once a day             0         1200                0/11                0/2             13
4 hours              789          411                6/11                0/2              7
hourly               834          366               11/11                0/2              2
5 minutes            837          363               11/11                0/2              2
5 seconds            839          361               11/11                2/2              0

transfer interval    transfers  fleet/day  overhead MiB    field window  records at risk     unclosed
once a day                   1        268             2         86400 s       11,454,000            0
4 hours                      6      1,608            13         23400 s        2,267,750           84
hourly                      24      6,432            50         23400 s        1,266,500           84
5 minutes                  288     77,184           603         23400 s        1,029,083          130
5 seconds                17280  4,631,040         36180         23400 s        1,007,860          130

shortening the interval from once a day to 5 minutes multiplies the transfer count by 288; on field laptops the effective window never drops below 23400 seconds
```

The table above counts what the central copy closes, and the amount it closes is exactly **the
number of records left outside the transfer window**. With once-a-day transfer, the center has no
records from that day at all, and 13 of the 13 tampered items stay invisible. In the four-hour
interval, six of the eleven deletions surface; in the hourly transfer, all eleven surface. The two
changed records show up only in the five-second transfer, because both fall after the last
transfer.

All of these rows sit inside the set the local chain calls **clean**. What makes the comparison
possible is a second copy, not the chain.

The table below writes the bill for shortening the window. Going from once-a-day transfer to five
minutes multiplies the transfer count by 288 — across the fleet, from 268 to 77,184 — and the
transfer overhead runs to 603 MiB a day. At five-second transfer the same overhead becomes 35 GiB.

The last two columns show the real limit. Records at risk drops from 11.45 million to 1.03 million
and stays there: pulling the interval from five minutes down to five seconds only lowers that
number by two percent. The reason is the fourth column — the effective window on field laptops
never drops below 23,400 seconds in any interval, because those machines spend six and a half
hours of the day offline. At the five-minute interval, the window never closes at all on 130
machines. **The window centralization closes is not a configuration, it is a connectivity limit.**

For the record that stays inside the window, only one option remains, and it, too, is an
architectural item: writing the record somewhere the machine that produced it cannot change — an
append-only medium, or a signature whose key is not present on the endpoint. This does not close
the window; it keeps the record inside the window from changing **silently**.

#### Summary

- The hash chain catches three tampering forms: deletion at sequence 744 with an 11-sequence gap,
  a digest-refreshed change at sequence 839, and, once the chain is recomputed, only the sequence
  gap.
- Once sequence numbers are reissued too, local verification says **clean**: a chain produced and
  verified on the same machine is proof of consistency against whoever holds that machine, not
  proof of integrity.
- The central copy shows 0 of the 13 tampered records with once-a-day transfer, 11 with hourly
  transfer, and all 13 with five-second transfer; what it closes is exactly the number of records
  left outside the transfer window.
- Pulling the interval from once a day to five minutes multiplies transfers by 288 and raises
  overhead to 603 MiB a day; at five seconds the overhead becomes 35 GiB.
- Records at risk drop from 11.45 million to 1.03 million and stay there: on 130 machines the
  window never closes at any interval, because it is offline time, not a setting, that determines
  the effective window.

#### Course Wrap-Up

| Lesson | Foothold point | Closed / visible | Cost to legitimate work |
|---|---|---|---|
| Hardening Principles | 101 items, 84 points | 40 close, 44 become visible | 41 manual steps |
| Patch Management | 1,038 points, 12 advisories | 0 close, window from 10.8 to 2.3 days | 939 unplanned outages |
| Configuration Benchmarks | 26 items, 104 decisions | 0 close, 588 points narrow | 48 manual steps, 53 exceptions |
| Application Allowlists | 1,038 points, 25 items | 972 close, 66 open | 7 items on 208 machines |
| Sandbox Usage | 15 items, 4 configurations | 0 close, 4 of 8 malicious items stop | 2 false blocks, 275 minutes |
| Disk Encryption | 1,038 point definitions, 18 items | 936 close, 102 open | 204 key copies, 14,112 minutes |
| Malware Types | 8 point types, 10 types | 8 types stop, 2 remain | 130 legitimate items |
| Anti-Malware | 20 items, 6 malicious | 7 points close, become visible in alert mode | 3 legitimate jobs, 7 alerts |
| Endpoint Detection and Response | 8 point types, 7 sources | 0 close, 6 visible, 2 blind | 19.5-day window, 2 new points |
| Host-Based Firewall | 729,252 paths, 290,772 intra-zone | 158,100 paths close, 60 types in the record | 2 legitimate flows cut |
| Living off the Land | 14 tools, 7 capabilities | 0 close, 5 of 6 runs separate out | 3 false positives |
| Persistence Mechanisms | 10 types, 161 legitimate items | 7.1 closed, 1.8 visible, 1.2 dark | 36 items, 52 manual approvals |
| Event Logs | 3,078 machine-type pairs | 0 close, 2,356 visible, 250 blind | 10.2 million records a day |
| Authentication Logs | 916 failures, 0 points | 0 close, 10 of 216 events visible | 5 false positives, 9 logins |
| Command-Line Auditing | 8 types by name, 5 suspicious | 0 close, 5 separate out, 3 in a single source | 410 GiB over 90 days |
| Log Integrity | 13 tampered records | 0 close, 13 visible centrally | 1.03 million records in the window |

Not a single row leaves the third column empty, and nine rows read "0 close" in the second column.
The course's rule follows from these two observations: **endpoint security is not a product list,
it is the count of automatically running items; every point that cannot be closed must at least be
made visible, because an invisible point is a point that has been ignored.** Any measurement that
writes closing and making-visible into the same column overstates its own coverage.

Across sixteen lessons the second column moved in one direction: hardening lowered the item count,
allowlisting and disk encryption closed large blocks, protection layers started making what they
could not close visible, and the last four lessons measured only visibility. The remaining number
always had the same shape.

There is one question that has gone unasked through all this measurement. For five courses the
view was always from the defender's side, and what each check missed was counted one by one — the
path that got through, the claim that slipped past, the point left open, the record left blind.
But **who** used these missed paths, **in what order**, and **why** was never addressed. Why does
an attacker pick one point and leave another, which step leads to which, and when do they stop?
The **M24/K06 Attack Techniques and the Threat Landscape** course begins with exactly this
question.

