---
title: 'Cloud and Virtualization Security'
source: 'https://academia.sh/en/courses/cloud-security'
language: en
updated: '2026-09-26T03:02:50+00:00'
license: 'CC BY-SA 4.0'
lessons: 12
outcomes:
    - 'Turns the shared responsibility boundary into an audit checklist and counts the items left in the gap'
    - 'Calculates on the access graph the blast radius that starts from a single misconfigured item'
    - 'Builds detection with cloud telemetry and measures how evidence collection differs from a host'
---

# Cloud and Virtualization Security

How many items a single misconfigured item reaches in rented infrastructure: turning shared responsibility by service model into an audit checklist, the item a leaked identity reaches directly and through role chaining, the path closed by network isolation and private endpoints, the data type exposed by publicly accessible storage items, the effect of encryption key ownership on the blast radius, the error caught at template level versus the one visible only at run time, the step that management plane audit logs see and do not see, continuous configuration posture management, the radius of container and hypervisor escape in a multi-tenant environment, the window in which a log entry can be collected from ephemeral workloads, credential leak and metadata service patterns, and how collecting evidence in the cloud differs from a host.

## Cloud Security Model

Measuring how many items a single misconfigured item can reach in rented infrastructure: the item that can be written to the audit list under a given service model and the item that stays in the gap, the item reachable directly and through role chaining from a leaked identity, the walk that a network path closes off, the type of data exposed by publicly reachable storage items, the effect of key ownership on the radius, and the item caught at the template level versus the item visible only at runtime.

### Cloud Service Models and Responsibility

Source: https://academia.sh/en/courses/cloud-security/cloud-service-models-and-responsibility

Translating the shared-responsibility boundary into an audit list: 21 items that can be misconfigured are classified across three service models, the items that can be automatically audited drop from 15 to 12 and 7 while the items left outside the audit list rise from 6 to 9 and 14, the share of seed weight goes from 35% to 51% and 66%, unowned items rise from 0 to 2 and 4, and the 28-unit blind spot that no list can read stays fixed across all three models.

The previous course built the evidence chain all the way through, and at every step it carried the
same silent assumption: the machine under examination is reachable. In rented infrastructure that
assumption falls away. The hardware belongs to someone else, the host operating system is
invisible, and the physical machine the instance runs on cannot even be named. This pushes the
evidence question to later and brings forward a question that comes earlier: **where the
misconfiguration can be, and whose list that misconfiguration is on**.

This course uses a single measure: **blast radius** — how many items are reached from a single
misconfigured item. Measuring the radius first requires a list of the items where a
misconfiguration can sit. **Shared responsibility** splits that list in two; this lesson turns the
split into an audit list and counts what falls outside it. The responsibility matrix itself was
measured in the Cloud Fundamentals course of the Cloud Computing curriculum and is not re-derived
here — it is taken as input.

- **CM1.** Everything below belongs to a fictional regional measurement network: meter collection,
  verification, invoice generation, and the subscriber portal all run on rented infrastructure. No
  real provider, service, region, or organization name appears; what is measured is a **model**
  written with `node`.
- **CM2.** A **misconfigurable item** is a configuration item that opens access when its value is
  set incorrectly. Twenty-one items are counted; the list is illustrative, not exhaustive.
- **CM3.** In each of the three service models, every item is set either by the customer, by the
  provider, or by no one at all. The third case does not come from the contract; it comes from
  each side assuming the other one handles it.
- **CM4.** **Readability** is a separate property: an item's effective value is either readable
  from the management plane or not readable at all. An item the provider sets is known to the
  customer only **by declaration**.
- **CM5.** **Seed weight** is the number of items directly opened up if an item is misconfigured.
  It is a fictional number, used in this lesson only to weight the items; the walk itself is run in
  the next lesson.
- **CM6.** A row in the audit list can only run automatically if the item is both owned and
  readable. In every other case, the cost of the row changes.

#### Items That Can Be Misconfigured

The model writes items with three columns — who sets it, per service model — and carries two more
properties: readability and seed weight. Four classes are derived from these three.

```js
// cloud/item.mjs — the MODEL of items that can be misconfigured in rented infrastructure.
// Fictional regional measurement network; no real provider, service, region, or contract name.

export const MODEL = ['infrastructure', 'platform', 'function'];
// Columns: who sets the item (C customer, P provider, N nobody) x three service models.
// read: how the item's effective value is read by the customer.
//   interface = read from the management plane, none = read from neither an interface nor a document.
// touch: number of items directly opened if the item is misconfigured (seed weight, fictional).
export const ITEM = `
  physical-access               P P P  interface    6
  hypervisor-patch               P P P  interface   14
  co-tenancy                     P P P  interface   11
  host-kernel-patch              C P P  interface    9
  runtime-version                C C P  interface    5
  image-source                   C C P  interface    7
  instance-metadata-version      C N P  interface    8
  management-endpoint-exposure   C N N  interface   10
  network-access-rule            C C N  interface    6
  private-endpoint-mapping       C C N  interface    4
  log-stream-destination         C C N  interface    3
  role-trust-list                 C C C  interface   12
  identity-role-definition        C C C  interface    9
  storage-permission-set          C C C  interface    7
  storage-public-flag             C C C  interface    5
  key-ownership                   C C C  interface    8
  secret-store-access             C C C  interface   13
  audit-log-enabled               C C C  interface    2
  template-default                C C C  none         9
  dependency-version              C C C  none         4
  in-workload-secret              C C C  none        15`
  .trim().split('\n').map((s) => {
    const [name, ...k] = s.trim().split(/\s+/);
    return { name, read: k[3], touch: Number(k[4]),
      side: Object.fromEntries(MODEL.map((m, i) => [m, k[i]])) };
  });

// Audit-list class: who sees this if the item is misconfigured, and with what.
export const classOf = (k, m) => k.side[m] === 'N' ? 'unowned'
  : k.side[m] === 'P' ? 'declared'
    : k.read === 'interface' ? 'audited' : 'blind';
export const CLASS = ['audited', 'declared', 'blind', 'unowned'];
// Cost of the audit row: rule that runs automatically, periodic question round, extra mechanism, owner assignment.
export const COST = { audited: 'rule', declared: 'question round', blind: 'extra mechanism',
  unowned: 'owner first' };
export const group = (m, c) => ITEM.filter((k) => classOf(k, m) === c);
export const weight = (l) => l.reduce((a, k) => a + k.touch, 0);
export const TOTAL = weight(ITEM);
```

```js
// cloud/run1.mjs — how many items are on the customer, how many are declared, how many are unowned; how many are outside the audit list
import { ITEM, MODEL, CLASS, TOTAL, group, weight, classOf } from './item.mjs';

const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-30, 16, 16, 16];
const changed = ITEM.filter((k) => new Set(MODEL.map((m) => classOf(k, m))).size > 1);
console.log(`${ITEM.length} items that can be misconfigured, total seed weight ${TOTAL}; ` +
  `${changed.length} items whose class changes across the three models:\n`);
print(A, 'item', ...MODEL);
for (const k of changed) print(A, k.name, ...MODEL.map((m) => classOf(k, m)));

console.log('');
print(A, 'class (21-item breakdown)', ...MODEL);
for (const c of CLASS) print(A, c, ...MODEL.map((m) => group(m, c).length));
print(A, 'outside the audit list', ...MODEL.map((m) =>
  CLASS.slice(1).reduce((a, c) => a + group(m, c).length, 0)));
print(A, '  seed weight', ...MODEL.map((m) =>
  CLASS.slice(1).reduce((a, c) => a + weight(group(m, c)), 0)));
print(A, '  weight percentage', ...MODEL.map((m) =>
  Math.round(CLASS.slice(1).reduce((a, c) => a + weight(group(m, c)), 0) / TOTAL * 100)));
```

```
21 items that can be misconfigured, total seed weight 167; 8 items whose class changes across the three models:

item                            infrastructure        platform        function
host-kernel-patch                      audited        declared        declared
runtime-version                        audited         audited        declared
image-source                           audited         audited        declared
instance-metadata-version              audited         unowned        declared
management-endpoint-exposure           audited         unowned         unowned
network-access-rule                    audited         audited         unowned
private-endpoint-mapping               audited         audited         unowned
log-stream-destination                 audited         audited         unowned

class (21-item breakdown)       infrastructure        platform        function
audited                                     15              12               7
declared                                     3               4               7
blind                                        3               3               3
unowned                                      0               2               4
outside the audit list                       6               9              14
  seed weight                               59              86             111
  weight percentage                         35              51              66
```

The top table shows only the eight items whose class changes; the remaining thirteen items stay in
the same class across all three models. The bottom table gives the actual counts: the items the
customer can read from the management plane and audit automatically drop from fifteen to twelve,
then to seven. In the same direction, the items left outside the audit list rise from six to nine,
then to fourteen.

The seed-weight row shows why this shift is not a flat trade. The **share of the total weight of
167 units that the audit list cannot see** goes from 35% to 51%, then to 66%. The item count grows
by a little more than double, while the weight share nearly triples — because the items that drop
off the list are the heavy ones. `hypervisor-patch` carries fourteen, `in-workload-secret` fifteen,
`secret-store-access` thirteen units.

#### The List Narrows as the Boundary Shifts

The top table separates four groups of items. `host-kernel-patch`, `runtime-version`, and
`image-source` move to the provider **explicitly**: the customer does not set them, the provider
takes them over, and the audit turns into a declaration. This is not a loss but a trade — the item
can still be misconfigured, but the party that can fix the misconfiguration is the same party that
made it.

`network-access-rule`, `private-endpoint-mapping`, and `log-stream-destination` take a different
path. The customer sets them in the infrastructure and platform models; in the function model, the
execution environment abstracts these items away and the provider does not take them over under
contract either. The result is unownedness: the item can still be misconfigured, but because no one
sets it, no one audits it.

`management-endpoint-exposure` stays unowned in two models at once, and at ten units of weight it
is the heaviest unowned item on the list. `instance-metadata-version` follows an interesting path:
the customer sets it in infrastructure, no one sets it in platform, and the provider takes it over
in function. As the boundary shifts deeper, an item does not always cross in a straight line — **it
can fall into a gap in one model along the way**.

#### The Cost of the Audit List

Each of the four classes enters the audit list at a different cost. For an owned, readable item, a
rule is written and it runs continuously. For an item known by declaration, a periodic question is
put to the provider; the accuracy of the answer cannot be tested, only recorded. A blind spot needs
an extra mechanism to pull its value out of the running system. An unowned item fits none of these:
before a row can be written, the item's owner must first be assigned.

```js
// cloud/run2.mjs — audit list: how many rows run automatically, how many need a question round
import { MODEL, CLASS, COST, TOTAL, group, weight } from './item.mjs';

const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const B = [-13, -18, 16, 16, 16];
print(B, 'class', 'row cost', ...MODEL);
for (const c of CLASS)
  print(B, c, COST[c], ...MODEL.map((m) => `${group(m, c).length} / ${weight(group(m, c))}`));
console.log('  (cell: item count / seed weight)\n');

const C = [-16, 9, 10, 9, 14, 12];
print(C, 'model', 'audit', 'covered', 'question', 'no owner', 'blind');
print(C, '', 'rows', 'weight', 'round', 'item/weight', 'weight');
for (const m of MODEL)
  print(C, m, group(m, 'audited').length,
    `${Math.round(weight(group(m, 'audited')) / TOTAL * 100)}%`,
    group(m, 'declared').length,
    `${group(m, 'unowned').length} / ${weight(group(m, 'unowned'))}`,
    weight(group(m, 'blind')));
```

```
class        row cost            infrastructure        platform        function
audited      rule                      15 / 108         12 / 81          7 / 56
declared     question round              3 / 31          4 / 40          7 / 60
blind        extra mechanism             3 / 28          3 / 28          3 / 28
unowned      owner first                  0 / 0          2 / 18          4 / 23
  (cell: item count / seed weight)

model               audit   covered question      no owner       blind
                     rows    weight    round   item/weight      weight
infrastructure         15       65%        3         0 / 0          28
platform               12       49%        4        2 / 18          28
function                7       34%        7        4 / 23          28
```

The first table breaks the cost down class by class. In the infrastructure model, fifteen rows run
automatically and cover a hundred and eight units of weight; in the function model, only seven rows
remain and the covered weight drops to fifty-six. The question round rises from three to seven:
each round is an item that is asked of the provider, whose answer is awaited, and that cannot be
tested.

The second table reads the same three models in a single row. The automatically covered weight
drops from 65% to 34%. Unowned items rise from zero to two and four, and their weight from zero to
eighteen and twenty-three. For these items the customer has one option — take on ownership — and
when it is taken on, eighteen and twenty-three units return to the list. The cost is that work not
in the contract gets written onto the customer's own team.

#### How Many Days Later Is the Misconfiguration Seen

Row count gives the width of the list, not its delay. A rule that runs automatically sees the
misconfiguration on the day it is set; a periodic question round sees it only on the day the round
comes around; if an extra mechanism collects weekly, it sees it within the week. An unowned item is
never asked at all.

```js
// cloud/run3.mjs — how many days later a misconfigured item is noticed; which weight is never noticed
import { MODEL, CLASS, TOTAL, group, weight } from './item.mjs';

// Detection delay (days): a rule runs continuously, a question round is periodic,
// an extra mechanism collects weekly once installed, an unowned item is never asked.
const DELAY = { audited: 0, declared: 90, blind: 7, unowned: null };
const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const D = [-16, 9, 15, 16, 17];
print(D, 'model', 'observed', 'weighted', 'never observed', 'blind mechanism');
print(D, '', 'weight', 'delay (days)', 'weight', 'if absent');
for (const m of MODEL) {
  const g = CLASS.filter((c) => DELAY[c] !== null);
  const seen = g.reduce((a, c) => a + weight(group(m, c)), 0);
  const sum = g.reduce((a, c) => a + weight(group(m, c)) * DELAY[c], 0);
  print(D, m, seen, (sum / seen).toFixed(1), TOTAL - seen,
    TOTAL - seen + weight(group(m, 'blind')));
}
console.log('\ncontribution to delay from weight falling under question round instead of rule:');
for (const m of MODEL)
  console.log(`  ${m.padEnd(15)}declared weight ${String(weight(group(m, 'declared'))).padStart(3)}` +
    `, ${Math.round(weight(group(m, 'declared')) * 90 /
      (weight(group(m, 'declared')) * 90 + weight(group(m, 'blind')) * 7) * 100)}% of the delay comes from here`);
```

```
model            observed       weighted  never observed  blind mechanism
                   weight   delay (days)          weight        if absent
infrastructure        167           17.9               0               28
platform              149           25.5              18               46
function              144           38.9              23               51

contribution to delay from weight falling under question round instead of rule:
  infrastructure declared weight  31, 93% of the delay comes from here
  platform       declared weight  40, 95% of the delay comes from here
  function       declared weight  60, 96% of the delay comes from here
```

The weighted delay goes from seventeen point nine days to twenty-five point five, then to
thirty-eight point nine. In other words, the same misconfigured item sits in place, on average,
more than twice as long in the function model. The bottom block says where the delay comes from:
across all three models, the share running from 93% to 96% comes **from the declared class**. The
blind spot's weekly mechanism contributes almost nothing to the delay, because seven days is small
next to ninety.

The last column carries a warning. The never-observed weight is zero, eighteen, and twenty-three —
but that number assumes an extra mechanism has been installed for the blind spot. If the mechanism
is not installed, the never-observed weight rises to twenty-eight, forty-six, and fifty-one; in the
function model, roughly a third of the total weight never lands on any list. This is not a gap in
the audit list's width: the list can be written as twenty-one rows and some of those rows will still
never read a value.

#### The Blind Spot Does Not Change With the Boundary

The last column carries this lesson's decisive number: the weight no list can read is **twenty-eight**
units in all three models. Three items — template default, dependency version, and the in-workload
secret — belong to the customer in all three models, can be misconfigured in all three, and cannot
be read from the management plane in any of the three.

This means deepening the service model does not shrink the blind spot. The boundary moves items
between customer and provider; it does not move readability. Taking on an unowned item does not
close the blind spot either — even after the platform model's two unowned items are taken on,
twenty-eight units remain in place, because ownership was already with the customer; what was
missing was visibility.

The audit list must therefore do three separate jobs at once: continuously test an owned, visible
item with a rule, record the provider's item through a question round, and pull the invisible item
out of the running system. The list at the end of this lesson has twenty-one rows, and in the
function model only seven of those rows run on their own.

#### Summary

- The 21 items that can be misconfigured split into four classes: audited, known by declaration,
  blind spot, unowned. Total seed weight is 167 units.
- Automatically audited items go 15 → 12 → 7; items left outside the audit list go 6 → 9 → 14.
  Covered weight goes 65% → 49% → 34%.
- The weight share drops faster than the item count, because the items that drop off the list are
  the heavy ones: `hypervisor-patch` carries 14, `in-workload-secret` 15, `secret-store-access` 13
  units.
- Unowned items go 0 → 2 → 4 and their weight 0 → 18 → 23. During the shift, an item can fall into
  a gap in one model instead of crossing over; `instance-metadata-version` is an example of this.
- Weighted detection delay goes 17.9 → 25.5 → 38.9 days. 93% to 96% of the delay comes from the
  declared class; the blind spot's weekly mechanism contributes almost nothing to the delay.
- The weight no list can read is 28 units in all three models. The boundary moves items, not
  readability; taking on an unowned item does not lower these 28 units either.

#### Next Step

The list is now written: twenty-one items, four classes, and a seed weight for each item. But seed
weight was given in this lesson as a number, not computed. `secret-store-access` was said to carry
thirteen, but where the thirteen comes from, and how many of those thirteen items are reached
directly and how many through another identity, was never asked. The next lesson builds this
course's unit of measure: identities and items are written as an access graph, the walk from a
single leaked identity is actually run, and **the radius becomes a number**.

### Identity and Access Management

Source: https://academia.sh/en/courses/cloud-security/identity-and-access-management

Turning the blast radius into a number: in an access graph of 10 identities and 18 items, a walk is run from a single leaked service account — against 2 directly reached items, 7 more are added through chaining, reaching a total of 9 items, with a 4.5x factor and a depth of 3. Without the assumption edge at all, the widest radius would be 4 instead of 9. Four audits are run on the same graph: removing one trust edge brings the radius down to 4 and stops 4 legitimate jobs a day, while multi-step verification does not lower the radius at all.

The previous lesson classified the twenty-one items that can be misconfigured and gave each one a
seed weight. That weight sat there as a number; it was never computed. This lesson computes it and
sets up the course's unit of measure: the access graph is written down, a walk is run from a single
leaked identity, and **the radius turns into a number**.

The Cloud Governance, Security and Cost course measured this same surface through a governance
lens — who can do what, which policy covers what, which permission goes unused. Here, no policy
text is ever written; what gets measured is access itself, and only one question is asked: if an
identity leaks, how many items can be reached from it. The walk itself is a graph traversal;
**no leak step, call sequence, or credential-harvesting method is ever written**. In a real
organization's access graph, a walk like this cannot be carried out without written authorization
and a defined scope; done without authorization, it is both illegal and unprofessional.

**CM7.** The graph's nodes are of two kinds: an **identity** (a service account or a role) and an
**item** (an accessed resource). An identity-to-item edge shows direct access; an
identity-to-identity edge shows a **role chain**.

**CM8.** The leaked identity is a node of the graph, and the walk starts from there. How the
identity leaks is not modeled in this lesson; what gets measured is the access that follows the
leak.

**CM9.** The **blast radius** is the number of **items** the walk reaches. It is the item count
that is counted, not the identity count, because the damage occurs in the item.

**CM10.** The **direct radius** consists of the leaked identity's own items; an **item reached
through chaining** is only seen after one or more assumption steps.

**CM11.** Assumption edges are transitive, and the graph is acyclic. How many legitimate jobs pass
over each edge per day is a fictional number.

**CM12.** The graph has ten identities and eighteen items; it is the fictional regional
measurement network's counterpart in rented infrastructure.

#### The Graph and the Walk

```js
// cloud/graph.mjs — this is a MODEL of an access graph in rented infrastructure. Fictional
// regional measurement network; no real provider, service, or identity name. Contains no leak
// step or call sequence.

// Identity -> the items it directly accesses.
export const ACCESS = {
  'account-collector': ['measurement-store', 'measurement-queue'],
  'account-validator': ['measurement-store', 'measurement-queue', 'raw-archive'],
  'account-portal': ['subscriber-database', 'portal-asset-store', 'work-order-queue'],
  'account-report': ['report-store', 'billing-output-store', 'log-store'],
  'role-billing': ['billing-database', 'billing-output-store', 'mail-dispatch', 'subscriber-database'],
  'role-archive': ['raw-archive', 'backup-store'],
  'role-backup': ['backup-store', 'billing-database', 'subscriber-database', 'measurement-store'],
  'role-distribution': ['distribution-configuration', 'template-store', 'instance-management'],
  'role-log': ['log-store', 'audit-log'],
  'role-management': ['instance-management', 'key-management', 'secret-store'],
};
// Assumption edge: [source identity, target identity, how many legitimate jobs pass over this edge per day]
export const ASSUME = [
  ['account-collector', 'role-archive', 96],
  ['account-validator', 'role-archive', 24],
  ['role-archive', 'role-backup', 4],
  ['role-backup', 'role-management', 1],
  ['account-portal', 'role-billing', 310],
  ['role-billing', 'role-log', 12],
  ['account-report', 'role-log', 8],
  ['role-distribution', 'role-management', 6],
];
export const IDENTITY = Object.keys(ACCESS);
export const ITEM = [...new Set(Object.values(ACCESS).flat())].sort();

// Radius walk: the set of items reached by expanding outward from the starting identity.
// Step 0 = the leaked identity's own items (direct radius).
export function walk(start, edge = ASSUME) {
  const seen = new Set([start]);
  const step = new Map([[start, 0]]);
  const item = new Map();                        // item -> the step it was first seen at
  let queue = [start];
  while (queue.length) {
    const next = [];
    for (const k of queue) {
      for (const r of ACCESS[k]) if (!item.has(r)) item.set(r, step.get(k));
      for (const [a, b] of edge)
        if (a === k && !seen.has(b)) { seen.add(b); step.set(b, step.get(k) + 1); next.push(b); }
    }
    queue = next;
  }
  const direct = [...item.values()].filter((d) => d === 0).length;
  return { identity: seen, item, radius: item.size, direct,
    chained: item.size - direct, depth: Math.max(...step.values()) };
}
```

The leaked identity is the service account for the meter-collection workload. This account sits at
the outer edge of the graph: it writes to two items and assumes a single role.

```js
// cloud/run1.mjs
import { ITEM, IDENTITY, walk } from './graph.mjs';

const r = walk('account-collector');
console.log(`graph: ${IDENTITY.length} identities, ${ITEM.length} items`);
console.log('leaked identity: account-collector');
console.log(`  identities reached ${r.identity.size}: ${[...r.identity].join(' -> ')}`);
console.log(`  radius ${r.radius}/${ITEM.length}  direct ${r.direct}  ` +
  `chained ${r.chained}  depth ${r.depth}`);
console.log(`  chain factor ${(r.radius / r.direct).toFixed(1)}x` +
  `, unreached ${ITEM.length - r.radius}\n`);
console.log('step  items reached');
for (let d = 0; d <= r.depth; d++)
  console.log(`${String(d).padStart(4)}  ` +
    ITEM.filter((k) => r.item.get(k) === d).join(', '));
const outside = ITEM.filter((k) => !r.item.has(k));
console.log('   -  ' + outside.slice(0, 5).join(', '));
console.log('      ' + outside.slice(5).join(', '));
```

```
graph: 10 identities, 18 items
leaked identity: account-collector
  identities reached 4: account-collector -> role-archive -> role-backup -> role-management
  radius 9/18  direct 2  chained 7  depth 3
  chain factor 4.5x, unreached 9

step  items reached
   0  measurement-queue, measurement-store
   1  backup-store, raw-archive
   2  billing-database, subscriber-database
   3  instance-management, key-management, secret-store
   -  audit-log, billing-output-store, distribution-configuration, log-store, mail-dispatch
      portal-asset-store, report-store, template-store, work-order-queue
```

The radius is nine. The leaked account's own authorization is two items; the remaining seven items
come after three assumption steps. The factor is four and a half times, and half of the eighteen
items fall outside the walk.

The step distribution carries the real information. At step zero there are the measurement items — the
two items the meter-collection workload actually needs. At step one, the archive; at step two,
**the subscriber and billing data**; at step three, key management and the secret store appear.
There is no relationship at all between the leaked account's job and the items of these last two
steps: a workload that collects meter readings touches neither the billing database nor key
management. Even so, a few steps later, both are inside the walk.

This shows that the radius arises not from **per-item permission** but from **trust between
identities**. None of the nine items is explicitly granted to the leaked account.

#### The Radius Changes from Identity to Identity

Measuring a single identity is not enough; which identity will be the one that leaks is not known.

```js
// cloud/run2.mjs — radius for each identity: how much does chaining multiply the radius by
import { ITEM, IDENTITY, walk } from './graph.mjs';

const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-19, 10, 10, 10, 9, 9];
print(A, 'leaked identity', 'direct', 'chained', 'radius', 'factor', 'depth');
const R = IDENTITY.map((k) => [k, walk(k)]);
for (const [k, r] of R)
  print(A, k, r.direct, r.chained, `${r.radius}/${ITEM.length}`,
    `${(r.radius / r.direct).toFixed(1)}x`, r.depth);
const y = R.map(([, r]) => r.radius);
console.log(`\nwidest radius ${Math.max(...y)}, average ` +
  `${(y.reduce((a, x) => a + x, 0) / y.length).toFixed(1)}, ` +
  `identities with a radius over 8: ${y.filter((x) => x > 8).length}/${IDENTITY.length}`);
const unchained = IDENTITY.map((k) => walk(k, []).radius);
console.log(`without the assumption edge at all: widest ${Math.max(...unchained)}, average ` +
  `${(unchained.reduce((a, x) => a + x, 0) / unchained.length).toFixed(1)}`);
```

```
leaked identity        direct   chained    radius   factor    depth
account-collector           2         7      9/18     4.5x        3
account-validator           3         6      9/18     3.0x        3
account-portal              3         5      8/18     2.7x        2
account-report              3         1      4/18     1.3x        1
role-billing                4         2      6/18     1.5x        1
role-archive                2         6      8/18     4.0x        2
role-backup                 4         3      7/18     1.8x        1
role-distribution           3         2      5/18     1.7x        1
role-log                    2         0      2/18     1.0x        0
role-management             3         0      3/18     1.0x        0

widest radius 9, average 6.1, identities with a radius over 8: 2/10
without the assumption edge at all: widest 4, average 2.9
```

The table says the opposite of intuition. The widest radius is not in the management role, but in
the two service accounts at the outermost edge of the graph: the collector and the validator each
reach nine items. `role-management` stays at three items, because it cannot assume any role — the
walk ends there. An identity's **own authorization** does not determine the number of items it
opens up when it leaks.

The last two lines give the chain's share. Without the assumption edge at all, the widest radius
would be four and the average two point nine. With the edges added, these rise to nine and six
point one — the widest radius grows two point twenty-five times over, the average two point one
times over. Eight assumption edges double the average radius in an eighteen-item graph.

#### The Inverse Direction: In How Many Walks Does an Item Show Up

The previous section asked "how far can this identity go." This section asks the reverse question:
"how many identities can reach this item."

```js
// cloud/run4.mjs — inverse direction: how many identities' walks does each item appear in
import { ITEM, IDENTITY, walk } from './graph.mjs';

const R = IDENTITY.map((k) => walk(k));
const count = (k, f) => R.filter((r) => r.item.has(k) && f(r.item.get(k))).length;
const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-24, 10, 10, 10];
print(A, 'item', 'reached', 'direct', 'chain');
print(A, '', 'identity', 'only', 'only');
const sorted = [...ITEM].sort((a, b) => count(b, () => true) - count(a, () => true));
for (const k of sorted.slice(0, 6))
  print(A, k, count(k, () => true), count(k, (d) => d === 0), count(k, (d) => d > 0));
console.log('  ...');
for (const k of sorted.slice(-3))
  print(A, k, count(k, () => true), count(k, (d) => d === 0), count(k, (d) => d > 0));
const pair = (f) => ITEM.reduce((a, k) => a + count(k, f), 0);
console.log(`\ntotal (identity, item) access pairs ${pair(() => true)}: ` +
  `direct ${pair((d) => d === 0)}, chained ${pair((d) => d > 0)}`);
console.log(`on average, an item appears in ${(pair(() => true) / ITEM.length).toFixed(1)} ` +
  `identities' walks`);
```

```
item                       reached    direct     chain
                          identity      only      only
billing-database                 6         2         4
instance-management              6         2         4
key-management                   6         1         5
secret-store                     6         1         5
subscriber-database              6         3         3
audit-log                        4         1         3
  ...
report-store                     1         1         0
template-store                   1         1         0
work-order-queue                 1         1         0

total (identity, item) access pairs 61: direct 29, chained 32
on average, an item appears in 3.4 identities' walks
```

Five items appear in six of the ten identities' walks. `secret-store` and `key-management` sit at
the top of this list, and both are granted directly to only a **single** identity; the remaining
five paths come through chaining. The same row splits three-to-three for `subscriber-database` —
that item is genuinely the work of three separate workloads.

The last line reduces the whole graph to a single number: of sixty-one access pairs, twenty-nine
are direct and thirty-two are chained. In other words, in this graph, **more than half** of the
ways an item can be seen by an identity are never explicitly granted anywhere. Twenty-nine can be
counted by looking at a permission list; the remaining thirty-two can only be seen by running the
walk.

#### The Audits Run on the Same Graph

Four audits are run over the same walk. Each one's cost is the legitimate work that passes over the
edge.

```js
// cloud/run3.mjs — four audits on the same graph: how far does the radius drop, at what cost in legitimate jobs per day
import { ITEM, IDENTITY, ASSUME, walk } from './graph.mjs';

const target = new Set(ASSUME.map(([, b]) => b));
const AUDIT = [                                    // [name, filter selecting the edges that remain]
  ['no audit', () => true],
  ['archive-backup trust removed', ([a, b]) => !(a === 'role-archive' && b === 'role-backup')],
  ['backup-management trust removed', ([a, b]) => !(a === 'role-backup' && b === 'role-management')],
  ['assumption reduced to a single step', ([a]) => !target.has(a)],
  ['multi-step verification for human users', () => true],
];
const TOTAL_WORK = ASSUME.reduce((a, [, , m]) => a + m, 0);
const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-40, 9, 10, 10, 10];
print(A, 'audit', 'radius', 'widest', 'average', 'stopped');
for (const [name, filt] of AUDIT) {
  const edge = ASSUME.filter(filt);
  const y = IDENTITY.map((k) => walk(k, edge).radius);
  print(A, name, walk('account-collector', edge).radius, Math.max(...y),
    (y.reduce((a, x) => a + x, 0) / y.length).toFixed(1),
    TOTAL_WORK - edge.reduce((a, [, , m]) => a + m, 0));
}
console.log(`\n(a total of ${TOTAL_WORK} legitimate jobs a day pass over the assumption edges)\n`);
console.log('if a single assumption edge is removed (leaked identity: account-collector):');
const B = [-19, -14, 9, 11];
print(B, 'edge', '', 'radius', 'stopped');
print(B, '', '', 'drop', 'per day');
for (const [a, b, m] of ASSUME)
  print(B, a, `-> ${b}`,
    9 - walk('account-collector', ASSUME.filter((e) => e[0] !== a || e[1] !== b)).radius, m);
```

```
audit                                      radius    widest   average   stopped
no audit                                        9         9       6.1         0
archive-backup trust removed                    4         8       4.5         4
backup-management trust removed                 6         8       4.9         1
assumption reduced to a single step             4         6       3.8        17
multi-step verification for human users         9         9       6.1         0

(a total of 461 legitimate jobs a day pass over the assumption edges)

if a single assumption edge is removed (leaked identity: account-collector):
edge                                radius    stopped
                                      drop    per day
account-collector  -> role-archive        7         96
account-validator  -> role-archive        0         24
role-archive       -> role-backup        5          4
role-backup        -> role-management        3          1
account-portal     -> role-billing        0        310
role-billing       -> role-log           0         12
account-report     -> role-log           0          8
role-distribution  -> role-management        0          6
```

The upper table puts the four audits side by side. Removing the archive role's assumption of the
backup role brings the radius down from nine to four and stops four legitimate jobs a day. Removing
the backup role's assumption of the management role brings it down from nine to six and stops a
single job a day. Reducing assumption to a single step — an already-assumed role cannot assume
another role — pulls the radius to four, the widest radius to six, and the average to three point
eight; its cost is seventeen jobs a day.

The last row carries this lesson's second finding. When multi-step verification is made mandatory
for human users, the radius stays at nine, the average stays at six point one, and the legitimate
work stopped is zero. The audit is not wrong, and it does close its own path — but the leaked
identity is a service account, and that account never passes through any verification step. **An
audit that does not lower the radius looks effective right up until it is run.**

The lower table ranks the audit edge by edge and shows how the choice should be made. Five of the
eight edges do not lower the radius at all for this leaked identity; among them is also the busiest
edge, carrying three hundred ten jobs a day. The biggest drop comes from removing the leaked
account's own assumption edge — seven items — but its cost is ninety-six jobs a day.
`role-archive -> role-backup` drops five items and stops four jobs a day. The cost per item is
seventeen times higher for the first one.

#### Summary

- The blast radius is the number of items reached by a walk that starts from a leaked identity.
  From the meter-collection account, the radius is 9/18; direct 2, chained 7, depth 3, factor 4.5x.
- The radius does not come from per-item permissions but from trust between identities: of a total
  of 61 `(identity, item)` access pairs, 29 are direct and 32 are chained. 6 identities reach
  `secret-store` and `key-management`, and both are granted directly to only a single identity.
- The widest radius is not in the management role but in the two service accounts at the outer
  edge of the graph (9/18); `role-management` stays at 3/18, because it cannot assume any role.
- Eight assumption edges raise the widest radius from 4 to 9 and the average from 2.9 to 6.1:
  chaining grows the average radius by close to double.
- Removing one trust edge brings the radius down from 9 to 4 and stops 4 legitimate jobs a day;
  reducing assumption to a single step brings it down to 4 and stops 17 jobs a day.
- Multi-step verification leaves the radius at 9 and stops no legitimate work; removing five of the
  eight edges also does not lower the radius at all — one of them is the busiest edge, carrying 310
  jobs a day.

#### Next Step

One thing went unquestioned throughout the walk: does an identity having access authorization to
an item mean it can actually **reach** that item. The graph had an edge, and the walk passed over
the edge; whether the network the item sits on is reachable from where that identity is located was
never looked at. The next lesson adds a second layer to the same graph: each item's network path.
The radius does not come out the same when the leaked identity is used from outside versus from
inside the network.

### Network Isolation

Source: https://academia.sh/en/courses/cloud-security/network-isolation

Intersecting the permission radius with the network path: the same 9-item walk drops to 6 from outside, 8 from the workload network, and 2 from the management network. Moving 10 items with no legitimate outside access to a private endpoint brings the outside radius down from 6 to 4 and stops zero legitimate jobs; pulling the threshold to 50 a day zeroes out the outside radius and stops 101 outside accesses a day. The workload network column stays at 8 across all four audits.

The previous lesson turned the radius into a number: nine items from the leaked service account.
Throughout that walk, one thing was never asked — does an identity having authorization to an item
mean it can actually **reach** that item. There was an edge, and the walk passed over the edge;
whether the network the item sits on is reachable from where the credential is used was never
looked at.

This lesson adds a second layer to the same graph. Every item has a **network path**: which
locations it is reachable from at the network level. The real radius is the intersection of two
sets — the items the permission walk reaches, and the items reachable at the network level from
that location. The Cloud Governance, Security and Cost course measured this same surface through an
access chart and name resolution; here, address plans, subnet arithmetic, and filter-rule syntax
never appear — only the **closed path** is counted.

- **CM13.** There are three **locations**: `outside` (outside the rented network), `workload` (the
  network a compromised workload instance sits on), and `management` (the network deployment jobs
  run on). The leaked credential is used from one of these locations.
- **CM14.** An item's **path** is which locations it is reachable from at the network level. Path is
  independent of permission; a path can be open for an identity that has no authorization, and
  closed for an identity that does.
- **CM15.** Role assumption happens through identity and access management, and can be done from
  any of the three locations. This is a model decision: if assumption were restricted, the walk
  would shorten, and the network layer's effect could not be measured.
- **CM16.** **Switching to a private endpoint** means an item's public endpoint is closed and it
  becomes reachable only from within the network; in the model, this is the `outside` location
  dropping out of the item's path.
- **CM17.** How many legitimate outside accesses each item gets per day is a fictional number, and
  when the public endpoint is closed, those accesses are assumed to stop.

#### The Permission Radius Is an Upper Bound

```js
// cloud/network.mjs — the previous lesson's access graph + each item's NETWORK PATH. This is a
// MODEL; a fictional regional measurement network. Address plans, subnet arithmetic, and
// filter-rule syntax are not present in this lesson.

export const ACCESS = {
  'account-collector': 'measurement-store measurement-queue',
  'account-validator': 'measurement-store measurement-queue raw-archive',
  'account-portal': 'subscriber-database portal-asset-store work-order-queue',
  'account-report': 'report-store billing-output-store log-store',
  'role-billing': 'billing-database billing-output-store mail-dispatch subscriber-database',
  'role-archive': 'raw-archive backup-store',
  'role-backup': 'backup-store billing-database subscriber-database measurement-store',
  'role-distribution': 'distribution-configuration template-store instance-management',
  'role-log': 'log-store audit-log',
  'role-management': 'instance-management key-management secret-store',
};
export const ASSUME = [['account-collector', 'role-archive'], ['account-validator', 'role-archive'],
  ['role-archive', 'role-backup'], ['role-backup', 'role-management'], ['account-portal', 'role-billing'],
  ['role-billing', 'role-log'], ['account-report', 'role-log'], ['role-distribution', 'role-management']];

// Location: where the leaked credential is used from. An item's path tells which location it is
// reachable from at the network level. outsideAccess = how many legitimate outside accesses a day
// pass through that item's public endpoint.
export const LOCATION = ['outside', 'workload', 'management'];
export const ITEM = `
  measurement-store            outside workload            40
  measurement-queue            workload                     0
  raw-archive                  outside workload             6
  backup-store                 outside workload management  2
  subscriber-database          workload                     0
  billing-database             workload                     0
  billing-output-store         outside workload           120
  portal-asset-store           outside workload          8600
  work-order-queue             workload                     0
  log-store                    workload management          0
  audit-log                    management                   0
  report-store                 outside management          30
  mail-dispatch                outside workload              0
  secret-store                 outside workload              0
  key-management                outside workload              0
  instance-management          outside management          14
  distribution-configuration   management                   0
  template-store                outside management           9`
  .trim().split('\n').map((s) => {
    const p = s.trim().split(/\s+/);
    return { name: p[0], path: p.slice(1, -1), outsideAccess: Number(p.at(-1)) };
  });
export const find = (name) => ITEM.find((k) => k.name === name);

// Permission walk: the set of items reached in the identity graph (same as the previous lesson).
export function permit(start) {
  const g = new Set([start]), item = new Set();
  let queue = [start];
  while (queue.length) {
    const next = [];
    for (const k of queue) {
      for (const r of ACCESS[k].split(' ')) item.add(r);
      for (const [a, b] of ASSUME) if (a === k && !g.has(b)) { g.add(b); next.push(b); }
    }
    queue = next;
  }
  return [...item];
}
// Real radius = permission AND network path. closed = names of items whose public endpoint is closed.
export const radius = (start, location, closed = new Set()) =>
  permit(start).filter((r) => find(r).path.includes(location) &&
    !(location === 'outside' && closed.has(r))).length;
```

```js
// cloud/run1.mjs — permission radius vs. radius passed through the network path: how many items by location
import { ITEM, LOCATION, find, permit, radius } from './network.mjs';

const u = permit('account-collector');
console.log(`leaked identity account-collector; permission walk ${u.length}/${ITEM.length} items\n`);
const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-24, 7, 9, 10, 11];
print(A, 'item entering the walk', ...LOCATION, 'legit ext.');
for (const r of u.sort())
  print(A, r, ...LOCATION.map((k) => find(r).path.includes(k) ? 'yes' : '-'), find(r).outsideAccess);
print(A, 'radius', ...LOCATION.map((k) => radius('account-collector', k)), '');
console.log('\nitem reachable at the network level (independent of permission), permission radius 9:');
for (const k of LOCATION)
  console.log(`  ${k.padEnd(9)}network surface ${String(ITEM.filter((r) => r.path.includes(k)).length)
    .padStart(2)}/${ITEM.length}, intersection with permission ${radius('account-collector', k)}`);
```

```
leaked identity account-collector; permission walk 9/18 items

item entering the walk  outside workloadmanagement legit ext.
backup-store                yes      yes       yes          2
billing-database              -      yes         -          0
instance-management         yes        -       yes         14
key-management              yes      yes         -          0
measurement-queue             -      yes         -          0
measurement-store           yes      yes         -         40
raw-archive                 yes      yes         -          6
secret-store                yes      yes         -          0
subscriber-database           -      yes         -          0
radius                        6        8         2

item reachable at the network level (independent of permission), permission radius 9:
  outside  network surface 11/18, intersection with permission 6
  workload network surface 13/18, intersection with permission 8
  managementnetwork surface  7/18, intersection with permission 2
```

The nine-item permission radius splits into three separate numbers: six from outside, eight from
the workload network, two from the management network. Same leaked identity, same authorizations,
three different radii. **Radius is not a property of an identity — it is a pair of an identity and
a location.**

The lower block shows how independent the two layers are from each other. Eleven items are
reachable from the outside location at the network level, but the leaked identity's permission
covers only six of them. On the workload network, the network surface is thirteen, the intersection
is eight. The network layer alone is not a boundary; the permission layer alone is not one either.
What is measured is the intersection of the two.

The sharpest row in the table is `instance-management`. That item is not reachable from the
workload network, but it is reachable from outside — meaning the leaked identity **cannot** be
reached when used from inside the network, but can when used from outside. `subscriber-database`
and `billing-database` are the reverse. The two locations' radii are not in a subset relationship;
neither is simply larger or smaller than the other.

#### The Path Closed by the Private Endpoint

Three audits are run on the same graph. Each closes the public endpoint of a set of items, and its
cost is the legitimate outside access that passes through those items.

```js
// cloud/run2.mjs — three audits: which item's public endpoint closes, what happens to the radius by location
import { ACCESS, ITEM, LOCATION, radius } from './network.mjs';

const set = (f) => new Set(ITEM.filter(f).map((k) => k.name));
const AUDIT = [
  ['no audit', new Set()],
  ['items with no legitimate outside access to private endpoint', set((k) => k.outsideAccess === 0)],
  ['items under 50 daily outside accesses too', set((k) => k.outsideAccess < 50)],
  ['only the management endpoint closes', set((k) => k.name === 'instance-management')],
];
const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-42, 8, 7, 9, 10, 14];
print(A, 'audit', 'closed', ...LOCATION, 'ext. stopped');
for (const [name, closed] of AUDIT)
  print(A, name, closed.size, ...LOCATION.map((k) => radius('account-collector', k, closed)),
    ITEM.filter((k) => closed.has(k.name)).reduce((a, k) => a + k.outsideAccess, 0));

console.log('\nwidest radius (all 10 identities are scanned):');
const B = [-42, 8, 9, 10];
print(B, 'audit', ...LOCATION);
for (const [name, closed] of AUDIT)
  print(B, name, ...LOCATION.map((k) =>
    Math.max(...Object.keys(ACCESS).map((s) => radius(s, k, closed)))));
```

```
audit                                       closedoutside workloadmanagement  ext. stopped
no audit                                         0      6        8         2             0
items with no legitimate outside access to private endpoint      10      4        8         2             0
items under 50 daily outside accesses too       16      0        8         2           101
only the management endpoint closes              1      5        8         2            14

widest radius (all 10 identities are scanned):
audit                                      outside workloadmanagement
no audit                                         6        8         3
items with no legitimate outside access to private endpoint       4        8         3
items under 50 daily outside accesses too        2        8         3
only the management endpoint closes              5        8         3
```

The first audit is free. Ten of the eighteen items get no legitimate outside access at all; closing
their public endpoints brings the outside radius down from six to four and stops zero legitimate
jobs a day. The two items that drop are `secret-store` and `key-management` — the two items that
appeared at the **third step** of the previous lesson's walk. The deepest point of the walk is the
cheapest point to close at the network layer.

The second audit pulls the threshold down to fifty outside accesses a day. The number of closed
items rises to sixteen, the outside radius drops to zero; its cost is a hundred and one outside
accesses a day. Most of these are field crews loading directly into `measurement-store` (forty) and
thirty accesses pulled from the report store. Bringing the outside radius down from four to zero
costs a hundred and one accesses a day; the first audit gained two items in the same direction while
stopping zero legitimate access.

The third audit targets a single item. Closing the management endpoint brings the outside radius
down from six to five and stops fourteen outside accesses a day. It pays fourteen accesses per item;
after the threshold audit's seventeen per item, this is the second most expensive audit.

#### All Thirty Pairs

A single leaked identity is one example. The whole graph is thirty pairs of ten identities and
three locations.

```js
// cloud/run3.mjs — radius per (identity, location) pair: the free audit before and after
import { ACCESS, ITEM, LOCATION, radius } from './network.mjs';

const free = new Set(ITEM.filter((k) => k.outsideAccess === 0).map((k) => k.name));
const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-19, 6, 9, 9, 3, 6, 9, 9];
console.log(''.padEnd(19) + 'no audit'.padStart(24) +
  'after the free audit'.padStart(27));
print(A, 'identity', ...LOCATION, '', ...LOCATION);
for (const s of Object.keys(ACCESS))
  print(A, s, ...LOCATION.map((k) => radius(s, k)), '',
    ...LOCATION.map((k) => radius(s, k, free)));
const pair = (closed) => Object.keys(ACCESS).flatMap((s) => LOCATION.map((k) => radius(s, k, closed)));
const before = pair(new Set()), after = pair(free);
console.log(`\n30 (identity, location) pairs; total radius ${before.reduce((a, x) => a + x, 0)} -> ` +
  `${after.reduce((a, x) => a + x, 0)}, pairs with a radius over 5 ` +
  `${before.filter((x) => x > 5).length} -> ${after.filter((x) => x > 5).length}`);
console.log(`widest radius ${Math.max(...before)} -> ${Math.max(...after)}; ` +
  `cost 0 legitimate outside access a day`);
```

```
                                   no audit       after the free audit
identity           outside workloadmanagement   outside workloadmanagement
account-collector       6        8        2        4        8        2
account-validator       6        8        2        4        8        2
account-portal          3        7        2        2        7        2
account-report          2        2        3        2        2        3
role-billing            2        5        2        1        5        2
role-archive            6        7        2        4        7        2
role-backup             5        6        2        3        6        2
role-distribution       4        2        3        2        2        3
role-log                0        1        2        0        1        2
role-management         3        2        1        1        2        1

30 (identity, location) pairs; total radius 106 -> 92, pairs with a radius over 5 8 -> 5
widest radius 8 -> 8; cost 0 legitimate outside access a day
```

The thirty pairs' total radius drops from a hundred and six to ninety-two — about thirteen percent.
Pairs with a radius over five drop from eight to five. All of this is the result of the free audit;
not a single legitimate outside access a day stops.

The table also shows where an identity is dangerous. `role-distribution` reaches four items from
outside, two from the workload network, three from the management network — its widest radius is
in the **outside** location, and the free audit brings it down from four to two. `account-portal` is
the reverse: three from outside, seven from the workload network. The same audit can close one
identity's most dangerous location while never touching another's.

The last row gives this its limit: the widest radius goes from eight to eight — it does not change
at all.

#### The Radius Isolation Does Not Lower

The workload network column is **eight** in all four rows. This is the number that carries this
lesson's verdict: private endpoints close the outside location, not the location inside the
network. Removing an item's public endpoint leaves access to that item from within the network open
by definition — that is exactly what a private endpoint does.

The consequence is this: if the leaked credential is used from a workload instance, all of the
private-endpoint work lowers the radius by zero items. The lower table confirms the same result for
all ten identities — the widest radius on the workload network is eight across all four audits,
three on the management network. Only the outside column drops, to four, two, and five.

Network isolation therefore does not replace an identity audit — it **complements** it. Zeroing out
the radius at the outside location leaves the radius at eight inside the network; the previous
lesson's removal of a trust edge, by contrast, was effective at every location at once. The two
audits do not lower the same number, and one's measurement does not stand in for the other's.

The practical takeaway of the measurement is an ordering. The ten free items are closed first: it
lowers the outside radius by two items, stops zero legitimate work, and needs no debate. Pulling the
threshold to fifty zeroes out the outside radius but stops a hundred and one accesses a day; this is
no longer a network decision but one about how field crews and report consumers do their work, and
it cannot be made without talking to those teams. The eight items on the workload network, on the
other hand, do not drop through any network decision — lowering that number requires going back to
the previous lesson's trust edges.

One wrong conclusion should specifically not be drawn from this table: zeroing out the outside
radius does not mean the breach becomes impossible. What goes to zero is the radius **in the case
where the credential is used from outside**. If the same credential is used through a path that
reaches a workload inside the network, the radius is eight again, and in the model this transition
is nothing more than a change of location.

#### Summary

- The real radius is the intersection of the permission walk and the network path. The same 9-item
  walk drops to 6 from outside, 8 from the workload network, 2 from the management network; radius
  is a property of the pair of identity and location, not of the identity alone.
- The two locations' radii are not in a subset relationship: `instance-management` is reachable
  only from outside, `subscriber-database` only from the workload network.
- Moving the 10 items with no legitimate outside access to a private endpoint brings the outside
  radius down from 6 to 4 and stops 0 legitimate jobs a day. The two items that drop are
  `secret-store` and `key-management`, at the deepest step of the walk.
- Pulling the threshold to 50 daily outside accesses zeroes out the outside radius; its cost is 101
  legitimate outside accesses a day. Closing the management endpoint alone lowers the radius by 1
  item and stops 14 accesses a day.
- The 30 `(identity, location)` pairs' total radius drops from 106 to 92 with the free audit, pairs
  with a radius over 5 drop from 8 to 5; the widest radius stays at 8.
- The workload network column is 8 across all four audits. A private endpoint closes the outside
  location, not the location inside the network; if the leaked identity is used from a workload
  instance, this work has zero effect on the radius.

#### Next Step

In this lesson, the network path was treated as a precondition for reaching an item: if the path
was closed, permission was useless. But some items in the list have their outside path open on
purpose — `portal-asset-store` gets eight thousand six hundred legitimate outside accesses a day and
cannot be closed. For that item, the question is not the path but the **permission set**: which
identity a request coming from outside is served with, and what a request presenting no identity at
all can read. The next lesson scans the permission set of storage items, counts items open to public
access, and measures the type of data readable from that item.

### Storage Exposure

Source: https://academia.sh/en/courses/cloud-security/storage-exposure

Two separate measures of a publicly open storage item: 7 of 13 object groups are open, with 61,697 records and 6 of 7 data types readable. Closing public access at the bucket level closes 9,260 personal records but leaves the spread radius at 10; closing the 47-record configuration item brings the radius down from 10 to 5 and stops 9 legitimate accesses a day. Closing all public access costs 8,655 accesses a day.

The previous lesson treated the network path as a precondition: if the path was closed, permission
did not matter. But some items have their outside path open on purpose — the subscriber portal's
asset store gets eight thousand six hundred legitimate outside accesses a day and cannot be closed.
For that item the question is not the path, it is **the permission set**: which identity an incoming
request is served with, and what a request that presents no identity at all can read.

This lesson scans the permission set of storage items. The Storage and Data in the Cloud course
measured bucket access and signed links; the mechanics are not repeated here, only **exposure** is
counted. Two separate figures come out, and the lesson's finding is that they diverge: the
**records** readable from an open item and the **spread radius** that starts from that item do not
drop with the same fix.

- **CM18.** Permission is written at two levels: the **bucket level** and the **object group
  level**. If a value is written at the object level, that is the effective permission; if not, the
  bucket level is inherited.
- **CM19.** A **publicly open** item is one that a request presenting no identity at all can read
  or list. Signed access does not count as open in this lesson.
- **CM20.** For every object group, a **data type**, a record count, and how many legitimate public
  accesses it gets a day are recorded. The numbers are fictional.
- **CM21.** An object group can carry an **identity reference**: the file inside it contains a
  role's name and the configuration that uses it. The credential itself is not modeled — only the
  reference's presence and its consequence are counted.
- **CM22.** The **exposure radius** is the union of two components: buckets read without an
  identity, and the items reached by the walk that starts from an identity reference in an open
  file.

#### The Permission Set and the Open Item

```js
// cloud/storage.mjs — object storage permission set and access graph MODEL. Fictional regional
// measurement network; storage class, signed-link mechanics, and provider name are not present
// in this lesson.

// Columns: bucket, object group, bucket-level permission, object-level permission ('-' = inherits
// from the bucket), data type, record count, legitimate public accesses a day, identity reference
// carried by the file in the group.
export const GROUP = `
  portal-asset-store    site-assets           public-read public-read  asset            1400 8600 -
  portal-asset-store    upload-preview        public-read -            subscriber-personal 9260 0 -
  measurement-store     daily-upload          private     public-read  measurement     41800   40 -
  measurement-store     raw-read              private     -            measurement    512000    0 -
  billing-output-store  subscriber-invoice    private     signed       billing         24500  120 -
  billing-output-store  bulk-export           private     public-list  billing           310    0 -
  raw-archive           historical-read       private     -            measurement   1240000    0 -
  raw-archive           shared-pull           private     public-read  measurement      8700    6 -
  backup-store          nightly-backup        private     -            backup            620    0 -
  backup-store          old-backup            private     public-read  backup            180    0 -
  template-store        distribution-template private     public-read  configuration      47    9 role-distribution
  report-store          monthly-report        private     signed       billing            36   30 -
  log-store             application-log       private     -            log             88000    0 -`
  .trim().split('\n').map((s) => {
    const p = s.trim().split(/\s+/);
    return { bucket: p[0], group: p[1], bucketPerm: p[2], objectPerm: p[3], type: p[4],
      records: Number(p[5]), legitimate: Number(p[6]), identity: p[7] === '-' ? null : p[7] };
  });
export const PUBLIC = ['public-read', 'public-list'];
// Effective permission: if the object level writes a value, that governs; if not, the bucket
// level is inherited.
export const effective = (g, d = {}) => {
  const bucket = d.bucketClosed ? 'private' : g.bucketPerm;
  const object = d.closed && d.closed(g) ? 'private' : g.objectPerm;
  return object === '-' ? bucket : object;
};
export const open = (g, d) => PUBLIC.includes(effective(g, d));

// Access graph (same as the previous lessons): the walk from an identity reference found in an
// open file.
const ACCESS = {
  'account-collector': 'measurement-store measurement-queue',
  'account-validator': 'measurement-store measurement-queue raw-archive',
  'account-portal': 'subscriber-database portal-asset-store work-order-queue',
  'account-report': 'report-store billing-output-store log-store',
  'role-billing': 'billing-database billing-output-store mail-dispatch subscriber-database',
  'role-archive': 'raw-archive backup-store',
  'role-backup': 'backup-store billing-database subscriber-database measurement-store',
  'role-distribution': 'distribution-configuration template-store instance-management',
  'role-log': 'log-store audit-log',
  'role-management': 'instance-management key-management secret-store',
};
const ASSUME = [['account-collector', 'role-archive'], ['account-validator', 'role-archive'],
  ['role-archive', 'role-backup'], ['role-backup', 'role-management'], ['account-portal', 'role-billing'],
  ['role-billing', 'role-log'], ['account-report', 'role-log'], ['role-distribution', 'role-management']];
export function walk(start) {
  const g = new Set([start]), item = new Set();
  let row = [start];
  while (row.length) {
    const s = [];
    for (const k of row) {
      for (const r of ACCESS[k].split(' ')) item.add(r);
      for (const [a, b] of ASSUME) if (a === k && !g.has(b)) { g.add(b); s.push(b); }
    }
    row = s;
  }
  return item;
}
// Exposure radius: buckets read without an identity + the walk of the identity reference in an
// open file.
export function radius(d) {
  const y = new Set(GROUP.filter((g) => open(g, d)).map((g) => g.bucket));
  for (const g of GROUP.filter((x) => open(x, d) && x.identity))
    for (const r of walk(g.identity)) y.add(r);
  return y;
}
```

```js
// cloud/run1.mjs — how many items are publicly open, which data type, how many records
import { GROUP, open, effective, radius } from './storage.mjs';

const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-22, -23, -22, -21, 9, 7];
print(A, 'bucket', 'object group', 'effective permission', 'data type', 'records', 'legit');
for (const g of GROUP.filter((x) => open(x)))
  print(A, g.bucket, g.group, effective(g), g.type, g.records, g.legitimate);
const records = (f) => GROUP.filter(f).reduce((a, g) => a + g.records, 0);
console.log(`\nopen item ${GROUP.filter((g) => open(g)).length}/${GROUP.length}, ` +
  `open records ${records((g) => open(g))} / total ${records(() => true)} ` +
  `(${(records((g) => open(g)) / records(() => true) * 100).toFixed(1)}%)`);
const type = [...new Set(GROUP.map((g) => g.type))];
console.log(`data type ${type.filter((t) => GROUP.some((g) => g.type === t && open(g))).length}/` +
  `${type.length} open; type left closed: ` +
  type.filter((t) => GROUP.every((g) => g.type !== t || !open(g))).join(', '));
const y = radius({});
console.log(`\nbucket read without identity ${new Set(GROUP.filter((g) => open(g)).map((g) => g.bucket)).size}` +
  `; radius with the identity reference in an open file ${y.size}`);
```

```
bucket                object group           effective permission  data type              records  legit
portal-asset-store    site-assets            public-read           asset                     1400   8600
portal-asset-store    upload-preview         public-read           subscriber-personal       9260      0
measurement-store     daily-upload           public-read           measurement              41800     40
billing-output-store  bulk-export            public-list           billing                    310      0
raw-archive           shared-pull            public-read           measurement               8700      6
backup-store          old-backup             public-read           backup                     180      0
template-store        distribution-template  public-read           configuration               47      9

open item 7/13, open records 61697 / total 1926853 (3.2%)
data type 6/7 open; type left closed: log

bucket read without identity 6; radius with the identity reference in an open file 10
```

Seven of thirteen object groups are publicly open. The open record count is sixty-one thousand six
hundred and ninety-seven, only three point two percent of the total — the volume measure looks
small. The data type measure does not say the same thing: six of seven types are open, and the only
type left closed is the application log.

Four rows need to be read separately. `site-assets` is open on purpose and gets eight thousand six
hundred legitimate accesses a day. `upload-preview` sits in the same bucket, inherits the
bucket-level permission, and carries nine thousand two hundred and sixty **subscriber personal
records**; it gets zero legitimate public accesses a day. The only difference between the two
groups is that the second one never wrote its own permission.

`daily-upload` and `shared-pull` are the opposite case: their buckets are private, openness is
written at the object level individually, and both have a legitimate justification. `old-backup`
and `bulk-export` are also open at the object level but get no legitimate access at all — meaning a
justification once existed, and when the justification lapsed, the permission did not.

The last row gives the lesson's second measure. A request with no identity reaches six buckets. The
distribution template inside `template-store` carries an identity reference, and the walk that
starts from that reference brings the radius up to ten. Four items — distribution configuration,
instance management, key management, and secret store — were never opened by any storage
permission; they come from a forty-seven-record configuration file.

#### Five Fixes

```js
// cloud/run2.mjs — five fixes: open items, open records, radius, and stopped legitimate access
import { GROUP, open, radius } from './storage.mjs';

const FIX = [
  ['no audit', {}],
  ['public access closes at the bucket level', { bucketClosed: true }],
  ['items with 0 legitimate public access close', { closed: (g) => g.legitimate === 0 }],
  ['the open item of type configuration closes', { closed: (g) => g.type === 'configuration' }],
  ['all public access closes', { bucketClosed: true, closed: () => true }],
];
const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-43, 7, 9, 9, 11];
print(A, 'fix', 'open', 'open', 'radius', 'stopped job');
print(A, '', 'item', 'record', '', 'a day');
for (const [name, d] of FIX) {
  const a = GROUP.filter((g) => open(g, d));
  print(A, name, a.length, a.reduce((x, g) => x + g.records, 0), radius(d).size,
    GROUP.filter((g) => open(g) && !open(g, d)).reduce((x, g) => x + g.legitimate, 0));
}
console.log('\nthe two components of the radius (buckets read without identity / items added from the reference):');
for (const [name, d] of FIX) {
  const bucket = new Set(GROUP.filter((g) => open(g, d)).map((g) => g.bucket)).size;
  console.log(`  ${name.padEnd(43)}${String(bucket).padStart(2)} / ${radius(d).size - bucket}`);
}
const dropped = [...radius({})].filter((r) => !radius(FIX[3][1]).has(r)).sort();
console.log(`\nitems dropped from the radius when the configuration-type item closes ${dropped.length}:`);
console.log('  ' + dropped.join(', '));
```

```
fix                                           open     open   radiusstopped job
                                              item   record               a day
no audit                                         7    61697       10          0
public access closes at the bucket level         6    52437       10          0
items with 0 legitimate public access close      4    51947        8          0
the open item of type configuration closes       6    61650        5          9
all public access closes                         0        0        0       8655

the two components of the radius (buckets read without identity / items added from the reference):
  no audit                                    6 / 4
  public access closes at the bucket level    6 / 4
  items with 0 legitimate public access close 4 / 4
  the open item of type configuration closes  5 / 0
  all public access closes                    0 / 0

items dropped from the radius when the configuration-type item closes 5:
  distribution-configuration, instance-management, key-management, secret-store, template-store
```

The second row is this lesson's decision-carrying finding. Closing public access at the bucket
level closes nine thousand two hundred and sixty personal records — fifteen percent of the open
records — and stops not a single legitimate job a day. Against that, **the spread radius stays at
ten**. The only item closed is the group that inherits the bucket-level permission; the six
permissions explicitly written at the object level stay in place, and the group carrying the
identity reference is one of them.

The fourth row shows the reverse. Closing the single open item of type configuration closes
forty-seven records — less than a thousandth of the open records — and brings the radius down from
ten to five. The cost is nine legitimate accesses a day. The bucket-level fix closes one hundred
and ninety-seven times as many records as this fix, and lowers the radius by zero items.

The block below separates out why. The radius has two components: the number of buckets read
without an identity, and the number of items added from the identity reference. The first three
fixes never touch the second component — the four items stay in place across all three. The fourth
fix zeroes out the second component and lowers the first component by only one. **The fix that
lowers record volume and the fix that lowers the radius are not the same fix.**

#### Each Open Group's Individual Share

Seven open groups give a combined figure; what each one carries is measured separately.

```js
// cloud/run3.mjs — open groups one by one: how large a radius each carries on its own
import { GROUP, open, radius, walk } from './storage.mjs';

const alone = (g) => {                              // radius if only this group were open
  const y = new Set([g.bucket]);
  if (g.identity) for (const r of walk(g.identity)) y.add(r);
  return y.size;
};
const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-21, -15, 9, 10, 8, 15];
print(A, 'open group alone', 'data type', 'records', 'radius', 'legit', 'records / item');
const openGroup = GROUP.filter((g) => open(g));
for (const g of openGroup) print(A, g.group, g.type, g.records, alone(g), g.legitimate, Math.round(g.records / alone(g)));
const total = openGroup.reduce((a, g) => a + alone(g), 0);
console.log(`\nsum of the individual radii ${total}, radius measured together ` +
  `${radius({}).size}: ${total - radius({}).size} item comes from two groups at once`);
```

```
open group alone     data type        records    radius   legit records / item
site-assets          asset               1400         1    8600           1400
upload-preview       subscriber-personal     9260         1       0           9260
daily-upload         measurement        41800         1      40          41800
bulk-export          billing              310         1       0            310
shared-pull          measurement         8700         1       6           8700
old-backup           backup               180         1       0            180
distribution-templateconfiguration         47         5       9              9

sum of the individual radii 11, radius measured together 10: 1 item comes from two groups at once
```

Six groups have a radius of one: the open bucket is read and that is where it ends. The seventh is
five. The last column turns this into a single ratio — records per item. `daily-upload` carries
forty-one thousand eight hundred records per item; `distribution-template` carries nine. The
four-thousand-six-hundred-fold difference explains why the two groups get different treatment on
the same list: one is data to be read, the other is a door to be opened.

The last row tests whether the measure is additive. The sum of the individual radii is eleven, the
radius measured together is ten. The difference is one item: because two open groups sit in the
same bucket, that bucket was counted twice. The spread radius is a set size, and it cannot be
summed group by group.

#### The Separation of the Two Measures

Exposure cannot be given as a single number, because there are two separate forms of harm. Reading
personal records is a direct harm, and its size is measured by record count: nine thousand two
hundred and sixty subscriber records are read without requiring any walk at all. Reading the
configuration file, on the other hand, is small on its own — forty-seven records — but it is a door
that opens onto four other items.

The two measures produce different priorities. Sorted by record volume, the first fix is
`upload-preview`; sorted by radius, the first fix is `distribution-template`. The right order is
that both are close to free: the first stops zero legitimate accesses a day, the second stops nine.
The fix in the third row — closing items with no legitimate access at all — comes down to four open
items and costs zero, but it does not cover the configuration item, because that item gets nine
legitimate accesses a day.

The last row draws the boundary. Closing all public access zeroes out the radius; the cost is eight
thousand six hundred and fifty-five accesses a day, and eight thousand six hundred of those belong
to the subscriber portal's assets. This is not a storage decision, it is a decision about how the
portal works — and that item being open contributes nothing to the radius, because it carries no
identity reference.

#### Summary

- 7 of 13 object groups are publicly open; the open record count is 61,697, 3.2% of the total. The
  data type measure is different: 6 of 7 types are open, and the only type left closed is the
  application log.
- The effective permission comes from the object level: because one of two groups sitting in the
  same bucket never wrote its own permission, 9,260 subscriber personal records stay open while
  getting 0 legitimate accesses a day.
- The exposure radius has two components: 6 buckets read without an identity and 4 items added from
  the identity reference in an open configuration file — a total of 10.
- Closing public access at the bucket level closes 9,260 records, stops 0 jobs a day, and leaves the
  radius at 10. The fix that lowers record volume may not lower the radius.
- Closing the 47-record configuration item brings the radius down from 10 to 5 and stops 9
  legitimate accesses a day; it closes 197 times fewer records than the bucket-level fix, and
  lowers the radius by 5 items.
- Closing all public access zeroes out the radius; the cost is 8,655 accesses a day, and 8,600 of
  those come from a single item that contributes nothing to the radius.

#### Next Step

This lesson took the content of an open file to be readable. The records were encrypted on disk,
but that was no obstacle for a subject that could read them — because decryption happened on the
provider's side, as part of the read request. The question not asked is this: does the radius
change depending on who holds the decryption key? The next lesson runs key ownership through three
modes and measures a single thing: the number of items the leaked identity **can read**, how many
when the key is with the provider and how many when it is with the customer.

### Key and Secret Management

Source: https://academia.sh/en/courses/cloud-security/key-and-secret-management

Key ownership's effect on the blast radius is run in three modes: under a provider key, the leaked identity reads 16 items; under a customer-managed key, still 16; under a customer-held key, 11. Taking a single key permission out of the chain drops managed mode from 16 to 6 and held mode from 11 to 3, and stops 1 legitimate operation a day. Reading the secret store adds 4 identities to the walk.

The previous lesson counted an open file's contents as readable. The records were encrypted on
disk, but that was no obstacle for a subject with read permission — decryption happened on the
provider's side, as part of the read request. The question that was not asked is this: does the
radius change depending on who holds the decryption key.

This lesson measures only that. The Cloud Governance, Security and Cost course measured
encryption at rest and in transit, envelope encryption, and the rotation window; none of that
encryption mechanics is repeated here. The only thing measured is **key ownership's effect on
the number of readable items**. The second half takes up the secret store, and this lesson's
second finding comes out of it: in the walk, the secret store is not an item — it is an
**edge source**.

- **CM23.** There are three **ownership modes**. `provider`: the key sits with the provider, and
  decryption happens as part of the read request. `managed`: the key belongs to the customer and
  sits in the key management service; decryption requires a separate **key-use permission**.
  `held`: the key is outside the leased infrastructure, and a leaked cloud identity cannot reach
  it.
- **CM24.** Key-use permission is a set **separate** from access permission. An identity can have
  access to an item while being unable to use its key.
- **CM25.** Each item records the strongest mode it supports. A managed service that decrypts
  server-side cannot support `held` mode; this is a modeling decision, and it follows from the
  service type.
- **CM26.** When the secret store can be read, each secret inside it becomes an **identity**, and
  the walk continues from there. The secrets' contents are not modeled; only which identity they
  correspond to is counted.
- **CM27.** The walk runs to a fixed point: as the identity set grows, key permission and
  readable items grow; as readable items grow, the identity set grows.

#### Three Modes, Three Numbers

```js
// cloud/key.mjs — ownership's effect on the radius is a MODEL. Encryption mechanics, envelope
// encryption, and the rotation window are not in this lesson; only readable items are counted.

const ACCESS = {
  'account-collector': 'measurement-store measurement-queue',
  'account-validator': 'measurement-store measurement-queue raw-archive',
  'account-portal': 'subscriber-database portal-asset-store work-order-queue',
  'account-report': 'report-store billing-output-store log-store',
  'role-billing': 'billing-database billing-output-store mail-dispatch subscriber-database',
  'role-archive': 'raw-archive backup-store',
  'role-backup': 'backup-store billing-database subscriber-database measurement-store',
  'role-distribution': 'distribution-configuration template-store instance-management',
  'role-log': 'log-store audit-log',
  'role-management': 'instance-management key-management secret-store',
};
const ASSUME = [['account-collector', 'role-archive'], ['account-validator', 'role-archive'],
  ['role-archive', 'role-backup'], ['role-backup', 'role-management'], ['account-portal', 'role-billing'],
  ['role-billing', 'role-log'], ['account-report', 'role-log'], ['role-distribution', 'role-management']];
// An identity's key-use permission: a set separate from its access permission.
export const KEY_PERMISSION = {
  'account-collector': 'k-measurement', 'account-validator': 'k-measurement', 'account-portal': 'k-subscriber',
  'account-report': 'k-billing k-log', 'role-billing': 'k-billing k-subscriber', 'role-archive': 'k-measurement',
  'role-backup': 'k-backup', 'role-distribution': 'k-secret', 'role-log': 'k-log',
  'role-management': 'k-secret',
};
// Item: whether it is encrypted, with which key, and the strongest ownership mode it supports.
// Modes: provider < managed < held. A managed key sits in the key management service; a held
// key is outside the leased infrastructure, and a leaked identity cannot reach it.
export const MODE = ['provider', 'managed', 'held'];
export const ITEM = `
  measurement-store           k-measurement   held
  raw-archive                 k-measurement   held
  backup-store                k-backup        held
  billing-output-store        k-billing       held
  report-store                k-billing       held
  distribution-configuration  k-secret        held
  subscriber-database         k-subscriber    managed
  billing-database            k-billing       managed
  log-store                   k-log           managed
  secret-store                k-secret        managed
  audit-log                   k-log           provider
  measurement-queue           -               -
  portal-asset-store          -               -
  work-order-queue            -               -
  mail-dispatch               -               -
  key-management              -               -
  instance-management         -               -
  template-store               -              -`
  .trim().split('\n').map((s) => {
    const [name, key, support] = s.trim().split(/\s+/);
    return { name, key: key === '-' ? null : key, support };
  });
export const find = (name) => ITEM.find((k) => k.name === name);
// If the secret store is read, the secrets inside it are each an identity; the walk continues from there.
export const SECRETS = ['account-portal', 'role-billing', 'account-report'];

// How many legitimate operations a day use which key: [identity, key, uses a day]
export const LEGITIMATE_KEY = [['account-collector', 'k-measurement', 1440],
  ['account-validator', 'k-measurement', 720], ['account-portal', 'k-subscriber', 3100],
  ['account-report', 'k-billing', 40], ['account-report', 'k-log', 12],
  ['role-billing', 'k-billing', 260], ['role-billing', 'k-subscriber', 260],
  ['role-archive', 'k-measurement', 96], ['role-backup', 'k-backup', 4],
  ['role-distribution', 'k-secret', 6], ['role-log', 'k-log', 88], ['role-management', 'k-secret', 1]];

// The items readable from a leaked identity: a fixed point. If the secret store is read, the identity set grows.
export function readable(target, excluded = new Set()) {
  const identity = new Set(['account-collector']);
  const close = () => {                            // assumption closure
    let n = -1;
    while (identity.size !== n) {
      n = identity.size;
      for (const k of [...identity]) for (const [a, b] of ASSUME) if (a === k) identity.add(b);
    }
  };
  let previous = -1, item = new Set(), permission = new Set();
  while (identity.size !== previous) {
    previous = identity.size;
    close();
    permission = new Set([...identity].flatMap((k) => KEY_PERMISSION[k].split(' '))
      .filter((a) => !excluded.has(a)));
    item = new Set([...identity].flatMap((k) => ACCESS[k].split(' ')).filter((r) => {
      const x = find(r);
      if (x.key === null) return true;                            // unencrypted item
      const mode = MODE.indexOf(target) < MODE.indexOf(x.support) ? target : x.support;
      return mode === 'provider' || (mode === 'managed' && permission.has(x.key));
    }));
    if (item.has('secret-store')) for (const s of SECRETS) identity.add(s);
  }
  return { identity, item, permission };
}
```

```js
// cloud/run1.mjs — the items a leaked identity can read in three ownership modes
import { ITEM, MODE, find, readable } from './key.mjs';

const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-23, -14, -11, 12, 12, 10];
print(A, 'item', 'key', 'support', ...MODE);
const R = Object.fromEntries(MODE.map((k) => [k, readable(k)]));
const seen = [...new Set(MODE.flatMap((k) => [...R[k].item]))].sort();
for (const r of seen.filter((x) => find(x).key))
  print(A, r, find(r).key, find(r).support, ...MODE.map((k) => R[k].item.has(r) ? 'readable' : '-'));
print(A, `+ ${seen.filter((x) => !find(x).key).length} unencrypted item`, '', '',
  ...MODE.map(() => 'readable'));
print(A, 'items read', '', '', ...MODE.map((k) => R[k].item.size));
console.log(`\nidentity entering the walk: ` + MODE.map((k) => `${k} ${R[k].identity.size}`).join(', '));
console.log("key permission from the leaked identity's chain: " +
  [...R.held.permission].sort().join(', '));
console.log(`unencrypted item ${ITEM.filter((k) => k.key === null).length}/${ITEM.length}`);
```

```
item                   key           support        provider     managed      held
audit-log              k-log         provider       readable    readable  readable
backup-store           k-backup      held           readable    readable         -
billing-database       k-billing     managed        readable    readable  readable
billing-output-store   k-billing     held           readable    readable         -
log-store              k-log         managed        readable    readable  readable
measurement-store      k-measurement held           readable    readable         -
raw-archive            k-measurement held           readable    readable         -
report-store           k-billing     held           readable    readable         -
secret-store           k-secret      managed        readable    readable  readable
subscriber-database    k-subscriber  managed        readable    readable  readable
+ 6 unencrypted item                                readable    readable  readable
items read                                                16          16        11

identity entering the walk: provider 8, managed 8, held 8
key permission from the leaked identity's chain: k-backup, k-billing, k-log, k-measurement, k-secret, k-subscriber
unencrypted item 7/18
```

The first two columns are the same: sixteen. The number of readable items does not change at all
when the key moves to the customer. The reason is the row before last — the leaked identity's
chain carries **all** six key permissions. Ownership mode ties decryption to a permission; if
that permission is on the same chain, tying it to something does not help.

The third column gives eleven. The five items that drop are the ones that support `held` mode:
the measurement store, the raw archive, the backup store, the billing output store, and the
report store. The remaining five cannot support it, because they are managed services whose
decryption happens server-side.

The row above the second-to-last is worth noting too. The number of identities entering the walk
is eight in all three modes — even though the access graph itself would end at four identities.
The four identities in between come from the secret store, and the secret store is readable in
all three modes.

#### Ownership Alone Is not Enough

Ownership mode and key-use permission are two separate levers. They are run both separately and
together.

```js
// cloud/run2.mjs — the combined effect of ownership mode and key permission; the cost of each
import { ITEM, MODE, LEGITIMATE_KEY, SECRETS, readable } from './key.mjs';

const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-12, 16, 15, 13, 12];
print(A, 'ownership', 'k-secret in chain', 'k-secret excluded', 'walked', 'walked');
print(A, 'mode', 'items read', 'items read', 'identity', 'identity');
for (const k of MODE) {
  const a = readable(k), b = readable(k, new Set(['k-secret']));
  print(A, k, a.item.size, b.item.size, a.identity.size, b.identity.size);
}
const secretOp = LEGITIMATE_KEY.filter(([k, a]) => a === 'k-secret' && k === 'role-management')
  .reduce((x, [, , n]) => x + n, 0);
console.log(`\ncost of taking the k-secret permission from the role in the chain: ${secretOp} legitimate operation a day`);
console.log(`secrets in the secret store ${SECRETS.length}; additional identity entering the walk if read ` +
  `${readable('provider').identity.size - 4}`);
const encrypted = ITEM.filter((k) => k.key);
const unsupported = encrypted.filter((k) => k.support !== 'held');
console.log(`\n${unsupported.length} of ${encrypted.length} encrypted items do not support held mode:`);
console.log('  ' + unsupported.map((k) => k.name).join(', '));
const external = LEGITIMATE_KEY.filter(([, a]) =>
  encrypted.some((k) => k.key === a && k.support === 'held'));
console.log(`(workload, key) pairs needing external key delivery ${external.length}; ` +
  `${external.reduce((x, [, , n]) => x + n, 0)} operations a day go through that path`);
```

```
ownership   k-secret in chaink-secret excluded       walked      walked
mode              items read     items read     identity    identity
provider                  16             16            8           8
managed                   16              6            8           4
held                      11              3            8           4

cost of taking the k-secret permission from the role in the chain: 1 legitimate operation a day
secrets in the secret store 3; additional identity entering the walk if read 4

5 of 11 encrypted items do not support held mode:
  subscriber-database, billing-database, log-store, secret-store, audit-log
(workload, key) pairs needing external key delivery 8; 2567 operations a day go through that path
```

The table has six cells, and three of them carry the decision. In the provider row, two columns
are the same: sixteen and sixteen. Changing the key permission changes nothing for the provider
key, because decryption never asks for permission — read access means plaintext.

The managed row gives this lesson's sharpest comparison. Moving the key to the customer on its
own leaves the radius at sixteen. In the same mode, taking a single key permission — the secret
store's key — out of the leaked identity's chain brings the radius down to **six**. Same
ownership mode, same graph, more than a two-and-a-half-fold difference. Ownership does not lower
the radius; who the owner grants permission to does.

The held row reads the same way: eleven down to three. But the gap between eleven and sixteen is
five items, and the cost is heavy — five encrypted items do not support this mode at all, and
eight `(workload, key)` pairs require external key delivery; two thousand five hundred sixty-seven
operations a day go through that path. The cost of removing a single permission, by contrast, is
one legitimate operation a day.

#### Six Keys, One by One

The secret store's key was picked as the example. Running all six keys through the same measure
shows why it was the one chosen.

```js
// cloud/run3.mjs — key permissions one by one: how much taking each one out of the chain lowers the radius
import { KEY_PERMISSION, LEGITIMATE_KEY, readable } from './key.mjs';

const KEY = [...new Set(Object.values(KEY_PERMISSION).flatMap((s) => s.split(' ')))].sort();
const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-14, 11, 11, 12, 13];
const base = readable('managed').item.size;
print(A, 'key', 'managed', 'held', 'radius', 'stopped op');
print(A, 'from chain', 'mode', 'mode', 'drop', 'a day');
for (const a of KEY) {
  const c = new Set([a]);
  print(A, a, readable('managed', c).item.size, readable('held', c).item.size,
    base - readable('managed', c).item.size,
    LEGITIMATE_KEY.filter(([, x]) => x === a).reduce((s, [, , n]) => s + n, 0));
}
const all = new Set(KEY);
console.log(`\nif all are excluded: managed ${readable('managed', all).item.size}, ` +
  `held ${readable('held', all).item.size}; ` +
  `${LEGITIMATE_KEY.reduce((s, [, , n]) => s + n, 0)} operations a day stop`);
```

```
key               managed       held      radius   stopped op
from chain           mode       mode        drop        a day
k-backup               15         11           1            4
k-billing              13         10           3          300
k-log                  15         10           1          100
k-measurement          14         11           2         2256
k-secret                6          3          10            7
k-subscriber           15         10           1         3360

if all are excluded: managed 3, held 3; 6027 operations a day stop
```

Five of the keys drop the radius by somewhere between one and three; the sixth drops it by ten.
`k-subscriber` carries three thousand three hundred sixty operations a day and, if removed, drops
the radius by a single item. `k-secret` carries seven operations a day and drops the radius by
ten items. The cost-per-item ratio between them differs by more than three thousand times.

The seven here should not be confused with the number from the previous section: here the
permission is removed from **every identity**, and the distribution role's six operations a day
stop as well. Taking it from just the one role that is on the leaked identity's chain is enough,
and that costs one operation a day.

The last row draws the boundary: if every key permission is removed, the radius drops to three
and six thousand twenty-seven operations a day stop. So the way to bring the radius down from ten
to three is not to remove every permission; removing one of the six gives most of the difference.

#### The Secret Store Is not an Item

The two right-hand columns show why the difference is this large. With `k-secret` in the chain,
eight identities enter the walk; with it removed, four. When the secret store is read, the three
secrets inside it correspond to three identities, and with the assumption closure, four new
identities join the walk.

This changes the secret store's place in the graph. The other items are the walk's **endpoint**:
they are read and that is where it ends. When the secret store is read, new **edges** of the walk
are born — meaning the secret store is not an item, it is an edge source. Looking at it as an item
in the radius measurement shows nine instead of sixteen.

For the same reason, the measurement is a fixed-point calculation. As the identity set grows, key
permission grows; as key permission grows, readable items grow; if the secret store is among the
readable items, the identity set grows again. The loop only stops once no new identity arrives. A
radius computed in a single pass comes up short in this model.

Combined with the previous lesson's finding, the pattern becomes clear: an identity reference
sitting in a configuration file grew the radius by five items; the secret store being readable
grows it by seven. Both are small items, and in both cases the damage is not in their own
contents — it is in the edges they open.

#### Summary

- Key ownership alone does not lower the radius: 16 items are read under the provider key, and
  still 16 under the customer-managed key. The leaked identity's chain carries all six key
  permissions.
- The customer-held key brings 16 down to 11; the five items that drop are the ones that support
  this mode. Of the 10 encrypted items the walk reaches, 5 decrypt server-side and so do not
  support this mode at all.
- Taking a single key permission out of the chain drops managed mode from 16 to 6 and held mode
  from 11 to 3. More than a two-and-a-half-fold difference within the same ownership mode; the
  cost is 1 legitimate operation a day.
- Held mode's cost is heavy: 8 `(workload, key)` pairs require external key delivery, and 2,567
  operations a day go through that path.
- The secret store is not an item in the walk, it is an edge source: when read, 3 secrets
  correspond to 3 identities, and with the closure, 4 identities enter the walk, taking the
  identity count from 4 to 8.
- The measurement is a fixed-point calculation; a radius computed in a single pass comes out as 9
  in this model, versus 16 at the fixed point.

#### Next Step

Over five lessons, misconfigured items were found one at a time: an overly broad trust list, an
unclosed public endpoint, a forgotten object-level permission, a key permission sitting on the
chain. All of them were found in **a running system**, and each was already in place by the time
it was found. The question that was not asked is this: how many of these could have been seen in
the template before they were ever put into the system. The next lesson runs the template set
through a rule set, and separates the item caught before deployment from the item that can only
be seen while running.

### Infrastructure as Code Security

Source: https://academia.sh/en/courses/cloud-security/infrastructure-as-code-security

The wrong items found in the previous five lessons are put back into the template set and run through a rule set: 4 of the 11 wrong items are caught before deployment, closing 23 of a total 34-unit radius share (68%); 5 items resolve only while running; 2 items have no value in the template to read. The rule set falsely flags 3 legitimate items; disabling the rule erases both the false flag and the real finding.

Across five lessons, wrong items were found one at a time: an overly broad trust list, a public
endpoint left open, a permission forgotten at the object level, a key permission sitting on top of
the chain. All of them were found in the running system, and each one was already in place by the
time it was found. This lesson asks one last question: how many of them could have been seen **in
the template**, before they were ever put into the system.

Setting up infrastructure with templates was measured in the Infrastructure as Code course of the
DevOps and Platform Engineering curriculum; provisioning models and drift scanning are not repeated
here. The only distinction measured here is between **a value known before deployment** and **a
value known only while running**. A rule can only decide on the first.

- **CM28.** A template writes a resource's fields. A field's value is either **resolvable**
  (known by reading the template) or it is not: a deployment parameter, another template's output,
  a default set by the provider, a change made by hand outside the template, or a file's content.
- **CM29.** The **rule set** looks at specific fields. A rule's result is one of three classes:
  **caught** (resolvable value, the rule fired), **unknown** (the rule looks at the field but
  cannot resolve the value), **unseen** (no rule looks at that field).
- **CM30.** Each field's **radius share** is the radius reduction measured for that item in the
  previous five lessons. The numbers are read from there; they are not re-derived here.
- **CM31.** A field can also be legitimate. If the rule fires even though the item is correct,
  that is a **false flag**, and it stops a deployment.
- **CM32.** A while-running scan reads the value of fields in the `unknown` class from the
  running system; it cannot reach the `unseen` class. The scan is daily, and the average delay is
  taken as half a day.

#### The Template Set Runs Through the Rule Set

```js
// cloud/template.mjs — the template set and rule set are a MODEL. Drift models and drift
// scanning are not in this lesson; what is measured is the item known before deployment versus
// the item known only while running.

// If a field's value starts with '<', it Cannot be resolved by reading the template: its value
// is set at deployment time or while running. radius = radius share measured in the previous
// five lessons. legitimate = whether the item is correct.
export const FIELD = `
  identity.tpl  role-archive         trust-list     role-backup     no      5
  identity.tpl  role-backup          trust-list     role-management no      3
  identity.tpl  account-collector    trust-list     role-archive    yes     0
  identity.tpl  role-management      key-permission k-secret        no     10
  identity.tpl  role-distribution    key-permission k-secret        yes     0
  store.tpl     template-store       public-access  open            no      5
  store.tpl     portal-asset-store   public-access  open            yes     0
  store.tpl     upload-preview       public-access  <inherited>     no      1
  store.tpl     measurement-store    public-access  <parameter>     no      1
  key.tpl       secret-store         public-endpoint <default>      no      1
  key.tpl       key-management       public-endpoint <default>      no      1
  network.tpl   private-endpoint-mapping target     <output>        no      2
  operations.tpl instance-management management-endpoint <out-of-template> no 1
  code.tpl      distribution-template file-content  <file>          no      4`
  .trim().split('\n').map((s) => {
    const [template, source, field, value, legitimate, radius] = s.trim().split(/\s+/);
    return { template, source, field, value, legitimate: legitimate === 'yes', radius: Number(radius) };
  });
export const resolvable = (a) => !a.value.startsWith('<');
// Rule set: which field it looks at, and when it fires for a resolvable value.
export const RULE = [
  ['trust-list', (d) => d.startsWith('role-')],
  ['key-permission', (d) => d === 'k-secret'],
  ['public-access', (d) => d === 'open'],
  ['public-endpoint', (d) => d === 'open'],
  ['target', () => false],
];
// Class: caught (resolvable value, rule fired), unknown (rule looks at the field but cannot
// resolve the value), unseen (no rule looks at that field).
export const classify = (a, exception = () => false) => {
  const k = RULE.find(([name]) => name === a.field);
  if (exception(a)) return 'unseen';
  if (k === undefined) return 'unseen';
  if (resolvable(a)) return k[1](a.value) ? 'caught' : 'clean';
  return 'unknown';
};
export const CLASS = ['caught', 'unknown', 'unseen'];
export const count = (c, exception, f = () => true) =>
  FIELD.filter((a) => f(a) && classify(a, exception) === c);
export const share = (l) => l.reduce((x, a) => x + a.radius, 0);
export const WRONG = FIELD.filter((a) => !a.legitimate);
```

```js
// cloud/run1.mjs — the template set run through the rule set: which item is known before deployment
import { FIELD, CLASS, WRONG, share, count, classify } from './template.mjs';

const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-26, -21, -18, 8, 9, 12];
print(A, 'source', 'field', 'value', 'legit', 'radius', 'class');
for (const a of FIELD)
  print(A, a.source, a.field, a.value, a.legitimate ? 'yes' : 'no', a.radius, classify(a));
console.log(`\n${WRONG.length} of ${FIELD.length} fields are wrong; total radius share ${share(WRONG)}`);
const B = [-13, 9, 11, 10];
print(B, 'class', 'wrong', 'radius', 'share of');
print(B, '', 'item', 'share', 'percent');
for (const c of CLASS) {
  const l = count(c, undefined, (a) => !a.legitimate);
  print(B, c, l.length, share(l), `${Math.round(share(l) / share(WRONG) * 100)}%`);
}
const falsePositive = count('caught', undefined, (a) => a.legitimate);
console.log(`\nfalsely flagged legitimate item ${falsePositive.length}: ` +
  falsePositive.map((a) => a.source).join(', '));
```

```
source                    field                value                legit   radius       class
role-archive              trust-list           role-backup             no        5      caught
role-backup               trust-list           role-management         no        3      caught
account-collector         trust-list           role-archive           yes        0      caught
role-management           key-permission       k-secret                no       10      caught
role-distribution         key-permission       k-secret               yes        0      caught
template-store            public-access        open                    no        5      caught
portal-asset-store        public-access        open                   yes        0      caught
upload-preview            public-access        <inherited>             no        1     unknown
measurement-store         public-access        <parameter>             no        1     unknown
secret-store              public-endpoint      <default>               no        1     unknown
key-management            public-endpoint      <default>               no        1     unknown
private-endpoint-mapping  target               <output>                no        2     unknown
instance-management       management-endpoint  <out-of-template>       no        1      unseen
distribution-template     file-content         <file>                  no        4      unseen

11 of 14 fields are wrong; total radius share 34
class            wrong     radius  share of
                  item      share   percent
caught               4         23       68%
unknown              5          6       18%
unseen               2          5       15%

falsely flagged legitimate item 3: account-collector, role-distribution, portal-asset-store
```

Item count and radius share read in opposite directions in this table. Only four of the eleven
wrong items are caught before deployment — thirty-six percent of them. But those four items carry
twenty-three of the thirty-four-unit radius share, that is, sixty-eight percent of it. The reason
sits in a single row: the `k-secret` permission written to the `role-management` role carries ten
units and sits in the template as a plain value.

There is also a result in the opposite direction. The five items in the `unknown` class carry a
total of six units — one point two per item. They are numerous but contribute little to the
radius. If a rule set is measured by "how many findings did it close," the weight of these five
items is overstated; measured by "how many units of radius did it close," it falls into place.

The bottom row shows the rule set's other face. The same rules flag three legitimate items too:
the collector assuming the archive role, the distribution role using the secret key, and the
portal asset store being publicly open. All three are correct configuration, and all three stop a
deployment. The rule text cannot tell these three apart from the others, because the values are
identical.

#### Two Ways to Write an Exception

```js
// cloud/run2.mjs — four rule-set variants and the share while-running scanning closes
import { WRONG, share, count } from './template.mjs';

const EXCEPTION_SOURCE = ['account-collector', 'role-distribution', 'portal-asset-store'];
const VARIANT = [
  ['no exception', () => false],
  ['source-level exception (3 lines)', (a) => EXCEPTION_SOURCE.includes(a.source)],
  ['key-permission rule disabled', (a) => a.field === 'key-permission'],
  ['all three rules disabled', (a) =>
    ['trust-list', 'key-permission', 'public-access'].includes(a.field)],
];
const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-36, 9, 9, 10, 13, 13];
print(A, 'rule set', 'before', 'before', 'false', 'closed by', 'never');
print(A, '', 'item', 'share', 'flag', 'scan share', 'seen share');
for (const [name, exc] of VARIANT) {
  const y = count('caught', exc, (a) => !a.legitimate);
  const b = count('unknown', exc, (a) => !a.legitimate);
  print(A, name, y.length, share(y), count('caught', exc, (a) => a.legitimate).length,
    share(y) + share(b), share(WRONG) - share(y) - share(b));
}
console.log(`\ntotal wrong item ${WRONG.length}, total radius share ${share(WRONG)}`);
const unseen = count('unseen', undefined, (a) => !a.legitimate);
console.log(`fields no rule looks at: ` +
  unseen.map((a) => `${a.field} (${a.value.slice(1, -1)})`).join(', '));
console.log(`these ${unseen.length} items carry radius share ${share(unseen)}/${share(WRONG)} ` +
  `(${Math.round(share(unseen) / share(WRONG) * 100)}%); no value in the template to read`);
// Delay: 0 days for items caught before deployment, 0.5 days average for daily scanning, none for unseen.
const c0 = share(count('caught', undefined, (a) => !a.legitimate));
const u0 = share(count('unknown', undefined, (a) => !a.legitimate));
console.log(`weighted delay: ${c0} share 0 days, ${u0} share 0.5 days -> ` +
  `${(u0 * 0.5 / (c0 + u0)).toFixed(2)} days; remaining ${share(WRONG) - c0 - u0} share never seen`);
```

```
rule set                               before   before     false    closed by        never
                                         item    share      flag   scan share   seen share
no exception                                4       23         3           29            5
source-level exception (3 lines)            4       23         0           29            5
key-permission rule disabled                3       13         2           19           15
all three rules disabled                    0        0         0            4           30

total wrong item 11, total radius share 34
fields no rule looks at: management-endpoint (out-of-template), file-content (file)
these 2 items carry radius share 5/34 (15%); no value in the template to read
weighted delay: 23 share 0 days, 6 share 0.5 days -> 0.10 days; remaining 5 share never seen
```

The second row is the answer we are looking for. When the exception is written at the source
level — three names, three lines — false flagging drops from three to zero and the caught share
stays at twenty-three. The cost is maintaining a three-line list: every new legitimate item needs
one more line, and a name that is not dropped from the list will not be flagged even if it is no longer
legitimate.

The third and fourth rows show what the cheap way actually costs. Removing the false flag by
disabling the rule also erases the real finding that rule caught: when the `key-permission` rule
is disabled, the caught share drops from twenty-three to thirteen and the never-seen share rises
from five to fifteen. When all three rules are disabled, nothing is caught before deployment at
all. **Disabling the rule erases both the false flag and the real finding; writing an exception
erases only the false flag and leaves a maintenance burden.**

The last three lines draw the lesson's permanent boundary. Two items cannot be seen by any rule
set: a resource's management endpoint was opened by hand outside the template, and an identity
reference sits in a file's content. The rule set can be extended, but there is no value in the
template to read. These two items carry fifteen percent of the total radius share.

#### Resolvability Varies by Template

The total is spread across six templates, and the spread is uneven.

```js
// cloud/run3.mjs — radius share and resolvability per template: where scanning pays off
import { FIELD, resolvable, share, classify } from './template.mjs';

const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-14, 7, 11, 10, 11, 10];
print(A, 'template', 'field', 'resolvable', 'radius', 'caught', 'caught');
print(A, '', 'count', 'fields', 'share', 'share', 'ratio');
for (const s of [...new Set(FIELD.map((a) => a.template))]) {
  const l = FIELD.filter((a) => a.template === s), y = l.filter((a) => !a.legitimate);
  const caught = l.filter((a) => !a.legitimate && classify(a) === 'caught');
  print(A, s, l.length, `${l.filter(resolvable).length}/${l.length}`, share(y), share(caught),
    share(y) === 0 ? '-' : `${Math.round(share(caught) / share(y) * 100)}%`);
}
const c = FIELD.filter((a) => !a.legitimate && resolvable(a));
console.log(`\nresolvable wrong field ${c.length}/${FIELD.filter((a) => !a.legitimate).length}, ` +
  `share ${share(c)}/${share(FIELD.filter((a) => !a.legitimate))}; ` +
  `resolvability ranges from 0% to 100% per template`);
```

```
template        field resolvable    radius     caught    caught
                count     fields     share      share     ratio
identity.tpl        5        5/5        18         18      100%
store.tpl           4        2/4         7          5       71%
key.tpl             2        0/2         2          0        0%
network.tpl         1        0/1         2          0        0%
operations.tpl      1        0/1         1          0        0%
code.tpl            1        0/1         4          0        0%

resolvable wrong field 4/11, share 23/34; resolvability ranges from 0% to 100% per template
```

The identity template is extreme in two respects: it carries the largest radius share — eighteen
of the thirty-four — and all of its fields are resolvable. Scanning does its job there at a
hundred percent. In the store template, two of four fields are resolvable, and the caught ratio
drops to seventy-one percent.

In the bottom four rows the ratio is zero, and the reason is not the same. In the key template,
the fields were never written and are set by the default provider; in the network template, the
value comes from another template's output; the operations template's source was changed by hand
afterward; the code template points to a file, and the file's content is not a field. All four are
"not written in the template," but three of them can be resolved by scanning, and one cannot.

The practical consequence of this spread is where the scanning investment should go. More than
half the radius share sits in a single template, entirely in resolvable fields; a rule set that
looks at that template gives the highest return. Writing rules for the bottom four rows gains
nothing — the gain comes only from a mechanism that reads from the running system.

#### Caught, Delayed, and Never Seen

The result splits into three parts. Twenty-three units of radius share close before deployment:
the deployment is blocked, the delay is zero days, and the wrong item never enters the system at
all. Six units resolve only while running; a daily scan sees them after half a day on average.
Five units are never seen at all.

The weighted delay is zero point one zero days, and that number can look misleading — the average
is small because most of the weight sits in the zero-delay class. But the average does not account
for the five units that are never seen at all; that share is not a delay, it is an absence.

The practical consequence of this split is this: template scanning can close two-thirds of the
radius share, and it does so with zero delay, but the remaining third needs another mechanism.
Part of that mechanism is while-running scanning; another part is a source the template never
knows about at all. Widening the rule set grows the first share; it does not change the second
share at all.

#### Summary

- 11 of the 14 fields in the template are wrong, and the total radius share is 34 units. The 4
  items caught before deployment carry 23 (68%) of this share; item count and radius share read
  in opposite directions.
- The 5 items in the `unknown` class carry a total of 6 units — 1.2 per item. Measuring the rule
  set by "how many findings" overstates the weight of these items.
- The rule set also flags 3 legitimate items and stops 3 deployments; because the values are
  identical, the rule text cannot tell them apart.
- A source-level exception drops the false flag from 3 to 0 and leaves the caught share at 23;
  its cost is maintaining a 3-line list. Disabling the rule drops the caught share from 23 to 13.
- 2 items cannot be seen by any rule set — one is a management endpoint opened by hand outside the
  template, the other is an identity reference in a file's content. 15% of the total share sits
  here.
- The weighted delay is 0.10 days; the average is small because most of the weight sits in the
  zero-delay class. The 5 units that are never seen at all do not enter this average.

#### Next Step

This topic carried one question all the way through: if one item is wrong, how many items can be
reached from it. The responsibility boundary was turned into a checklist, a walk was run from a
single leaked identity, the network path was intersected with permission, publicly open items
were counted, key ownership was measured in three modes, and finally, how many of the wrong items
could be seen before deployment was calculated. Each step produced a number, and the cost of each
check was written down.

All of these measurements have one thing in common: every one of them was done **by looking at
the configuration**. A trust list was read, a permission set was scanned, a template field was
resolved. Radius can even be measured when the configuration itself is correct — but then there is
nothing to measure, because there is no wrong item. The question that was not asked is this: if the
configuration is correct and a credential leaks anyway, how would this be known. At that moment,
no field is wrong, no rule fires, no scan finds anything; the only thing that can show whether the
walk actually ran is a **record**. The next topic, `Cloud Detection and Hardening`, begins with
that record: how many steps of the radius walk the management plane's audit log sees, and how many
it never sees at all.

## Cloud Detection and Hardening

How much of the spread walk is visible and which control actually lowers the radius: the step that is visible and the step that is never visible in management plane logs, the step that continuous configuration posture management closes before deployment and the window left open by remediation delay, the radius of container and hypervisor escape in a multi-tenant environment, the window in seconds during which a log entry can be collected in ephemeral workloads, the item-path-log-control quartet of cloud attack patterns, and how collecting evidence in the cloud differs from a host image.

### Audit Logs

Source: https://academia.sh/en/courses/cloud-security/audit-logs

Counting how much of the spread walk is visible in the log: in a 28-item graph, a single leaked identity reaches 23 items in 22 steps, and 11 of those steps land in a log entry the tenant can read; the first log entry drops at minute 9, by which point 8 items have been reached. Adding the data-read log entry raises the visible steps to 19, but the radius stays at 23; for the same cost of three legitimate jobs, automatic session cancellation brings the radius down to 22, and closing off role chaining brings it down to 18.

The previous topic spent six lessons counting misconfigured items and measuring the radius of each one. They all had one thing in common: the misconfigured item was found **by looking at configuration** — a permission was over-granted, an endpoint was open to the public, a template carried a wrong default. When the configuration is correct, a leaked credential is invisible this way, because every call that identity makes with its own authority conforms to the configuration. Only a log entry can reveal it.

This lesson's question is how much of the walk the log shows. The measure is still the blast radius; a second number is placed on top of it: how many of the walk's steps land in a log entry the tenant can read, and how many never show up anywhere. The fact that management plane and data plane calls are logged separately, that the log itself is a surface, and that retention carries a cost was measured in the **Cloud Governance, Security and Cost** course; it is not repeated here, it is taken as input. The only thing counted here is the visible part of the walk.

The measurement is on the **regional measurement network** and is **fictional**: a subscriber portal, meter collection, and billing jobs running on rented infrastructure. The graph is a **model** written in `node`; there is no real provider, service, region, or account.

- **CD1.** The graph has 28 items and 23 edges; the single leaked item is the portal's service account.
- **CD2.** Each edge carries a step type and the number of minutes that step takes; the walk runs in earliest-arrival order.
- **CD3.** A step's log entry falls into one of four classes: a management plane audit log the tenant can read, the workload's own log, a log entry that stays only with the provider, or a log entry that exists nowhere.
- **CD4.** The workload's own log reaches the tenant only if that item has a logging agent installed; five of the 28 items have an agent.
- **CD5.** The log's delivery delay to the tenant is 5 minutes on the management plane and 20 minutes for the workload's own log (fictional).
- **CD6.** Eight legitimate jobs are named in the model; three of them depend on chained role assumption.

#### The Walk's Steps and Who Owns the Log

```js
// walk.mjs — the spread walk from a single leaked identity and each step's log class. IT IS A MODEL.
// The regional measurement network IS FICTIONAL; there is no real provider, service, account, or endpoint.

const split = (s) => s.trim().split("\n").map((l) => l.trim().split(/\s+/)).flat()
  .map((t) => t.split("|"));

// Step type -> [where the log entry lands, delivery delay (min)]. The "tenant-application"
// class only actually reaches the tenant if the target item has a logging agent.
export const STEP = {
  "role-assumption": ["tenant-management", 5],
  "resource-listing": ["tenant-management", 5],
  "secret-read": ["tenant-management", 5],
  "key-decrypt": ["tenant-management", 5],
  "database-query": ["tenant-application", 20],
  "in-instance-run": ["tenant-application", 20],
  "object-read": ["provider-internal", 0],
  "queue-consumption": ["provider-internal", 0],
  "instance-identity-fetch": ["unlogged", 0],
  "network-connection": ["unlogged", 0],
};

export const ITEM = Object.fromEntries(split(`
  portal-service-account|0   portal-instance|1        portal-instance-identity|0
  subscriber-database|1      secret-store|0           collection-workload-identity|0
  raw-meter-bucket|0         measurement-queue|0      billing-batch-role|0
  billing-archive-bucket|0   key-management|0         customer-key|0
  backup-role|0              backup-bucket|0          deployment-role|0
  template-store|0           configuration-bucket|0   workload-instance|0
  reader-role|0              report-bucket|0          log-bucket|0
  monitoring-role|0          measurement-dashboard|1  management-role|1
  audit-bucket|0             payment-gateway|1        field-device-record|0
  certificate-store|0`.replace(/\s+/g, "\n")).map(([a, j]) => [a, +j]));

export const EDGE = split(`
  portal-service-account|portal-instance|network-connection|3
  portal-service-account|reader-role|role-assumption|4
  portal-instance|portal-instance-identity|instance-identity-fetch|2
  portal-instance-identity|subscriber-database|database-query|6
  portal-instance-identity|secret-store|secret-read|4
  secret-store|collection-workload-identity|secret-read|3
  collection-workload-identity|raw-meter-bucket|object-read|5
  collection-workload-identity|measurement-queue|queue-consumption|4
  collection-workload-identity|monitoring-role|role-assumption|4
  monitoring-role|measurement-dashboard|resource-listing|3
  portal-instance-identity|billing-batch-role|role-assumption|3
  billing-batch-role|billing-archive-bucket|object-read|6
  billing-batch-role|key-management|key-decrypt|4
  key-management|customer-key|key-decrypt|2
  billing-batch-role|backup-role|role-assumption|3
  backup-role|backup-bucket|object-read|7
  backup-role|deployment-role|role-assumption|4
  deployment-role|template-store|object-read|5
  deployment-role|configuration-bucket|object-read|4
  deployment-role|workload-instance|in-instance-run|9
  reader-role|report-bucket|object-read|4
  reader-role|log-bucket|object-read|5
  reader-role|configuration-bucket|object-read|6`)
  .map(([k, h, t, d]) => ({ k, h, t, d: +d }));

export const START = "portal-service-account";

export function walk(block = () => false) {
  const arrival = { [START]: 0 }, arrivedVia = {}, steps = [];
  for (;;) {
    let best = null;
    for (const e of EDGE) {
      if (!(e.k in arrival) || e.h in arrival || block(e, arrivedVia)) continue;
      const t = arrival[e.k] + e.d;
      if (best === null || t < best.t) best = { e, t };
    }
    if (best === null) return { arrival, steps };
    arrival[best.e.h] = best.t; arrivedVia[best.e.h] = best.e.t;
    steps.push({ ...best.e, end: best.t });
  }
}

// Whether a step's log entry reaches the tenant, and when, if it does.
export function visible(a) {
  const [cls, delay] = STEP[a.t];
  if (cls === "tenant-management") return { cls, at: a.end + delay };
  if (cls === "tenant-application")
    return ITEM[a.h] ? { cls, at: a.end + delay } : { cls: "agentless", at: null };
  return { cls, at: null };
}

const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0]))
  .join("");

if (import.meta.url === `file://${process.argv[1]}`) {
  const { arrival, steps } = walk();
  const total = Object.keys(ITEM).length;
  console.log(`model: ${total} items, ${EDGE.length} edges; leaked item ${START}`);
  console.log(`walk ${steps.length} steps, reached ${Object.keys(arrival).length}/${total} ` +
    `items, unreached ${total - Object.keys(arrival).length}; last step at minute ${steps.at(-1).end}`);

  const counts = {};
  for (const a of steps) { const c = visible(a).cls; counts[c] = (counts[c] || 0) + 1; }
  const row = gen([20, 8, 12, 28]);
  console.log("\n" + row(["log class", "steps", "delay", "where the log entry lands"]));
  for (const [c, name, g] of [["tenant-management", "management plane log entry", "5 min"],
    ["tenant-application", "workload's own log", "20 min"], ["agentless", "no agent installed", "-"],
    ["provider-internal", "provider only", "-"], ["unlogged", "nowhere", "-"]])
    console.log(row([c, counts[c] || 0, g, name]));

  const seen = steps.filter((a) => visible(a).at !== null);
  const first = seen.reduce((m, a) => Math.min(m, visible(a).at), Infinity);
  const firstStep = steps.findIndex((a) => visible(a).at === first) + 1;
  const priorItems = steps.filter((a) => a.end <= first).length + 1;
  console.log(`\nvisible steps ${seen.length}/${steps.length}, never visible ` +
    `${steps.length - seen.length}`);
  console.log(`first visible log entry drops at minute ${first} (step ${firstStep} of the walk); ` +
    `${priorItems} items reached by then`);

  // Radius reconstructed from the log: only the items touched by visible steps.
  const reconstructed = new Set([START]);
  for (const a of seen) { reconstructed.add(a.k); reconstructed.add(a.h); }
  console.log(`analyst reading only the log infers ${reconstructed.size} items, ` +
    `real radius ${Object.keys(arrival).length}; unseen items ` +
    `${Object.keys(arrival).length - reconstructed.size}`);
  const chain = steps.filter((a) => a.t === "role-assumption").length;
  console.log(`role assumption steps ${chain} (all logged); data-read steps ` +
    `${steps.filter((a) => STEP[a.t][0] === "provider-internal").length} (none logged)`);
}
```

```
model: 28 items, 23 edges; leaked item portal-service-account
walk 22 steps, reached 23/28 items, unreached 5; last step at minute 24

log class              steps       delay   where the log entry lands
tenant-management         10       5 min  management plane log entry
tenant-application         1      20 min          workload's own log
agentless                  1           -          no agent installed
provider-internal          8           -               provider only
unlogged                   2           -                     nowhere

visible steps 11/22, never visible 11
first visible log entry drops at minute 9 (step 2 of the walk); 8 items reached by then
analyst reading only the log infers 13 items, real radius 23; unseen items 10
role assumption steps 5 (all logged); data-read steps 8 (none logged)
```

The walk reaches 23 items in 22 steps and takes 24 minutes. Half the steps land in a log entry the tenant can read, half never do. The split is not random: **the steps that leave a log entry are identity moves**, the ones that do not are data moves. All five of the role-assumption steps are logged. None of the eight data-read steps are; they stay only in the provider's own log and are never handed to the tenant. Two steps exist nowhere — fetching the instance-bound identity and the network connection.

One step falls into an in-between class: the workload instance started by the deployment role does not have a logging agent installed, because the agent is added to the image afterward. The scope of the log entry is not a matter of configuration, but of installation order.

#### The Blind Prefix

The sharpest numbers are in the last two lines. The first log entry reaches the tenant at minute 9; by then the walk has already reached 8 items. The walk's first step is in the unlogged class — the network connection is written nowhere — and logging only begins with the second step, the leaked identity assuming the reader role. That step ends at minute 4, and its log entry arrives with a 5-minute delivery delay. This is the spread's **blind prefix**: in the nine minutes during which the log says nothing, a third of the radius is already built. The blind prefix is not only an interval at the start: since every logged step arrives with its own delay, the step looks as if it does not exist for the whole length of that delay.

The second is how much the log tells afterward. An analyst looking only at the 11 visible steps infers 13 items; the real radius is 23. Ten items stay outside the investigation because they leave no trace in the log at all: the six buckets that were read plus a template store, the queue that was consumed, the leaked portal's own instance, and the deployed workload instance. The log is not merely incomplete; it **reports a wrong radius**, and that report is a little over half the real number.

#### Visibility or Narrowing

```js
// cutoff.mjs — the log's visibility and the radius's drop are measured separately. IT IS A MODEL.
import { STEP, ITEM, EDGE, START, visible } from "./walk.mjs";

const DATA_DELAY = 10;   // if data-read logging is turned on, it reaches the tenant with this delay (fictional)

// Legitimate job: name|identities it uses|does it require chained role assumption (daily run, fictional)
const LEGIT = `
  nightly-billing-generation|portal-instance-identity billing-batch-role backup-role|1
  archive-backup|backup-role|1
  template-deployment|deployment-role|1
  meter-collection|collection-workload-identity|0
  subscriber-portal-session|portal-service-account portal-instance-identity|0
  monthly-report|reader-role|0
  dashboard-refresh|monitoring-role|0
  key-rotation|key-management|0`.trim().split("\n")
  .map((l) => l.trim().split("|")).map(([name, k, z]) => ({ name, identity: k.split(" "), chain: +z }));

// The minute at which a step's log entry reaches the tenant (also checked when data logging is on).
const at = (a, dataLog) => visible(a).at ??
  (dataLog && STEP[a.t][0] === "provider-internal" ? a.end + DATA_DELAY : null);

// Walk: if chaining is off, chained role assumption is blocked; steps after identities canceled
// at the cutoff moment stop.
function walk({ chain = false, cutoff = Infinity, canceled = new Set() } = {}) {
  const arrival = { [START]: 0 }, arrivedVia = {}, steps = [];
  for (;;) {
    let best = null;
    for (const e of EDGE) {
      if (!(e.k in arrival) || e.h in arrival) continue;
      if (chain && e.t === "role-assumption" && arrivedVia[e.k] === "role-assumption") continue;
      const t = arrival[e.k] + e.d;
      if (t > cutoff && canceled.has(e.k)) continue;
      if (best === null || t < best.t) best = { e, t };
    }
    if (best === null) return { arrival, steps };
    arrival[best.e.h] = best.t; arrivedVia[best.e.h] = best.e.t;
    steps.push({ ...best.e, end: best.t });
  }
}

function scenario({ dataLog = false, cancel = null, chain = false, log = true }) {
  const free = walk({ chain });
  const seen = free.steps.filter((a) => at(a, dataLog) !== null);
  const first = log && seen.length ? Math.min(...seen.map((a) => at(a, dataLog))) : null;
  if (cancel === null || first === null)
    return { first, cutoff: null, visible: log ? seen.length : 0, steps: free.steps.length,
      radius: Object.keys(free.arrival).length, stopped: chainCost(chain) };
  // Cancellation only covers identities that have been seen in the log: an analyst cannot
  // cancel what they have not seen.
  const cutoff = first + cancel, canceled = new Set();
  for (const a of seen) if (at(a, dataLog) <= cutoff) { canceled.add(a.k); canceled.add(a.h); }
  const k = walk({ chain, cutoff, canceled });
  const stopped = LEGIT.filter((m) => (chain && m.chain) || m.identity.some((x) => canceled.has(x)));
  return { first, cutoff, visible: seen.length, steps: k.steps.length,
    radius: Object.keys(k.arrival).length, stopped: stopped.length, canceled: canceled.size };
}
const chainCost = (z) => z ? LEGIT.filter((m) => m.chain).length : 0;

const SCENARIO = [
  ["no logging", { log: false }],
  ["management plane log entry", {}],
  ["+ data-read log entry", { dataLog: true }],
  ["+ automatic session cancellation", { cancel: 4 }],
  ["role chaining off", { chain: true }],
  ["chain off + cancellation", { chain: true, cancel: 4 }],
];

const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0]))
  .join("");
const row = gen([34, 9, 11, 11, 10, 16]);
const total = Object.keys(ITEM).length;

console.log(row(["control", "visible", "first log", "cutoff", "radius", "jobs stopped"]));
for (const [name, s] of SCENARIO) {
  const r = scenario(s);
  console.log(row([name, `${r.visible}/${r.steps}`, r.first === null ? "-" : `${r.first} min`,
    r.cutoff === null ? "-" : `${r.cutoff} min`, `${r.radius}/${total}`, r.stopped]));
}

const base = scenario({ log: false }), data = scenario({ dataLog: true });
const canc = scenario({ cancel: 4 }), ch = scenario({ chain: true });
const both = scenario({ chain: true, cancel: 4 });
console.log(`\nturning on logging brings the visible steps to ${scenario({}).visible}, and turning on ` +
  `data-read logging too makes it ${data.visible}; the radius stays ${base.radius} in all three`);
console.log(`automatic cancellation covers ${canc.canceled} items (only the ones seen in the log) and ` +
  `brings the radius from ${base.radius} to ${canc.radius}; the cost is ${canc.stopped}/${LEGIT.length} ` +
  `legitimate jobs`);
console.log(`closing off role chaining brings the radius to ${ch.radius} without waiting on the log at ` +
  `all; the cost is ${ch.stopped}/${LEGIT.length} legitimate jobs`);
console.log(`both together give ${both.radius}/${total}; the remaining ${both.radius - 1} items were ` +
  `reached before minute ${both.cutoff}`);
```

```
control                             visible  first log     cutoff    radius    jobs stopped
no logging                             0/22          -          -     23/28               0
management plane log entry            11/22      9 min          -     23/28               0
+ data-read log entry                 19/22      9 min          -     23/28               0
+ automatic session cancellation      11/21      9 min     13 min     22/28               3
role chaining off                      9/17      9 min          -     18/28               3
chain off + cancellation               9/16      9 min     13 min     17/28               5

turning on logging brings the visible steps to 11, and turning on data-read logging too makes it 19; the radius stays 23 in all three
automatic cancellation covers 4 items (only the ones seen in the log) and brings the radius from 23 to 22; the cost is 3/8 legitimate jobs
closing off role chaining brings the radius to 18 without waiting on the log at all; the cost is 3/8 legitimate jobs
both together give 17/28; the remaining 16 items were reached before minute 13
```

The first three rows give the same radius: 23. Turning on the management plane log entry brings the visible steps from zero to 11, and turning on the data-read log entry too brings it to 19; the number of items reached never changes. This is the plainest form of the course's rule — a log entry is not a gate, it is a window. Visibility climbs from zero steps to nineteen steps while the radius stays fixed, because a log entry never rejects a step.

The fourth row ties the log to a decision: 4 minutes after the first log entry drops, the sessions of identities seen in the log are automatically canceled. The radius drops from 23 to 22. One item. The reason is that two limits overlap. The cutoff happens at minute 13, and 13 of the walk's 24 minutes have already passed by then; on top of that, the cancellation covers only the four items **seen in the log**, because an analyst cannot cancel an identity they have not seen. The log's blind spots are paid for a second time here: an unseen identity is not canceled, and an uncanceled identity keeps walking. Even so, the cost is not zero — three of the eight legitimate jobs stop.

The fifth row does not use the log at all. When chained role assumption is closed — meaning that if you arrived by assuming one role, you cannot assume a second role from there — the radius drops from 23 to 18, and no alert is waited on for this. The cost is again three legitimate jobs: nightly billing generation, archive backup, and template deployment all use the chain and need to be rewritten. Comparing the two rows gives the lesson's result: **for the same cost** — three legitimate jobs — a cancellation tied to the log lowers it by one item, a narrowing tied to structure lowers it by five items. The last row combines both and drops to 17: the single item that cancellation gains while chaining is off is paid for by raising the stopped jobs from three to five.

#### Summary

- In a 28-item graph, a single leaked identity reaches 23 items in 22 steps; 11 of the steps land in a log entry the tenant can read, and 11 never do.
- The split runs between identity and data: all five role-assumption steps are logged, none of the eight data-read steps are; two steps exist nowhere, and one step takes place on an instance without an agent installed.
- The first log entry drops at minute 9, and by then 8 items have already been reached; this blind prefix, during which the log says nothing, covers a third of the radius.
- An analyst looking only at the log infers 13 items; the real radius is 23. Ten items leave no trace at all and stay outside the investigation.
- Turning on logging brings the visible steps to 11, and the data-read log entry brings it to 19; the radius stays at 23 in all three cases — a log entry never rejects a step.
- For the same cost, three legitimate jobs, automatic session cancellation brings the radius to 22 and closing off role chaining brings it to 18; because cancellation covers only the four items seen in the log, it cannot stop what remains.

#### Next Step

The fact that closing off role chaining works independently of the log points in a direction: what lowers the radius is not a mechanism that watches after the walk has started, but an item closed before the walk starts. The next lesson measures this direction all the way through — how many of the walk's steps a continuously running configuration posture management mechanism closes before deployment, how many it only finds after the resource is already running, and how many minutes the delay between detection and remediation leaves the radius open.

### Configuration Posture Management

Source: https://academia.sh/en/courses/cloud-security/configuration-posture-management

Measuring whether a misconfigured item closes before or after deployment: 13 of the graph's 23 edges are tied to a misconfigured item, 7 of those are in the template, 2 in acceptance, and 4 are only seen at runtime. The template gate lowers the radius from 23 to 8, the acceptance gate closes two more items and does not lower the radius at all, and the runtime scan alone lowers it to 4. In a one-week window, daily scanning with approved remediation leaves the radius open for 39 hours, hourly scanning for 25 hours, and automatic remediation for 2 hours; moving to continuous scanning gains 1 hour at the cost of 12 times the evaluations.

The previous lesson left a direction: what lowers the radius is not a mechanism that watches after
the walk has started, but an item closed before the walk starts. Closing off role chaining lowered
the radius from 23 to 18 without waiting on the log at all. This lesson measures that direction all
the way through and ties it to a single question: **does the misconfigured item close before it is
used, or after?**

**Configuration posture management** is the mechanism that continuously reads the resource set and
flags any item that drifts from the desired state. How compliance rules are written, how many real
violations are caught per rule, and how many are false flags were measured in the **Cloud
Governance, Security and Cost** course; they are not repeated here. The only thing measured here is
**time**: the phase in which the item can be seen, the delay between detection and remediation, and
how many hours that delay leaves the radius open.

The graph is the same as the previous lesson's: the **regional measurement network** is
**fictional**, a **model** written in `node`.

**CD7.** Thirteen of the graph's 23 edges are tied to a misconfigured item; if the item is closed,
that edge cannot be walked. The remaining 10 edges are legitimate and cannot be closed.
**CD8.** A misconfigured item can be seen in one of three phases: **template** (before deployment,
as infrastructure-as-code text), **acceptance** (at deployment time, while the desired state is
evaluated), **runtime** (only by a scan that looks at the running resource).
**CD9.** Four runtime items are born within a week; their birth minutes are fixed in the model
(fictional).
**CD10.** Given scan interval $S$ and remediation delay $R$, an item closes at minute
$\lceil d/S \rceil \cdot S + R$; $d$ is the moment of birth.
**CD11.** The measurement window is one week (10,080 minutes), and the radius is calculated
separately for every minute.
**CD12.** Each scan evaluates 13 items; the weekly evaluation count is a fictional resource cost.

#### The Phase in Which a Misconfigured Item Can Be Seen

```js
// posture.mjs — the phase in which a misconfigured item can be seen and the step each phase closes. IT IS A MODEL.
// The graph is the same as the previous lesson's; here every edge is tied to a misconfigured item.
const split = (s) => s.trim().split("\n").map((l) => l.trim().split("|"));

// Edge: source|target|duration(min)|misconfigured item that makes the edge possible ("-" if the edge is legitimate)
export const EDGE = split(`
  portal-service-account|portal-instance|3|-
  portal-service-account|reader-role|4|-
  portal-instance|portal-instance-identity|2|instance-identity-sessionless
  portal-instance-identity|subscriber-database|6|-
  portal-instance-identity|secret-store|4|portal-identity-secret-read
  secret-store|collection-workload-identity|3|secret-store-long-lived-identity
  collection-workload-identity|raw-meter-bucket|5|-
  collection-workload-identity|measurement-queue|4|-
  collection-workload-identity|monitoring-role|4|role-assumption-to-workload
  monitoring-role|measurement-dashboard|3|-
  portal-instance-identity|billing-batch-role|3|portal-identity-batch-role
  billing-batch-role|billing-archive-bucket|6|-
  billing-batch-role|key-management|4|key-usage-unconditional
  key-management|customer-key|2|key-policy-no-condition
  billing-batch-role|backup-role|3|chained-role-assumption
  backup-role|backup-bucket|7|-
  backup-role|deployment-role|4|deployment-to-backup-role
  deployment-role|template-store|5|template-store-open-write
  deployment-role|configuration-bucket|4|-
  deployment-role|workload-instance|9|deployment-role-instance-launch
  reader-role|report-bucket|4|-
  reader-role|log-bucket|5|log-bucket-open-read
  reader-role|configuration-bucket|6|configuration-bucket-broad-read`)
  .map(([k, h, d, y]) => ({ k, h, d: +d, y }));

// The phase in which a misconfigured item can be seen. MODEL input.
// template:   before deployment, read as infrastructure-as-code text
// acceptance: at deployment time, read while the desired state is evaluated
// runtime:    only found by a scan that looks at the running resource
export const PHASE = Object.fromEntries(split(`
  portal-identity-secret-read|template          role-assumption-to-workload|template
  portal-identity-batch-role|template      key-policy-no-condition|template
  chained-role-assumption|template               deployment-to-backup-role|template
  deployment-role-instance-launch|template   key-usage-unconditional|acceptance
  template-store-open-write|acceptance           instance-identity-sessionless|runtime
  secret-store-long-lived-identity|runtime             configuration-bucket-broad-read|runtime
  log-bucket-open-read|runtime`
  .replace(/\s{2,}/g, "\n")));

export const START = "portal-service-account";
export const TOTAL = 28;   // total items in the graph; five are unreachable by any walk

// Radius given a set of open misconfigured items: only legitimate or open edges are walked.
export function radius(open) {
  const arrival = { [START]: 0 };
  for (;;) {
    let best = null;
    for (const e of EDGE) {
      if (!(e.k in arrival) || e.h in arrival) continue;
      if (e.y !== "-" && !open.has(e.y)) continue;
      const t = arrival[e.k] + e.d;
      if (best === null || t < best.t) best = { e, t };
    }
    if (best === null) return { radius: Object.keys(arrival).length, arrival };
    arrival[best.e.h] = best.t;
  }
}

const ITEMS = [...new Set(EDGE.map((e) => e.y).filter((y) => y !== "-"))];
const byPhase = (...p) => new Set(ITEMS.filter((y) => p.includes(PHASE[y])));

const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0]))
  .join("");

if (import.meta.url === `file://${process.argv[1]}`) {
  const count = (p) => ITEMS.filter((y) => PHASE[y] === p).length;
  console.log(`${ITEMS.length} of ${EDGE.length} edges are tied to a misconfigured item, ` +
    `${EDGE.length - ITEMS.length} are legitimate`);
  console.log(`phase distribution: template ${count("template")}, acceptance ${count("acceptance")}, ` +
    `runtime ${count("runtime")}`);

  const row = gen([34, 14, 12, 10, 20]);
  console.log("\n" + row(["gate", "items closed", "items open", "radius", "closes when"]));
  const stage = [
    ["no gate (all open)", byPhase("template", "acceptance", "runtime"), "-"],
    ["runtime scan only", byPhase("template", "acceptance"), "after deployment"],
    ["template gate", byPhase("acceptance", "runtime"), "before deployment"],
    ["template + acceptance gate", byPhase("runtime"), "before deployment"],
    ["all three", new Set(), "before and after"],
  ];
  for (const [name, open, when] of stage) {
    const r = radius(open);
    console.log(row([name, ITEMS.length - open.size, open.size, `${r.radius}/${TOTAL}`, when]));
  }

  // How far the radius would drop if each misconfigured item were closed alone.
  const full = radius(new Set(ITEMS)).radius;
  const singleton = ITEMS.map((y) => {
    const a = new Set(ITEMS); a.delete(y);
    return { y, drop: full - radius(a).radius, phase: PHASE[y] };
  }).sort((a, b) => b.drop - a.drop);
  const row2 = gen([36, 10, 10]);
  console.log("\n" + row2(["item closed alone", "phase", "radius"]));
  for (const t of singleton.slice(0, 5)) console.log(row2([t.y, t.phase, `${full} -> ${full - t.drop}`]));
  console.log(`the remaining ${singleton.length - 5} items each lower the radius by one item ` +
    `or not at all`);
}
```

```
13 of 23 edges are tied to a misconfigured item, 10 are legitimate
phase distribution: template 7, acceptance 2, runtime 4

gate                                items closed  items open    radius         closes when
no gate (all open)                             0          13     23/28                   -
runtime scan only                              4           9      4/28    after deployment
template gate                                  7           6      8/28   before deployment
template + acceptance gate                     9           4      8/28   before deployment
all three                                     13           0      4/28    before and after

item closed alone                        phase    radius
instance-identity-sessionless          runtime   23 -> 6
portal-identity-batch-role            template  23 -> 14
portal-identity-secret-read           template  23 -> 17
secret-store-long-lived-identity       runtime  23 -> 18
chained-role-assumption               template  23 -> 18
the remaining 8 items each lower the radius by one item or not at all
```

The top table forces two columns to be read separately: **items closed** and **radius** do not move
in the same direction. The template gate closes seven of the thirteen items — more than half —
before deployment, and the radius drops from 23 to 8. The acceptance gate closes two more items —
up to nine — and the radius **stays at 8.** The two additionally closed items were already
protecting items reached through another edge even while closed; the path to key management had
already been cut when the billing role closed. This is the configuration-side counterpart of the
previous lesson's log-entry line: **counting items is not counting radius.**

There is a sharper number in the opposite direction. The runtime scan alone closes four items and
the radius drops to 4 — the same result as opening all three gates together. The reason is in the
table below: the item that alone lowers the radius from 23 to 6 is the instance-bound identity
being issued without per-session authentication, and that item is in the **runtime** class. The
template text is written correctly; an instance launched from an old image carries the old
behavior, and only a scan that looks at the running instance can see it.

#### What the Gate Closes and What It Cannot

The bottom table also gives the ranking. Four of the thirteen items genuinely move the radius; the
remaining eight lower it by one item or not at all. The item at the top of the ranking is in the
runtime class, the next three are in the template class. The value of closing before deployment is
here, and it is measured in time: an item closed in the template phase **never comes into
existence**, so the time it stays open is zero. An item in the runtime class, however, is born,
lives for a while, and only then closes. The next section counts that time.

#### The Window Opened by Remediation Delay

```js
// window.mjs — how many hours the detection-and-remediation delay leaves the radius open. IT IS A MODEL.
import { PHASE, TOTAL, radius } from "./posture.mjs";

const WEEK = 7 * 24 * 60;                       // 10,080-minute window
const RUNTIME = Object.keys(PHASE).filter((y) => PHASE[y] === "runtime");
// The minute the misconfigured item is born (fictional): the moment the drift appears.
const BORN = { "configuration-bucket-broad-read": 900, "instance-identity-sessionless": 2600,
  "secret-store-long-lived-identity": 4300, "log-bucket-open-read": 7100 };
const FIXED = Object.keys(PHASE).filter((y) => PHASE[y] !== "runtime");

// When an item closes, given scan interval S and remediation delay R.
const closes = (born, S, R) => S === Infinity ? Infinity : Math.ceil(born / S) * S + R;

function run({ S, R, gate }) {
  const closeAt = Object.fromEntries(RUNTIME.map((y) => [y, closes(BORN[y], S, R)]));
  const cache = new Map();
  let total = 0, peak = 0, openMin = 0, heavyMin = 0;
  const baseline = radius(gate ? new Set() : new Set(FIXED)).radius;
  for (let t = 0; t < WEEK; t++) {
    const open = new Set(gate ? [] : FIXED);
    for (const y of RUNTIME) if (t >= BORN[y] && t < closeAt[y]) open.add(y);
    const key = [...open].sort().join(",");
    if (!cache.has(key)) cache.set(key, radius(open).radius);
    const r = cache.get(key);
    total += r; peak = Math.max(peak, r);
    if (r > baseline) openMin++;
    if (r >= 10) heavyMin++;
  }
  return { avg: (total / WEEK).toFixed(1), peak, baseline,
    open: (openMin / 60).toFixed(0), heavy: (heavyMin / 60).toFixed(0),
    evaluations: S === Infinity ? 0 : Math.round((WEEK / S) * Object.keys(PHASE).length) };
}

const SETTING = [
  ["no scan, no gate", { S: Infinity, R: 0, gate: false }],
  ["daily scan + approved remediation", { S: 1440, R: 480, gate: false }],
  ["hourly scan + approved remediation", { S: 60, R: 480, gate: false }],
  ["hourly scan + automatic remediation", { S: 60, R: 10, gate: false }],
  ["+ template and acceptance gate", { S: 60, R: 10, gate: true }],
  ["continuous scan + automatic remediation", { S: 5, R: 10, gate: true }],
];

const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0]))
  .join("");
const row = gen([41, 9, 9, 9, 12, 13, 15]);

console.log(row(["setting", "baseline", "average", "peak", "open hours", "radius>=10", "evaluations"]));
for (const [name, a] of SETTING) {
  const r = run(a);
  console.log(row([name, `${r.baseline}/${TOTAL}`, r.avg, `${r.peak}/${TOTAL}`, `${r.open} h`,
    `${r.heavy} h`, r.evaluations]));
}

const approved = run({ S: 60, R: 480, gate: false }), auto = run({ S: 60, R: 10, gate: false });
const daily = run({ S: 1440, R: 480, gate: false });
console.log(`\ntaking the scan interval from 1440 -> 60 min brings the open time from ${daily.open} -> ` +
  `${approved.open} hours; taking the remediation delay from 480 -> 10 min brings it ` +
  `${approved.open} -> ${auto.open} hours`);
const gated = run({ S: 60, R: 10, gate: true });
console.log(`adding the gate takes the baseline ${auto.baseline} -> ${gated.baseline} and the peak ` +
  `${auto.peak} -> ${gated.peak}; open hours ${auto.open} -> ${gated.open}`);
const continuous = run({ S: 5, R: 10, gate: true });
console.log(`taking the scan interval from 60 -> 5 min brings the open hours from ${gated.open} -> ` +
  `${continuous.open} and takes the weekly evaluations from ${gated.evaluations} -> ` +
  `${continuous.evaluations}: ${(continuous.evaluations / gated.evaluations).toFixed(0)}x ` +
  `the work for ${gated.open - continuous.open} hour(s) gained`);
console.log(`the four runtime items are born at minutes ${Object.values(BORN).join(", ")}; ` +
  `the instance-identity item is born at minute 2600, and while open the radius is ${gated.peak} ` +
  `in the gated setup, ${auto.peak} in the ungated one`);
```

```
setting                                   baseline  average     peak  open hours   radius>=10    evaluations
no scan, no gate                              4/28     17.0    23/28       153 h        125 h              0
daily scan + approved remediation             4/28      5.1    17/28        39 h         13 h             91
hourly scan + approved remediation            4/28      4.8    17/28        25 h          9 h           2184
hourly scan + automatic remediation           4/28      4.1    17/28         2 h          1 h           2184
+ template and acceptance gate                4/28      4.0     6/28         2 h          0 h           2184
continuous scan + automatic remediation       4/28      4.0     6/28         1 h          0 h          26208

taking the scan interval from 1440 -> 60 min brings the open time from 39 -> 25 hours; taking the remediation delay from 480 -> 10 min brings it 25 -> 2 hours
adding the gate takes the baseline 4 -> 4 and the peak 17 -> 6; open hours 2 -> 2
taking the scan interval from 60 -> 5 min brings the open hours from 2 -> 1 and takes the weekly evaluations from 2184 -> 26208: 12x the work for 1 hour(s) gained
the four runtime items are born at minutes 900, 2600, 4300, 7100; the instance-identity item is born at minute 2600, and while open the radius is 6 in the gated setup, 17 in the ungated one
```

With no mechanism at all, the radius stands above the baseline for 153 of the week's hours, and its
average is 17. The drift opens the moment it is born and never closes; the entire measurement
window is one open window.

#### Frequency or Delay

Two numbers set the ranking, and they are not tied to the same knob. Taking the scan interval from
daily to hourly pulls the open time from 39 hours to 25 hours: a fourteen-hour gain for twenty-four
times the scanning. Taking the remediation delay from eight hours to ten minutes, on the other
hand, pulls those 25 hours down to **2 hours**: a twenty-three-hour gain, with no added scanning at
all. What determines the open window is not how often the scan runs, but **when the flagged item
actually closes.** An eight-hour approval window gives back most of what the hourly scan gained.

This does not make automatic remediation free. It was counted in the earlier course that automatic
remediation demands broad write authority on the rented layer and can break legitimate
configurations; the contribution here is to write down that the return on that cost is a
**twenty-three-hour window**. Keeping the approval window short and automating remediation get
written into the same column.

The last two rows add the gate. The template and acceptance gate does not change the open time at
all — 2 hours, unchanged — because the open time is set by the runtime items. What it does change
is the **peak radius**: it drops from 17 to 6. The gate narrows the window's depth, not its length.
Two controls do two separate jobs in the same table, and neither substitutes for the other. The
bottom row shows the limit: taking the scan interval from an hour to five minutes raises the weekly
evaluations from 2,184 to 26,208 — twelve times the work — for a gain of one hour.

#### Summary

- Thirteen of the graph's 23 edges are tied to a misconfigured item; 7 of the items are in the
  template phase, 2 are in acceptance, and 4 can only be seen by looking at the running resource.
- The template gate closes seven items before deployment and lowers the radius from 23 to 8; the
  acceptance gate closes two more items and does not lower the radius at all — counting items is
  not counting radius.
- The single item that lowers the radius the most is the instance-bound identity being issued
  without per-session authentication (from 23 to 6), and that item is not visible in the template —
  it can only be seen at runtime.
- With no mechanism at all, the radius sits above the baseline for 153 of the week's hours; daily
  scanning with approved remediation brings this to 39 hours, hourly scanning to 25 hours.
- What determines the open window is not scan frequency but remediation delay: taking an
  eight-hour approval window down to ten minutes pulls 25 hours down to 2, with no added scanning
  required.
- The template and acceptance gate does not change the open time but lowers the peak radius from
  17 to 6; pulling the scan interval from an hour to five minutes gains one hour at the cost of
  twelve times the evaluations.

#### Next Step

Up to this point, every misconfigured item lived inside the tenant's own account and was a
configuration that could be closed. One item does not fit this description: the boundary of the
partition or guest system the workload runs on. That boundary is not a configuration the tenant
wrote, and when it falls, the set of items reached is no longer bounded by the tenant's account
either. The next lesson measures this boundary in a multi-tenant environment: what the radius of a
partition escape is for a single tenant, and what it becomes once tenants sharing the same hardware
are counted in.

### Container and Hypervisor Security

Source: https://academia.sh/en/courses/cloud-security/container-and-hypervisor-security

Measuring escape radius in a multi-tenant environment: with 18 workloads, three tenants, and six hosts, a container escape in a shared placement reaches 18 items in the worst case, spilling into another tenant in seven of eight workloads. Separating by tenant zeroes out cross-tenant escape while raising our own worst-case radius to 21; separating by identity leaves the worst case at 18, lowers the average from 12.5 to 8.5, and raises the host count from 6 to 13. Strengthening isolation raises the item count required to cross from 1 to 3, but does not lower the radius at all.

Up to this point, every misconfigured item lived inside the tenant's own account. One item does not
fit this description: the boundary of the partition or the guest system the workload runs on. The
tenant does not write that boundary — the provider sets it up — and when it falls, the set of items
reached is no longer bounded by the tenant's account, because other tenants' workloads may be
running on the same physical host.

The items shared between guest and host, the number of items that must be wrong at the same time to
cross the boundary, and how far container and guest-system isolation differ on that measure were
measured in the **IT and Systems Foundations** course. They are not repeated here; they are **taken
as input**. This lesson has a single question: once the boundary falls, **how many items** are
reached, and how does that number change between a single-tenant host and a shared host. The escape
is a **model**: no exploit step, call sequence, or code is written; the boundary's fall is simply
assumed and what follows is counted. Testing a partition or guest boundary on a real system, without
written authorization and a defined scope, is both illegal and unprofessional.

**CD13.** The model consists of three tenants, 18 workloads, and eight-core hosts; every workload is
tied to a tenant, an identity, and a core requirement (fictional).
**CD14.** Placement is first-fit; the separation rule is none, by tenant, or by identity.
**CD15.** The escape model is this: the boundary falls on one workload, and **every workload on the
same host** is reached. Spread between hosts is not part of the model.
**CD16.** The identities of our own workloads that are reached become input to the access graph; the
radius is the number of items reached from those identities.
**CD17.** Other tenants' graphs are closed to the tenant; that side is counted only as **the number
of workloads and tenants reached**.
**CD18.** The shared-item and required-item counts for each isolation type are taken from the
earlier course; the guest system's one-core-per-workload overhead is fixed in the model.

#### The Neighbor the Escape Reaches

```js
// placement.mjs — the radius of a container escape: the workloads on the same host and their identities.
// IT IS A MODEL. The escape is a MODEL step; no exploit step, call sequence, or code is written.
const split = (s) => s.trim().split("\n").map((l) => l.trim().split("|"));

// Workload: name|tenant|identity|core requirement. Three tenants, 18 workloads (fictional).
export const WORKLOAD = split(`
  portal-1|measurement|portal-instance-identity|2      b-portal|tenant-b|b-identity-1|3
  c-gateway|tenant-c|c-identity-1|3               collection-1|measurement|collection-workload-identity|3
  b-stream|tenant-b|b-identity-2|3          c-account|tenant-c|c-identity-2|3
  portal-2|measurement|portal-instance-identity|2      b-batch|tenant-b|b-identity-3|4
  collection-2|measurement|collection-workload-identity|3  c-report|tenant-c|c-identity-3|2
  billing-batch|measurement|billing-batch-role|4  b-archive|tenant-b|b-identity-3|2
  backup|measurement|backup-role|2           b-dashboard|tenant-b|b-identity-4|1
  c-backup|tenant-c|c-identity-3|1         report|measurement|reader-role|1
  b-testing|tenant-b|b-identity-4|1        dashboard|measurement|monitoring-role|1`
  .replace(/\s{2,}/g, "\n")).map(([name, tenant, identity, c]) => ({ name, tenant, identity, c: +c }));

// Access graph (the graph from the previous lessons, without the duration column): source|target
const EDGE = split(`
  portal-service-account|portal-instance        portal-service-account|reader-role
  portal-instance|portal-instance-identity        portal-instance-identity|subscriber-database
  portal-instance-identity|secret-store           secret-store|collection-workload-identity
  collection-workload-identity|raw-meter-bucket    collection-workload-identity|measurement-queue
  collection-workload-identity|monitoring-role       monitoring-role|measurement-dashboard
  portal-instance-identity|billing-batch-role  billing-batch-role|billing-archive-bucket
  billing-batch-role|key-management     key-management|customer-key
  billing-batch-role|backup-role     backup-role|backup-bucket
  backup-role|deployment-role               deployment-role|template-store
  deployment-role|configuration-bucket          deployment-role|workload-instance
  reader-role|report-bucket                   reader-role|log-bucket
  reader-role|configuration-bucket`.replace(/\s{2,}/g, "\n"));

// The set of items reached from an identity (breadth-first search).
export function reach(start) {
  const reached = new Set(start), queue = [...start];
  while (queue.length) {
    const n = queue.shift();
    for (const [k, h] of EDGE) if (k === n && !reached.has(h)) { reached.add(h); queue.push(h); }
  }
  return reached;
}

// Placement: first-fit host. The separation rule can be "none", "tenant", or "identity".
export function place(separation, overhead = 0, CAPACITY = 8) {
  const host = [];
  for (const w of WORKLOAD.map((i) => ({ ...i, c: i.c + overhead }))) {
    const label = separation === "none" ? null : separation === "tenant" ? w.tenant : w.identity;
    let h = host.find((x) => x.remaining >= w.c && (label === null || x.label === label));
    if (!h) { h = { name: `host-${host.length + 1}`, label, remaining: CAPACITY, workloads: [] }; host.push(h); }
    h.remaining -= w.c; h.workloads.push(w);
  }
  return host;
}

// Escape model: the boundary falls on one workload, and every workload on the same host is reached.
export function escape(host, startWorkload) {
  const h = host.find((x) => x.workloads.some((w) => w.name === startWorkload));
  const reached = h.workloads;
  const ours = reached.filter((w) => w.tenant === "measurement");
  const others = reached.filter((w) => w.tenant !== "measurement");
  const items = reach(ours.map((w) => w.identity));
  return { host: h.name, workloads: reached.length, ownWorkloads: ours.length, otherWorkloads: others.length,
    otherTenants: new Set(others.map((w) => w.tenant)).size,
    identities: new Set(reached.map((w) => w.identity)).size, items: items.size };
}

const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0]))
  .join("");

if (import.meta.url === `file://${process.argv[1]}`) {
  const row = gen([24, 8, 11, 14, 20, 15, 10]);
  console.log(`${WORKLOAD.length} workloads, ${new Set(WORKLOAD.map((w) => w.tenant)).size} tenants, ` +
    `host capacity 8 cores; total demand ${WORKLOAD.reduce((t, w) => t + w.c, 0)} cores`);
  console.log("\n" + row(["separation rule", "hosts", "occupancy", "escape host", "workloads reached",
    "other tenants", "radius"]));
  for (const [name, separation] of [["none (shared)", "none"], ["by tenant", "tenant"],
    ["by identity", "identity"]]) {
    const h = place(separation);
    const r = escape(h, "portal-1");
    const occupancy = Math.round(100 * WORKLOAD.reduce((t, w) => t + w.c, 0) / (h.length * 8));
    console.log(row([name, h.length, `${occupancy}%`, r.host, `${r.workloads} (${r.ownWorkloads} own)`,
      r.otherTenants, `${r.items}/28`]));
  }

  // Escape from every workload: worst-case and average radius, in the shared and tenant-separated setups.
  console.log();
  const row2 = gen([24, 14, 12, 18, 16]);
  console.log(row2(["separation rule", "worst case", "average", "to other tenant", "own items"]));
  for (const [name, separation] of [["none (shared)", "none"], ["by tenant", "tenant"],
    ["by identity", "identity"]]) {
    const h = place(separation);
    const r = WORKLOAD.filter((w) => w.tenant === "measurement").map((w) => escape(h, w.name));
    const avg = (r.reduce((t, x) => t + x.items, 0) / r.length).toFixed(1);
    console.log(row2([name, `${Math.max(...r.map((x) => x.items))}/28`, avg,
      `${r.filter((x) => x.otherWorkloads > 0).length}/${r.length} escapes`,
      `${(r.reduce((t, x) => t + x.otherWorkloads, 0) / r.length).toFixed(1)} workloads`]));
  }
}
```

```
18 workloads, 3 tenants, host capacity 8 cores; total demand 41 cores

separation rule            hosts  occupancy   escape host   workloads reached  other tenants    radius
none (shared)                  6        85%        host-1           3 (1 own)              2     18/28
by tenant                      7        73%        host-1           4 (4 own)              0     21/28
by identity                   13        39%        host-1           2 (2 own)              0     18/28

separation rule             worst case     average   to other tenant       own items
none (shared)                    18/28        12.5       7/8 escapes   1.3 workloads
by tenant                        21/28        16.9       0/8 escapes   0.0 workloads
by identity                      18/28         8.5       0/8 escapes   0.0 workloads
```

The top table looks at a single escape. In the shared placement, the portal workload's host has
three workloads, and only one of them is our own tenant's; the escape reaches two other tenants.
Eighteen of our own items are reached. In the tenant-separated placement, all four workloads on the
same host are ours, and the radius climbs to 21. Cross-tenant escape is zero; our own radius is
bigger.

#### Our Own Item and the Other Tenant

The bottom table runs the escape separately from each of the eight workloads and shows the two
columns moving **in opposite directions**. In the shared placement, our own worst-case radius is
18, average 12.5; but seven of the eight escapes reach another tenant, and each escape reaches an
average of 1.3 foreign workloads. Separating by tenant brings those seven escapes to zero — no
escape crosses the tenant boundary — but it raises our own worst-case radius from 18 to 21, and the
average from 12.5 to 16.9.

The reason it rises is clear and must be stated: separating by tenant **concentrates** our own
workloads on the same hosts. When the foreign neighbor is removed, our own neighbor takes its
place, and our own neighbor's identity is a node in the access graph. Tenant separation is done not
because it lowers the measure, but because it lowers **someone else's** measure.

Separating by identity does not rescue the worst case either: it still reads 18. The reason is that
a single identity is a hub in the graph — the portal's instance-bound identity alone reaches 18
items, whether or not it has a neighbor. What separating by identity lowers is the **average**:
from 12.5 to 8.5. Playing with placement never shortens a hub identity's own radius.

#### The Neighbor's Invisibility

All three columns in the table look measurable, but only one of them can actually be measured by
the tenant. The tenant **cannot count** which host it stands on or who else is on that host: the
placement criterion is a request made to the provider, and its answer shows up neither in a log nor
in an inventory. The "to other tenant" column can be computed in this model because we built the
placement ourselves; a real tenant never sees that number directly.

On the detection side, this is another form of the first lesson's unlogged-step class. When an
escape happens, the only thing that can show up in the tenant's log is **the use of its own
identities**: the move to the neighboring workload, movement across the host, and the spillover
into a foreign tenant fall into none of the tenant's logs. This is why the measure of cross-tenant
escape is not a detection number for the tenant, but a **contract question**: which separation
criterion is applied, how it is verified, and whether the tenant will be notified when an incident
occurs in the neighbor set must be written down in advance. If it is not written down, the last
column of the table is empty for the tenant.

#### Isolation Strength or Placement

```js
// isolation.mjs — isolation strength and placement separation are measured separately. IT IS A MODEL.
import { WORKLOAD, place, escape } from "./placement.mjs";

// Isolation type: the number of shared items and the number of items that must be wrong AT THE
// SAME TIME to cross the boundary were measured in the IT and Systems Foundations course; here they
// are INPUT. overhead: extra core requirement per workload (the guest system's own load).
const ISOLATION = {
  "container (same core)": { shared: 12, required: 1, overhead: 0 },
  "guest (hypervisor)": { shared: 5, required: 3, overhead: 1 },
};
const SEPARATION = [["none", "none"], ["tenant", "tenant"], ["identity", "identity"]];
const OURS = WORKLOAD.filter((w) => w.tenant === "measurement");

const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0]))
  .join("");
const row = gen([25, 10, 10, 8, 11, 12, 18]);

console.log(row(["isolation", "separation", "required", "hosts", "occupancy", "worst case", "to other tenant"]));
const result = {};
for (const [name, y] of Object.entries(ISOLATION)) for (const [label, separation] of SEPARATION) {
  const host = place(separation, y.overhead);
  const demand = WORKLOAD.reduce((t, w) => t + w.c + y.overhead, 0);
  const r = OURS.map((w) => escape(host, w.name));
  const cross = r.filter((x) => x.otherWorkloads > 0).length;
  result[`${name}|${separation}`] = { worst: Math.max(...r.map((x) => x.items)), cross, host: host.length,
    avg: (r.reduce((s, x) => s + x.items, 0) / r.length).toFixed(1) };
  console.log(row([name, label, y.required, host.length,
    `${Math.round(100 * demand / (host.length * 8))}%`,
    `${Math.max(...r.map((x) => x.items))}/28`, `${cross}/${OURS.length} escapes`]));
}

const b = result["container (same core)|none"], k = result["guest (hypervisor)|none"];
const ba = result["container (same core)|tenant"], ka = result["guest (hypervisor)|identity"];
console.log(`\nstrengthening isolation takes the required item count from 1 -> 3; on the same ` +
  `placement, both isolation types give the same radius (${b.worst}/28). Isolation type changes ` +
  `the crossing condition, not the items reached once crossed`);
console.log(`the guest system's one-core-per-workload overhead takes the host count from ` +
  `${b.host} -> ${k.host}; the worst-case radius stays the same at ${k.worst}/28`);
console.log(`separating by tenant lowers cross-tenant escapes from ${b.cross}/${OURS.length} -> ` +
  `${ba.cross}/${OURS.length} but increases our own radius: worst case ${b.worst} -> ${ba.worst}, ` +
  `average ${b.avg} -> ${ba.avg}`);
console.log(`separating by identity does not lower the worst-case radius (${ka.worst}/28) because ` +
  `the widest identity alone reaches that many items; what it lowers is the average: ` +
  `${b.avg} -> ${ka.avg}. The cost is ${b.host} -> ${ka.host} hosts`);
```

```
isolation                separation  required   hosts  occupancy  worst case   to other tenant
container (same core)          none         1       6        85%       18/28       7/8 escapes
container (same core)        tenant         1       7        73%       21/28       0/8 escapes
container (same core)      identity         1      13        39%       18/28       0/8 escapes
guest (hypervisor)             none         3       8        92%       18/28       8/8 escapes
guest (hypervisor)           tenant         3       9        82%       18/28       0/8 escapes
guest (hypervisor)         identity         3      13        57%       18/28       0/8 escapes

strengthening isolation takes the required item count from 1 -> 3; on the same placement, both isolation types give the same radius (18/28). Isolation type changes the crossing condition, not the items reached once crossed
the guest system's one-core-per-workload overhead takes the host count from 6 -> 8; the worst-case radius stays the same at 18/28
separating by tenant lowers cross-tenant escapes from 7/8 -> 0/8 but increases our own radius: worst case 18 -> 21, average 12.5 -> 16.9
separating by identity does not lower the worst-case radius (18/28) because the widest identity alone reaches that many items; what it lowers is the average: 12.5 -> 8.5. The cost is 6 -> 13 hosts
```

#### Two Levers

The table lays the two levers side by side and shows that they are not written into the same
column. **Isolation strength** changes the "required" column: crossing the boundary in a partition
sharing the same core takes only one item being wrong, while at the guest-system boundary three
items must be wrong at the same time. This is the boundary's crossing condition. **Placement
separation**, on the other hand, changes the "worst case" and "to other tenant" columns; it
determines what is found once the boundary is crossed.

That the two must not be confused shows up in the table's two separation rows: with no placement
separation and with separation by identity alike, container and guest isolation give the **same
number** in the "worst case" column. Moving to the guest system takes the host count from 6 to 8,
moves occupancy from 85% to 92%, and leaves the radius at 18. This is the course's second example
of a "control that does not lower the radius"; its difference from the first — the log — is that
this time the control genuinely does something: it raises the boundary's crossing condition from
one item to three, but it does not touch the number of items reached once crossed.

The cost column must also be read. Separating by identity zeroes out cross-tenant escape and lowers
the average from 12.5 to 8.5, but leaves the worst case at 18; in return, the host count climbs
from 6 to 13 and occupancy drops from 85% to 39%. The middle option — separating by tenant — brings
cross-tenant escape from seven escapes to zero, adds one host, and grows our own worst-case radius
by three items. None of the three rows brings the worst case below 18; the only way to lower it
lies outside this lesson, and it runs through narrowing the hub identity's own authority.

#### Summary

- In the shared placement, a container escape reaches 18 of our own items in the worst case; seven
  of the eight escapes reach another tenant, and each escape makes an average of 1.3 foreign
  workloads reachable.
- Separating by tenant brings cross-tenant escape from seven to zero, but raises our own
  worst-case radius from 18 to 21, and the average from 12.5 to 16.9: when the foreign neighbor is
  removed, our own neighbor takes its place.
- Separating by identity leaves the worst case at 18 and only lowers the average from 12.5 to 8.5;
  the cost is the host count climbing from 6 to 13 and occupancy dropping from 85% to 39%.
- Isolation strength changes the boundary's **crossing condition**: one item is enough in a
  partition sharing the same core, while the guest-system boundary requires three items to be
  wrong at the same time.
- The radius is 18 or 21 in all six rows; neither the isolation type nor the guest system's extra
  core load lowers it, because the hub identity alone reaches that many items.
- The tenant cannot count the neighbor set: the placement criterion is a request made to the
  provider, and the part of the escape that spills into the neighbor falls into none of the
  tenant's logs, which is why it must be written into the contract.

#### Next Step

This entire lesson's measurement assumed that the workload is **standing** at the moment of
escape: it sits on the host, its identity is usable, its neighbor is visible. Some workloads do not
satisfy this assumption. In a function-execution environment, a workload lasts seconds, finishes
its work, and disappears; it leaves behind neither a host record nor a process to examine. The next
lesson measures the window in which a log can be collected for these workloads: how many seconds a
workload lives, and how many items are lost if they are not collected while it lives.

### Serverless Security

Source: https://academia.sh/en/courses/cloud-security/serverless-security

Measuring the window in which evidence can be collected for short-lived workloads: the median lifetime of 400 calls is 447 ms; manual collection tied to an alert catches none of the 400 calls, automatic collection catches 15. Continuous streaming from within the environment takes 3,541 of 3,600 evidence items; a 60-second hold gives the same result at 34 times the running time in environment-seconds. The identity's validity period, meanwhile, is 8,054 times the lifetime, and one hundred percent of the open item-seconds pass after the workload has ended.

The previous lesson's entire measurement assumed that the workload is **standing**: it sits on
the host, its identity is usable, its neighbor is countable. One class of workload does not
satisfy this assumption. In a function-execution environment, a workload lives from a hundred
milliseconds to a few seconds, finishes its work, and the environment is reclaimed. There is no
process to look at, no shell to open, no disk to image once the alert arrives.

What this lesson measures is therefore the **window**: how many seconds the workload lives, and
how many of the evidence items never come back if they are not collected before that window
closes. The runtime constraints, cold start, and scaling behavior of short-lived workloads were
measured in the **Serverless and Edge Computing** course; they are not repeated here. The
measurement is on the **regional measurement network**, is **fictional**, and is a **model**
written in `node`; the generator is self-written, the seed appears in the output.

**CD19.** The model consists of 400 calls; lifetimes are generated from three bands and are in
milliseconds (fictional).
**CD20.** Nine evidence items fall into three classes: five can only be collected **while the
workload is live**, three can also be collected **afterward**, and one is the workload's own log,
delivered only if it is **flushed** before termination.
**CD21.** Collection delay $T$ is the time from the alert to the start of collection; hold $H$ is
how long the environment is kept alive after the call ends.
**CD22.** Items collectible while live are only collected if $T < \text{lifetime} + H$.
**CD23.** Every call gets its own short-lived identity; the token's validity period is $V$ seconds
(fictional).
**CD24.** Open item-seconds is the product of the number of items the identity reaches and the
duration the token stays valid. The number of items reached is taken from the walk in the first
lesson.

#### The Window in Which Evidence Can Be Collected

```js
// lifetime.mjs — the window in which evidence can be collected for short-lived workloads. IT IS A MODEL.
// The regional measurement network is FICTIONAL. The generator is self-written; the seed appears below.
export const SEED = 20260805;
const generator = (t) => { let s = t >>> 0;
  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; };

// Evidence item: name|class. live: collectible while the workload is standing; afterward: also
// collectible later; flush: the workload's own log, delivered only if flushed before termination.
export const EVIDENCE = `
  memory-contents|live          process-list|live
  open-connection|live           environment-variable-value|live
  temp-file|live            call-metadata|afterward
  management-plane-log|afterward   function-version|afterward
  function-own-log|flush`.trim().split(/\s{2,}|\n/).map((x) => x.trim().split("|"))
  .map(([name, cls]) => ({ name, class: cls }));

const r = generator(SEED);
// 400 calls: lifetime in milliseconds, three bands. The flush flag says whether the log is
// delivered or not (fictional ratio).
export const CALL = Array.from({ length: 400 }, () => {
  const u = r();
  const lifetime = u < 0.72 ? 80 + Math.floor(r() * 520)
    : u < 0.94 ? 600 + Math.floor(r() * 3400)
    : 4000 + Math.floor(r() * 41000);
  return { lifetime, flushed: r() > 0.14 };
});

export const LIVE = EVIDENCE.filter((k) => k.class === "live").length;
export const AFTERWARD = EVIDENCE.filter((k) => k.class === "afterward").length;

// How many evidence items are collected in a given setting. T: collection delay (ms), H: hold
// after the call ends (ms).
export function collect(T, H) {
  let alive = 0, items = 0;
  for (const c of CALL) {
    const window = c.lifetime + H;
    if (T < window) { alive++; items += LIVE; }
    items += AFTERWARD + (c.flushed ? 1 : 0);
  }
  return { alive, items, total: CALL.length * EVIDENCE.length,
    envSeconds: Math.round((CALL.length * H) / 1000) };
}

const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0]))
  .join("");

if (import.meta.url === `file://${process.argv[1]}`) {
  const sorted = [...CALL].map((c) => c.lifetime).sort((a, b) => a - b);
  const pct = (p) => sorted[Math.floor(p * sorted.length)];
  console.log(`model (seed ${SEED}): ${CALL.length} calls, ${EVIDENCE.length} evidence items ` +
    `(${LIVE} live, ${AFTERWARD} afterward, 1 flush-dependent)`);
  console.log(`lifetime (ms): median ${pct(0.5)}, 90% ${pct(0.9)}, 99% ${pct(0.99)}, ` +
    `longest ${sorted.at(-1)}; total lifespan ${Math.round(sorted.reduce((a, b) => a + b) / 1000)} s`);

  const tbl = gen([40, 10, 10, 22, 18, 16]);
  console.log("\n" + tbl(["setting", "delay", "hold", "evidence collectible", "items collected",
    "cost"]));
  const SETTING = [
    ["alert + manual collection", 300000, 0, "-"],
    ["automatic collection", 10000, 0, "-"],
    ["automatic collection + hold", 10000, 60000, "env-sec"],
    ["automatic collection + long hold", 2000, 300000, "env-sec"],
    ["continuous streaming from environment", 0, 0, "8 ms per call"],
  ];
  for (const [name, T, H, cost] of SETTING) {
    const s = collect(T, H);
    console.log(tbl([name, `${T / 1000} s`, `${H / 1000} s`,
      `${s.alive}/${CALL.length} calls`, `${s.items}/${s.total}`,
      H ? `${s.envSeconds} env-sec` : cost]));
  }

  const manual = collect(300000, 0), auto = collect(10000, 0), stream = collect(0, 0);
  console.log(`\nmanual collection catches ${manual.alive}/${CALL.length} calls, automatic ` +
    `collection ${auto.alive}/${CALL.length}; the difference between the two is ` +
    `${auto.items - manual.items} items`);
  console.log(`streaming from within the environment catches ${stream.alive}/${CALL.length} ` +
    `calls and collects ${stream.items}/${stream.total} items: the ${stream.total - stream.items} ` +
    `items that remain missing are the own-log not flushed before termination`);
  const hold = collect(10000, 60000);
  console.log(`a 60 s hold takes the calls with collectible evidence from ${auto.alive} -> ` +
    `${hold.alive}; its cost is ${hold.envSeconds} environment-seconds, that is ` +
    `${Math.round(hold.envSeconds / (sorted.reduce((a, b) => a + b) / 1000))} times the running time`);
}
```

```
model (seed 20260805): 400 calls, 9 evidence items (5 live, 3 afterward, 1 flush-dependent)
lifetime (ms): median 447, 90% 3249, 99% 29130, longest 40604; total lifespan 703 s

setting                                      delay      hold  evidence collectible   items collected            cost
alert + manual collection                    300 s       0 s           0/400 calls         1541/3600               -
automatic collection                          10 s       0 s          15/400 calls         1616/3600               -
automatic collection + hold                   10 s      60 s         400/400 calls         3541/3600   24000 env-sec
automatic collection + long hold               2 s     300 s         400/400 calls         3541/3600  120000 env-sec
continuous streaming from environment          0 s       0 s         400/400 calls         3541/3600   8 ms per call

manual collection catches 0/400 calls, automatic collection 15/400; the difference between the two is 75 items
streaming from within the environment catches 400/400 calls and collects 3541/3600 items: the 59 items that remain missing are the own-log not flushed before termination
a 60 s hold takes the calls with collectible evidence from 15 -> 400; its cost is 24000 environment-seconds, that is 34 times the running time
```

The median call lives 447 milliseconds; nine-tenths of calls finish under 3.2 seconds. In this
distribution, manual collection tied to an alert — with a five-minute delay — does not catch
**any** of the 400 calls. Zero. When automatic collection brings the delay down to ten seconds, it
catches 15 calls, that is, 3.75% of them; the 75 evidence items this gains are 2.1% of the total
3,600 items.

#### The Item Collection Never Catches

How stark this number is shows that this is not a problem that shortening the delay fixes. A
ten-second delay is already a fast response, and it still misses 96% of calls; dropping to two
seconds is not enough either, because the median lifetime is half a second. **Shortening the
collection delay is a lever that does not work for this workload class.**

The blind prefix from the first lesson takes another form here. There, the log's delay made the
first steps of the walk invisible, but the steps still sat in the log afterward; here, the delay
causes the evidence **to never exist at all**. The alert arriving 5 minutes later does not delay
the log — it eliminates the thing to be collected. The 1,541 items collected come only from the
three classes that can also be collected afterward, and from flushed own-logs; all 2,000 of the
items collectible only while live are lost.

There are two levers that work, and neither of them extends the window — they **remove** it. The
first is hold: if the environment is kept alive for sixty seconds after the call ends, the number
of calls with collectible evidence goes from 15 to 400, and the items collected go from 1,616 to
3,541. The cost is 24,000 environment-seconds — while the 400 calls' total running time is 703
seconds, the hold consumes **34 times** that many environment-seconds. What is more, raising the
hold to 300 seconds brings no additional items and raises the cost to 120,000
environment-seconds: five times the price for the same result.

The second is continuous streaming from within the environment: the items collectible while live
are written out as the workload runs. This gives the same 3,541 items with an added delay of 8
milliseconds per call. The 59 items that remain missing are the same across all three settings and
come from a single class — the own-log not flushed before termination. Neither hold nor streaming
saves it; flushing the log before termination is something **the workload itself** has to do.

#### The Radius Window Is Longer Than the Evidence Window

```js
// token.mjs — the workload's lifetime is compared with the validity period of the identity it gets. IT IS A MODEL.
import { CALL } from "./lifetime.mjs";

// The number of items the identity reaches is taken from the walk run in the first lesson: MODEL input.
const ACCESS = { collection: 5, narrowed: 2 };
const TOTAL_LIFETIME = CALL.reduce((t, c) => t + c.lifetime, 0) / 1000;   // seconds

// Open item-seconds in a given setting: item count x the duration the token stays valid, per call.
// The cancel option invalidates the token as soon as the workload ends.
function measure({ V, cancel = false, narrow = false }) {
  const items = ACCESS[narrow ? "narrowed" : "collection"];
  let open = 0, deadOpen = 0;
  for (const c of CALL) {
    const duration = cancel ? c.lifetime / 1000 : V;
    open += items * duration;
    deadOpen += items * Math.max(0, duration - c.lifetime / 1000);
  }
  return { open: Math.round(open), deadOpen: Math.round(deadOpen), items,
    share: `${Math.round(100 * deadOpen / open)}%` };
}

const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0]))
  .join("");
const tbl = gen([32, 12, 8, 20, 12, 30]);

console.log(`${CALL.length} calls, total running time ${TOTAL_LIFETIME.toFixed(0)} s; every call ` +
  `gets its own short-lived identity`);
console.log("\n" + tbl(["setting", "validity", "items", "open item-seconds", "dead share", "cost"]));
const SETTING = [
  ["default token", { V: 3600 }, "none"],
  ["validity 900 s", { V: 900 }, "renewal 4x"],
  ["validity 300 s", { V: 300 }, "renewal 12x"],
  ["cancel when workload ends", { V: 3600, cancel: true }, `${CALL.length} cancellation calls`],
  ["cancel + narrowed identity", { V: 3600, cancel: true, narrow: true }, "1 legitimate task rewritten"],
];
for (const [name, a, cost] of SETTING) {
  const s = measure(a);
  console.log(tbl([name, a.cancel ? "= lifetime" : `${a.V} s`, s.items, s.open, s.share, cost]));
}

const baseline = measure({ V: 3600 }), canceled = measure({ V: 3600, cancel: true });
const narrowed = measure({ V: 3600, cancel: true, narrow: true });
const median = [...CALL].map((c) => c.lifetime).sort((a, b) => a - b)[CALL.length >> 1];
console.log(`\nthe evidence window and the radius window are not on the same scale: the median ` +
  `call lives ${median} ms, the token it gets is valid for 3600 s — ` +
  `${Math.round(3600000 / median)} times`);
console.log(`in the default setting, ${baseline.share} of the open item-seconds is time that ` +
  `passes AFTER the workload ends; making the validity 300 s brings this share to ` +
  `${measure({ V: 300 }).share}`);
console.log(`canceling when the workload ends brings the open item-seconds from ${baseline.open} ` +
  `-> ${canceled.open}, that is ${Math.round(baseline.open / canceled.open)} times; its cost is ` +
  `one cancellation call per call`);
console.log(`narrowing the identity (5 -> 2 items) brings the same window from ${canceled.open} ` +
  `-> ${narrowed.open}; the two controls work multiplicatively, together ` +
  `${Math.round(baseline.open / narrowed.open)} times`);
```

```
400 calls, total running time 703 s; every call gets its own short-lived identity

setting                             validity   items   open item-seconds  dead share                          cost
default token                         3600 s       5             7200000        100%                          none
validity 900 s                         900 s       5             1800000        100%                    renewal 4x
validity 300 s                         300 s       5              600000         99%                   renewal 12x
cancel when workload ends         = lifetime       5                3514          0%        400 cancellation calls
cancel + narrowed identity        = lifetime       2                1405          0%   1 legitimate task rewritten

the evidence window and the radius window are not on the same scale: the median call lives 447 ms, the token it gets is valid for 3600 s — 8054 times
in the default setting, 100% of the open item-seconds is time that passes AFTER the workload ends; making the validity 300 s brings this share to 99%
canceling when the workload ends brings the open item-seconds from 7200000 -> 3514, that is 2049 times; its cost is one cancellation call per call
narrowing the identity (5 -> 2 items) brings the same window from 3514 -> 1405; the two controls work multiplicatively, together 5125 times
```

The table gives the lesson's real number. The workload lives 447 milliseconds; the token it gets
is valid for 3,600 seconds: **8,054 times as long**. The evidence window is measured in
milliseconds while the radius window is measured in hours, and the two are faces of the same
event. In the default setting, nearly all of the open item-seconds — rounded, one hundred percent
of them — is time that passes **after the workload has ended**. A valid credential sits there
while nothing is running.

#### Separating the Two Windows

Shortening the validity period is a lever in the right direction, but a slow one: dropping from
3,600 seconds to 300 seconds lowers the open item-seconds twelvefold, while the dead share only
drops from 100% to 99%, and in return the number of token renewals rises twelvefold. The ratio
does not change because the problem is not the length of the period, but that the period is not
tied to the workload's lifetime.

Tying it works. Canceling the token as soon as the workload ends brings the open item-seconds down
from 7,200,000 to 3,514 — **2,049 times** — and zeroes out the dead share. The cost is one
cancellation call per call, that is, 400 extra management-plane calls for 400 calls. The last row
adds the second lever: lowering the number of items the identity reaches from 5 to 2 pulls the
window down to 1,405. The two controls work **multiplicatively**; together, 5,125 times. The cost
is a legitimate task being rewritten.

The lesson's two tables thus arrive at the same sentence: in a short-lived workload, the evidence
window closes on its own, the radius window does not. Keeping the first one open is something you
pay for; closing the second one is something you decide to do.

#### Summary

- The median lifetime of 400 calls is 447 ms, 90% of them under 3.2 seconds; five of the nine
  evidence items can only be collected while the workload is live.
- With a five-minute delay, manual collection does not catch any of the 400 calls; a ten-second
  automatic collection catches 15 of them and gains only 2.1% of the total 3,600 items.
- Holding the environment for 60 seconds raises the calls with collectible evidence to 400 and
  collects 3,541 items; the cost is 24,000 environment-seconds, 34 times the total running time,
  and raising it to 300 seconds brings no additional items.
- Continuous streaming from within the environment gives the same 3,541 items with an added 8 ms
  delay per call; in all three settings, the 59 items that remain missing are the own-log not
  flushed before termination.
- The token's validity period is 8,054 times the median lifetime, and in the default setting,
  nearly all of the open item-seconds pass after the workload has ended.
- Shortening the period does not change the ratio (at 300 seconds the dead share is still 99%);
  canceling when the workload ends lowers the window 2,049-fold, and narrowing the identity lowers
  it 5,125-fold together.

#### Next Step

Up to this point, every lesson picked a single misconfigured item and measured its radius: an
overprivileged role, an open bucket, an environment that is not held, a long-lived token. These
were measured one by one, but how they connect to each other was not written down. The next lesson
gathers these connections at the pattern level: which item being wrong opens which path, which log
falls along that path, and which control closes the path.

### Cloud Attack Patterns

Source: https://academia.sh/en/courses/cloud-security/cloud-attack-patterns

Counting thirteen patterns on the same graph with the item-step-log-control quadruple: eight of the patterns open with a step that falls into a management-plane log, three with a step that stays only at the provider, one with a step that falls into no log at all. The pattern with the largest contribution — the instance-bound identity being given without session verification — on its own raises the radius from 6 to 23, and it is in the unlogged class. The order of controls determines the cost: by unit benefit, one control and two cost; starting from cheapest, eight controls and eight cost reach the same radius.

The four lessons up to this point measured items one at a time: a step a log did not see, a drift
born after deployment, a neighbor sharing a host, a token that outlives its owner. Each was
counted in its own context, but the connection between them was not written down. This lesson
gathers that connection at the **pattern level** and writes each pattern with four fields: which
item is misconfigured, which step it opens, which log that step falls into, which control closes
it.

The patterns are told as a **model**. No intrusion step, no call sequence, no endpoint path, no
request format, and no credential-harvesting method is written down; the code that runs is only a
graph walk and a control evaluation. Testing any of these patterns in a cloud environment, without
written authorization and a defined scope, is both illegal and unprofessional. The measurement is
on the **regional measurement network** and is **fictional**.

**CD25.** Every pattern corresponds to a misconfigured item, and that item corresponds to an edge;
if the item is closed, the edge cannot be walked.
**CD26.** The pattern's "step it opens" is the edge's step type; the log class comes from the
first lesson's classification.
**CD27.** A pattern's contribution is the number of items the radius drops by when that item alone
is closed.
**CD28.** Every pattern has a closing control; the control changes the number of preconditions
that must be satisfied for the pattern to work (model input).
**CD29.** A control's cost is the number of legitimate tasks it stops (fictional unit).
**CD30.** Ordering is done by two metrics: the control that removes the most items per unit cost,
or simply the cheapest control.

#### A Catalog at the Pattern Level

```js
// pattern.mjs — cloud attack patterns AT THE PATTERN LEVEL: which edge opens when which item is
// misconfigured, which log class that edge falls into, how far the radius drops when it is closed. IT IS A MODEL.
// No intrusion step, call sequence, endpoint path, or request format is written down.
const split = (s) => s.trim().split("\n").map((l) => l.trim().split("|"));

// Edge: source|destination|step type|duration(min)|misconfigured item that enables the edge ("-" if legitimate)
export const EDGE = split(`
  portal-service-account|portal-instance|network-connection|3|-
  portal-service-account|reader-role|role-assumption|4|-
  portal-instance|portal-instance-identity|instance-identity-retrieval|2|instance-identity-sessionless
  portal-instance-identity|subscriber-database|database-query|6|-
  portal-instance-identity|secret-store|secret-read|4|portal-identity-secret-read
  secret-store|collection-workload-identity|secret-read|3|secret-store-long-lived-identity
  collection-workload-identity|raw-meter-bucket|object-read|5|-
  collection-workload-identity|measurement-queue|queue-consumption|4|-
  collection-workload-identity|monitoring-role|role-assumption|4|role-assumption-to-workload
  monitoring-role|measurement-dashboard|resource-listing|3|-
  portal-instance-identity|billing-batch-role|role-assumption|3|portal-identity-batch-role
  billing-batch-role|billing-archive-bucket|object-read|6|-
  billing-batch-role|key-management|key-decrypt|4|key-usage-unconditional
  key-management|customer-key|key-decrypt|2|key-policy-no-condition
  billing-batch-role|backup-role|role-assumption|3|chained-role-assumption
  backup-role|backup-bucket|object-read|7|-
  backup-role|deployment-role|role-assumption|4|deployment-to-backup-role
  deployment-role|template-store|object-read|5|template-store-open-write
  deployment-role|configuration-bucket|object-read|4|-
  deployment-role|workload-instance|in-instance-execution|9|deployment-role-instance-launch
  reader-role|report-bucket|object-read|4|-
  reader-role|log-bucket|object-read|5|log-bucket-open-read
  reader-role|configuration-bucket|object-read|6|configuration-bucket-broad-read`)
  .map(([k, h, t, d, y]) => ({ src: k, dst: h, type: t, dur: +d, flaw: y }));

// Whether the step type's log reaches the tenant's hands (the first lesson's classification).
const LOG = { "role-assumption": "management", "resource-listing": "management", "secret-read": "management",
  "key-decrypt": "management", "database-query": "application", "in-instance-execution": "agentless",
  "object-read": "provider-side", "queue-consumption": "provider-side",
  "instance-identity-retrieval": "unlogged", "network-connection": "unlogged" };

// Pattern: name|triggering misconfigured item|closing control|precondition count before the control|after
// The step counts are the number of preconditions that must be satisfied for the pattern to work. MODEL input.
export const PATTERN = split(`
  identity-from-metadata-service|instance-identity-sessionless|per-session-verification|1|3
  broad-access-to-secret-store|portal-identity-secret-read|scope-limit-secret-access|1|2
  long-lived-identity-from-secret-store|secret-store-long-lived-identity|secret-store-short-lived-identity-only|1|3
  role-assumption-from-workload|role-assumption-to-workload|separate-workload-identity-from-role|1|2
  lateral-role-transition|portal-identity-batch-role|narrow-role-by-identity|1|2
  unconditional-key-usage|key-usage-unconditional|add-condition-to-key-usage|1|2
  unconditional-key-decrypt|key-policy-no-condition|add-condition-to-key-policy|1|2
  role-chain|chained-role-assumption|chain-depth-limit|1|3
  from-backup-to-deployment|deployment-to-backup-role|remove-deployment-from-backup-role|1|2
  write-to-template-store|template-store-open-write|make-template-store-read-only|1|2
  new-instance-from-deployment|deployment-role-instance-launch|remove-instance-authority-from-deployment-role|1|2
  configuration-read|configuration-bucket-broad-read|narrow-bucket-policy|1|2
  log-read|log-bucket-open-read|close-log-bucket|1|2`)
  .map(([name, item, control, before, after]) => ({ name, item, control, before: +before, after: +after }));

export const ITEMS = PATTERN.map((k) => k.item);
export const START = "portal-service-account";

// Walk with an open item set: items reached and edges walked.
export function walk(open) {
  const arrival = { [START]: 0 }, steps = [];
  for (;;) {
    let best = null;
    for (const e of EDGE) {
      if (!(e.src in arrival) || e.dst in arrival) continue;
      if (e.flaw !== "-" && !open.has(e.flaw)) continue;
      const t = arrival[e.src] + e.dur;
      if (best === null || t < best.t) best = { e, t };
    }
    if (best === null) return { items: Object.keys(arrival).length, steps };
    arrival[best.e.dst] = best.t; steps.push(best.e);
  }
}

const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0]))
  .join("");

if (import.meta.url === `file://${process.argv[1]}`) {
  const all = new Set(ITEMS), full = walk(all);
  console.log(`${PATTERN.length} patterns, ${EDGE.length} edges; radius when everything is open ` +
    `${full.items}/28, steps walked ${full.steps.length}`);
  const tbl = gen([40, 28, 14, 12, 10]);
  console.log("\n" + tbl(["pattern", "step opened", "log", "radius", "contrib"]));
  const ranked = PATTERN.map((p) => {
    const a = new Set(ITEMS); a.delete(p.item);
    const edge = EDGE.find((e) => e.flaw === p.item);
    return { ...p, type: edge.type, log: LOG[edge.type], radius: walk(a).items };
  }).sort((a, b) => a.radius - b.radius);
  for (const p of ranked)
    console.log(tbl([p.name, p.type, p.log, `${full.items} -> ${p.radius}`, full.items - p.radius]));

  const visible = ranked.filter((p) => p.log === "management").length;
  console.log(`\n${PATTERN.length} of the patterns, ${visible} open with a step that falls into ` +
    `a management-plane log, ${ranked.filter((p) => p.log === "provider-side").length} with a ` +
    `step that stays only at the provider, ${ranked.filter((p) => p.log === "unlogged").length} ` +
    `with a step that falls into no log at all`);
  const top = ranked[0];
  console.log(`the largest contribution comes from the ${top.name} pattern: on its own ` +
    `${top.radius} -> ${full.items}, that is ${full.items - top.radius} items, and the step it ` +
    `opens is in the ${top.log} class`);
  console.log(`the control that closes that pattern (${top.control}) turns the required ` +
    `precondition count ${top.before} -> ${top.after}: the software requesting the identity ` +
    `must now also be verified at the session level`);
}
```

```
13 patterns, 23 edges; radius when everything is open 23/28, steps walked 22

pattern                                                  step opened           log      radius   contrib
identity-from-metadata-service           instance-identity-retrieval      unlogged     23 -> 6        17
lateral-role-transition                              role-assumption    management    23 -> 14         9
broad-access-to-secret-store                             secret-read    management    23 -> 17         6
long-lived-identity-from-secret-store                    secret-read    management    23 -> 18         5
role-chain                                           role-assumption    management    23 -> 18         5
from-backup-to-deployment                            role-assumption    management    23 -> 20         3
role-assumption-from-workload                        role-assumption    management    23 -> 21         2
unconditional-key-usage                                  key-decrypt    management    23 -> 21         2
unconditional-key-decrypt                                key-decrypt    management    23 -> 22         1
write-to-template-store                                  object-read provider-side    23 -> 22         1
new-instance-from-deployment                   in-instance-execution     agentless    23 -> 22         1
log-read                                                 object-read provider-side    23 -> 22         1
configuration-read                                       object-read provider-side    23 -> 23         0

13 of the patterns, 8 open with a step that falls into a management-plane log, 3 with a step that stays only at the provider, 1 with a step that falls into no log at all
the largest contribution comes from the identity-from-metadata-service pattern: on its own 6 -> 23, that is 17 items, and the step it opens is in the unlogged class
the control that closes that pattern (per-session-verification) turns the required precondition count 1 -> 3: the software requesting the identity must now also be verified at the session level
```

The table should be read by a single ordering: the **contribution column**. Of the thirteen
patterns, one grows the radius by seventeen items, one by nine, one by six; the remaining ten
together do not come close to any of these. The pattern at the bottom — the configuration bucket
being open to broad reads — does not change the radius at all, because that bucket is already
reachable through a legitimate edge from the deployment role. A pattern's existence does not make
it a risk item; what measures that is the question of **how many items go missing when it is
closed**. The same measure also splits the middle of the list: two patterns each bring five items,
but because they open from different step types, they do not close together with a single
control.

The log column gives a second ordering, and it runs opposite to the first. Eight of the thirteen
patterns open with a step that falls into a management-plane log: role assumption, secret read,
key decrypt. Three open with a step that stays only at the provider, one with a step that leaves
no log at all, and one in an instance with no logging agent. The eight patterns that fall into a
log have a combined contribution of thirty-three items; the single pattern that falls into no log
has a contribution of seventeen.

#### The Pattern the Log Does Not See

That one pattern must be told by name, because it both gives the largest contribution and falls
into no log at all: **the instance-bound identity being obtained from the instance metadata
service.**

The path the pattern opens is born from a single design decision. When an instance is given an
identity, what is asked is not **who** the software requesting that identity is, but **where** it
is running. The identity is the reward for running on the instance. The consequence is this: any
software that has reached the instance through an unrelated flaw holds the identity just as much
as the application legitimately running on that instance does. A bug in a web layer, code coming
from a dependency, or a debugging tool — all of them ask for the same identity and get it. In the
graph, this is the third step of the walk, and on its own it gains seventeen items.

The second consequence is on the detection side. Granting the identity is not a management-plane
call in the tenant's account; it happens inside the instance itself. It falls into no log the
tenant can read. The step that grows the radius the most is, at the same time, the step that never
appears in any log — the blind prefix from the first lesson was born from exactly this step.

The closing control is the identity-granting side changing the question: the identity is given to
a requester verified at the session level. In the model, this raises the pattern's precondition
from one to three — running code on the instance is no longer enough; the requesting software must
also carry a session credential given to it at launch. Software that reaches the instance
afterward does not have this credential.

#### The Order of Controls

The contribution column alone does not give an order, because controls do not cost the same. The
file below puts the same thirteen controls into two separate orderings and reruns the radius at
every step: the first picks, at each step, the control that removes the most items per unit cost;
the second picks only the cheapest one. The second metric is not a made-up comparison; it is the
natural order for a team working with a constrained budget — the items that stop no one get closed
first.

```js
// priority.mjs — the order in which controls are closed determines how fast the radius drops. IT IS A MODEL.
import { PATTERN, ITEMS, walk } from "./pattern.mjs";

// The number of legitimate tasks the control stops (fictional unit, MODEL input).
const COST = Object.fromEntries(`
  per-session-verification|2          scope-limit-secret-access|1
  secret-store-short-lived-identity-only|2  separate-workload-identity-from-role|1
  narrow-role-by-identity|2        add-condition-to-key-usage|1
  add-condition-to-key-policy|1       chain-depth-limit|3
  remove-deployment-from-backup-role|1   make-template-store-read-only|2
  remove-instance-authority-from-deployment-role|1   narrow-bucket-policy|1
  close-log-bucket|1`.trim().replace(/\s{2,}/g, "\n").split("\n")
  .map((l) => { const [a, b] = l.trim().split("|"); return [a, +b]; }));

const radius = (closed) => walk(new Set(ITEMS.filter((y) =>
  !PATTERN.some((p) => p.item === y && closed.has(p.control))))).items;

// Two orderings: "unit" picks, at each step, the control that removes the most items per unit
// cost; "cheap" picks only the cheapest control.
function order(metric) {
  const closed = new Set(), seq = [];
  let now = radius(closed), cost = 0;
  while (closed.size < PATTERN.length) {
    let best = null;
    for (const p of PATTERN) {
      if (closed.has(p.control)) continue;
      const drop = now - radius(new Set([...closed, p.control]));
      const score = metric === "unit" ? drop / COST[p.control] : -COST[p.control];
      if (best === null || score > best.score) best = { p, drop, score };
    }
    closed.add(best.p.control); cost += COST[best.p.control]; now -= best.drop;
    seq.push({ control: best.p.control, drop: best.drop, cost, radius: now });
  }
  return seq;
}

const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0]))
  .join("");
const tbl = gen([6, 48, 9, 8, 40, 9, 8]);

const A = order("unit"), B = order("cheap");
console.log(tbl(["step", "by unit benefit", "radius", "cost", "cheapest first",
  "radius", "cost"]));
for (const i of [0, 1, 2, 3, 6, 9, 12])
  console.log(tbl([i + 1, A[i].control, `${A[i].radius}/28`, A[i].cost,
    B[i].control, `${B[i].radius}/28`, B[i].cost]));

const first = (s, y) => s.findIndex((x) => x.radius <= y);
console.log(`\nbringing the radius below 10: by unit benefit ${first(A, 10) + 1} controls and ` +
  `${A[first(A, 10)].cost} cost; starting from cheapest ${first(B, 10) + 1} controls and ` +
  `${B[first(B, 10)].cost} cost`);
console.log(`the first control alone brings the radius from 23 -> ${A[0].radius} and ` +
  `costs ${A[0].cost}; the remaining ${PATTERN.length - 1} controls bring ` +
  `${A.at(-1).cost - A[0].cost} more cost for ${A[0].radius - A.at(-1).radius} more items`);
const zero = A.filter((s) => s.drop === 0);
console.log(`${zero.length} controls remove no items at all and cost a total of ` +
  `${zero.reduce((t, s) => t + COST[s.control], 0)}; when all thirteen are closed, ` +
  `the radius is ${A.at(-1).radius}/28 and the total cost is ${A.at(-1).cost}`);
```

```
step                                   by unit benefit   radius    cost                          cheapest first   radius    cost
1                             per-session-verification     6/28       2               scope-limit-secret-access    17/28       1
2                                 narrow-bucket-policy     5/28       3    separate-workload-identity-from-role    17/28       2
3                                     close-log-bucket     4/28       4              add-condition-to-key-usage    15/28       3
4                            scope-limit-secret-access     4/28       5             add-condition-to-key-policy    15/28       4
7                              narrow-role-by-identity     4/28      10                    narrow-bucket-policy    11/28       7
10                                   chain-depth-limit     4/28      15  secret-store-short-lived-identity-only     4/28      12
13      remove-instance-authority-from-deployment-role     4/28      19                       chain-depth-limit     4/28      19

bringing the radius below 10: by unit benefit 1 controls and 2 cost; starting from cheapest 8 controls and 8 cost
the first control alone brings the radius from 23 -> 6 and costs 2; the remaining 12 controls bring 17 more cost for 2 more items
10 controls remove no items at all and cost a total of 15; when all thirteen are closed, the radius is 4/28 and the total cost is 19
```

#### The Cost of Ordering

Both columns apply the same thirteen controls to the same graph and arrive at the same place: when
all thirteen are closed, the radius is 4 and the total cost is 19. The difference is along the
way. In the by-unit-benefit ordering, **the first control** brings the radius from 23 down to 6
and costs two; the remaining twelve controls spend seventeen cost to remove only two more items.
In the cheapest-first ordering, the same radius arrives only after eight controls and a cost of
eight.

Two sentences follow from this. First: **the number of controls is not a measure.** At the fourth
step of the second column, four controls have been closed and the radius is still 15; at the first
step of the first column, one control has been closed and the radius is 6. The question "how many
controls were applied" cannot stand in for the question "how far did the radius drop." Second: ten
of the thirteen controls remove no items at all in this graph and together cost fifteen. Every one
of them is individually defensible — adding a condition to the key policy, making the template
store read-only, limiting the chain depth — but **in this graph** their contribution is zero,
because the target of the edges they close is also reachable through another edge.

This does not mean those controls are unnecessary; it means the measure depends on the graph. When
the graph changes — an edge becomes legitimate, an item is added — a zero-contribution control can
suddenly become the highest-contribution one. This is why the ordering is not done once and set
aside; it is rerun with every configuration change.

#### Summary

- Thirteen patterns were written on the same graph with the item, the step it opens, the log
  class, and the closing control as a quadruple; contributions start at 17, 9, 6, 5, 5 and drop to
  zero.
- Eight of the patterns open with a step that falls into a management-plane log, three with a step
  that stays only at the provider, one with a step that falls into no log at all, one in an
  agentless instance.
- The pattern with the largest contribution — the instance-bound identity being granted based on
  where it runs — brings seventeen items on its own and is in the unlogged class; it never appears
  in any log.
- The control that closes that pattern changes the question: the identity is given to a requester
  verified at the session level, and the pattern's precondition rises from one to three.
- One pattern does not change the radius at all, because its target is also reachable through a
  legitimate edge; ten of the thirteen controls remove zero items in this graph and cost fifteen.
- The ordering determines the cost: by unit benefit, one control and a cost of two; starting from
  cheapest, eight controls and a cost of eight reach the same radius.

#### Next Step

The patterns have been counted, their logs classified, and the closing controls ordered. One
question remains: what can be collected when one of these patterns **actually works**. Earlier
lessons showed that a portion of the evidence cannot be collected; the next lesson counts this by
comparing it against a host image — how many items collectible on a host cannot be collected in
the cloud, at which plane the isolation is done, and what evidentiary value a snapshot has.

### Cloud Differences in Incident Response

Source: https://academia.sh/en/courses/cloud-security/cloud-differences-in-incident-response

Counting how collecting evidence in the cloud differs from a host image: of 20 evidence items, 16 are collectible on a host you manage yourself, and of these, 4 are not collectible on a managed instance, 11 on a container service, 14 in a function environment. Isolation can be done from four planes: the identity plane cuts at minute 11 and stops the radius at 13, preserving all five volatile evidence items, while terminating the instance gives the same radius by erasing all five volatile items. The snapshot carries 10 of the 16 items, and verification for all of them rests on the provider's attestation.

The previous lesson counted the patterns and put the closing controls in order. One question
remained: what can be collected when one of these patterns actually occurs. The incident response
lifecycle, evidence integrity, chain of custody, and write-protected copying were measured in the
**Security Operations and Incident Response** course; the response steps are not repeated here.
The only thing this lesson measures is the **difference**: how many items collectible on a host
you manage yourself are not collectible on rented infrastructure, from which plane isolation is
done, and what evidentiary value a snapshot has.

The measurement is on the **regional measurement network**, is **fictional**, and is a **model**
written with `node`.

**CD31.** Twenty evidence items are marked in four columns: the host you manage yourself, the
managed instance, the managed container service, the function execution environment.
**CD32.** The mark has three values: the tenant collects it directly, it comes only by request to
the provider, it cannot be collected.
**CD33.** For every item, who can perform verification is written down: the tenant, the provider's
attestation, or no one.
**CD34.** Five items are **volatile**: they disappear when the workload ends. The snapshot does
not carry the volatile class or deleted-file remnants.
**CD35.** Isolation can be done from four planes; each plane's application time, the edge class it
cuts, and the number of legitimate tasks it stops are written into the model (fictional). The
moment the alert drops is minute 9, taken from the first lesson.

#### Collected on the Host, Not Collectible in the Cloud

```js
// evidence.mjs — collectibility in the cloud of evidence items collectible on a host. IT IS A MODEL.
// The evidence items themselves and the chain of custody were measured in the Security Operations
// and Incident Response course; here only the difference between HOST and CLOUD is counted.
const split = (s) => s.trim().split("\n").map((l) => l.trim().split("|"));

// Item: name|host|managed instance|container service|function environment|who can verify
// 1 = tenant collects directly, 2 = only comes by request to the provider, 0 = cannot be collected
export const ITEM = split(`
  full-disk-image|1|2|0|0|provider
  memory-dump|1|0|0|0|-
  deleted-file-remnants|1|0|0|0|-
  hardware-layer-log|1|0|0|0|-
  hypervisor-log|1|0|0|0|-
  filesystem-timestamps|1|2|0|0|provider
  process-list|1|1|1|0|tenant
  open-network-connections|1|1|1|0|tenant
  loaded-kernel-modules|1|1|0|0|tenant
  shell-history|1|1|0|0|tenant
  scheduled-tasks|1|1|0|0|tenant
  local-user-accounts|1|1|0|0|tenant
  cache-and-temp-files|1|1|0|0|tenant
  system-logs|1|1|1|0|tenant
  application-logs|1|1|1|1|tenant
  network-flow-log|1|1|1|1|provider
  management-plane-log|0|1|1|1|provider
  call-metadata|0|1|1|1|provider
  snapshot-metadata|0|1|1|0|provider
  image-authentication|0|1|1|1|provider`)
  .map(([name, k, o, c, i, d]) => ({ name, host: +k, instance: +o, container: +c, func: +i, verify: d }));

const FORM = [["managed instance", "instance"], ["container service", "container"],
  ["function environment", "func"]];

const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0]))
  .join("");

// Volatile items: disappear when the workload ends. The snapshot carries the disk class.
export const VOLATILE = ["memory-dump", "process-list", "open-network-connections",
  "loaded-kernel-modules", "cache-and-temp-files"];
export const SNAPSHOT_EXCLUDED = [...VOLATILE, "deleted-file-remnants"];

const onHost = ITEM.filter((k) => k.host === 1);

if (import.meta.url === `file://${process.argv[1]}`) {
  const tbl = gen([22, 8, 15, 17, 17, 16]);
  console.log(`${ITEM.length} evidence items; collectible on a host you manage yourself ` +
    `${onHost.length}, not present on the host at all ${ITEM.length - onHost.length}`);
  console.log("\n" + tbl(["deployment form", "direct", "from provider", "not collectible",
    "missing on host", "new from cloud"]));
  for (const [name, field] of FORM) {
    const direct = ITEM.filter((k) => k[field] === 1).length;
    const provider = ITEM.filter((k) => k[field] === 2).length;
    const missing = onHost.filter((k) => k[field] === 0).length;
    const fresh = ITEM.filter((k) => k.host === 0 && k[field] >= 1).length;
    console.log(tbl([name, direct, provider, ITEM.length - direct - provider, missing, fresh]));
  }

  const D = ["tenant", "provider", "-"];
  console.log("\n" + tbl(["verified by", "items", "on host", "on instance", "in container", "in function"]));
  for (const d of D) {
    const g = ITEM.filter((k) => k.verify === d);
    console.log(tbl([d === "-" ? "none" : d, g.length, g.filter((k) => k.host === 1).length,
      g.filter((k) => k.instance >= 1).length, g.filter((k) => k.container >= 1).length,
      g.filter((k) => k.func >= 1).length]));
  }

  const tenantVerify = ITEM.filter((k) => k.verify === "tenant");
  console.log(`\nof the ${onHost.length} items collectible on the host, ` +
    `${onHost.filter((k) => k.instance === 0).length} are not collectible on a managed instance, ` +
    `${onHost.filter((k) => k.container === 0).length} on a container service, ` +
    `${onHost.filter((k) => k.func === 0).length} in a function environment`);
  console.log(`in return, the cloud gives ${ITEM.filter((k) => k.host === 0).length} items: ` +
    `verification for all of them rests on the provider's attestation`);
  console.log(`of the ${tenantVerify.length} items the tenant can verify itself, ` +
    `${tenantVerify.filter((k) => k.func >= 1).length} remains in the function environment; for ` +
    `everything else, verification is the provider's attestation`);
}
```

```
20 evidence items; collectible on a host you manage yourself 16, not present on the host at all 4

deployment form         direct  from provider  not collectible  missing on host  new from cloud
managed instance            14              2                4                4               4
container service            9              0               11               11               4
function environment         5              0               15               14               3

verified by              items        on host      on instance     in container     in function
tenant                       9              9                9                4               1
provider                     7              3                7                5               4
none                         4              4                0                0               0

of the 16 items collectible on the host, 4 are not collectible on a managed instance, 11 on a container service, 14 in a function environment
in return, the cloud gives 4 items: verification for all of them rests on the provider's attestation
of the 9 items the tenant can verify itself, 1 remains in the function environment; for everything else, verification is the provider's attestation
```

Sixteen of the twenty items are collectible on a host you manage yourself. On a managed instance,
four of that number drop away, and the ones that drop are not random: memory dump, deleted file
remnants, hardware-layer log, hypervisor log. All four come from **the layer beneath the host**,
and that layer does not belong to the tenant. On a container service, eleven items become
uncollectible; in a function execution environment, fourteen do. Of the five items that remain in
the function environment, four are the provider's own logs.

The bill is not one-sided. The cloud gives four items that do not exist on a host at all:
management-plane log, call metadata, snapshot metadata, and image authentication. These are real
evidence items with no counterpart on a host you manage yourself. But verification for all four
rests on the **provider's attestation**: the tenant cannot reach the plane where these records are
produced, and therefore cannot independently recompute their contents.

The table below turns this distinction into a number. There are nine items the tenant can verify
itself, and all nine exist on the host; on a managed instance all nine remain, on a container
service that drops to four, and in the function environment it drops to **one**. The decline in
evidence and the decline in verifiability move in the same direction, and in the function
environment both bottom out together.

#### Which Plane Isolation Is Done From

```js
// response.mjs — from which plane isolation is done determines where the radius stops and how
// many volatile evidence items are lost. IT IS A MODEL. The response lifecycle was measured in
// the earlier course; here only the plane choice is counted.
import { ITEM, VOLATILE, SNAPSHOT_EXCLUDED } from "./evidence.mjs";

const split = (s) => s.trim().split("\n").map((l) => l.trim().split("|"));
const EDGE = split(`
  portal-service-account|portal-instance|network|3
  portal-service-account|reader-role|management|4
  portal-instance|portal-instance-identity|in-instance|2
  portal-instance-identity|subscriber-database|network|6
  portal-instance-identity|secret-store|management|4
  secret-store|collection-workload-identity|management|3
  collection-workload-identity|raw-meter-bucket|network|5
  collection-workload-identity|measurement-queue|network|4
  collection-workload-identity|monitoring-role|management|4
  monitoring-role|measurement-dashboard|management|3
  portal-instance-identity|billing-batch-role|management|3
  billing-batch-role|billing-archive-bucket|network|6
  billing-batch-role|key-management|management|4
  key-management|customer-key|management|2
  billing-batch-role|backup-role|management|3
  backup-role|backup-bucket|network|7
  backup-role|deployment-role|management|4
  deployment-role|template-store|network|5
  deployment-role|configuration-bucket|network|4
  deployment-role|workload-instance|in-instance|9
  reader-role|report-bucket|network|4
  reader-role|log-bucket|network|5
  reader-role|configuration-bucket|network|6`).map(([k, h, d, s]) => ({ src: k, dst: h, plane: d, dur: +s }));

const ALERT = 9;   // the first visible log moment measured in the first lesson (min)

// Isolation plane: name|application time (min)|edge planes it cuts|is volatile evidence preserved|
// legitimate tasks stopped (fictional unit)
const PLANE = split(`
  no isolation|0|-|1|0
  network plane|6|network|1|4
  identity plane|2|management in-instance|1|3
  instance plane|3|network management in-instance|0|2
  account plane|45|network management in-instance|1|8`)
  .map(([name, t, cuts, vol, cost]) => ({ name, time: +t, cuts: cuts.split(" "), volatile: +vol, cost: +cost }));

// At the moment of the cut, edges in the planes cut can no longer be walked.
function walk(p) {
  const cut = p.time === 0 ? Infinity : ALERT + p.time;
  const arrival = { "portal-service-account": 0 };
  for (;;) {
    let best = null;
    for (const e of EDGE) {
      if (!(e.src in arrival) || e.dst in arrival) continue;
      const t = arrival[e.src] + e.dur;
      if (t > cut && p.cuts.includes(e.plane)) continue;
      if (best === null || t < best.t) best = { e, t };
    }
    if (best === null) return { items: Object.keys(arrival).length, cut };
    arrival[best.e.dst] = best.t;
  }
}

const gen = (W) => (row) => row.map((v, j) => j ? String(v).padStart(W[j]) : String(v).padEnd(W[0]))
  .join("");
const tbl = gen([16, 9, 9, 8, 15, 15]);

console.log(tbl(["plane", "applies", "cuts at", "radius", "volatile lost", "tasks stopped"]));
for (const p of PLANE) {
  const r = walk(p);
  console.log(tbl([p.name, p.time ? `${p.time} min` : "-", r.cut === Infinity ? "-" : `min ${r.cut}`,
    `${r.items}/28`, p.volatile ? `0/${VOLATILE.length}` : `${VOLATILE.length}/${VOLATILE.length}`, p.cost]));
}

const identity = walk(PLANE[2]), instance = walk(PLANE[3]), account = walk(PLANE[4]);
console.log(`\nthe fastest plane is identity: it cuts at minute ${identity.cut} and stops the ` +
  `radius at ${identity.items}/28; all ${VOLATILE.length} volatile evidence items stay in place`);
console.log(`terminating the instance brings the radius to ${instance.items}/28 but erases all ` +
  `${VOLATILE.length} volatile items; account freeze arrives at minute ${account.cut} and the ` +
  `radius becomes ${account.items}/28`);

// The snapshot's evidentiary value: which items it carries, how many the tenant can verify.
const carried = ITEM.filter((k) => k.host === 1 && !SNAPSHOT_EXCLUDED.includes(k.name));
console.log(`\nthe snapshot carries ${carried.length} of the ${ITEM.filter((k) => k.host === 1).length} ` +
  `items collectible on the host, and does not carry ${SNAPSHOT_EXCLUDED.length}; the content of ` +
  `${carried.filter((k) => k.verify === "tenant").length} of what it carries can be read by the tenant`);
console.log(`the source's hash cannot be recomputed: there is no offline access to the raw device, ` +
  `so for all ${carried.length} items where the snapshot matches the source, verification rests ` +
  `on the provider's attestation`);
```

```
plane             applies  cuts at  radius  volatile lost  tasks stopped
no isolation            -        -   23/28            0/5              0
network plane       6 min   min 15   19/28            0/5              4
identity plane      2 min   min 11   13/28            0/5              3
instance plane      3 min   min 12   13/28            5/5              2
account plane      45 min   min 54   23/28            0/5              8

the fastest plane is identity: it cuts at minute 11 and stops the radius at 13/28; all 5 volatile evidence items stay in place
terminating the instance brings the radius to 13/28 but erases all 5 volatile items; account freeze arrives at minute 54 and the radius becomes 23/28

the snapshot carries 10 of the 16 items collectible on the host, and does not carry 6; the content of 5 of what it carries can be read by the tenant
the source's hash cannot be recomputed: there is no offline access to the raw device, so for all 10 items where the snapshot matches the source, verification rests on the provider's attestation
```

The table runs all four planes on the same timeline. The alert drops at minute 9 and the walk ends
at minute 24; the fifteen minutes in between are the entire window in which the decision can be
made. The network plane is applied in six minutes, cuts at minute 15, and stops the radius at 19 —
because management-plane calls do not travel over the network, the identity is still valid. The
identity plane is applied in two minutes, cuts at minute 11, and stops the radius at **13**: the
best outcome, at the second-lowest cost.

The instance plane gives the same radius, 13. The difference is in the evidence column:
terminating the instance erases **all five at once** of the five volatile items. Same radius, five
fewer items of evidence. This is the trade-off at the center of the lesson, and it is what
simplifies the decision — if two options stop the same thing, the one that preserves the evidence
is chosen.

The account plane is the last row, and it arrives at minute 54 because of its forty-five-minute
approval time. Since the walk ends at minute 24, it cuts nothing at all: the radius stays at 23,
and all eight legitimate tasks stop. The broadest authority, the highest cost, zero effect.

#### The Snapshot's Evidentiary Value

The snapshot carries ten of the sixteen items collectible on the host and does not carry six: the
five volatile items plus deleted file remnants. Of the ten it carries, the content of five can be
read by the tenant.

The real limit is on the verification side. A copy's evidentiary value rests on the source's hash
being recomputable. On rented infrastructure, the tenant has no offline access to the raw device;
therefore, for all ten items, the fact that the image matches the source is **the provider's
attestation.** A snapshot does not produce evidence, it carries evidence; the trust it produces
comes from the provider's signature. This does not make the image worthless — it is the one
mechanism that preserves most of the items collectible on the host, and if taken before the
instance is terminated, it also leaves the volatile class in place. All it has to do is state its
limit: at which plane the record is held and whom the verification rests on are written in the
investigation report, because the other side will ask.

#### Summary

- Sixteen of the twenty evidence items are collectible on a host you manage yourself; four are not
  collectible on a managed instance, eleven on a container service, fourteen in a function
  environment.
- The four items that drop away on a managed instance come from the layer beneath the host: memory
  dump, deleted file remnants, hardware-layer log, hypervisor log.
- The cloud gives four items that do not exist on a host, but verification for all four rests on
  the provider's attestation; the nine items the tenant can verify itself drop to one in the
  function environment.
- Isolation from the identity plane cuts at minute 11, stops the radius at 13, and preserves all
  five volatile items; terminating the instance gives the same radius and erases all five at once.
- The account plane arrives at minute 54 with a forty-five-minute approval; since the walk ends at
  minute 24, the radius stays at 23 and all eight legitimate tasks stop.
- The snapshot carries ten of the sixteen items and does not carry six; because the source's hash
  cannot be recomputed, verification for everything it carries is the provider's attestation.

#### Course Wrap-Up

| Lesson | Single misconfigured item | Blast radius | What the control removes |
|---|---|---|---|
| Service models | unowned item | 14 items off the list | 0 items, 28 units blind |
| Identity and access | leaked service account | 9/18 items | to 4, 4 tasks/day |
| Network isolation | endpoint open to external access | 6/9 from outside | to 4, 0 tasks/day |
| Storage exposure | open configuration item | 10 items | to 5, 9 accesses/day |
| Key and secret management | key permission in the chain | 16 items | to 6, 1 operation/day |
| Infrastructure as code | wrong field in the template | 34-unit radius share | to 11 units, 3 deployments stopped |
| Audit logs | leaked service account | 23/28 items | to 18, 3 legitimate tasks |
| Posture management | drift born while running | 23/28, open 153 hours | to 6, to 2 hours |
| Container and hypervisor | compartment boundary failing | worst case 18/28 | 0 cross-tenant, average 8.5 |
| Serverless security | long-lived token | 7,200,000 item-seconds | to 3,514, 2,049x |
| Cloud attack patterns | sessionless instance identity | 23/28 items | to 6, 2 cost |
| Cloud incident response | isolation plane not chosen | 23/28 items | to 13, 3 legitimate tasks |

The table makes the course's rule readable in a single line: **the measure of security in the
cloud is not how many controls are open, but how many items are reachable from a single
misconfigured item.** If that number does not drop, the control added has only provided
visibility. Three examples of this came up over the course: enabling logging raised the visible
step count from eleven to nineteen and left the radius at 23; the admission gate closed two more
items and left the radius at eight; guest-system isolation raised the item count needed to cross
the boundary from one to three and left the radius at eighteen. All three are useful, all three
have a cost, and all three are zero on this measure.

Across nine courses, the path each defense closed and that closure's cost to legitimate work were
counted. One question was never asked: which of these should be done **first**. The last two
lessons of this course touched that question for the first time — one of thirteen controls removes
seventeen items for a cost of two, ten remove zero for a cost of fifteen — but the metric that
produced the ordering came from a single graph. In a real organization, budget is not the only
scarce thing: attention, the approval window, and change capacity are scarce too. **M24/K10
Governance, Risk and Compliance** takes up these three questions: which defense to do first, how
to allocate a scarce budget, and how to prove to someone that it was done.

