Skip to content
academia.sh

Lesson 14 / 15

Continuous Configuration Audit

The 147 open findings of continuous audit are run through the ownership resolution chain: the owner label resolves 65 of them, namespace ownership 24 and repository path mapping 18, and 40 (27.2%) remain unowned. 81 of the findings fall to application teams and 26 stay with the platform; because the capacity of the 40 unowned findings is zero, the queue grows to 112 in twelve weeks while the backlog of the fixing teams shrinks. 31 of the unowned findings are on resources outside the cluster, and no cluster mechanism can resolve them; 122 of the 147 findings could have been closed at an earlier stage, but for 25 of the 40 unowned findings there is no earlier stage.

Published Updated

In this lesson

  • Runs the findings of continuous audit through the ownership resolution chain and counts separately the findings that fall to an application team, those that stay with the platform and those that are unowned
  • Runs the queue for twelve weeks together with capacity and measures why the unowned finding does not shrink
  • Ties the scope of the ownership levers and the stage at which the findings can be closed to numbers
Contents

The gate of the previous lesson saw only the object that was sent, and all ten rules read the declared fields of the object. Even if the gate is tightened today, objects accepted yesterday stay where they are; storage, network and identity definitions outside the cluster pass through no admission gate at all. Only a continuously running audit scans that area.

This lesson measures that audit, but not the number of findings. Which misconfiguration closes at which gate was measured in the Cloud and Virtualization Security course and is not repeated here. The measure here is a single question: whose job does a finding fall to. How many findings return to an application team, how many stay with the platform team and how many have no owner at all. An unowned finding is different from an unmeasured finding: it is visible on the dashboard, it enters the count, it is reported, and no one closes it.

  • IP18. The audit scans the whole of the fictional regional measurement network: cluster objects and the storage, network, identity and registry resources outside the cluster. The finding inventory is a model built with node.
  • IP19. The content of the rule set is not measured; the numbers are read from the open finding inventory. As long as the same resource violates the same rule it is counted as a single finding.
  • IP20. The ownership resolution chain has three steps and they are tried in order: the owner label of the resource, the namespace ownership table, the repository path mapping. The first matching step wins; if none matches the finding is unowned.
  • IP21. At which step a finding is resolved and to which team it falls is given in the model. In production this is read from the actual state of the inventory that day and changes over time.
  • IP22. Capacity is fixed per week and counts only the item set aside for these findings. The queue runs by max(0, queue + new - capacity); the difference in how hard the findings are to close is not modeled.

The Finding Inventory and the Ownership Chain

// finding.mjs — the open finding inventory of continuous audit and the ownership chain. IT IS A MODEL.
// [name, resource type, open findings, severity, step where ownership resolves, team, earlier closing stage]
export const FINDINGS = [
  ['no pod resource limit', 'cluster', 18, 'medium', 'label', 'subscriber', 'deploy'],
  ['root filesystem not read-only', 'cluster', 14, 'medium', 'label', 'field', 'deploy'],
  ['secret in environment variable', 'cluster', 6, 'high', 'label', 'billing', 'code'],
  ['mutable image tag', 'cluster', 9, 'low', 'namespace', 'subscriber', 'build'],
  ['unsigned image', 'registry', 7, 'high', 'namespace', 'field', 'build'],
  ['broadly accessible storage bucket', 'storage', 11, 'high', 'label', 'measurement', 'design'],
  ['unencrypted backup volume', 'storage', 5, 'high', 'repo', 'platform', 'design'],
  ['open management port', 'network', 9, 'high', 'none', '-', 'design'],
  ['unused identity definition', 'identity', 16, 'low', 'none', '-', 'none'],
  ['overly permissive role binding', 'identity', 8, 'high', 'namespace', 'platform', 'design'],
  ['resource with logging disabled', 'network', 13, 'low', 'repo', 'platform', 'deploy'],
  ['outdated runtime version', 'cluster', 16, 'medium', 'label', 'measurement', 'build'],
  ['old namespace left without an owner', 'cluster', 9, 'medium', 'none', '-', 'none'],
  ['hand-created network rule', 'network', 6, 'medium', 'none', '-', 'deploy'],
];

export const CHAIN = ['label', 'namespace', 'repo'];
export const STEP_NAME = { label: 'owner label', namespace: 'namespace ownership', repo: 'repo path mapping' };
// The item a team sets aside for security findings per week; the unowned finding has no capacity.
export const CAPACITY = { subscriber: 4, billing: 2, field: 3, measurement: 5, platform: 3, unowned: 0 };
export const NEW_PER_WEEK = 22; // new findings per week; tied to the deployment rate, given in the model

export const side = (b) => (b[4] === 'none' ? 'unowned' : b[5] === 'platform' ? 'platform' : 'application');
export const bucket = (b) => (b[4] === 'none' ? 'unowned' : b[5]);
export const sum = (f) => FINDINGS.filter(f).reduce((a, b) => a + b[2], 0);
export const gen = (B) => (h) => h.map((v, j) => j ? String(v).padStart(B[j]) : String(v).padEnd(B[0])).join('');

The three steps of the chain read from three different places and all three are an inventory decision: has a label been put on the resource, is the namespace tied to a team, does the file that defines the resource sit under a repository path. None of them is a decision of the audit itself.

Whose Job the Finding Falls To

// run1.mjs — the ownership chain is run: whose job does the finding fall to
import { FINDINGS, CHAIN, STEP_NAME, side, sum, gen } from './finding.mjs';

const SEV = ['high', 'medium', 'low'];
const TOTAL = sum(() => true);
console.log(`${FINDINGS.length} finding classes; open findings ${TOTAL} ` +
  `(${SEV.map((s) => `${s} ${sum((b) => b[3] === s)}`).join(' / ')})\n`);

const hs = gen([22, 8, 12, 9]);
console.log(hs(['resolution step', 'findings', 'cumulative', 'share']));
let k = 0;
for (const a of CHAIN) {
  k += sum((b) => b[4] === a);
  console.log(hs([STEP_NAME[a], sum((b) => b[4] === a), k, `${(100 * k / TOTAL).toFixed(1)}%`]));
}
console.log(hs(['unresolved', TOTAL - k, TOTAL, `${(100 * (TOTAL - k) / TOTAL).toFixed(1)}%`]));

const ht = gen([22, 10, 9, 8, 9]);
console.log('\n' + ht(['who gets the finding', 'findings', ...SEV]));
for (const [name, t] of [['application teams', 'application'], ['platform team', 'platform'], ['unowned', 'unowned']])
  console.log(ht([name, sum((b) => side(b) === t), ...SEV.map((s) => sum((b) => side(b) === t && b[3] === s))]));
const un = FINDINGS.filter((b) => side(b) === 'unowned');
console.log(`unowned findings in ${un.length} classes: ` + un.map((b) => `${b[0]} (${b[2]})`).join(', '));
14 finding classes; open findings 147 (high 46 / medium 63 / low 38)

resolution step       findings  cumulative    share
owner label                 65          65    44.2%
namespace ownership         24          89    60.5%
repo path mapping           18         107    72.8%
unresolved                  40         147    27.2%

who gets the finding    findings     high  medium      low
application teams             81       24      48        9
platform team                 26       13       0       13
unowned                       40        9      15       16
unowned findings in 4 classes: open management port (9), unused identity definition (16), old namespace left without an owner (9), hand-created network rule (6)

The first step of the chain is the strongest: the owner label alone finds the owner of sixty-five findings, forty-four percent of the inventory. The second and third steps resolve twenty-four and eighteen findings more. The three steps together reach seventy-three percent and stop there. The remaining forty findings are items the audit sees but cannot send to anyone.

The lower table gives the real distribution. Eighty-one findings return to application teams, twenty-six stay with the platform team and forty are unowned. The severity columns separate these three buckets from each other: half of the platform team’s findings are high severity, and the majority of the application teams’ findings are medium. The unowned bucket holds nine high severity findings, and all nine are open management ports. This shows that being unowned is not limited to low severity items — the reason a finding is unowned is not that it is unimportant but that the resource has not been tied to anyone in the inventory.

If the Queue Runs for Twelve Weeks

// run2.mjs — the twelve-week course of the queue, the source of unownedness and the early stage
import { FINDINGS, CAPACITY, NEW_PER_WEEK, bucket, sum, gen } from './finding.mjs';

const TOTAL = sum(() => true), WEEKS = 12;
const hq = gen([12, 7, 15, 10, 14, 11]);
console.log(hq(['bucket', 'open', 'new per week', 'capacity', 'net per week', `week ${WEEKS}`]));
const last = {};
for (const bk of Object.keys(CAPACITY)) {
  const open = sum((b) => bucket(b) === bk), added = NEW_PER_WEEK * open / TOTAL, net = added - CAPACITY[bk];
  let q = open;
  for (let w = 0; w < WEEKS; w++) q = Math.max(0, q + net);
  last[bk] = Math.round(q);
  console.log(hq([bk, open, added.toFixed(1), CAPACITY[bk], net.toFixed(1), last[bk]]));
}
const owned = Object.keys(CAPACITY).filter((k) => k !== 'unowned');
const cap = owned.reduce((a, k) => a + CAPACITY[k], 0);
const unOpen = sum((b) => bucket(b) === 'unowned'), need = NEW_PER_WEEK * unOpen / TOTAL + unOpen / WEEKS;
console.log(`total backlog ${TOTAL} -> ${Object.values(last).reduce((a, b) => a + b, 0)}; owned buckets ` +
  `${TOTAL - unOpen} -> ${owned.reduce((a, k) => a + last[k], 0)}, unowned bucket ${unOpen} -> ${last.unowned}`);
console.log(`clearing the unowned bucket in ${WEEKS} weeks takes ${need.toFixed(1)} items a week: ` +
  `${(need / Math.max(...owned.map((k) => CAPACITY[k]))).toFixed(1)} times the capacity of the largest single team, ` +
  `${(100 * need / cap).toFixed(0)}% of the total capacity of the five teams (${cap})`);

const un = (f) => sum((b) => bucket(b) === 'unowned' && f(b));
const inside = un((b) => b[1] === 'cluster'), allUn = un(() => true), high = un((b) => b[3] === 'high');
console.log(`\nof the ${allUn} unowned findings ${inside} are in cluster objects, ${allUn - inside} in resources outside the cluster ` +
  `(${[...new Set(FINDINGS.filter((b) => bucket(b) === 'unowned' && b[1] !== 'cluster').map((b) => b[1]))].join(', ')})`);

const LEVERS = [['namespace ownership row', inside, high - un((b) => b[1] === 'cluster' && b[3] === 'high'), 'only cluster objects'],
  ['owner field in resource inventory', allUn, 0, 'all resources'],
  ['muting the low severity class', un((b) => b[3] === 'low'), high, 'finding disappears, resource stays'],
  ['owner label at the admission gate', 0, high, 'only newly created resources']];
const hl = gen([34, 10, 11, 16, 35]);
console.log('\n' + hl(['lever', 'resolved', 'remaining', 'remaining high', 'scope']));
for (const [name, c, h, scope] of LEVERS) console.log(hl([name, c, allUn - c, h, scope]));
console.log(`the admission gate does not touch the existing ${allUn} findings; it lowers the cluster share of weekly new unowned ` +
  `from ${(NEW_PER_WEEK * inside / TOTAL).toFixed(1)} to 0`);

const STAGES = ['design', 'build', 'code', 'deploy', 'none'];
const hg = gen([14, 10, 9]);
console.log('\n' + hg(['earlier stage', 'findings', 'unowned']));
for (const s of STAGES) console.log(hg([s, sum((b) => b[6] === s), un((b) => b[6] === s)]));
const early = sum((b) => b[6] !== 'none');
console.log(`${early} findings (${(100 * early / TOTAL).toFixed(0)}%) could have been closed at an earlier stage; ` +
  `for ${un((b) => b[6] === 'none')} of the ${allUn} unowned findings there is no earlier stage`);
bucket         open   new per week  capacity  net per week    week 12
subscriber       27            4.0         4           0.0         27
billing           6            0.9         2          -1.1          0
field            21            3.1         3           0.1         23
measurement      27            4.0         5          -1.0         15
platform         26            3.9         3           0.9         37
unowned          40            6.0         0           6.0        112
total backlog 147 -> 214; owned buckets 107 -> 102, unowned bucket 40 -> 112
clearing the unowned bucket in 12 weeks takes 9.3 items a week: 1.9 times the capacity of the largest single team, 55% of the total capacity of the five teams (17)

of the 40 unowned findings 9 are in cluster objects, 31 in resources outside the cluster (network, identity)

lever                               resolved  remaining  remaining high                              scope
namespace ownership row                    9         31               9               only cluster objects
owner field in resource inventory         40          0               0                      all resources
muting the low severity class             16         24               9 finding disappears, resource stays
owner label at the admission gate          0         40               9       only newly created resources
the admission gate does not touch the existing 40 findings; it lowers the cluster share of weekly new unowned from 1.3 to 0

earlier stage   findings  unowned
design                33        9
build                 32        0
code                   6        0
deploy                51        6
none                  25       25
122 findings (83%) could have been closed at an earlier stage; for 25 of the 40 unowned findings there is no earlier stage

The upper table sends six buckets in different directions. The capacity of the billing and measurement teams is above the incoming load, so their backlog shrinks: billing goes to zero in twelve weeks, measurement goes from twenty-seven to fifteen. The subscriber team stands in exact balance. The field and platform teams fall a little behind every week, and the platform’s backlog rises from twenty-six to thirty-seven. All of these are manageable numbers, and what they have in common is that each has an owner.

The sixth row is something else. In the unowned bucket capacity is zero, because capacity is a concept that belongs to a team and this bucket has no team. The forty findings receive six new findings every week, none ever closes, and in twelve weeks they rise to one hundred twelve. While the total backlog of the five buckets that have an owner falls from one hundred seven to one hundred two in the same period, the total inventory rises from one hundred forty-seven to two hundred fourteen: the whole of the increase comes from a single bucket, and that bucket responds to no fixing effort.

The last line writes the price of this. Clearing the unowned bucket in twelve weeks takes a capacity of nine point three items a week. This number is one point nine times the capacity of the largest single team and fifty-five percent of the total capacity of the five teams. That is, if the unowned findings were handed over to one team in bulk, that team’s whole capacity would not be enough; not even the capacity of the two largest teams would be enough, the share of a third team would be needed, and those teams would have to give up their own findings. The cost of being unowned is not that no one looks, but that the number to be paid if someone were to look is this large.

Who Will Give the Ownership

The middle block separates where the unownedness comes from: of the forty findings only nine are in a cluster object, and thirty-one are in network and identity resources. This separation determines the whole of the lever table. Writing a row into the namespace ownership table resolves nine findings and leaves thirty-one in place — because those resources are not in a namespace. The owner label rule added to the admission gate of the previous lesson touches none of the existing forty findings; it affects only newly created cluster objects and lowers the cluster share of weekly new unowned from one point three to zero. Muting the low severity class lowers the inventory from forty to twenty-four but never touches the remaining nine high severity findings; the resource stays where it is, it only becomes invisible.

The only row that resolves all forty of the forty is the second: an owner field in the resource inventory. That field is not a mechanism of the cluster and not an output of the audit either — it is metadata filled in when the resource is created. Continuous audit cannot produce ownership; it can only report its absence.

Where It Would Have Closed Earlier

Looked at with two columns, the last table gives the result of the lesson. One hundred twenty-two of the hundred forty-seven findings — eighty-three percent — could have been closed at an earlier stage: fifty-one with a manifest check at the deploy stage, thirty-three with an access decision at design, thirty-two with a rebuild at the build stage, six in code. Continuous audit finds these one hundred twenty-two again in every scan and reports them again in every scan.

The second column reads in the opposite direction. For twenty-five of the forty unowned findings an earlier stage does not exist, and this is not a coincidence: unused identity definitions and the old namespace left without an owner are resources that were born at some stage and forgotten. The decision that produced them belongs not to a stage but to a record that was never kept. The measure of this course — how many times cheaper it is to close an item earlier — takes another form here: ownership is not an item that can be closed at a stage but a field the resource has to carry from the first moment it exists. A finding that appears when the field is left empty becomes cheaper at no stage; it merely accumulates in a list that no one looks at.

Summary

  • The ownership of 147 open findings is resolved by a three-step chain: owner label 65, namespace ownership 24, repository path mapping 18. The chain stops at 72.8% and 40 findings (27.2%) remain unowned.
  • 81 of the findings fall to application teams and 26 stay with the platform team. The unowned bucket has 9 high severity findings; being unowned is not limited to low severity.
  • The capacity of the unowned bucket is zero: while the 40 findings rise to 112 in twelve weeks, the total backlog of the five owned buckets falls from 107 to 102. Clearing this bucket in the same period takes 9.3 items a week, that is a share of 55% of the total capacity of the five teams.
  • 31 of the 40 unowned findings are in network and identity resources outside the cluster; namespace ownership resolves only 9 of them, the admission gate touches none of the existing ones, and muting makes 16 of them invisible and leaves the resource in place. The only thing that resolves all forty is an owner field in the resource inventory.
  • 122 of the 147 findings (83%) could have been closed at an earlier stage; for 25 of the 40 unowned findings there is no earlier stage, because ownership is a decision of resource creation and not of a stage.

Next Step

In the earlier stage column thirty-two findings read build: the mutable image tag, the unsigned image and the outdated runtime version. What these three classes have in common is that none of them can be fixed in place — to close them a new image has to be built and thirty-one replicas redeployed. The audit finds them again in every scan, because the distance between scan and fix is not a configuration change but a release cycle. The next lesson measures that cycle: after a fix is published, how many days does it take to reach the replicas, what is the difference in days between in-place patching and rebuilding, and how many replicas does the disruption that rebuilding brings in return touch.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close