Lesson 10 / 12
Configuration and Secret Separation
The three environments' configuration files are written to disk, and a running checker counts how many keys actually change, how many sit in configuration without changing, and how many are undefined in an environment; the four surfaces a secret can leak through are scanned with a scanner, and caught, escaped, and false positives are written separately.
Contents
What makes the same software the same software across three environments is not the code being identical — it is where the values the code reads sit. An application reading its configuration from the environment, keeping secret values separate from source, was built in the Server-Side Fundamentals course. This lesson does not repeat that setup. The unit here is not a single application — it is the difference between environments: how many values actually change from environment to environment, how many sit in configuration without changing, how many are undefined in some environment at all, and through how many separate paths do values counted as secrets leak out.
The measurement is done on the regional measurement network: fictional software that collects readings from water meters, verifies the readings, converts them into invoices, and opens work orders for field crews. It has three environments and a nightly batch job. The files below are genuinely written to disk, the checkers genuinely run; every value they contain is fiction, and none of them is a real secret.
DC19. The only distinction between environments is the configuration files; code, the build
artifact, and operating steps are the same across the three environments.
DC20. Configuration sits in plain files in KEY=value form; keys counted as secrets are
identified by a name pattern, there is no separate secret store.
DC21. There are four surfaces a secret can leak through, and all four are represented by a
file on disk: build artifact, log, error message, and the file listing committed to the version
repository.
DC22. The leaks are deliberately planted before the scan, and their count is known; counting
what escapes depends on that.
The Three Environments’ Files
#!/usr/bin/env bash # Writes the fictional regional measurement network's three-environment configuration to disk. set -e mkdir -p config source cat > config/development.env <<'SON' ENVIRONMENT_LABEL=development MEASUREMENT_DATA_ADDRESS=127.0.0.1:5432 MEASUREMENT_DATA_POOL=4 MEASUREMENT_DATA_PASSWORD=fake-dev-p4t9 METER_READ_TIMEOUT_MS=2000 VERIFICATION_THRESHOLD_PERCENT=12 BATCH_JOB_START=02:30 BATCH_JOB_CONCURRENCY=2 FIELD_WORK_ORDER_ADDRESS=127.0.0.1:8081 INVOICE_CURRENCY=TRY LOG_LEVEL=detail MAX_RETRIES=3 SON cat > config/test.env <<'SON' ENVIRONMENT_LABEL=test MEASUREMENT_DATA_ADDRESS=measure-test.internal:5432 MEASUREMENT_DATA_POOL=8 MEASUREMENT_DATA_PASSWORD=fake-test-r7w2 METER_READ_TIMEOUT_MS=2000 VERIFICATION_THRESHOLD_PERCENT=12 BATCH_JOB_START=02:30 BATCH_JOB_CONCURRENCY=4 FIELD_WORK_ORDER_ADDRESS=work-order-test.internal:8081 INVOICE_CURRENCY=TRY LOG_LEVEL=detail MAX_RETRIES=3 TEST_SEED=4471 SON cat > config/production.env <<'SON' ENVIRONMENT_LABEL=production MEASUREMENT_DATA_ADDRESS=measure-prod.internal:5432 MEASUREMENT_DATA_POOL=32 MEASUREMENT_DATA_PASSWORD=fake-prod-h3m8 METER_READ_TIMEOUT_MS=2000 VERIFICATION_THRESHOLD_PERCENT=12 BATCH_JOB_START=02:30 BATCH_JOB_CONCURRENCY=16 INVOICE_CURRENCY=TRY LOG_LEVEL=summary MAX_RETRIES=3 INVOICE_SIGNING_KEY=fake-sig-9c1e SON
The application’s read point is singular and contains no branching that looks at the environment’s name.
// source/config.mjs — the fictional application's single settings point: every value is read from the environment. export const config = { env: process.env.ENVIRONMENT_LABEL, dataAddress: process.env.MEASUREMENT_DATA_ADDRESS, password: process.env.MEASUREMENT_DATA_PASSWORD, workOrderAddress: process.env.FIELD_WORK_ORDER_ADDRESS, signingKey: process.env.INVOICE_SIGNING_KEY, pool: Number(process.env.MEASUREMENT_DATA_POOL), timeoutMs: Number(process.env.METER_READ_TIMEOUT_MS ?? 2000), thresholdPercent: Number(process.env.VERIFICATION_THRESHOLD_PERCENT ?? 12), batchStart: process.env.BATCH_JOB_START ?? "02:30", concurrency: Number(process.env.BATCH_JOB_CONCURRENCY ?? 2), currency: process.env.INVOICE_CURRENCY ?? "TRY", logLevel: process.env.LOG_LEVEL ?? "summary", maxRetries: Number(process.env.MAX_RETRIES ?? 3), rereadDays: Number(process.env.REREAD_DAYS ?? 30), };
Counting the Difference
The checker reads the three files and the source’s read points separately, then compares the two.
// check.mjs — compares the three environment files against the source's read points. import { readFileSync } from "node:fs"; const ENVIRONMENTS = ["development", "test", "production"]; const readEnvFile = (a) => Object.fromEntries(readFileSync(`config/${a}.env`, "utf8") .split("\n").filter(Boolean).map((s) => [s.slice(0, s.indexOf("=")), s.slice(s.indexOf("=") + 1)])); const files = Object.fromEntries(ENVIRONMENTS.map((a) => [a, readEnvFile(a)])); const keys = [...new Set(ENVIRONMENTS.flatMap((a) => Object.keys(files[a])))].sort(); const sourceText = readFileSync("source/config.mjs", "utf8"); // which keys are read, const sourceReads = new Map(); // how many have an embedded default for (const [, name, opt] of sourceText.matchAll(/process\.env\.([A-Z_]+)(\s*\?\?)?/g)) sourceReads.set(name, Boolean(opt)); const counts = { changing: [], fixed: [], missing: [] }; for (const k of keys) { const v = ENVIRONMENTS.map((a) => files[a][k]); if (v.some((x) => x === undefined)) counts.missing.push(k); else if (new Set(v).size === 1) counts.fixed.push(k); else counts.changing.push(k); } const hiddenDefault = [...sourceReads].filter(([k]) => !keys.includes(k)).map(([k]) => k); const required = [...sourceReads].filter(([, v]) => !v).map(([k]) => k); const print = (label, list) => { // print the count, wrap names three per line console.log(`${label.padEnd(37)}: ${list.length}`); for (let i = 0; i < list.length; i += 3) console.log(" " + list.slice(i, i + 3).join(" ")); }; console.log("total keys (across three files)".padEnd(37) + `: ${keys.length}`); print(" present in all, value differs", counts.changing); print(" present in all, value the same", counts.fixed); print(" missing in at least one env", counts.missing); console.log("keys the source reads".padEnd(37) + `: ${sourceReads.size}, without a default: ${required.length}`); print(" in no environment file", hiddenDefault); print(" in a file, source does not read it", keys.filter((k) => !sourceReads.has(k))); for (const a of ENVIRONMENTS) print(`fails at runtime / ${a}`, required.filter((k) => files[a][k] === undefined));
total keys (across three files) : 14
present in all, value differs : 6
BATCH_JOB_CONCURRENCY ENVIRONMENT_LABEL LOG_LEVEL
MEASUREMENT_DATA_ADDRESS MEASUREMENT_DATA_PASSWORD MEASUREMENT_DATA_POOL
present in all, value the same : 5
BATCH_JOB_START INVOICE_CURRENCY MAX_RETRIES
METER_READ_TIMEOUT_MS VERIFICATION_THRESHOLD_PERCENT
missing in at least one env : 3
FIELD_WORK_ORDER_ADDRESS INVOICE_SIGNING_KEY TEST_SEED
keys the source reads : 14, without a default: 6
in no environment file : 1
REREAD_DAYS
in a file, source does not read it : 1
TEST_SEED
fails at runtime / development : 1
INVOICE_SIGNING_KEY
fails at runtime / test : 1
INVOICE_SIGNING_KEY
fails at runtime / production : 1
FIELD_WORK_ORDER_ADDRESS
Of fourteen keys, only six are defined across all three environments and actually differ in value. This is where the difference is hidden — these six lines. The remaining eight keys do not carry the difference; they carry its appearance.
Five keys carry the same value in all three environments. These are not configuration — they are
constants; sitting in the configuration file has a cost: they can be hand-changed in a single
environment. When VERIFICATION_THRESHOLD_PERCENT is pulled from twelve to fifteen in production,
verification behavior silently diverges, and this change falls under no code review, because the
code did not change.
This is where the declared difference and the difference visible only at runtime split apart. The
declared kind is the six lines in the files: written, readable, comparable. The runtime-only kind
splits in two. REREAD_DAYS is in no environment file; its default in the source is thirty. So
a fourth environment definition sits inside the source, and all three environments use it.
Changing its value takes changing code, not configuration; the principle that configuration lives
in the environment breaks exactly here, and where it breaks is read from no file. TEST_SEED is
this pattern’s mirror image: it is written in the test file, but the source never reads it. A
declared difference cannot be assumed to be effective; someone who thinks test runs with a fixed
seed is looking at a test that runs seedless.
The defect class the difference conceals shows up in how the missing keys are distributed.
FIELD_WORK_ORDER_ADDRESS is missing only in production: the work-order-opening path runs fine
in development and test, and in production it goes to an undefined address on its first work
order. This defect class’s name is the path visible only in production, and test cannot see
it, because nothing is missing in test. INVOICE_SIGNING_KEY is the mirror case: because it is
defined only in production, the signing path has never run outside production at all. Both gaps
carry the same count — one per environment — but one blows up in production, and the other is
tried there for the first time.
When the Difference Shows Up
The count reported one missing required key in each environment; it did not report when the gap would show up. The same number corresponds to two separate defect classes, and what determines the difference between them is whether the application reads that key at startup or on first use.
// start.mjs — validates the required keys at startup; the signing path runs at job time. import { config } from "./source/config.mjs"; const STARTUP_REQUIRED = ["env", "dataAddress", "password", "workOrderAddress"]; const missing = STARTUP_REQUIRED.filter((k) => config[k] === undefined); if (missing.length) { console.log(` startup rejected -> missing: ${missing.join(", ")}`); process.exit(1); } console.log(` started -> pool=${config.pool} concurrency=${config.concurrency} log=${config.logLevel}`); try { // nightly batch job's signing step if (!config.signingKey) throw new Error("signing key undefined"); console.log(` 03:00 batch job -> signed`); } catch (e) { console.log(` 03:00 batch job -> failed: ${e.message}`); }
#!/usr/bin/env bash # Loads each environment's file and starts the application separately. for o in development test production; do echo "$o:" ( set -a; . "config/$o.env"; set +a; node start.mjs ) || true done
development: started -> pool=4 concurrency=2 log=detail 03:00 batch job -> failed: signing key undefined test: started -> pool=8 concurrency=4 log=detail 03:00 batch job -> failed: signing key undefined production: startup rejected -> missing: workOrderAddress
All three environments were missing a required key, and the count was one in all three. The run
produced three separate outcomes. Production was rejected at startup: this is the cheapest form
of the gap, because the failure is visible while the eyes of whoever is deploying are on the
screen. Development and test started, and the failure came out at three in the morning, in the
middle of the nightly batch job. The gap is the same; its cost is not — the only thing that
produces the difference is the length of the startup-validation list. Because signingKey is not
on that list, it fails late in all three environments.
The output also shows two differences that are not the measurement’s subject. The log level is detail in two environments and summary in production. The record of a path visible only in production is kept in a log that is throttled in that same environment: the difference throttles, in the same environment, the very signal that would let someone see the defect it produces. The nightly batch job’s concurrency is two, four, and sixteen. A race condition that passes with four workers in test shows up on the first night in production with sixteen. This is a declared difference — it is written in the file — but being declared does not mean it is been tested.
Who changes these three values is a separate question too. Pool size, concurrency, and log level are in the operating team’s hands and usually change after an incident; the verification threshold and the payment form are the writing team’s decision. Because both sit in the same file, the changes look identical, and which difference came from which team can only be told apart by looking at the file’s history.
The Secret’s Four Ways Out
A secret is also a value that changes from environment to environment, and it is included in the count up to this point. The reason it is counted separately is a single asymmetry: a wrong port gets fixed, a leaked secret cannot be fixed — it can only be rotated, and it stays valid until it is. The four surfaces it can leak through are written to disk below with fictional content.
#!/usr/bin/env bash # Four surfaces: build artifact, log, error message, file listing committed to the repository. set -e mkdir -p build log error cat > build/package.mjs <<'SON' // Build artifact file (fictional): source bundled into a single file together with production values. const config = { address: "measure-prod.internal:5432", MEASUREMENT_DATA_PASSWORD: "fake-prod-h3m8", signature: "fake-sig-" + "9c1e", }; export const connect = () => config; SON cat > log/nightly.jsonl <<'SON' {"time":"03:12:04","event":"batch_job_started","env":"production","meterCount":48210} {"time":"03:12:04","event":"data_connection","connection":"measure:[email protected]:5432"} {"time":"03:12:05","event":"config_summary","MEASUREMENT_DATA_PASSWORD":"****","LOG_LEVEL":"summary"} {"time":"04:41:19","event":"invoice_signed","signatureB64":"ZmFrZS1zaWctOWMxZQ==","count":48210} SON cat > error/last-error.txt <<'SON' Uncaught error: invoice signature could not be verified (nightly batch job, production) request: POST /invoice/sign?signature=fake-sig-9c1e config : { env: 'production', MEASUREMENT_DATA_PASSWORD: 'fake-prod-h3m8', pool: 32 } stack : batch-job.mjs:118 -> sign.mjs:42 SON cat > repository-listing.txt <<'SON' source/config.mjs config/example.env config/production.env package.json SON
The scanner has two rules: a literal match on the secret’s value, and the secret key’s name pattern.
// scanner.mjs — searches four surfaces for secrets; rule=value is a literal match, rule=name is the name pattern. import { readFileSync } from "node:fs"; const SURFACES = { build: "build/package.mjs", log: "log/nightly.jsonl", error: "error/last-error.txt", repo: "repository-listing.txt" }; const NAME_PATTERN = /PASSWORD|KEY|\.env$/; const values = ["development", "test", "production"] // secret values across the three files .flatMap((a) => readFileSync(`config/${a}.env`, "utf8").split("\n").filter(Boolean)) .filter((s) => NAME_PATTERN.test(s.slice(0, s.indexOf("=")))) .map((s) => s.slice(s.indexOf("=") + 1)); const findings = []; for (const [name, path] of Object.entries(SURFACES)) readFileSync(path, "utf8").split("\n").filter(Boolean).forEach((line, i) => { const rule = [values.some((v) => line.includes(v)) && "value", NAME_PATTERN.test(line.trim()) && "name"].filter(Boolean); if (rule.length) findings.push({ name, lineNo: i + 1, line, rule: rule.join("+") }); }); // Planted leaks: surface name + that line's distinguishing trace. const PLANTED = [["build", "MEASUREMENT_DATA_PASSWORD:"], ["build", 'signature: "fake-sig-'], ["log", '"connection"'], ["log", '"signatureB64"'], ["error", "MEASUREMENT_DATA_PASSWORD:"], ["error", "signature=fake"], ["repo", "production.env"]]; const matched = (f) => PLANTED.some(([s, trace]) => s === f.name && f.line.includes(trace)); const escaped = PLANTED.filter(([s, trace]) => !findings.some((f) => f.name === s && f.line.includes(trace))); console.log(`secret values: ${values.length} surfaces: ${Object.keys(SURFACES).length} ` + `planted leaks: ${PLANTED.length} reported lines: ${findings.length}`); console.log(`caught: ${PLANTED.length - escaped.length} escaped: ${escaped.length} ` + `false positives: ${findings.filter((f) => !matched(f)).length}`); for (const f of findings) console.log(` [${matched(f) ? "real" : "FALSE"}] ${f.name}:${f.lineNo} rule=${f.rule}`); for (const [s, trace] of escaped) console.log(` [ESCAPED] ${s} trace=${trace}`);
secret values: 4 surfaces: 4 planted leaks: 7 reported lines: 7 caught: 5 escaped: 2 false positives: 2 [real] build:4 rule=value+name [real] log:2 rule=value [FALSE] log:3 rule=name [real] error:2 rule=value [real] error:3 rule=value+name [FALSE] repo:2 rule=name [real] repo:3 rule=name [ESCAPED] build trace=signature: "fake-sig- [ESCAPED] log trace="signatureB64"
Caught, Escaped, False Positive
The scanner reported seven lines, and seven leaks were planted. The counts are equal — the sets are not. This equality is a warning for every reading that judges coverage by looking at a scan report’s total.
What the two escaped leaks share is that the secret’s value does not sit in the form being searched
for. One is split in two by concatenation ("fake-sig-" + "9c1e"), the other is turned into base
64 (ZmFrZS1zaWctOWMxZQ==). Both carry the same secret, and both fall outside the value-matching
rule. The general shape of this is: a literal value search falls behind every step that
transforms the value — a build tool’s line concatenation, a log writer’s encoding, a message
formatter’s truncation. All five caught leaks are lines where the value sits unchanged.
The way to raise the catch rate is known: transformed forms of the searched value are also
generated — its encoded form, its truncated form, each of its parts — and all of them are searched
separately. The cost of this is written directly to false positives: the shorter the value, the
greater the chance of a random match, and a four-character fragment like 9c1e turns up in
ordinary text too. This is the relationship between the two rules; every widening that reduces
escapes increases false positives, and unless the two counts are written separately, which one is
rising stays invisible.
Both false positives come from the name rule, and both are correct behavior itself: the masked log line was written to hide the secret, and the example configuration file is exactly what belongs in the version repository. Its cost is not numeric — it is behavioral. Once two false positives are on the list, whoever reads that list stops checking lines one by one on the next scan; the chance of finding the two escaped leaks drops right along with the false-positive count.
The four surfaces also have separate owners: the build artifact belongs to the compile step, the log to operations, the error message to application code, the repository listing to version control. When a leak is closed on one surface, the others stay open, because whoever closes it only sees their own surface. The decision the number delivers here is this: secret separation is not a decision made in one place — it is a boundary maintained separately across four distinct surfaces.
Summary
- Six of the fourteen keys in the three environment files actually change; this is where the difference is hidden. Five keys carry the same value in all three environments, meaning they are constants, not configuration.
- Three keys are missing in at least one environment; one of the six required keys stays undefined in every environment. The work order address missing in production blows up only in production, while the signing key defined only in production means the signing path has never run outside it.
- In the run, the same count produced three separate outcomes: production was rejected at startup, the other two environments started and failed at three in the morning in the middle of the batch job. What produces the difference is the length of the startup-validation list.
- The declared difference is the six lines in the files; the difference visible only at runtime is
the fourth environment defined by a default embedded in the source (
REREAD_DAYS) and the dead key no code reads (TEST_SEED). - Five of the seven leaks planted across four surfaces were caught, two escaped; both escapees were transformed forms of the value (concatenation and base 64).
- The scanner reported seven lines, and there were seven leaks; two were false positives, and both flagged correct behavior. Equal totals are not proof of coverage.
Next Step
Configuration living in the environment is not a rule that stands alone — it is a member of an interdependent set; this lesson measured one member of that set and, while measuring, saw the rule silently break through a default embedded in the source. The full set was built with its names in the Server-Side Fundamentals course. Rather than repeat those names, the next lesson asks a single question: can each principle be turned into a check that runs against the files on disk? The ones that can are run against the fictional application’s real files, with caught and escaped counted separately; for the ones that cannot, what replaces them is written down too.
To keep your progress and take notes, Log in
My notes
Log in to take notes.