fix: omit AWS's empty-object placeholders from the cloned PDF policy
Build and deploy / build-and-deploy (push) Failing after 4s

`configure.mjs --apply` failed on its first write, 2026-09-04, and nothing
reached the distribution. `get-response-headers-policy` returns
`"ContentSecurityPolicy": {}` for a member the source does not define, and
sending that back fails `create-response-headers-policy` on ParamValidation
before the call leaves the machine — a config AWS hands back is not
necessarily a config AWS will accept.

Of the 16 structures reachable from `ResponseHeadersPolicyConfig` in the CLI's
service model, 15 declare a required field, so `{}` is illegal there and can
only be the placeholder; the one exception is `SecurityHeadersConfig` itself,
which section 4 already skips on when empty. The strip is therefore recursive.
The dry run now asserts the generated config carries no empty object, and does
so as a section-4 SKIP rather than a throw — section 4 must never block
sections 1-3 from re-applying `router.js`.

The two functions move to `policy-shapes.mjs` with a 23-case test (7 of 7
mutations killed), because `configure.mjs` reads argv and calls AWS at import
time and the runbook was otherwise claiming a proof nobody could re-run.

Also: the handler was redeployed 2026-09-04 via docs/09 §5.5. Re-read against
production — the two bundled SDK clients moved 3.1125.0 -> 3.1126.0 with no
file in this repository changing, which is what §7's own row predicted. §12
gains R22, because that row named itself as the reminder covering them while
no such reminder existed. docs/05, docs/06 and docs/09 §5.5 each held their
own stale copy of the deployed commit; all three now cite §7.

Reviewed twice by adversarial-reviewer: 7 findings, then 8, of which five were
defects in the first round's repairs. All 15 fixed.

Nothing was applied to the distribution and nothing was deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
Pouya Lajevardi
2026-09-04 12:59:04 -04:00
co-authored by Claude Opus 5
parent 3c3ba5dc6e
commit bbe535d158
7 changed files with 802 additions and 182 deletions
+155 -3
View File
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -31,9 +31,12 @@ behaviour the form posts to does not exist"* — true when written under D11, fa
from the moment `docs/09` Parts 3, 5 and 6 ran at cutover, and two days stale in from the moment `docs/09` Parts 3, 5 and 6 ran at cutover, and two days stale in
the document an implementer reads before touching the handler. **Measured the document an implementer reads before touching the handler. **Measured
2026-09-04:** the function carries `handler.handler` with six environment 2026-09-04:** the function carries `handler.handler` with six environment
variables and its two source entries are byte-identical to commit `02739ad`; the API has variables and its deployed source entries match a commit **§7 names**; the API has
exactly one route, `POST /api/intake`; §7 holds the full state and this spec does exactly one route, `POST /api/intake`; §7 holds the full state and this spec does
not restate it. `/contact/` still publishes the email address beside the form, not restate it. ⚠️ **THIS SENTENCE NAMED THE COMMIT — `02739ad` — IN THE SAME
BREATH AS DISCLAIMING RESTATEMENT, AND THE 2026-09-04 REDEPLOY MADE IT FALSE**
(three source entries now, matching a later commit). The count is gone with it:
both were facts §7 owns. `/contact/` still publishes the email address beside the form,
which is now a courtesy rather than a fallback. which is now a courtesy rather than a fallback.
### The form is a plain HTML POST, and it answers 303 ### The form is a plain HTML POST, and it answers 303
+5 -2
View File
@@ -792,8 +792,11 @@ the decision is re-readable rather than re-litigated.
those two findings was a bare POST with no `Origin` header, which the those two findings was a bare POST with no `Origin` header, which the
handler rejects by design. `docs/09` §7.1 run correctly returns **303**, and handler rejects by design. `docs/09` §7.1 run correctly returns **303**, and
on 2026-09-04 the function's own configuration and its deployed artefact on 2026-09-04 the function's own configuration and its deployed artefact
were read: `handler.handler`, six environment variables, both source files were read: `handler.handler`, six environment variables, and source files
byte-identical to commit `02739ad`. The paragraph above is preserved as what the pass byte-identical to the commit **§7 records**. ⚠️ **THIS SAID "both source
files … `02739ad`" AND THE REDEPLOY LATER THAT DAY MADE BOTH HALVES
FALSE** — three files now, and a later commit. A count and a commit are
§7's to hold; this line cites it. The paragraph above is preserved as what the pass
found; **only findings 10, 11 and 13 outlived it, and 10 and 13 are now found; **only findings 10, 11 and 13 outlived it, and 10 and 13 are now
ruled** — see item 3 of the callout near the top of this file, which is the ruled** — see item 3 of the callout near the top of this file, which is the
current tally and this is not. current tally and this is not.
+116 -8
View File
@@ -321,8 +321,24 @@ node infra/cloudfront/configure.mjs --dist "$DIST_ID" --api-domain "$API_DOMAIN"
--function-arn "$ROUTER_ARN" --function-arn "$ROUTER_ARN"
``` ```
**Expect** — this is the dry run, and the output on a distribution in the state ⚠️ **THE BLOCK BELOW IS THE `+` CHANGE LINES AND THE TWO RESOLVED-POLICY LINES.
Part 0.3 records is exactly: IT IS NOT THE WHOLE OUTPUT, AND IT SAID "exactly" UNTIL 2026-09-04.** Against
the **live** distribution the dry run is **60 lines**, and all 60 account for:
2 resolved-policy lines, **10** `·` lines, **4** `+` lines, a **40-line JSON
dump** of the `*.pdf` behaviour it would add, 2 blank lines, the
`N change(s) to distribution …` header and the `DRY RUN — nothing was sent.`
footer `[measured 2026-09-04, exit 0, nothing written]`.
⚠️ **THAT 10/4 SPLIT IS THE LIVE STATE, NOT THE STATE THE BLOCK BELOW
DESCRIBES.** Each of the four things Parts 13 have already applied prints `·`
when it is found and `+` when it is staged, so in the Part 0.3 state those four
move across: **6** `·` and **8** `+`, same 60 lines. **The total is not the
check — the `+` lines are.** An operator told the output is "exactly" 14 lines
either stops on a wall of unexplained text or stops comparing, and the paragraph
after the block tells them a different set means *"stop and re-read it"*.
**Expect** — this is the dry run, and on a distribution in the state Part 0.3
records the change lines are:
``` ```
resolved Managed-CachingDisabled = 4135ea2d-6df8-44a3-9df3-4b5a84be39ad resolved Managed-CachingDisabled = 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
@@ -359,6 +375,96 @@ and the two for section 5** — `[measured 2026-09-04, dry run against `E1OK7G98
`E2EUQ1WTGCTBG2`, exit 0, nothing written]`. More than eight, or a different set, `E2EUQ1WTGCTBG2`, exit 0, nothing written]`. More than eight, or a different set,
means the distribution is not in the state 0.3 recorded: stop and re-read it. means the distribution is not in the state 0.3 recorded: stop and re-read it.
🛑 **INCIDENT, 2026-09-04 — THE FIRST `--apply` FAILED ON ITS FIRST WRITE, AND
NOTHING REACHED THE DISTRIBUTION.** Section 4's clone was sent to
`create-response-headers-policy` verbatim and the AWS CLI rejected it
**client-side**, before the call left the machine:
```
An error occurred (ParamValidation): Parameter validation failed:
Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "Override"
Missing required parameter in ResponseHeadersPolicyConfig.SecurityHeadersConfig.ContentSecurityPolicy: "ContentSecurityPolicy"
```
**The cause, and it generalises past this script: a config AWS hands back is not
necessarily a config AWS will accept.** `get-response-headers-policy` on
`Managed-SecurityHeadersPolicy` returns `"ContentSecurityPolicy": {}` — an empty
object standing for a member the policy does not define — and
`ResponseHeadersPolicySecurityHeadersConfig` has **no required members** while
**every one of its six members requires at least `Override`**. So an empty member
is always "undefined here" and is **never** a legal input.
⚠️ **AND THE SAME IS TRUE ONE LEVEL UP, WHICH THE FIRST FIX MISSED.** Every
sibling member of `ResponseHeadersPolicyConfig` also declares required fields —
`CorsConfig` five of them, `RemoveHeadersConfig` and `CustomHeadersConfig` a
`Quantity`, `ServerTimingHeadersConfig` an `Enabled` — while the container
itself requires only `Name`. So `{}` is a placeholder at **both** levels, and a
fix covering only the inner one turns the outer placeholder into a hard abort
instead of an omission. All of that is read out of the CLI's own service model,
not inferred from the symptom.
**State after the failure** ``[verified 2026-09-04 — `get-distribution-config`, `list-response-headers-policies --type custom`, `list-origin-request-policies --type custom`]``**:** `/api/*` still on
`b689b0a8-53d0-40ab-baf2-68738e2966ac`, **no** `*.pdf` behaviour, **zero** custom
response-headers policies, **zero** custom origin request policies.
⚠️ **THE "ONE REVIEWABLE TRANSACTION" PROPERTY IS ABOUT THE DISTRIBUTION, NOT
THE ACCOUNT — AND THIS RECORD ASSERTED THE WIDER VERSION FOR ONE ROUND.**
`update-distribution` is the script's last call, so a throw above it does leave
the **distribution** untouched. But sections 4 and 5 each make their own write
first — `create-response-headers-policy` and `create-origin-request-policy` —
and the script's own comment on section 5's drift throw documents a reachable
path where section 4 has **already created `adr-sml-pdf-noindex`** when section
5 aborts. **So after any failed `--apply`, check for an orphaned policy as well
as for a changed distribution**, with both of these:
```bash
aws cloudfront list-response-headers-policies --type custom --output json \
--query 'ResponseHeadersPolicyList.Quantity'
aws cloudfront list-origin-request-policies --type custom --output json \
--query 'OriginRequestPolicyList.Quantity'
```
An orphan is harmless and self-healing — the next run finds it by name, matches
it and attaches it — **so do not delete it by hand.** Both returned `0` after
the 2026-09-04 failure, which is why nothing needed doing that time
`[verified 2026-09-04]`.
**Two changes came out of it.** The clone now **omits** any empty member at
either of those two levels — a `ResponseHeadersPolicyConfig` member, or a
`SecurityHeadersConfig` member — and the dry run **asserts** that the generated
config carries no empty object at any *other* level, naming the dotted path if
it does. The asymmetry is deliberate: those two levels are where AWS is known to
synthesise a placeholder, and anywhere else is unaccounted for and stops the
run rather than being discarded quietly. The assertion runs before every branch, so **the dry run now catches this
class** rather than an `--apply` discovering it — and if it ever does fire it
**skips section 4** rather than throwing, so `router.js` can still be
re-applied.
**The proof is a command rather than a session**, which is the point of
`infra/cloudfront/policy-shapes.mjs` existing as its own module — `configure.mjs`
reads argv and calls AWS at import time, so the two functions could not
otherwise be reached:
```bash
node infra/cloudfront/policy-shapes.test.mjs
```
**Expect** `policy-shapes: 23 of 23 cases pass`, exit 0. Its first case is this
incident verbatim — the live `SecurityHeadersConfig`, empty `ContentSecurityPolicy`
and all `[verified 2026-09-04; 7 of 7 mutations killed]`.
**One of the `·` lines carries a number worth reading**, and it is not a
warning:
```
· cloning 5 defined security header(s); omitting 1 the source does not define (ContentSecurityPolicy)
```
**Five is the number to read.** It is the count of security headers the PDF
policy will carry, and the verification block at the end of this Part counts the
same five arriving on the live PDF. A drop in this number is a partial clone
announcing itself one step earlier than that `curl` would.
⚠️ **RUN IT WITHOUT `--function-arn` ONLY IF THE ROUTER IS ALREADY ATTACHED.** ⚠️ **RUN IT WITHOUT `--function-arn` ONLY IF THE ROUTER IS ALREADY ATTACHED.**
Omitting the flag prints `· no --function-arn given, leaving FunctionAssociations Omitting the flag prints `· no --function-arn given, leaving FunctionAssociations
alone` and skips change 1 — which is right on a re-run and wrong on a first one, alone` and skips change 1 — which is right on a re-run and wrong on a first one,
@@ -734,12 +840,14 @@ file: `CLAUDE.md`'s rule is that a version is checked against the registry and
never recalled, and a literal here would be stale the week after it was written. never recalled, and a literal here would be stale the week after it was written.
⚠️ **THIS PATH WAS TAKEN — 2026-09-02, and the live function is the bundled ⚠️ **THIS PATH WAS TAKEN — 2026-09-02, and the live function is the bundled
zip** `[measured 2026-09-04 — the deployed artefact was downloaded via zip.** ⚠️ **THE TWO RESOLVED VERSIONS AND THE COMMIT THE DEPLOYED SOURCES MATCH
`get-function` `Code.Location` and read]`. Its two source entries were ARE IN `AGENTS.md` §7 AND ARE DELIBERATELY NOT REPEATED HERE.** They were
byte-identical to commit `02739ad`, and the two packages inside it are repeated here until 2026-09-04, and the paragraph directly above is the argument
**`@aws-sdk/client-dynamodb@3.1125.0`** and **`@aws-sdk/client-sesv2@3.1125.0`**. against it — **the literals went stale in two days rather than a week**: the
`AGENTS.md` §7 now records them, which this step required in terms and which did 2026-09-04 redeploy moved both packages one patch and added a third source file,
not happen at the time. and this copy still named the old versions and a superseded commit while reading
as a measurement. Read §7's two Lambda rows; this step's duty is to **update**
them, not to mirror them.
✅ **THE `cp` AND `zip` LINES BELOW DERIVE THE FILE LIST THE SAME WAY 5.1 DOES.** ✅ **THE `cp` AND `zip` LINES BELOW DERIVE THE FILE LIST THE SAME WAY 5.1 DOES.**
They were a second hand-typed copy until 2026-09-04, not derived from 5.1's and They were a second hand-typed copy until 2026-09-04, not derived from 5.1's and
+81 -8
View File
@@ -41,6 +41,14 @@
*/ */
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
/* Section 4's clone, and why an empty member is always AWS's placeholder
rather than a value: `policy-shapes.mjs`. Tested there, because this file
reads argv and calls AWS at import time. */
import {
withoutEmptyMembers,
isEmptyObject,
emptyObjectPaths,
} from './policy-shapes.mjs';
const args = process.argv.slice(2); const args = process.argv.slice(2);
const flag = (name) => { const flag = (name) => {
@@ -383,18 +391,49 @@ if (!defaultRhpId) {
instead, naming the diff. */ instead, naming the diff. */
const source = getResponseHeadersPolicy(defaultRhpId); const source = getResponseHeadersPolicy(defaultRhpId);
const srcCfg = source?.ResponseHeadersPolicy?.ResponseHeadersPolicyConfig; const srcCfg = source?.ResponseHeadersPolicy?.ResponseHeadersPolicyConfig;
const clonedShc = withoutEmptyMembers(srcCfg?.SecurityHeadersConfig);
const undefinedMembers = Object.keys(
srcCfg?.SecurityHeadersConfig ?? {},
).filter((k) => !(k in clonedShc));
/* ⚠️ SKIP, NOT THROW — same rule as the missing-id case above, and it was /* ⚠️ SKIP, NOT THROW — same rule as the missing-id case above, and it was
inconsistent for one round. A policy carrying only `CorsConfig` is legal; inconsistent for one round. A policy carrying only `CorsConfig` is legal;
an ABSENT source is section 4's problem alone and must not stop sections an ABSENT source is section 4's problem alone and must not stop sections
1-3 from re-applying `router.js`. The DRIFT throw below is different: that 1-3 from re-applying `router.js`. TWO THROWS BELOW ARE DELIBERATE
is a divergence, not an absence, and `docs/09` Part 3 argues for it. */ EXCEPTIONS, and they are exceptions for the same reason: DRIFT is a
divergence rather than an absence (`docs/09` Part 3 argues for it), and the
EMPTY-OBJECT throw means this script generated an invalid config — a bug in
the script, not a state of the world. Neither is something a later section
should be allowed to paper over. */
if (!srcCfg?.SecurityHeadersConfig) { if (!srcCfg?.SecurityHeadersConfig) {
skipped.push( skipped.push(
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has no SecurityHeadersConfig to clone`, `${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has no SecurityHeadersConfig to clone`,
); );
} else if (Object.keys(clonedShc).length === 0) {
/* Distinct from the branch above, and the distinction is the operator's:
ABSENT means the source policy is a different kind of thing, while
ALL-EMPTY means it is the right kind and defines nothing. Both leave the
PDF without security headers; only the second would read as a bug. */
skipped.push(
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — response-headers policy ${defaultRhpId} has a SecurityHeadersConfig that defines nothing` +
(undefinedMembers.length
? ` (all ${undefinedMembers.length} of its members are empty: ${undefinedMembers.join(', ')})`
: ' — it is an empty object') +
`, so there are no security headers to clone`,
);
} else { } else {
const wanted = { /* ⚠️ UNCONDITIONAL. This was gated on `undefinedMembers.length`, so on a
SecurityHeadersConfig: srcCfg.SecurityHeadersConfig, source that defines all six the line DISAPPEARED rather than reading six
— and `docs/09` Part 3 tells the operator to read this number as the
count of headers the PDF will carry. An absent line is not a smaller
number; it is nothing to compare (`adversarial-reviewer`). */
console.log(
`· cloning ${Object.keys(clonedShc).length} defined security header(s)` +
(undefinedMembers.length
? `; omitting ${undefinedMembers.length} the source does not define (${undefinedMembers.join(', ')})`
: ''),
);
const wanted = withoutEmptyMembers({
SecurityHeadersConfig: clonedShc,
...(srcCfg.CorsConfig ? { CorsConfig: srcCfg.CorsConfig } : {}), ...(srcCfg.CorsConfig ? { CorsConfig: srcCfg.CorsConfig } : {}),
...(srcCfg.RemoveHeadersConfig ...(srcCfg.RemoveHeadersConfig
? { RemoveHeadersConfig: srcCfg.RemoveHeadersConfig } ? { RemoveHeadersConfig: srcCfg.RemoveHeadersConfig }
@@ -406,11 +445,40 @@ if (!defaultRhpId) {
Quantity: (srcCfg.CustomHeadersConfig?.Items ?? []).length + 1, Quantity: (srcCfg.CustomHeadersConfig?.Items ?? []).length + 1,
Items: [...(srcCfg.CustomHeadersConfig?.Items ?? []), XRT], Items: [...(srcCfg.CustomHeadersConfig?.Items ?? []), XRT],
}, },
}; });
/* ⚠️ ASSERT BEFORE ANY BRANCH, SO THE DRY RUN CARRIES IT TOO — Pouya's
ruling of 2026-09-04. The clone that failed was syntactically fine and
printed a clean dry run; a check that runs only on the writing path
reports the class after it has already cost the run.
⚠️ A SKIP, NOT A THROW — it was a throw for one round. An empty object
here is section 4's problem alone, and this file's contract is that
section 4 never blocks sections 1-3 from re-applying `router.js`, without
which 22 of 23 pages return S3's AccessDenied. A skip is already loud: it
prints under its own heading and the NOTHING TO CHANGE guard names it. */
const empties = emptyObjectPaths(wanted);
if (empties.length) {
skipped.push(
`${PDF_PATTERN} / ${PDF_POLICY_NAME} — the generated policy config still ` +
`contains ${empties.length} empty object(s) (${empties.join(', ')}), and ` +
`AWS rejects those on ParamValidation before the call leaves the machine. ` +
`withoutEmptyMembers should have removed them, so either it is broken or ` +
`this script built one itself — see policy-shapes.mjs and its test.`,
);
} else {
if (existingPdfPolicy) { if (existingPdfPolicy) {
const have = existingPdfPolicy.ResponseHeadersPolicyConfig; const have = existingPdfPolicy.ResponseHeadersPolicyConfig;
const norm = (o) => JSON.stringify(o ?? null); /* ⚠️ `{}` AND ABSENT MUST NORMALISE ALIKE, ON BOTH SIDES. `wanted` has
had AWS's placeholders stripped; the live policy may still echo them.
Stripping alone is not enough — that leaves `norm({})` as `"{}"`
against `norm(undefined)` as `"null"`, which reports drift that no
`update-response-headers-policy` can clear, on the intake form's own
path. An empty member is never a real divergence. */
const norm = (o) => {
const v = withoutEmptyMembers(o);
return JSON.stringify(isEmptyObject(v) ? null : (v ?? null));
};
const drift = [ const drift = [
'SecurityHeadersConfig', 'SecurityHeadersConfig',
'CorsConfig', 'CorsConfig',
@@ -456,7 +524,9 @@ if (!defaultRhpId) {
`· response-headers policy ${PDF_POLICY_NAME} exists and matches the default behaviour`, `· response-headers policy ${PDF_POLICY_NAME} exists and matches the default behaviour`,
); );
} else if (!APPLY) { } else if (!APPLY) {
console.log(`· would CREATE response-headers policy ${PDF_POLICY_NAME}`); console.log(
`· would CREATE response-headers policy ${PDF_POLICY_NAME}`,
);
changes.push( changes.push(
`create response-headers policy ${PDF_POLICY_NAME} (SecurityHeadersConfig cloned from ${defaultRhpId} + X-Robots-Tag: noindex)`, `create response-headers policy ${PDF_POLICY_NAME} (SecurityHeadersConfig cloned from ${defaultRhpId} + X-Robots-Tag: noindex)`,
); );
@@ -501,7 +571,9 @@ if (!defaultRhpId) {
`ResponseHeadersPolicyId is ${foundPdf.ResponseHeadersPolicyId ?? '(none)'}, expected ${pdfPolicyId ?? '(the policy this script manages)'}`, `ResponseHeadersPolicyId is ${foundPdf.ResponseHeadersPolicyId ?? '(none)'}, expected ${pdfPolicyId ?? '(the policy this script manages)'}`,
); );
} }
if (foundPdf.TargetOriginId !== cfg.DefaultCacheBehavior.TargetOriginId) { if (
foundPdf.TargetOriginId !== cfg.DefaultCacheBehavior.TargetOriginId
) {
wrong.push( wrong.push(
`TargetOriginId is ${foundPdf.TargetOriginId}, expected ${cfg.DefaultCacheBehavior.TargetOriginId}`, `TargetOriginId is ${foundPdf.TargetOriginId}, expected ${cfg.DefaultCacheBehavior.TargetOriginId}`,
); );
@@ -579,6 +651,7 @@ if (!defaultRhpId) {
} }
} }
} }
}
} }
/* ---- 5. CloudFront-Viewer-Address on /api/* ----------------------------- /* ---- 5. CloudFront-Viewer-Address on /api/* -----------------------------
+77
View File
@@ -0,0 +1,77 @@
/**
* Shape helpers for the CloudFront policy configs `configure.mjs` builds.
*
* ⚠️ **A POLICY AWS HANDS BACK IS NOT A POLICY AWS WILL ACCEPT.**
* `get-response-headers-policy` returns `{}` for a member the source does not
* define — `Managed-SecurityHeadersPolicy` does it for `ContentSecurityPolicy`
* — and sending that back fails `create-response-headers-policy` on
* ParamValidation before the call leaves the machine. `docs/09` Part 3 carries
* the incident and the exact error.
*
* **Dropping an empty member is safe at every depth, and that is a measurement
* rather than a hope.** Of the 16 structures reachable from
* `ResponseHeadersPolicyConfig` in the CLI's own service model, **15 declare at
* least one required field** — so `{}` is not a legal value there and can only
* be the placeholder. The single exception is `SecurityHeadersConfig` itself,
* and `configure.mjs` skips before it can build one of those empty, because a
* PDF policy cloning no security headers is the thing that section exists to
* avoid.
*
* They live in their own module so they can be tested: `configure.mjs` reads
* argv and calls AWS at import time, so importing THAT to reach two pure
* functions is not possible. Same reason `fields.mjs` sits beside
* `handler.mjs`. See `policy-shapes.test.mjs`.
*/
/**
* Every empty-object member removed, at every depth, bottom-up — so a member
* left empty by stripping its own children is removed in turn.
*
* Arrays are recursed into but never have elements removed: an element index is
* load-bearing against its `Quantity` sibling, and an empty object inside one
* would be this script's own construction rather than an AWS placeholder. That
* case is left for `emptyObjectPaths` to report.
*/
export const withoutEmptyMembers = (value) => {
if (Array.isArray(value)) return value.map(withoutEmptyMembers);
if (!value || typeof value !== 'object') return value;
const out = {};
for (const [k, v] of Object.entries(value)) {
const cleaned = withoutEmptyMembers(v);
const isEmptyObject =
cleaned &&
typeof cleaned === 'object' &&
!Array.isArray(cleaned) &&
Object.keys(cleaned).length === 0;
if (!isEmptyObject) out[k] = cleaned;
}
return out;
};
/** True for `{}` — the value AWS accepts nowhere in these configs. */
export const isEmptyObject = (v) =>
Boolean(v) &&
typeof v === 'object' &&
!Array.isArray(v) &&
Object.keys(v).length === 0;
/**
* The dotted path of every empty object left in a config. A post-condition on
* the strip above, not a filter: if this returns anything, the strip did not do
* what this module claims it does.
*
* Empty ARRAYS are not reported — `{Quantity: 0, Items: []}` is valid and
* common, while an empty object is valid nowhere.
*/
export function emptyObjectPaths(value, path = '') {
if (Array.isArray(value)) {
return value.flatMap((v, i) => emptyObjectPaths(v, `${path}[${i}]`));
}
if (value && typeof value === 'object') {
if (Object.keys(value).length === 0) return [path || '(root)'];
return Object.entries(value).flatMap(([k, v]) =>
emptyObjectPaths(v, path ? `${path}.${k}` : k),
);
}
return [];
}
+204
View File
@@ -0,0 +1,204 @@
/**
* Tests for `policy-shapes.mjs` — the two functions that answer the 2026-09-04
* `--apply` failure recorded in `docs/09` Part 3.
*
* The first case is that failure verbatim: the `SecurityHeadersConfig` the live
* `Managed-SecurityHeadersPolicy` returns, empty `ContentSecurityPolicy` and
* all, which is what `create-response-headers-policy` rejected.
*
* node infra/cloudfront/policy-shapes.test.mjs
*/
import {
withoutEmptyMembers,
emptyObjectPaths,
isEmptyObject,
} from './policy-shapes.mjs';
let pass = 0;
const failures = [];
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const t = (name, got, want) => {
if (eq(got, want)) pass += 1;
else
failures.push(
`${name}\n got ${JSON.stringify(got)}\n want ${JSON.stringify(want)}`,
);
};
/* The live source policy, copied from `get-response-headers-policy` on
67f7725c-6f97-4210-82d7-5512b31e9d03 [verified 2026-09-04]. */
const LIVE_SECURITY_HEADERS = {
XSSProtection: { Override: false, Protection: true, ModeBlock: true },
FrameOptions: { Override: false, FrameOption: 'SAMEORIGIN' },
ReferrerPolicy: {
Override: false,
ReferrerPolicy: 'strict-origin-when-cross-origin',
},
ContentSecurityPolicy: {},
ContentTypeOptions: { Override: true },
StrictTransportSecurity: {
Override: false,
AccessControlMaxAgeSec: 31536000,
},
};
/* ---- the incident itself ------------------------------------------------ */
const stripped = withoutEmptyMembers(LIVE_SECURITY_HEADERS);
t(
'the 2026-09-04 breach: ContentSecurityPolicy is dropped',
Object.keys(stripped).sort(),
[
'ContentTypeOptions',
'FrameOptions',
'ReferrerPolicy',
'StrictTransportSecurity',
'XSSProtection',
],
);
t(
'and five survive — the count docs/09 Part 3 tells the operator to read',
Object.keys(stripped).length,
5,
);
t(
'the surviving members are untouched',
stripped.StrictTransportSecurity,
LIVE_SECURITY_HEADERS.StrictTransportSecurity,
);
t('nothing empty is left behind', emptyObjectPaths(stripped), []);
/* ---- the placeholder one level up, which a SecurityHeadersConfig-only strip
turned into a hard abort (adversarial-reviewer, round 1) ------------- */
t(
'a top-level policy-config member is dropped',
withoutEmptyMembers({
Name: 'p',
CorsConfig: {},
SecurityHeadersConfig: stripped,
}),
{ Name: 'p', SecurityHeadersConfig: stripped },
);
/* ---- and the one BELOW that, which the first repair still aborted on
(adversarial-reviewer, round 2) -------------------------------------- */
t(
'a CorsConfig member is dropped, and the emptied CorsConfig with it',
withoutEmptyMembers({
Name: 'p',
CorsConfig: { AccessControlExposeHeaders: {} },
}),
{ Name: 'p' },
);
t(
'but a CorsConfig that still has content survives',
withoutEmptyMembers({
CorsConfig: { AccessControlExposeHeaders: {}, OriginOverride: false },
}),
{ CorsConfig: { OriginOverride: false } },
);
/* ---- things that must NOT be discarded ---------------------------------- */
t(
'an empty ARRAY is kept — {Quantity: 0, Items: []} is valid and common',
withoutEmptyMembers({ RemoveHeadersConfig: { Quantity: 0, Items: [] } }),
{ RemoveHeadersConfig: { Quantity: 0, Items: [] } },
);
t(
'false, 0, null and empty string are kept',
withoutEmptyMembers({ a: false, b: 0, c: null, d: '' }),
{ a: false, b: 0, c: null, d: '' },
);
t(
'array elements are recursed into but never removed',
withoutEmptyMembers({ Items: [{ Header: 'X', Sub: {} }, {}] }),
{ Items: [{ Header: 'X' }, {}] },
);
t(
'the custom-headers list the script builds is untouched',
withoutEmptyMembers({
CustomHeadersConfig: {
Quantity: 1,
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
},
}),
{
CustomHeadersConfig: {
Quantity: 1,
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
},
},
);
t('stripping is idempotent', withoutEmptyMembers(stripped), stripped);
/* ---- the drift comparison: {} and absent must normalise alike ------------
Round 1's repair stripped children but left `norm({})` as "{}" against
`norm(undefined)` as "null", which reported permanent, unrepairable drift on
the intake form's own path. */
const norm = (o) => {
const v = withoutEmptyMembers(o);
return JSON.stringify(isEmptyObject(v) ? null : (v ?? null));
};
t('norm({}) equals norm(undefined)', norm({}), norm(undefined));
t('norm({CorsConfig:{}}) equals norm({})', norm({ CorsConfig: {} }), norm({}));
t(
'but a real difference still differs',
norm({ a: 1 }) === norm({ a: 2 }),
false,
);
/* ---- emptyObjectPaths, the post-condition ------------------------------- */
t(
'reports the incident path',
emptyObjectPaths({ SecurityHeadersConfig: LIVE_SECURITY_HEADERS }),
['SecurityHeadersConfig.ContentSecurityPolicy'],
);
t(
'reports round 2s deeper path',
emptyObjectPaths({ CorsConfig: { AccessControlExposeHeaders: {} } }),
['CorsConfig.AccessControlExposeHeaders'],
);
t(
'reports an empty object inside an array, with its index',
emptyObjectPaths({ Items: [{ Header: 'X' }, {}] }),
['Items[1]'],
);
t(
'reports every one, not just the first',
emptyObjectPaths({ a: {}, b: { c: {} } }),
['a', 'b.c'],
);
t('silent on an empty array', emptyObjectPaths({ a: [] }), []);
t(
'silent on null, undefined and primitives',
emptyObjectPaths({ a: null, b: undefined, c: 1, d: 'x', e: true }),
[],
);
t('names the root when the whole config is empty', emptyObjectPaths({}), [
'(root)',
]);
/* ---- the invariant the two functions exist to hold together ------------- */
t(
'THE INVARIANT: nothing survives the strip that the assertion would report',
emptyObjectPaths(
withoutEmptyMembers({
Name: 'adr-sml-pdf-noindex',
SecurityHeadersConfig: LIVE_SECURITY_HEADERS,
CorsConfig: { AccessControlExposeHeaders: {} },
ServerTimingHeadersConfig: {},
CustomHeadersConfig: {
Quantity: 1,
Items: [{ Header: 'X-Robots-Tag', Value: 'noindex', Override: true }],
},
}),
),
[],
);
if (failures.length) {
console.error(
`policy-shapes: ${failures.length} FAILED\n - ${failures.join('\n - ')}`,
);
process.exit(1);
}
console.log(`policy-shapes: ${pass} of ${pass} cases pass`);