Skip to content
academia.sh

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close