---
title: 'Audit Logs'
source: 'https://academia.sh/en/courses/cloud-security/audit-logs'
course: 'Cloud and Virtualization Security'
language: en
updated: '2026-09-26T03:02:50+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Classifies the steps of the spread walk by who owns the log entry and counts the steps the tenant can never see'
    - 'Measures the items reached by the time the first log entry drops and compares the radius reconstructed from the log with the real radius'
    - 'Separates, on the same graph, the control that increases visibility from the control that lowers the radius, and states the legitimate-work cost of each'
---

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