Skip to content
academia.sh

Lesson 15 / 17

Hardcoded Secrets

The number of commits a secret stays exposed in a fictional repository history, and what pattern scanning covers: five secrets appear in 124 commits in the working tree and 223 commits in history, history cleanup fails to reach eighteen of nineteen copies, snapshot scanning sees only one of the five secrets, and the same rule produces a 76.8x increase in findings with a 0.69 false-positive rate over history.

Published Updated

In this lesson

  • Counts separately the number of commits a secret appears in within the working tree and within the repository history
  • Derives the number of copies history cleanup cannot reach and compares it with rotation
  • Runs the same pattern rule against a snapshot and the full history, and compares the finding, missed-secret, and false-positive counts
  • Distinguishes the coverage of pattern scanning from that of the secrets-store pattern when a new secret is added
Contents

The previous two lessons measured how a key and a token are generated, and both rested on the same assumption: that the generated value stays only in the process that produced it. This assumption never holds for a subset of values. Those values were not generated; they were typed by hand into a configuration line, and from there entered version control. The moment they enter, they no longer sit in a file — they sit in a history, and history has a measure of its own.

The Secrets Management lesson measured the rotation window of a value in production and how long it stays valid after it leaks. What is measured here is not storage practice but coverage: how many commits a secret has stayed in, how many commits deletion closes off, how many points a scanning rule sees, and how many false positives are paid to see them. The repository history below is a model; the commit counts, file names, and values are fictional and none of them carries a real secret format.

A Secret in Repository History

SC7 — a value enters repository history the moment it is committed; deleting it from the working tree is not deleting it from history. Deletion is a new commit, and the old commits stay in place. The moment a secret is truly closed off is the moment the value is invalidated — rotated.

// repo/history.mjs — a fictional repository history (model): 60 commits, five fictional secrets. Secret
// values below are generated with our own generator; no real secret format, real key pattern, or
// real service name is used. entered: the commit the value was committed in; deleted: the commit it
// was removed from the working tree in; rotated: the commit the value was invalidated in (null = never).
export const COMMITS = 60;
let state = 4703;
export const random = () => (state = (state * 1103515245 + 12345) % 2 ** 31) / 2 ** 31;
const ABC = 'abcdefghijkmnpqrstuvwxyzACDEFGHJKLMNPQRTUVWXYZ2346789';
export const generate = (n) => Array.from({ length: n }, () => ABC[Math.floor(random() * ABC.length)]).join('');

export const secrets = [
  { name: 'field signing key', entered: 7, deleted: 19, rotated: null },
  { name: 'report export password', entered: 12, deleted: null, rotated: null },
  { name: 'portal session salt', entered: 3, deleted: 41, rotated: 44 },
  { name: 'ingestion master key', entered: 22, deleted: 26, rotated: null },
  { name: 'work-order callback key', entered: 33, deleted: 55, rotated: 51 },
];

const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(''));
const none = (x) => (x === null ? '-' : x);

// SC7: a secret enters history the moment it is committed; deleting it from the working tree
// is not deleting it from history. Closure happens only through rotation.
const D = [-23, 9, 9, 9, 6, 9, 6];
write(D, 'secret', 'entered', 'deleted', 'rotated', 'tree', 'history', 'open');
let tree = 0, history = 0, open = 0;
for (const s of secrets) {
  const a = (s.deleted ?? COMMITS) - s.entered;
  const h = COMMITS - s.entered;
  const c = (s.rotated ?? COMMITS) - s.entered;
  tree += a; history += h; open += c;
  write(D, s.name, s.entered, none(s.deleted), none(s.rotated), a, h, c);
}
write(D, 'total', '', '', '', tree, history, open);
console.log(`secrets deleted from the tree but not rotated: `
  + `${secrets.filter((s) => s.deleted !== null && s.rotated === null).length}/${secrets.length}`);

// SC8: history cleanup changes only the copy it reaches; rotation devalues every
// copy. The copy counts are fictional.
console.log('\ncopies the value reaches once committed:');
const holders = [
  ['central repository', 1, true],
  ['developer clone', 9, false],
  ['mirror and backup', 2, false],
  ['build workspace and log', 4, false],
  ['packaged build output', 3, false],
];
const E = [-23, 8, 17, 10];
write(E, 'holder', 'copies', 'cleanup reaches', 'rotation');
for (const [name, n, reaches] of holders) write(E, name, n, reaches ? 'yes' : 'no', 'yes');
const total = holders.reduce((a, [, n]) => a + n, 0);
const reached = holders.filter(([, , e]) => e).reduce((a, [, n]) => a + n, 0);
write(E, 'total', total, `${reached}`, `${total}`);
console.log(`copies uncovered by history cleanup: ${total - reached}/${total}`);
secret                   entered  deleted  rotated  tree  history  open
field signing key              7       19        -    12       53    53
report export password        12        -        -    48       48    48
portal session salt            3       41       44    38       57    41
ingestion master key          22       26        -     4       38    38
work-order callback key       33       55       51    22       27    18
total                                                124      223   198
secrets deleted from the tree but not rotated: 2/5

copies the value reaches once committed:
holder                   copies  cleanup reaches  rotation
central repository            1              yes       yes
developer clone               9               no       yes
mirror and backup             2               no       yes
build workspace and log       4               no       yes
packaged build output         3               no       yes
total                        19                1        19
copies uncovered by history cleanup: 18/19

What Deletion Does not Close

The three number columns correspond to three separate questions, and all three come out different for the same five secrets. The five secrets together appear in the working tree for a total of 124 commits; the same five secrets are reachable in repository history for 223 commits; the time they are actually left open is 198 commits. The gap between the middle column and the left one is the size of the illusion that deletion produces: a span of ninety-nine commits looks closed off as “cleaned up,” but the content still sits in history.

Reading the rows one by one sharpens the measure further. The ingestion service’s master key stayed in the working tree for only four commits — it was noticed and deleted four commits after it entered. This looks like a quick fix. The history column shows 38 for the same value, and the open column also shows 38, because the value was never rotated. Four commits of visibility corresponds to thirty-eight commits of exposure. The same pattern shows up in the field signing key too: twelve commits in the tree, fifty-three commits open.

The last row shows the arrangement done in the right order. The work-order callback key was rotated at commit 51 and deleted at commit 55; the time it stayed open is 18 commits, the shortest of the five secrets. This is the only row where the time in the tree (22 commits) is longer than the time left open. Rotating first and cleaning up after is the only row that reverses the measure.

The second table counts why. A committed value reaches nineteen copies once it is pushed: one central repository, nine developer clones, two mirrors and backups, four build workspaces and logs, three packaged build outputs. Rewriting history reaches one of these; the remaining eighteen stay untouched. Each clone has to be updated with a separate commit, build logs are mostly immutable, and packaged output has already been distributed. The rotation column, on the other hand, says yes for nineteen of nineteen — because rotation does not delete the copy, it makes the copy worthless. Once a value has spread, the only way out is rotation; rewriting history does not replace that, at best it stops a new copy from being produced.

Same Rule, Two Separate Scans

SC9 — a scanning rule only covers the points in the set it scans; a snapshot and a history are two separate sets. The rule below is given and is not up for debate: a value longer than twenty characters with a character entropy above 3.6 is flagged. What is measured is not the quality of the rule, but what the same rule covers across two separate sets.

// repo/scan.mjs — pattern scanning over the same fictional repository history (model, runs on its own).
// The rule is given: a value with length >= 20 and character entropy >= 3.6 is flagged.
// Values are generated with our own generator; none of them carries a real secret format.
const COMMITS = 60;
let state = 4703;
const random = () => (state = (state * 1103515245 + 12345) % 2 ** 31) / 2 ** 31;
const ABC = 'abcdefghijkmnpqrstuvwxyzACDEFGHJKLMNPQRTUVWXYZ2346789';
const generate = (n) => Array.from({ length: n }, () => ABC[Math.floor(random() * ABC.length)]).join('');

// [file, value, is secret, entered commit, deleted commit]
const items = [
  ['field/config.mjs', generate(32), true, 7, 19],
  ['report/export.mjs', generate(28), true, 12, null],
  ['portal/session.mjs', generate(30), true, 3, 41],
  ['ingestion/source.mjs', generate(14), true, 22, 26],
  ['work-order/endpoint.mjs', generate(26), true, 33, 55],
  ['tracing/sample.mjs', generate(28), false, 0, null],
  ['portal/icon.mjs', generate(40), false, 0, null],
  ['build/version.mjs', generate(24), false, 5, null],
  ['catalog/id.mjs', generate(22), false, 0, null],
  ['field/test.mjs', generate(21), false, 9, 38],
  ['penalty/config.mjs', './data/temp/penalty', false, 0, null],
  ['notification/sample.mjs', 'CHANGE-ME', false, 0, null],
  ['membership/tier.mjs', 'subscriber-std-plan', false, 0, null],
];

const entropy = (d) => {
  const count = {};
  for (const c of d) count[c] = (count[c] ?? 0) + 1;
  return -Object.values(count).reduce((a, n) => a + (n / d.length) * Math.log2(n / d.length), 0);
};
const RULE = (d) => d.length >= 20 && entropy(d) >= 3.6;
const exists = (k, commit) => commit >= k[3] && (k[4] === null || commit < k[4]);

const write = (g, ...s) => console.log(s.map((v, i) =>
  (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(''));
const D = [-22, 9, 7, 8, 8, 16, 6];
write(D, 'scan', 'scanned', 'found', 'caught', 'missed', 'false positive', 'rate');

const measure = (name, pairs, dedup) => {
  const found = dedup ? new Set(pairs.filter(([k]) => RULE(k[1])).map(([k]) => k[1])).size
    : pairs.filter(([k]) => RULE(k[1])).length;
  const caught = dedup ? new Set(pairs.filter(([k]) => RULE(k[1]) && k[2]).map(([k]) => k[1])).size
    : pairs.filter(([k]) => RULE(k[1]) && k[2]).length;
  const secretTotal = dedup ? new Set(pairs.filter(([k]) => k[2]).map(([k]) => k[1])).size
    : pairs.filter(([k]) => k[2]).length;
  write(D, name, pairs.length, found, caught, secretTotal - caught, found - caught,
    found ? ((found - caught) / found).toFixed(2) : '-');
};

const snapshot = items.filter((k) => exists(k, COMMITS - 1)).map((k) => [k]);
const history = [];
for (let i = 0; i < COMMITS; i++) for (const k of items) if (exists(k, i)) history.push([k, i]);
measure('snapshot (last commit)', snapshot, false);
measure('full history', history, false);
measure('full history + dedup', history, true);

const secretHistory = history.filter(([k]) => k[2]).length;
console.log(`\nsecret items still present at the last commit: ${snapshot.filter(([k]) => k[2]).length}/5;`
  + ` secret item-commit pairs seen in history: ${secretHistory}`);
const missed = items.filter((k) => k[2] && !RULE(k[1]));
console.log(`secret item the rule misses: ${missed.map((k) => `${k[0]} (length ${k[1].length})`).join(', ')}`);
console.log(`history scan's finding multiplier over the snapshot: `
  + `${(history.filter(([k]) => RULE(k[1])).length / snapshot.filter(([k]) => RULE(k[1])).length).toFixed(1)}x`);
scan                    scanned  found  caught  missed  false positive  rate
snapshot (last commit)        8      5       1       0               4  0.80
full history                568    384     120       4             264  0.69
full history + dedup        568      9       4       1               5  0.56

secret items still present at the last commit: 1/5; secret item-commit pairs seen in history: 124
secret item the rule misses: ingestion/source.mjs (length 14)
history scan's finding multiplier over the snapshot: 76.8x

The Cost of the False Positive

The first row gives coverage in its clearest form. The working tree at the last commit holds only eight items, and only one of them is secret. The snapshot scan finds that one, misses nothing, and looks perfect. Yet four of the five secrets have already been deleted, and the scan never sees them at all. The coverage ratio on this row is 1/5, and the scan does not report that; what it reports is a finding it caught.

The line beneath the table writes this same 1/5 from another angle: the number of item-commit pairs in which a secret item appears in history is 124. That is the same number as the total of the first table’s “tree” column, which shows that the two measurements are reading the same model. The snapshot scan sees only the one at the last commit out of these 124 pairs.

The second row applies the same rule to the full history. The scanned item-commit pairs rise to 568 and findings to 384, and all five of the five secrets become visible. The finding count has grown by 76.8 times over the snapshot. The false-positive count is 264 and its rate is 0.69 — meaning two out of every three findings is a non-secret value. This rate does not come from the rule being bad; it comes from the same harmless value being counted sixty times across sixty commits.

The third row removes this multiplier. Once findings are deduplicated by content, the 384 findings drop to nine: four secret, five false positive, rate 0.56. The number of items to review shrinks by two orders of magnitude, and coverage does not change at all. What should be measured in a history scan is not the finding count but the count of distinct values; otherwise the scan measures its own noise.

The last line writes the rule’s limit: the ingestion service’s master key is fourteen characters long, stays under the threshold, and does not show up in any scan. Four of the five secrets are covered; one is left out. This is a gap the scan cannot show with its own measure; only someone who knows what it missed from somewhere else can count it.

When a New Secret Is Added

The third column carries this lesson’s sharpest distinction. Pattern scanning is a filter, not a guarantee: a secret added tomorrow is covered only if it clears the threshold. One of the five secrets is under the threshold today, so the chance that a new value gets covered is not something measured but something hoped for. Lowering the threshold increases coverage and grows the false-positive count; the false-positive rate is already 0.56, and every increase multiplies the review workload.

The pattern in which the secret never enters the source tree at all solves this problem from a different direction. If the value is read from a secrets store at run time, no value ever exists in the source at all; coverage no longer rests on what the scan finds, but on there being no value in the source to begin with. When a new secret is added, coverage continues on its own, because there is no place to write the value into the code. In this arrangement, scanning does not disappear — it stays to find the point that falls outside the pattern — but it is no longer the only defense. When the coverage of the two defenses is measured on the same five secrets, one gives 4/5, the other 5/5, and the one-row gap between them corresponds to a 38-commit exposure across a sixty-commit history.

Summary

  • Five secrets appear in the working tree for 124 commits and in repository history for 223 commits; the time they are actually left open is 198 commits. Deletion makes a ninety-nine-commit span look closed off.
  • The ingestion service’s key stayed in the tree for four commits but, since it was never rotated, was left open for 38 commits; as long as the time in the tree is shorter than the time left open, deletion gives no real measure.
  • The only row that rotated first and cleaned up after produced the shortest exposure: 18 commits.
  • A committed value reaches nineteen copies; history cleanup reaches one, leaving eighteen uncovered its reach. Rotation makes all nineteen worthless at once.
  • Snapshot scanning sees only one of the five secrets; the same rule finds all five across the full history, but findings grow 76.8-fold and the false-positive rate reaches 0.69. Content deduplication brings the findings down to nine without changing coverage.
  • Pattern scanning covers a new secret only if it clears the threshold (4/5); the pattern that never writes the value to the source covers the new point on its own.

Next Step

This lesson counted the trace a secret leaves in the source tree and in repository history. That trace closes once the value is pulled out of the source and moved into a store — but the value still sits in the process at run time, and the process talks. The response that comes back when a request errors, the log line written when an operation runs, the stack trace that lands in a file after a crash: all of these are points where values inside the process leak out. The next lesson counts these points as items — how many responses carry a stack trace, how many log lines carry a credential or personal data, how many of them a central masking layer closes off, and how many items are left exposed.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close