---
title: 'Pipeline Security'
source: 'https://academia.sh/en/courses/ci-cd/pipeline-security'
course: 'Continuous Integration and Delivery'
language: en
updated: '2026-09-13T05:08:19+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "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'
---

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

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

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

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

```js
// 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?
