Skip to content
academia.sh

Lesson 21 / 21

Pipeline Security

The pipeline itself is measured: how many secrets and targets nine job steps reach, the access that drops and the cost paid when moving to least privilege, the state carried between jobs run back to back on the same runner, and, across a known set of misuse attempts, what each constraint closes, misses, and how much legitimate work it stops.

Published Updated

In this lesson

  • Counts the pipeline's authority surface step by step: the access that drops moving to least privilege and the identity-definition cost paid for it
  • Counts by kind the state carried between jobs run back to back on the same runner, and measures isolation's cache cost
  • Writes down separately, for each constraint, the attempts it closes, its marginal value, and the legitimate work it stops
Contents

The previous lesson signed the output and verified it at deployment time: all eighteen of the eighteen forgeries were stopped, the key stayed in a single subject. That subject was not a person, it was the pipeline itself. Every gate measured in this course rested on the assumption that the machine running the gate behaves honestly, and that assumption has never been counted.

This lesson counts it: how many secrets the pipeline reaches, what gets carried between two jobs, how many misuse attempts get closed. The measure is still the gate — what stands in front of it is no longer a change, it is the pipeline’s own step.

RD31. The fictional regional measurement network’s pipeline is nine steps; 12 secrets and 6 targets are modeled with node, and it is written that it is a model — secret values are not written, only their names appear. RD32. A step’s actual need is what it genuinely uses, its declared set is what is written into the pipeline definition; the two diverge at two steps.

The Pipeline’s Authority Surface

// measurement-network/authority.mjs — the pipeline's authority surface: which step reaches which secret and target (model)
const SECRET = ["source-repo-read", "package-repo-read", "package-repo-write", "object-store-write",
  "signing-key", "deploy-token-development", "deploy-token-staging", "deploy-token-production",
  "database-staging", "database-production", "notification-token", "collector-key"];
const TARGET = ["development", "staging", "production", "build-output-repo", "object-store", "notification-channel"];

// Fictional regional measurement network pipeline: nine steps. `need` is what the step actually
// uses, `declared` is the set written into the pipeline definition. The two diverge at two steps.
const STEP = [
  ["format-parsing", [], []],
  ["build", ["source-repo-read", "package-repo-read"], []],
  ["unit-test", [], []],
  ["contract-test", [], []],
  ["integration-test", ["database-staging"], ["staging"]],
  ["packaging", ["package-repo-write"], ["build-output-repo"]],
  ["inventory-generation", ["package-repo-read"], ["build-output-repo"]],
  ["signing", ["signing-key"], ["build-output-repo"]],
  ["deployment", ["deploy-token-production", "object-store-write", "notification-token"],
    ["production", "object-store", "notification-channel"]],
].map(([name, secrets, targets]) => ({ name, secrets, targets }));
const MISSING = { "inventory-generation": ["package-repo-read"], deployment: ["notification-token"] };
const declared = (a) => a.secrets.filter((s) => !(MISSING[a.name] ?? []).includes(s));

const total = (f) => STEP.reduce((t, a) => t + f(a), 0);
const singleSecret = STEP.length * SECRET.length, singleTarget = STEP.length * TARGET.length;
const perStepSecret = total((a) => declared(a).length), perStepTarget = total((a) => a.targets.length);
const widest = Math.max(...STEP.map((a) => declared(a).length + a.targets.length));

const COLUMN = [26, 12, 15, 15, 0];
const row = (s) => console.log(s.map((v, i) => String(v).padEnd(COLUMN[i])).join(""));
row(["layout", "identities", "secret access", "target access", "reach if a step is compromised"]);
row(["single pipeline identity", 1, `${singleSecret}/${singleSecret}`, `${singleTarget}/${singleTarget}`,
  `${SECRET.length} secrets + ${TARGET.length} targets`]);
row(["per-step identity", STEP.length, `${perStepSecret}/${singleSecret}`, `${perStepTarget}/${singleTarget}`,
  `at most ${widest} entries`]);

const secretless = total((a) => (a.secrets.length === 0 ? 1 : 0));
const blocked = STEP.filter((a) => a.secrets.length > declared(a).length).map((a) => a.name);
console.log("");
console.log(`access dropped ${singleSecret + singleTarget - perStepSecret - perStepTarget}/${singleSecret + singleTarget} ` +
  `(${(100 * (1 - (perStepSecret + perStepTarget) / (singleSecret + singleTarget))).toFixed(0)}%); cost ` +
  `${STEP.length} identity definitions and ${perStepSecret + perStepTarget} binding entries`);
console.log(`steps using no secret at all ${secretless}/${STEP.length} — ` +
  `still reach ${SECRET.length} secrets under a single identity`);
console.log(`actual need ${total((a) => a.secrets.length)} secrets, declared ${perStepSecret}; ` +
  `${blocked.length} steps under-declared (${blocked.join(", ")}) -> ${blocked.length} legitimate work stalls`);
layout                    identities  secret access  target access  reach if a step is compromised
single pipeline identity  1           108/108        54/54          12 secrets + 6 targets
per-step identity         9           7/108          7/54           at most 5 entries

access dropped 148/162 (91%); cost 9 identity definitions and 14 binding entries
steps using no secret at all 3/9 — still reach 12 secrets under a single identity
actual need 9 secrets, declared 7; 2 steps under-declared (inventory-generation, deployment) -> 2 legitimate work stalls

Under a single pipeline identity, the access count is 162: each of the nine steps reaches all twelve secrets and all six targets. Under a per-step identity, the same pipeline runs with 14 accesses; the 148 accesses that drop are 91% of the surface, at a cost of nine identity definitions and fourteen binding entries.

Three of the nine steps use no secret at all — format parsing, unit testing, contract testing — but under a single identity, those three also reached all twelve secrets. This is not a bug, it is a default: identity is granted to the pipeline, not to the step. The record count a compromised step can reach falls from 18 to at most 5.

The last line is the gate’s other face: actual need is nine secrets, declared is seven, two jobs stall. The source of the wrongful hold is not the constraint, it is a list again — the trigger filter in pipeline-design/08 and the cache key in pipeline-design/05 broke from the same place.

What Gets Carried Between Two Jobs

RD33. Four kinds of state can carry over on the runner: cache, environment variable, leftover file, credential memory; the leave and read draws come from seed 70413+i. RD34. Baseline runtime is 120 seconds, a cache hit saves 45 seconds.

// measurement-network/isolation.mjs — state carried between jobs run back to back on the same runner (model)
const generator = (seed) => () =>
  (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
const KIND = ["cache", "environment variable", "leftover file", "credential memory"];

// Fictional job sequence: twelve jobs, four services, one comes from an external contribution.
// Each job leaves some state behind and reads some; leave and read draws come from the seed.
const JOB = Array.from({ length: 12 }, (_, i) => {
  const r = generator(70413 + i);
  return { number: i + 1, externalContribution: i === 6,
    leaves: KIND.filter((_, k) => k === 0 || r() < 0.45),      // cache is always left behind
    reads: KIND.filter(() => r() < 0.5) };
});

// Three isolation levels: which kind of state is cleared between jobs.
const LEVEL = [
  ["shared runner", []],
  ["clean workspace per job", ["leftover file"]],
  ["new runner per job", KIND],
];

// Cost: runtime stretches out as hit rate falls. Baseline 120 s, a hit saves 45 s.
const COLUMN = [26, 7, 5, 10, 12, 18, 6, 0];
const row = (s) => console.log(s.map((v, i) => String(v).padEnd(COLUMN[i])).join(""));
row(["isolation", "cache", "env", "leftover", "credential", "total / external", "hits", "twelve jobs"]);

for (const [name, cleared] of LEVEL) {
  const count = Object.fromEntries(KIND.map((t) => [t, 0]));
  let crossedOut = 0, hits = 0;
  const remaining = new Map();                     // kind -> number of the job that left it
  for (const job of JOB) {
    for (const t of cleared) remaining.delete(t);   // kinds cleared before the job starts
    for (const t of job.reads) {
      if (!remaining.has(t)) continue;
      count[t] += 1;                                // the previous job's state carried into this one
      if (t === "cache") hits += 1;
      if (job.externalContribution || remaining.get(t) === 7) crossedOut += 1;   // to/from external contribution
    }
    for (const t of job.leaves) remaining.set(t, job.number);
  }
  const total = KIND.reduce((a, t) => a + count[t], 0);
  row([name, ...KIND.map((t) => count[t]), `${total} / ${crossedOut}`,
    `${hits}/${JOB.length}`, `${JOB.length * 120 - hits * 45} s`]);
}
isolation                 cache  env  leftover  credential  total / external  hits  twelve jobs
shared runner             6      9    8         5           28 / 5            6/12  1170 s
clean workspace per job   6      9    0         5           20 / 4            6/12  1170 s
new runner per job        0      0    0         0           0 / 0             0/12  1440 s

On a shared runner, 28 carry-overs occur among the twelve jobs; five of them have an externally contributed job on one end. Only six are cache — nine are environment variable, eight are leftover file, five are credential memory, meaning a token the previous job decrypted stays in memory. This is exactly where the “per-step identity” gain in the authority surface gets punctured: binding authority to a step does not stop the next job from reading what the step left behind.

The middle row is the isolation most commonly applied: clearing the workspace brings leftover files’ eight down to zero, but environment variable and credential memory remain — total 20, four crossing outward, 71% of the carry-over is still open.

A new runner per job closes all four at once: 28 of 28 carry-overs are zeroed out. The cost is a single item — the hit rate falls from six to zero, and the twelve jobs take 1,440 seconds instead of 1,170: 270 seconds, 23%. The cost accounting for runner types was measured in pipeline-design/06 and is not repeated here; the novelty here is that the “clean start” advantage measured there also reads as a carry-over count.

A Known Set of Misuse Attempts

RD35. The misuse set is fictional: 40 attempts across three classes and 60 legitimate jobs, seed 56092. An attempt succeeds while at least one of its paths is open. RD36. The “authorized subject” path is the path no constraint closes.

// measurement-network/misuse.mjs — a known set of misuse attempts and the ledger of constraints (model)
const generator = (seed) => () =>
  (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
const r = generator(56092);                       // seed visible
const CLASS = [
  ["secret access from an external contribution", "external-contribution-secret", "carried-over-state", 0.40],
  ["running code through a dependency", "install-script", "carried-over-state", 0.30],
  ["changing the pipeline definition", "definition-change", "external-contribution-secret", 0.25],
];

// Fictional ledgers. An attempt succeeds when at least one of its paths is open.
const ATTEMPT = Array.from({ length: 40 }, () => {
  const [name, path, second, p] = CLASS[Math.floor(r() * CLASS.length)];
  const paths = [path];
  if (r() < p) paths.push(second);
  if (r() < 0.15) paths.push("authorized-subject");   // the path no constraint closes
  return { class: name, paths };
});
const LEGITIMATE = Array.from({ length: 60 }, () => ({
  externalContribution: r() < 0.35, wantsSecret: r() < 0.30,
  installScript: r() < 0.12, definitionChange: r() < 0.18,
}));

const CONSTRAINT = [
  ["no secret is given to external contributions", "external-contribution-secret", (m) => m.externalContribution && m.wantsSecret],
  ["external-contribution jobs wait for manual approval", "external-contribution-secret", (m) => m.externalContribution],
  ["dependency install scripts are not run", "install-script", (m) => m.installScript],
  ["pipeline definition changes require separate approval", "definition-change", (m) => m.definitionChange],
  ["new runner per job", "carried-over-state", () => false],
];
const passes = (active) => ATTEMPT.filter((g) =>
  g.paths.some((y) => !active.some(([, closes]) => closes === y))).length;
const FULL = passes(CONSTRAINT);

const COLUMN = [56, 8, 10, 0];
const row = (s) => console.log(s.map((v, i) => String(v).padEnd(COLUMN[i])).join(""));
row(["constraint", "closes", "marginal", "legitimate work stopped"]);
for (const k of CONSTRAINT)
  row([k[0], ATTEMPT.filter((g) => g.paths.includes(k[1])).length,
    passes(CONSTRAINT.filter((o) => o !== k)) - FULL, LEGITIMATE.filter(k[2]).length]);

const zero = CONSTRAINT.filter((k) => passes(CONSTRAINT.filter((o) => o !== k)) === FULL);
console.log("");
console.log(`attempts ${ATTEMPT.length} (${CLASS.map(([a]) =>
  ATTEMPT.filter((g) => g.class === a).length).join("/")}), legitimate work ${LEGITIMATE.length}; seed 56092`);
console.log(`passing with no constraints ${ATTEMPT.length}, with the full set ${FULL} ` +
  `— all through the authorized-subject path`);
console.log(`zero-marginal constraints ${zero.length}: ${zero.map((k) => k[0]).join(" | ")}; ` +
  `removing both at once, passing ` +
  `${passes(CONSTRAINT.filter((o) => o[1] !== "external-contribution-secret"))}/${ATTEMPT.length}`);
constraint                                              closes  marginal  legitimate work stopped
no secret is given to external contributions            16      0         7
external-contribution jobs wait for manual approval     16      0         16
dependency install scripts are not run                  14      12        8
pipeline definition changes require separate approval   13      12        9
new runner per job                                      5       3         0

attempts 40 (13/14/13), legitimate work 60; seed 56092
passing with no constraints 40, with the full set 5 — all through the authorized-subject path
zero-marginal constraints 2: no secret is given to external contributions | external-contribution jobs wait for manual approval; removing both at once, passing 19/40

The full constraint set closes thirty-five of forty attempts. The five that get through, get through by the same path: the authorized subject using its own authority. No constraint closes this, because closing it would mean removing the authority; this is the counterpart of the “whoever has access to the key can sign” limit in repository-based-deployment/05 — 12.5%.

The marginal column separates the constraints: the install-script ban closes 12 attempts by itself, pipeline-definition approval closes 12. New runner per job closes 5, 3 of which are marginal, and it stops no legitimate work at all — its cost is only 270 seconds.

The last line gives the constraint that is better off removed. Two constraints have zero marginal value, because they close the same path; removing both at once, passing attempts rise to 19, meaning the path must stay closed. The legitimate-work column tells us which one should close it: “no secret is given” stops 7 jobs, “waits for manual approval” stops 16. The one that buys the same protection at twice the price is removed.

Summary

  • Under a single pipeline identity, 162 of 162 accesses are open, under a per-step identity 14 are; the 148 accesses that drop are 91% of the surface, at a cost of 9 identity definitions and 14 binding entries.
  • Three of the nine steps use no secret at all but reach all twelve secrets under a single identity; the record count a compromised step can reach falls from 18 to at most 5. The 2 under-declared steps stall 2 legitimate jobs.
  • On a shared runner, 28 carry-overs occur (6/9/8/5), 5 with an external contribution on one end; workspace cleanup brings it to 20, a new runner to 0. The cost is 1,440 seconds instead of 1,170.
  • The full constraint set closes 35 of 40 attempts; all 5 that get through do so via the authorized-subject path, which no constraint closes.
  • Two zero-marginal constraints close the same path; removing both at once, passing attempts rise to 19. The path must stay closed — with the one that stops 7 legitimate jobs, not the one that stops 16.

Course Wrap-Up

Lesson Gate Holds / misses / wrongly holds Time added
pipeline-design/01 merge 90 conflict / 10 mismatch / 2 23 min/change
pipeline-design/02 manual approval 5, marginal 2 / 38 / 26 226.5 min wait
pipeline-design/03 contract test 18, marginal 1 / 119 / 17 6,138 runner min
pipeline-design/04 retention rule 38, marginal 1 / 2 / 47 338 min recovery
pipeline-design/05 integration test 3, marginal 2 / 2 from cache / — 768 s, eight runs
pipeline-design/06 clean-starting runner 8, marginal 2 / 0 / 0 40 s/run
pipeline-design/07 schema validator 4 errors / 2 errors / 0 80 s, twenty changes
pipeline-design/08 path filter 7 unnecessary runs / 0 / 10 3.1 hr delay
release-strategies/01 integration 34, marginal 24 / 8 / 2–54 490–1,638 gate min
release-strategies/02 environment promotion 7, marginal 7 and 1 / 7 / 5 3 hr 56 min wait
release-strategies/03 percentage ramp 200/200 distinct / 18–60% faint / 27–32% 30–70 min staircase
release-strategies/04 rollback 18, single-round 11 / 7 releases, 491 rows / 178 40,320 replica-min
release-strategies/05 flag condition 0, 76 invoices / 4 paths / 1 pending 18 read points
release-strategies/06 precondition check 5, marginal 2 / 1 / 2 12 rounds, 17-round cost
release-strategies/07 compatibility check 1, marginal 1 / 1 / 1 3 rounds, three schema steps
repository-based-deployment/01 review 24, marginal 13 / 11 / 7 796 hours, ninety days
repository-based-deployment/02 reconciliation round 48, marginal 17 / 6 / 9 8.5 min propagation
repository-based-deployment/03 decrypt authority 682 pairs, all marginal / 24 / 11 385 min escalation
repository-based-deployment/04 output-tree audit 14, all marginal / 0 / 3 12 files/change
repository-based-deployment/05 verification at deploy 18 forgeries, all marginal / 0 / 18 digest and list lookup
repository-based-deployment/06 least privilege, isolation 35, marginal 12-12-3 / 5 subjects / 16 270 s, twelve jobs

The course’s rule reads from the table: a gate is worth exactly as much as the defect it alone catches; a gate cannot be defended unless what it holds, misses, and holds up are written together. The count it holds does not carry the decision by itself — the format gate holds 51 and holds 51 of them by itself alone, the contract gate holds 18 and holds 1.

Zero-marginal gates were measured in four places across the course: pipeline-design/08’s time trigger (84 empty runs, 0.0-second contribution to delay), repository-based-deployment/04’s gate that reads manifest files (holds zero undeclared components), repository-based-deployment/05’s pipeline-end verification (lets 18 of 18 forgeries through), and here, “waits for manual approval” (16 legitimate jobs). All four can be removed; the reason is not that they are weak, it is that they hold only what someone else already holds. The measure also works in reverse: pipeline-design/07’s review holds a single error in 714 minutes, but that error is a change that removes a gate.

The pipeline now carries the application to production, and every step of the path is counted. The machines, network, and storage underneath it, though, are still built through another path: one that passes through no gate, keeps no record, and has no defined rollback unit. M22/K05 Infrastructure as Code asks this — can they too pass through the same gates, and when they do, what does this table’s twenty-one rows show for them?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close