Repository navigation
delete() has no secret masker, and the delete bag CAN carry plaintext on the in-process rollback path #2007
Description
Summary
Issue #1932 item 3 added SecretMaskingContext.maskSecrets to CreateContext
and UpdateContext. DeleteContext was deliberately left without it. This
issue records the residual, because the FIRST rationale written for that
exclusion was factually wrong and the corrected one is narrower than it sounds.
The bag CAN carry plaintext (the wrong rationale)
The shipped comment originally claimed every delete() call site hands the
provider a bag read from cdkd STATE, which holds the redacted
{{resolve:...}} expression. That is false. Trace:
deploy-engine.tsCREATE writes
properties: this.propertiesToRecord(resolvedProps, result)into the
IN-MEMORYstateResourcesmap —resolvedPropsis RESOLVED PLAINTEXT.- Redaction happens at the SAVE choke point:
redactStateForPersistbuilds a
NEW record per resource viascrubResourceRecordand never mutates the
in-memory one. performRollbackhands that same in-memory map toreplayRollback.rollback-executor.ts's reverse-replacement delete arms read
current.propertiesstraight off it (current = stateResources[op.logicalId]).
So an in-process rollback can hand a provider delete() a bag carrying a
concrete secret. The comment now states the narrower true thing: no provider
delete() interpolates a property value into a log line today.
Why the capability was NOT added in that PR
Not effort — correctness. Threading it is not the mechanical change it is on
the create / update side:
- The plaintext in
current.propertieswas resolved by the deploy, whose
bag lives inDeployEngine.perResourceSecrets. RollbackExecutorContexthas no secrets field.- The executor's own per-op map is built by re-resolving
previousState.properties— the previous generation.
A delete-side masker bound to that per-op map would therefore miss exactly the
value it exists to catch whenever the two generations differ (a rotated secret,
or a property only the new generation carries). That is protection which fences
nothing, and it is worse than no capability at all, because its presence stops
the next author looking.
What doing it properly needs
- A secrets field on
RollbackExecutorContext, populated from
DeployEngine.perResourceSecretsinrollbackExecutorContext(), keyed per
logical id — with a decision about which bag wins for an op whose previous
and current generations resolved different values. DeleteContext extends SecretMaskingContext.DeleteContextlives in
src/provisioning/region-check.tswhileSecretMaskingContextlives in
src/types/resource.ts, which importsDeleteContextback — so the shared
base likely has to move to thesrc/deployment/secret-redaction.tsleaf
(zero imports) to avoid a type-level cycle.- Threading at the 10 plaintext-capable delete sites (5 in
deploy-engine.ts, 5 inrollback-executor.ts).
destroy-runner.ts's single site reads state from S3, which IS redacted, so
it has nothing to mask.
Interim rule (already in the code and docs)
SecretMaskingContext and .claude/rules/providers.md both state it as a MUST:
before adding any delete() log line that interpolates a property value, thread
the capability first. Do not read the absence of maskSecrets on
DeleteContext as evidence that a delete bag is safe to print.
Note #1997's provider list was audited for create / update log lines only, not
for delete ones.
Session-fit: next (not this session) — needs new cross-component plumbing
(RollbackExecutorContext ← DeployEngine.perResourceSecrets) plus a design
call on which generation's bag wins, and it has zero consumers today /
Effort: ~3 h+ (the plumbing, a type move to break the cycle, 10 threading
points, unit matrix, and a review round)
Consumers found later, folded here rather than filed separately
The missing secrets field on RollbackExecutorContext (item 1 above) is the
precondition for more than the delete-side masker this issue was filed for.
Each row below is a distinct consumer blocked on that same plumbing.
-
rollback-executor.tsnested-stack parameter collapse -- DONE, and the
premise recorded here when it was folded in was WRONG. It was filed as
blocked on item 1 above; it was not.secretsis hoisted to the top of the
revert arm and already in scope, with the resolved plaintext and the
expression-bearing JOURNAL both in hand, so no new plumbing was needed --
and item 1's "wrong generation" objection does not apply, because a rollback
WANTS the previous generation's expressions, which is the generation it is
reverting to. Fixed in the fix(secret-redaction): two child Parameters resolving to ONE plaintext collapse onto a single expression, so a nested-stack leaf persists its siblings version stage #2291 PR: the three resolution arms
that feed the fourwithCurrentResourceSecretsbinders now record from the
journal underSTATE_DERIVED_RULES, DESIRED side only. Left here rather
than deleted so the wrong premise is visible instead of quietly removed. -
s3-directory-bucket-provider.tsnotEmptyRefusalMessage(the delete-path not-empty refusal): rendersaws s3 rm s3://<recorded bucket name>with no masker, so a secret-derived bucket name prints on destroy. Found by the fix(provisioning): the ASG / ELBv2 deletion-protection remedy pastes a pre-rotation secret-derived name unmasked #4339 sweep of everyrenderDisableCommandcaller. It is a delete-path site, so it waits on this issue'sDeleteContextmasker. Low severity: destroy-time, and only for a secret-derived directory bucket name.
Activity
docs(deployment): point the delete-side masking residual at issue #2007
fix(deployment): give providers a secret masker, and mask the VALUE b…
fix(provisioning): adopt the maskSecrets contract in the five provide…
fix(provisioning): adopt the maskSecrets contract in the five provide…
fix(provisioning): thread maskSecrets into the ELBv2 LoadBalancer arm…
go-to-k commentedon Aug 26, 2026
Concrete instance found while landing #2178:
custom-resource-provider.ts's DELETE-arm payload refusalFiling this as a checklist row here rather than as its own issue — the root cause is exactly the one
this issue already owns (
DeleteContextcarries nomaskSecrets, and the delete bag CAN carryplaintext), so a separate issue would split it.
Dup-check: searched the open issue list for an existing delete-path masker root cause
(
mask/secret/redact/delete-path/DeleteContext/custom resourcetitle scan overall open issues). HIT: this issue. #2179 is the destroy-path events store, a different sink;
#2177 is the create/update
logger.*sweep, which its own body scopes away from delete.The site
CustomResourceProvider.delete()builds its handler request withResourceProperties: this.stringifyProperties(properties)— i.e. the delete bag, which per thisissue's trace is RESOLVED PLAINTEXT on the in-process rollback path. A handler routinely echoes
ResourcePropertiesback in its responseData, andparseLambdaPayloadstringifies that echointo its "Invalid Lambda response payload format" refusal when the payload does not match the
expected shape.
Before #2178 that refusal was handed
maskerOrIdentity(undefined)on the delete path — an identitymasker, which is precisely the "protection which fences nothing ... worse than no capability at all,
because its presence stops the next author looking" this issue's own rationale section warns about.
#2178 removed it: the constant is now
DELETE_PATH_UNMASKED = undefined, the refusal is split intoa masked create/update arm and an explicitly unmasked DELETE arm, and the unmasked arm is recorded
as a re-audited
EXEMPTrow inscripts/check-provider-secret-mask.ts. That row fails the buildthe moment the site becomes masked, so closing this issue automatically forces the exemption out.
One correction to this issue's body
That is still true of LOG lines, but it is now worth stating more precisely, because the
custom-resource delete arm does interpolate a delete-derived value into a THROWN message:
try { … } catch { this.logger.debug('Lambda payload parse failed for …') }, which discards theerror object without reading
error.message;logger.debug(error.message)away from being a liveleak. Swallowing the parse failure is the only thing holding the line, and that is a smell in
its own right rather than a safety property anyone chose.
What closing this issue should also do
Add to the "threading at the 10 plaintext-capable delete sites" checklist:
custom-resource-provider.ts: retireDELETE_PATH_UNMASKED, thread the real capability intoinvokeCustomResourceWithRetry/getCustomResourceResponse/parseLambdaPayload, collapsethe two-arm refusal back into one masked arm, and drop the
custom-resource-provider.tsparsedrow fromEXEMPTinscripts/check-provider-secret-mask.ts(the exemption auditwill fail until it is dropped, so this cannot be forgotten).
catchshould surface the parse failure at all. If itever starts logging
error.message, the delete arm leaks — the critic classifies per SITE,not per PATH, so a single masked-looking site would cover both paths and hide it.
test(providers): fence the pre-stringify secret walk with a dataflow-…
test(providers): fence the pre-stringify secret walk with a dataflow-…
test(providers): fence the pre-stringify secret walk with a dataflow-…
fix(providers): mask eighteen pre-stringify secret sites, and fence t…
fix(providers): mask eighteen pre-stringify secret sites, and fence t…
fix(providers): mask eighteen pre-stringify secret sites, and fence t…
fix(providers): mask eighteen pre-stringify secret sites, and fence t…
68 remaining items
test(integ): a secret-named directory bucket's replacement refusal pr…
fix(destroy): cdkd destroy and a template-dropped delete hand the pro…
docs(provisioning): every cdkd delete site passes a masker; restate t…
test(provisioning): pin the delete masker on the remaining provider l…
test(integ): the secret-named directory bucket's refusal on destroy, …
fix(deploy): a replay delete's masker covers the printing bags bound …
test(integ): print an undetermined bucket probe through hide, keeping…
revert(deploy): withdraw the composed replay delete masker; pin the r…
chore(docs): regenerate the CLI flag coverage page after the rebase (#…
test(provisioning): pin the global table delete lines' per-value mask…
test(integ): record the real-AWS batch for the delete-path masker (#2007
fix(deploy): a provider's delete masks the secrets the deploy resolved (
chore(docs): regenerate the matrices after rebasing onto main (#2007)
fix(deploy): a provider's delete masks the secrets the deploy resolved (