Lesson 06 / 12
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.
Published Updated
In this lesson
- Separates template fields into resolvable and unresolvable values, and shows which ones the rule set can decide on
- Separates the radius share caught before deployment from the share visible only while running
- Measures the rule set's false-flagging cost and compares two ways of writing an exception
Contents
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
unknownclass from the running system; it cannot reach theunseenclass. The scan is daily, and the average delay is taken as half a day.
The Template Set Runs Through the Rule Set
// 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);
// 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
// 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.
// 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
unknownclass 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.