---
title: 'Shift Left'
source: 'https://academia.sh/en/courses/secure-lifecycle/shift-left'
course: 'The Secure Development Lifecycle'
language: en
updated: '2026-09-26T03:02:40+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Derives the per-stage fix cost from the work that gets undone and shows that the multiplier changes with the stage an item is born in'
    - 'Runs the same item set through two flows with late and early checks: calculates the overall multiplier, the hours prevented, and the net gain after subtracting the check load'
    - "Separates out the class of items for which catching them early gains nothing, and counts that class's share of the early flow's cost"
---

# Shift Left

The fix cost per stage is derived from the work that gets undone, and the same 23-item set is run through two flows: on the 18 items common to both, the late flow costs 318 hours, the early flow 68 hours, a 4.68 multiplier, and once the check load is subtracted from the 250 hours prevented, a net gain of 217.5 hours remains. The multiplier is not a single number — it is 22.5 for an item born in the requirement stage and 13 for one born in deployment; half of the early flow's cost comes from the single item shift left can never touch.

The previous lesson moved the check: with the check spread across six stages instead of a single
gate, items caught rose from 18 to 23 and total delay dropped from 35 stages to 3 stages. Delay was
a number, but a **stage** is not a unit of currency. Fixing an item at the design stage is a
meeting's decision; taking the same item back out of a running system is a data migration, a
release plan, and an outage window. The two jobs cannot be measured with the same unit.

This lesson builds that unit. Moving the check earlier in the flow is called **shift left**, and
the phrase by itself carries no decision — it does not answer how far left, for which item, or at
what cost. Here the per-stage fix cost enters the model, the same item set runs through two flows
with late and early checks, and the overall multiplier is calculated. The second half of the lesson
looks the other way: it separates out the class of items for which shifting gains nothing, and
counts that class's share of the cost.

- **DA7.** The item set is the same as the previous lesson's and does not change; the only thing
  added is cost.
- **DA8.** The fix cost is not a given multiplier, it is derived from the work that gets undone:
  however many stages have passed since the item was born, the work produced in those stages is
  redone as well.
- **DA9.** The per-stage undo costs and the base fix effort are model values; the unit is fictional
  developer-hours. In a real organization these numbers are drawn from past fix records.
- **DA10.** The comparison is made only on items caught in both flows. The cost of an item the late
  flow never sees is not zero, it is **unpaid**; it is written down separately.
- **DA11.** The running load of the early checks is taken from the previous lesson and subtracted
  from the net gain as a single item. Setup, training, and maintenance load are not in the model;
  all of them would lower the net gain further.

## The Per-Stage Fix Cost

Cost is not given as a multiplier, it is built. Every stage produces work; if the item is fixed
after that stage has passed, the work produced there gets undone too. The total cost is the base
fix effort plus the undo cost of every stage passed through.

```js
// cost.mjs — the same item set, now with a per-stage fix cost (model)
export const STAGE = ['requirement', 'design', 'code', 'build', 'deployment', 'runtime'];
// The undo cost of passing a stage (fictional developer-hours): if the item has passed
// that stage, fixing it redoes the work produced there as well.
export const REDO = [0, 3, 8, 2, 6, 24];
export const BASE = 2; // the item's own fix effort, independent of stage
// Cost: base effort plus the undo cost of every stage passed between birth and catch.
export const cost = (born, caught) =>
  BASE + REDO.slice(born + 1, caught + 1).reduce((s, x) => s + x, 0);
export const ITEM = `
  no-data-classification        0 100000
  undefined-retention-period    0 100001
  unclear-personal-data-scope   0 100010
  unauthenticated-internal-call 1 011110
  weak-session-model            1 010011
  trust-boundary-free-flow      1 010000
  missing-role-in-authz-model   1 011001
  missing-authorization-check   2 001101
  embedded-secret-key           2 001110
  unvalidated-input             2 001001
  personal-data-in-logs         2 001001
  incomplete-error-path         2 001000
  weak-randomness                2 001000
  unbounded-query-size          2 000001
  outdated-dependency           3 000110
  unsigned-build-output         3 000110
  unnecessary-build-tool        3 000100
  default-password              4 000011
  overly-broad-network-rule     4 000011
  unencrypted-backup            4 000010
  open-admin-endpoint           4 000011
  race-under-scale              5 000001
  third-party-disclosed-flaw    5 000001`
  .trim().split('\n').map((r) => {
    const [name, d, g] = r.trim().split(/\s+/);
    return { name, born: +d, g };
  });
export const firstSeen = (k, stages) =>
  stages.find((i) => i >= k.born && k.g[i] === '1') ?? null;
```

```js
// run1.mjs — the multiplier is not one number: it changes with the stage of birth
import { STAGE, REDO, cost, ITEM } from './cost.mjs';

console.log('birth stage     redo work      earliest cost   latest cost   x     items');
for (const [i, a] of STAGE.entries()) {
  const early = cost(i, i), late = cost(i, STAGE.length - 1);
  console.log(a.padEnd(16) + String(REDO[i]).padEnd(15) + String(early).padEnd(16) +
    String(late).padEnd(14) + (late / early).toFixed(1).padEnd(6) +
    ITEM.filter((k) => k.born === i).length);
}
console.log('\nsame item, delayed stage by stage (an item born in design)');
console.log('stage caught in    cost   rise from previous stage');
let previous = null;
for (let j = 1; j < STAGE.length; j++) {
  const b = cost(1, j);
  console.log(STAGE[j].padEnd(19) + String(b).padEnd(7) +
    (previous === null ? '-' : `${b - previous} hours, ${(b / previous).toFixed(2)}x`));
  previous = b;
}
```

```
birth stage     redo work      earliest cost   latest cost   x     items
requirement     0              2               45            22.5  3
design          3              2               42            21.0  4
code            8              2               34            17.0  7
build           2              2               32            16.0  3
deployment      6              2               26            13.0  4
runtime         24             2               2             1.0   2

same item, delayed stage by stage (an item born in design)
stage caught in    cost   rise from previous stage
design             2      -
code               10     8 hours, 5.00x
build              12     2 hours, 1.20x
deployment         18     6 hours, 1.50x
runtime            42     24 hours, 2.33x
```

The first table shows why reducing the multiplier column to a single number is wrong. An item born
in the requirement stage, when caught at the latest, costs 22.5 times more to fix; for an item born
in deployment, the same number is 13. The difference does not come from the item's severity but
from **how many stages' worth of work got produced after the point it was born**. The sentence "a
vulnerability found late costs multiple times more" is true, but which multiple depends on where
the item comes from, and there is a 1.7-fold difference between the two.

The second table says something sharper: the rise is not even. When an item born in design slips
into the code stage, the cost jumps to five times; moving on from there into the build stage, only
to 1.2 times. There are two boundaries where the rise concentrates — the stage where the code is
written and the stage where the system becomes live. The build and deployment boundaries between
them are cheap. This directly determines where to shift the check to: pulling a check back from
deployment to build gains 6 hours, pulling it back from code to design gains 8 hours. The same
effort crosses different boundaries, and they are not all equal.

## Two Flows, Overall Multiplier

The same item set runs through two flows: a late flow, where the check exists only at deployment
and runtime, and an early flow, where it exists in all six stages.

```js
// run2.mjs — the same item set in two flows: checked late and checked early
import { STAGE, cost, ITEM, firstSeen } from './cost.mjs';

const LATE = [4, 5], EARLY = [0, 1, 2, 3, 4, 5];
const CHECK_LOAD = 32.5; // running load of the early checks (from the previous lesson, hours)
const rows = ITEM.map((k) => {
  const g = firstSeen(k, LATE), e = firstSeen(k, EARLY);
  return { k, g, e, gb: g === null ? null : cost(k.born, g), eb: cost(k.born, e) };
});
const common = rows.filter((s) => s.g !== null);
const earlyOnly = rows.filter((s) => s.g === null);
const noGain = common.filter((s) => s.g === s.e);
const sum = (l, f) => l.reduce((a, s) => a + f(s), 0);

console.log(`${ITEM.length} items; caught in both ${common.length}, ` +
  `never seen by late flow ${earlyOnly.length}`);
const gt = sum(common, (s) => s.gb), et = sum(common, (s) => s.eb);
console.log(`late flow ${gt} hours, early flow ${et} hours, x ${(gt / et).toFixed(2)}, ` +
  `prevented ${gt - et} hours`);
console.log(`net gain: ${gt - et} - ${CHECK_LOAD} = ${(gt - et - CHECK_LOAD).toFixed(1)} hours`);
console.log(`the ${earlyOnly.length} items the late flow never sees close out at ` +
  `${sum(earlyOnly, (s) => s.eb)} hours in the early flow; in the late flow their cost is never paid`);

console.log('\nwhere the gain concentrates');
console.log('birth stage     items  late cost  early cost   prevented  share %');
for (const [i, a] of STAGE.entries()) {
  const l = common.filter((s) => s.k.born === i);
  if (l.length === 0) { console.log(a.padEnd(16) + '0'); continue; }
  const g = sum(l, (s) => s.gb), e = sum(l, (s) => s.eb);
  console.log(a.padEnd(16) + String(l.length).padEnd(7) + String(g).padEnd(11) +
    String(e).padEnd(13) + String(g - e).padEnd(11) + (((g - e) / (gt - et)) * 100).toFixed(1));
}

// Items with no gain fall into two classes: those unreachable because they are born
// late, and those born early but visible only late.
const lateBorn = noGain.filter((s) => s.k.born >= 4);
const lateSeen = noGain.filter((s) => s.k.born < 4);
console.log(`\n${noGain.length} items the early check does not help, two classes`);
console.log('class                     items  cost in early flow    % of early flow cost');
for (const [name, l] of [['born early, seen late', lateSeen], ['born late', lateBorn]])
  console.log(name.padEnd(26) + String(l.length).padEnd(7) + String(sum(l, (s) => s.eb)).padEnd(22) +
    ((sum(l, (s) => s.eb) / et) * 100).toFixed(1));
for (const s of lateSeen)
  console.log(`  ${s.k.name}: born in the ${STAGE[s.k.born]} stage, visible in the ${STAGE[s.e]} ` +
    `stage, ${s.eb} hours in both flows`);
```

```
23 items; caught in both 18, never seen by late flow 5
late flow 318 hours, early flow 68 hours, x 4.68, prevented 250 hours
net gain: 250 - 32.5 = 217.5 hours
the 5 items the late flow never sees close out at 10 hours in the early flow; in the late flow their cost is never paid

where the gain concentrates
birth stage     items  late cost  early cost   prevented  share %
requirement     2      66         4            62         24.8
design          3      78         6            72         28.8
code            5      146        42           104        41.6
build           2      16         4            12         4.8
deployment      4      8          8            0          0.0
runtime         2      4          4            0          0.0

7 items the early check does not help, two classes
class                     items  cost in early flow    % of early flow cost
born early, seen late     1      34                    50.0
born late                 6      12                    17.6
  unbounded-query-size: born in the code stage, visible in the runtime stage, 34 hours in both flows
```

On the eighteen items common to both, the late flow spends 318 hours, the early flow 68 hours; the
overall multiplier is 4.68. This is considerably lower than the 13–22.5 range read off a single
item, and it should be lower: the multiplier is weighted by the distribution of when items are
born, and a quarter of the set is already born in deployment or runtime and produces no gain at
all. A program's real multiplier is always smaller than the multiplier of any single item shift
left can reach.

Once the running load of the early checks is subtracted from the 250 hours prevented, a net gain of
217.5 hours remains. The form of the number matters as much as the number itself: shift left has
become something that can be written not as a principle but as a two-item subtraction. Setup,
training, and maintenance loads, which are not in the model, would lower this number further; in a
real organization the decision looks at whether the hours prevented also cover these three items.

The same subtraction also gives a break-even point. The 250 hours prevented can carry at most 217.5
hours of setup, training, and maintenance on top of the running load; every hour above that
threshold turns shifting into a loss. If setting up all six checks does not fit this budget, the
right decision is not to set up all of them but to start with the stages whose share of the gain is
highest — and that order is written in this table's share column.

A silent assumption of the model needs to be said out loud here: every item caught was counted as
fixed. In reality, a portion of caught items are deliberately deferred. A deferred item follows the
same path as an item never caught, and in the end pays the late flow's cost; the early check has
only made the payment **visible**. This is why the 250 hours prevented is not a gain but an **upper
bound**: realizing it depends on having the capacity to actually fix the items that were caught.

The five items the late flow never sees were left out of the comparison. The early flow closes them
out at 10 hours; in the late flow their cost is not zero, it is **unpaid**. This is the most
misleading row a fix-cost table can have: an item that is never found looks, in the table, like the
cheapest item of all.

The distribution of the gain says where to shift to. Of the 250 hours prevented, 41.6% comes from
items born in the code stage, 28.8% from design, 24.8% from items born in the requirement stage.
Items born in the build stage produce only 4.8% of the total; those born in deployment and runtime
produce zero. A check placed on the first three stages carries 95% of the gain.

## The Item Shifting Does Not Help

The last table is this lesson's real finding. Seven of the eighteen items gain nothing at all from
the early check, and these seven items form two separate classes.

The first class has six items and is unreachable by definition: they are born in deployment or
runtime. A configuration item or a dependency vulnerability disclosed after the fact cannot be
discussed in a design session, because it does not exist yet when that session happens. This
class's cost is low — 17.6% of the early flow's cost — because they are caught in the very stage
they are born in anyway.

The second class is a single item, and it is this lesson's hard finding. `unbounded-query-size` is
born in the code stage but becomes visible only at runtime, and it costs 34 hours in both flows.
Exactly half of the early flow's total 68-hour cost comes from this one item. No matter how far
left you push the check, this number does not drop, because there is nothing to see in the code:
there is no wrongly written line, only a limit that was never put in place, and the need for that
limit shows up only once the data grows.

What this class has in common is that the item's very existence depends on a scale, a data
distribution, or an external event. It includes race conditions that surface under load, missing
limits that only become meaningful past a certain data volume, and dependency vulnerabilities that
become known only through an outside disclosure. For these items, the right decision is not to
shift the check but to make **seeing them at runtime cheaper** — measurement, limit checks, and
rollback readiness. A program that does not write down the scope of shift left reads this class's
cost as a failure of shifting too, and puts more checks in the wrong place. The measurable
counterpart of this distinction is: if an item's late catch comes from a missing check, the early
flow makes it cheaper; if the item itself only becomes visible late, the early flow's cost stays
**exactly the same** as the late flow's. The last column of the table looks for exactly this
equality.

## Summary

- The fix cost is not given as a multiplier, it is derived from the work that gets undone: base
  effort plus the undo cost of every stage passed between birth and catch.
- The multiplier is not a single number: 22.5 for an item born in the requirement stage, 13 for
  one born in deployment. The rise is not even either — moving from design to code is a 5x jump,
  from code to build only 1.2x.
- On the 18 items common to both, the late flow spends 318 hours, the early flow 68 hours; the
  overall multiplier is 4.68, prevented hours 250. After subtracting the check load, net gain is
  217.5 hours.
- The 5 items the late flow never sees are outside the comparison: their cost is not zero, it is
  unpaid, and the early flow closes them out at 10 hours.
- 95% of the hours prevented come from items born in the first three stages; those born in build
  produce 4.8%, and those born in deployment and runtime produce 0%.
- 7 of the 18 items gain nothing at all from the early check. Six are unreachable because they are
  born late; the seventh is born in code and becomes visible only at runtime, carrying half of the
  early flow's cost on its own.

## Next Step

This lesson pinned the gain and the limit of shifting to a number, but left the stage carrying the
largest share of that gain inside a black box. A quarter of the hours prevented comes from items
born in the requirement stage, and the check that catches those items passed by in a single table
row: eighteen requirement reviews, five alerts, three real items. How a requirement review catches
an item was never opened up. For it to be able to catch one, the requirement has to be written
**testably**; a line written as "the system must be secure" produces no item in any review. The
next lesson classifies security requirements along the axis of testability: how many requirements
are automatically testable, how many can only be checked by eye, how many cannot be tested at all,
and how many new requirements enter the list once an abuse case is written.
