fix: omit AWS's empty-object placeholders from the cloned PDF policy
Build and deploy / build-and-deploy (push) Failing after 4s
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:
co-authored by
Claude Opus 5
parent
3c3ba5dc6e
commit
bbe535d158
@@ -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`);
|
||||
Reference in New Issue
Block a user