@go-to-k/cdkd 0.284.30 → 0.284.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { $ as endCommandInterruptScope, $t as createAssetRedirectResolver, A as MULTI_REGION_RECREATE_BLOCKED_TYPES, An as resolveUseCdkBootstrapAssets, At as maskSecretsInText, B as collectDeclaredOutputNames, Bn as canonicalizeRegion, Bt as INTRINSIC_KEYS, C as ccRoutedFinalSnapshotError, Cn as getLegacyStateBucketName, Ct as STATE_SOURCED_READBACK_RULES, D as unsupportedFinalSnapshotError, Dn as resolveSkipPrefix, Dt as errorCauseChain, E as refusesFinalSnapshot, En as resolveCaptureObservedState, Et as dynamicReferenceTokens, F as cyan, Fn as MIGRATE_TMP_PREFIX, Ft as s3BucketDualStackDomainName, G as stateKeySecretExposure, Gn as resolveBucketRegion, Gt as LockManager, H as exportAliasCollisionScrubWarning, Hn as AssemblyReader, Ht as withRetry, I as gray, In as findLargeInlineResources, It as s3BucketRegionalDomainName, J as clearOnUpdateRemoval, Jn as resetAwsClients, Jt as shouldRetainResource, K as IAMRoleProvider, Kn as AwsClients, Kt as S3StateBackend, L as green, Ln as uploadCfnTemplate, Lt as s3BucketWebsiteUrl, M as renderStatefulReason, Mn as warnDeprecatedNoPrefixCliFlag, Mt as scrubResourceRecord, N as formatResourceLine, Nn as CFN_TEMPLATE_BODY_LIMIT, Nt as s3BucketArn, O as makeCanonicalizePropertiesFn, On as resolveStateBucketWithDefault, Ot as isSingleDynamicReferenceToken, P as bold, Pn as CFN_TEMPLATE_URL_LIMIT, Pt as s3BucketDomainName, Q as beginCommandInterruptScope, Qt as buildAssetRedirectMap, R as red, Rn as expectedOwnerParam, Rt as applyRoleArnIfSet, S as buildFinalSnapshotIdentifier, Sn as getDefaultStateBucketName, Sr as markNonRetryable, St as STATE_SOURCED_CROSS_GENERATION_RULES, T as isFinalSnapshotError, Tn as resolveAutoAssetStorage, Tt as createSecretMasker, U as isExportAliasCollision, Un as processStackMessages, Ut as DagBuilder, V as collectPublishedOutputNames, Vn as derivePartitionAndUrlSuffix, Vt as describeTypeWithThrottleRetry, W as secretBearingStateKeyWarning, Wt as TemplateParser, X as findActionableSilentDrops, Xt as stringifyValue, Y as ProviderRegistry, Yn as setAwsClients, Yt as AssetPublisher, Z as findSilentDropProperties, Zn as CdkdError, Zt as WorkGraph, _ as maskingRetryLogger, _n as runDockerStreaming, _r as normalizeAwsError, _t as readConfigString, a as DeploymentEventsStore, an as BOOTSTRAP_MARKER_PREFIX, ar as MissingCdkCliError, at as isTerminationProtectionPropagationError, b as ATOMIC_FINAL_SNAPSHOT_TYPES, bn as Synthesizer, br as isRetryableTransientError, bt as requireConfigObject, c as planRollback, cn as parseBootstrapMarker, cr as ProvisioningError, ct as getAccountInfo, d as replayRollback, dn as validateContainerRepoName, dr as StackHasActiveImportsError, dt as normalizeAwsTagsToCfn, en as loadPublishableAssetManifest, er as DeployCancelledError, et as isInterruptedWaitError, f as updatePartialMessage, fn as buildDenyExternalAccessPolicy, fr as StackTerminationProtectionError, ft as resolveExplicitPhysicalId, g as withResourceDeadline, gn as runDockerForeground, gt as configStringRefusal, h as deleteSkipReason, hn as getDockerCmd, ht as configBooleanRefusal, i as DeploymentEventsReader, in as AssetModeResolver, it as disableInstanceApiTermination, j as isStatefulRecreateTargetSync, jn as stateBucketExistenceConfirmed, jt as redactSecretsForState, k as extractDeploymentEventError, kn as resolveStateBucketWithDefaultAndSource, kt as maskSecretsInError, l as producerRegionsFromState, ln as readBootstrapMarkerBody, lr as ResourceTimeoutError, lt as refStateLookupFromResource, m as UNSPECIFIED_SKIP_REASON, mn as formatDockerLoginError, mt as coerceCfnBoolean, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as escapeRegExp, nr as LocalMigrateError, nt as CloudControlProvider, o as classifyReplaySecretRegion, on as ensureAssetStorage, or as NestedStackChildDirectDestroyError, ot as IntrinsicFunctionResolver, p as updatePartialReason, pn as buildDockerImage, pt as assertRegionMatch, q as collectInlinePolicyNamesManagedBySiblings, qn as getAwsClients, qt as rebuildClientForBucketRegion, r as DeployEngine, rn as stripControlChars, rr as LocalStartServiceError, rt as slowCcOperationTimeoutMs, s as planFailedOps, sn as getBootstrapMarkerKey, sr as PartialFailureError, st as cfnRefValueFromPhysicalId, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as rewriteTemplateAssetReferences, tr as LocalInvokeBuildError$1, tt as startInterruptWatch, u as replayFailedOperations, un as validateAssetBucketName, ur as ResourceUpdateNotSupportedError, ut as WAFv2WebACLProvider, v as IMPLICIT_DELETE_DEPENDENCIES, vn as AssetManifestLoader, vr as withErrorHandling, vt as replayWarn, w as createPreDeleteFinalSnapshot, wn as resolveApp, wt as TEMPLATE_SOURCED_RULES, x as PRE_DELETE_SNAPSHOT_TYPES, xn as synthesisStatusMessage, xr as isThrottlingError, xt as requireConfigString, y as computeImplicitDeleteEdges, yn as getDockerImageBySourceHash, yr as isMarkedNonRetryable, yt as requireConfigArray, z as yellow, zn as PARTITION_TABLE, zt as DiffCalculator } from "./deploy-engine-BFgi0RJr.js";
2
+ import { $ as endCommandInterruptScope, $t as buildAssetRedirectMap, A as MULTI_REGION_RECREATE_BLOCKED_TYPES, An as resolveStateBucketWithDefaultAndSource, At as maskSecretsInError, B as collectDeclaredOutputNames, Bn as PARTITION_TABLE, Bt as DiffCalculator, C as ccRoutedFinalSnapshotError, Cn as getDefaultStateBucketName, Cr as isThrottlingError, Ct as STATE_SOURCED_CROSS_GENERATION_RULES, D as unsupportedFinalSnapshotError, Dn as resolveCaptureObservedState, Dt as dynamicReferenceTokens, E as refusesFinalSnapshot, En as resolveAutoAssetStorage, Et as createSecretMasker, F as cyan, Fn as CFN_TEMPLATE_URL_LIMIT, Ft as s3BucketDomainName, G as stateKeySecretExposure, Gt as TemplateParser, H as exportAliasCollisionScrubWarning, Hn as derivePartitionAndUrlSuffix, Ht as describeTypeWithThrottleRetry, I as gray, In as MIGRATE_TMP_PREFIX, It as s3BucketDualStackDomainName, J as clearOnUpdateRemoval, Jn as getAwsClients, Jt as rebuildClientForBucketRegion, K as IAMRoleProvider, Kn as resolveBucketRegion, Kt as LockManager, L as green, Ln as findLargeInlineResources, Lt as s3BucketRegionalDomainName, M as renderStatefulReason, Mn as stateBucketExistenceConfirmed, Mt as redactSecretsForState, N as formatResourceLine, Nn as warnDeprecatedNoPrefixCliFlag, Nt as scrubResourceRecord, O as makeCanonicalizePropertiesFn, On as resolveSkipPrefix, Ot as errorCauseChain, P as bold, Pn as CFN_TEMPLATE_BODY_LIMIT, Pt as s3BucketArn, Q as beginCommandInterruptScope, Qn as CdkdError, Qt as WorkGraph, R as red, Rn as uploadCfnTemplate, Rt as s3BucketWebsiteUrl, S as buildFinalSnapshotIdentifier, Sn as synthesisStatusMessage, Sr as isRetryableTransientError, St as requireConfigString, T as isFinalSnapshotError, Tn as resolveApp, Tt as TEMPLATE_SOURCED_RULES, U as isExportAliasCollision, Un as AssemblyReader, Ut as withRetry, V as collectPublishedOutputNames, Vn as canonicalizeRegion, Vt as INTRINSIC_KEYS, W as secretBearingStateKeyWarning, Wn as processStackMessages, Wt as DagBuilder, X as findActionableSilentDrops, Xn as setAwsClients, Xt as AssetPublisher, Y as ProviderRegistry, Yn as resetAwsClients, Yt as shouldRetainResource, Z as findSilentDropProperties, Zt as stringifyValue, _ as maskingRetryLogger, _n as runDockerForeground, _t as configStringRefusal, a as DeploymentEventsStore, an as AssetModeResolver, ar as LocalStartServiceError, at as isTerminationProtectionPropagationError, b as ATOMIC_FINAL_SNAPSHOT_TYPES, bn as getDockerImageBySourceHash, br as withErrorHandling, bt as requireConfigArray, c as planRollback, cn as getBootstrapMarkerKey, cr as NestedStackChildDirectDestroyError, ct as cfnRefValueFromPhysicalId, d as replayRollback, dn as validateAssetBucketName, dr as ResourceTimeoutError, dt as WAFv2WebACLProvider, en as createAssetRedirectResolver, er as CrossAccountSecretRefusalError, et as isInterruptedWaitError, f as updatePartialMessage, fn as validateContainerRepoName, fr as ResourceUpdateNotSupportedError, ft as normalizeAwsTagsToCfn, g as withResourceDeadline, gn as getDockerCmd, gt as configBooleanRefusal, h as deleteSkipReason, hn as formatDockerLoginError, ht as coerceCfnBoolean, i as DeploymentEventsReader, in as stripControlChars, ir as LocalMigrateError, it as disableInstanceApiTermination, j as isStatefulRecreateTargetSync, jn as resolveUseCdkBootstrapAssets, jt as maskSecretsInText, k as extractDeploymentEventError, kn as resolveStateBucketWithDefault, kt as isSingleDynamicReferenceToken, l as producerRegionsFromState, ln as parseBootstrapMarker, lr as PartialFailureError, lt as getAccountInfo, m as UNSPECIFIED_SKIP_REASON, mn as buildDockerImage, mr as StackTerminationProtectionError, mt as assertRegionMatch, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as rewriteTemplateAssetReferences, nr as DeployCancelledError, nt as CloudControlProvider, o as classifyReplaySecretRegion, on as BOOTSTRAP_MARKER_PREFIX, ot as IntrinsicFunctionResolver, p as updatePartialReason, pn as buildDenyExternalAccessPolicy, pr as StackHasActiveImportsError, pt as resolveExplicitPhysicalId, q as collectInlinePolicyNamesManagedBySiblings, qn as AwsClients, qt as S3StateBackend, r as DeployEngine, rn as escapeRegExp, rr as LocalInvokeBuildError$1, rt as slowCcOperationTimeoutMs, s as planFailedOps, sn as ensureAssetStorage, sr as MissingCdkCliError, st as carriesDynamicReference, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as loadPublishableAssetManifest, tt as startInterruptWatch, u as replayFailedOperations, un as readBootstrapMarkerBody, ur as ProvisioningError, ut as refStateLookupFromResource, v as IMPLICIT_DELETE_DEPENDENCIES, vn as runDockerStreaming, vt as readConfigString, w as createPreDeleteFinalSnapshot, wn as getLegacyStateBucketName, wr as markNonRetryable, wt as STATE_SOURCED_READBACK_RULES, x as PRE_DELETE_SNAPSHOT_TYPES, xn as Synthesizer, xr as isMarkedNonRetryable, xt as requireConfigObject, y as computeImplicitDeleteEdges, yn as AssetManifestLoader, yr as normalizeAwsError, yt as replayWarn, z as yellow, zn as expectedOwnerParam, zt as applyRoleArnIfSet } from "./deploy-engine-DsUQ4GxO.js";
3
3
  import { a as getLiveRenderer, c as PATTERN_B_RESOURCE_TYPES, d as generateResourceNameWithFallback, f as getCurrentStackName, h as withStackName, i as runStackBuffered, m as withSkipPrefix, n as getLogger, o as PATTERN_B_NAME_OPTIONS, s as PATTERN_B_NAME_PROPERTIES, u as generateResourceName } from "./logger-zRrlbaQt.js";
4
- import { t as ASGProvider } from "./asg-provider-qMgJEhCM.js";
4
+ import { t as ASGProvider } from "./asg-provider-BVzQ7mQX.js";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { createHash, randomBytes, randomUUID } from "node:crypto";
7
7
  import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketEncryptionCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketOwnershipControlsCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
@@ -77,6 +77,162 @@ import { setTimeout as setTimeout$1 } from "node:timers/promises";
77
77
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
78
78
  import { WebSocketServer } from "ws";
79
79
 
80
+ //#region src/cli/commands/state-file-keys.ts
81
+ /**
82
+ * Shared helpers for enumerating and describing cdkd state-bucket keys.
83
+ *
84
+ * Extracted from `bootstrap-destroy.ts` (issue #1010) so that `cdkd gc`
85
+ * (issue #1012) reuses the exact same whole-bucket state-file discovery
86
+ * instead of duplicating it — the "scan the WHOLE bucket, not just the
87
+ * default `cdkd/` prefix" rule was a review blocker once already (PR #1018)
88
+ * and must not drift between the two commands.
89
+ */
90
+ /**
91
+ * Every cdkd state file ends with this suffix, regardless of the
92
+ * `--state-prefix` it was deployed under (`{prefix}/{stack}/{region}/state.json`,
93
+ * or legacy `{prefix}/{stack}/state.json`).
94
+ */
95
+ const STATE_FILE_SUFFIX = "/state.json";
96
+ /**
97
+ * The prefix `cdkd bootstrap` writes under, and the one `gc` /
98
+ * `bootstrap --destroy` configure their own state backend with.
99
+ *
100
+ * Shared here rather than spelled per command for the same reason this module
101
+ * exists at all: `bootstrap-destroy.ts` and `gc.ts` each held their own copy,
102
+ * and it is now also the `knownPrefix` argument to {@link describeStateKey},
103
+ * where a copy that drifted from the backend's actual prefix would silently
104
+ * send every key back to the shape heuristic instead of the exact depth rule.
105
+ *
106
+ * Note this is only the DEFAULT. Other commands accept `--state-prefix`, so
107
+ * the whole-bucket listings deliberately do not scope to it.
108
+ */
109
+ const DEFAULT_STATE_PREFIX = "cdkd";
110
+ /**
111
+ * Segments left under a known prefix for the legacy `{prefix}/{stack}` layout.
112
+ *
113
+ * The twin of `S3StateBackend`'s `LEGACY_KEY_DEPTH`, which counts the
114
+ * `state.json` segment as well; this one counts what remains after the prefix
115
+ * and the suffix are stripped, so it is that value minus one. Fenced against it
116
+ * by a unit test rather than imported, because a value import from
117
+ * `s3-state-backend.js` breaks the suites that mock that module.
118
+ */
119
+ const LEGACY_SEGMENTS_UNDER_PREFIX = 1;
120
+ /**
121
+ * Every cdkd stack lock ends with this suffix — the lock lives next to the
122
+ * state file (`{prefix}/{stack}/{region}/lock.json`, or the legacy region-less
123
+ * `{prefix}/{stack}/lock.json` that `lock-manager.ts` still writes for a
124
+ * pre-v2 stack).
125
+ */
126
+ const LOCK_FILE_SUFFIX = "/lock.json";
127
+ /**
128
+ * `us-east-1` / `ap-northeast-1` / `us-gov-west-1` — a region-shaped segment.
129
+ *
130
+ * The first token is `[a-z]{2}` OR the literal `eusc` (issue #2001). Every
131
+ * region AWS ships has a two-letter first token except the European Sovereign
132
+ * Cloud partition's, which is four — `eusc-de-east-1` — and the old
133
+ * `^[a-z]{2}` rejected that whole partition. Enumerated from
134
+ * `aws-cdk-lib/region-info` and `@aws-sdk/util-endpoints`: 46 REAL regions,
135
+ * first tokens all two letters or `eusc`, none three. (A count of 53 includes
136
+ * the `aws-global` / `aws-iso-*-global` PSEUDO-regions, whose first token is
137
+ * `aws`; they carry no trailing `-<digits>`, so this pattern rejects them and
138
+ * so did the old one — no behavioural difference, but at 53 the "two letters
139
+ * or eusc" claim is false as stated.)
140
+ *
141
+ * **The exception is enumerated rather than widened, and that is the whole
142
+ * design.** The obvious fix — relaxing the bound to `{2,4}` — was written
143
+ * first and review measured what it cost: `api-prod-1`, `demo-app-1`,
144
+ * `dev-api-1`, `core-api-1`, `test-stack-2` all become region-shaped under it.
145
+ * Those are idiomatic STACK names, and in the legacy region-less layout a
146
+ * stack name sits exactly where a region would, so the pattern would report
147
+ * the key's PREFIX as the stack for each of them — the same wrong answer this
148
+ * issue is about, arriving from the other side and at a far higher rate. A
149
+ * length class cannot separate `eusc` from `demo`; only naming the partition
150
+ * can.
151
+ *
152
+ * The cost of enumerating is that a future partition with a new non-two-letter
153
+ * token needs this line updated. That is the same failure this issue fixed, it
154
+ * is a one-token change, and it fails in the recoverable direction (a region
155
+ * read as a stack name) rather than corrupting ordinary stack names.
156
+ *
157
+ * It stays SHAPE-based and deliberately does not reuse `isClientSafeRegion`
158
+ * from `intrinsic-function-resolver.ts`: that predicate is charset-based (its
159
+ * job is to keep a value inside a hostname label, so it accepts anything
160
+ * lowercase-alphanumeric) and would classify almost every stack name as a
161
+ * region. This one has the opposite job — telling a region segment apart from
162
+ * a stack name in the same position — so it must constrain the shape.
163
+ *
164
+ * A two-letter STACK name like `db-prod-1` is still misread in the legacy
165
+ * layout; that is pre-existing (`main` does the same) and is what the
166
+ * `knownPrefix` depth rule below repairs for callers that can supply one.
167
+ */
168
+ const REGION_SEGMENT = /^(?:[a-z]{2}|eusc)(-[a-z]+)+-\d+$/;
169
+ /**
170
+ * List every state file in the bucket — the WHOLE bucket, not just the
171
+ * default `cdkd/` prefix. Other commands accept `--state-prefix`, so live
172
+ * stack state may exist under ANY prefix in this bucket; scoping this
173
+ * listing to the default prefix would let reference scans and teardown
174
+ * guards silently miss those stacks and delete live data.
175
+ */
176
+ async function listAllStateKeys(stateBackend) {
177
+ return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(STATE_FILE_SUFFIX));
178
+ }
179
+ /**
180
+ * List every stack lock file in the bucket — same whole-bucket rule as
181
+ * {@link listAllStateKeys}. A lock under ANY prefix means a deploy /
182
+ * destroy may be in flight for that stack.
183
+ */
184
+ async function listAllLockKeys(stateBackend) {
185
+ return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(LOCK_FILE_SUFFIX));
186
+ }
187
+ /**
188
+ * `{prefix}/{stack}/{region}/state.json` → `stack (region)`; legacy
189
+ * `{prefix}/{stack}/state.json` → `stack`. Pass {@link LOCK_FILE_SUFFIX} to
190
+ * describe lock keys.
191
+ *
192
+ * The shape heuristic below cannot be made exact, and it errs in BOTH
193
+ * directions -- a region it fails to recognise is reported AS the stack name,
194
+ * while a region-shaped STACK name in the legacy layout makes it report the
195
+ * key's PREFIX as the stack. Neither is safe: `cdkd gc`'s corrupt-state abort
196
+ * turns whichever answer it gets into a `cdkd state show ...` recovery
197
+ * command, so a wrong one is handed to the user as a command to run.
198
+ *
199
+ * `knownPrefix` narrows it where a caller can: a key with exactly one segment
200
+ * under a prefix we know is the legacy `{stack}` layout by construction,
201
+ * whatever the stack is named, so no shape test is needed there at all. That
202
+ * repairs the pre-existing two-letter case (`cdkd/db-prod-1/state.json`, which
203
+ * `main` describes as `cdkd (db-prod-1)`).
204
+ *
205
+ * **It is a narrowing, not a safety net for the pattern above.** A first cut
206
+ * treated it as one — widening `REGION_SEGMENT` to `{2,4}` and relying on this
207
+ * rule to undo the damage — and review measured that the damage is repo-wide
208
+ * while this rule reaches only keys under the ONE prefix a caller names. The
209
+ * listing deliberately spans the whole bucket, so foreign and nested prefixes
210
+ * are in scope and unreachable from here; six shapes regressed against `main`
211
+ * that way. The pattern must be right on its own.
212
+ *
213
+ * **It deliberately does NOT claim the two-segment case.** A first cut did, on
214
+ * the reasoning that at that depth the last segment must be the region -- and
215
+ * review found `--state-prefix cdkd/team-a`, which nests inside the default
216
+ * prefix, so `cdkd/team-a/MyStack/state.json` is a LEGACY key two segments
217
+ * deep and would have been mis-split as `team-a (MyStack)` where `main` said
218
+ * `MyStack`. Depth cannot tell that apart from a real `{stack}/{region}`; only
219
+ * the shape of the tail can, so the heuristic keeps that case.
220
+ */
221
+ function describeStateKey(key, suffix = STATE_FILE_SUFFIX, knownPrefix) {
222
+ const body = key.slice(0, -suffix.length);
223
+ const prefix = knownPrefix?.replace(/\/+$/, "");
224
+ if (prefix !== void 0 && prefix !== "" && key.startsWith(`${prefix}/`)) {
225
+ const rest = body.slice(prefix.length + 1).split("/");
226
+ if (rest.length === LEGACY_SEGMENTS_UNDER_PREFIX && rest[0]) return rest[0];
227
+ }
228
+ const segments = body.split("/");
229
+ const last = segments[segments.length - 1] ?? key;
230
+ const secondLast = segments[segments.length - 2];
231
+ if (secondLast !== void 0 && secondLast !== "" && REGION_SEGMENT.test(last)) return `${secondLast} (${last})`;
232
+ return last;
233
+ }
234
+
235
+ //#endregion
80
236
  //#region src/cli/options.ts
81
237
  /**
82
238
  * Parse context key=value pairs from CLI arguments into a Record
@@ -152,7 +308,7 @@ const appOptions = [new Option("-a, --app <command>", "CDK app command (e.g., \"
152
308
  * --state-bucket is optional: falls back to CDKD_STATE_BUCKET env var,
153
309
  * then cdk.json context.cdkd.stateBucket
154
310
  */
155
- const stateOptions = [new Option("--state-bucket <bucket>", "S3 bucket for state storage. Falls back to CDKD_STATE_BUCKET env or cdk.json"), new Option("--state-prefix <prefix>", "S3 key prefix for state files").default("cdkd")];
311
+ const stateOptions = [new Option("--state-bucket <bucket>", "S3 bucket for state storage. Falls back to CDKD_STATE_BUCKET env or cdk.json"), new Option("--state-prefix <prefix>", "S3 key prefix for state files").default(DEFAULT_STATE_PREFIX)];
156
312
  /**
157
313
  * Stack options
158
314
  */
@@ -1025,91 +1181,9 @@ function regionNeedsReconciliation(effective) {
1025
1181
  return effective.region !== LEGACY_DEFAULT_REGION;
1026
1182
  }
1027
1183
 
1028
- //#endregion
1029
- //#region src/cli/commands/state-file-keys.ts
1030
- /**
1031
- * Shared helpers for enumerating and describing cdkd state-bucket keys.
1032
- *
1033
- * Extracted from `bootstrap-destroy.ts` (issue #1010) so that `cdkd gc`
1034
- * (issue #1012) reuses the exact same whole-bucket state-file discovery
1035
- * instead of duplicating it — the "scan the WHOLE bucket, not just the
1036
- * default `cdkd/` prefix" rule was a review blocker once already (PR #1018)
1037
- * and must not drift between the two commands.
1038
- */
1039
- /**
1040
- * Every cdkd state file ends with this suffix, regardless of the
1041
- * `--state-prefix` it was deployed under (`{prefix}/{stack}/{region}/state.json`,
1042
- * or legacy `{prefix}/{stack}/state.json`).
1043
- */
1044
- const STATE_FILE_SUFFIX = "/state.json";
1045
- /**
1046
- * Every cdkd stack lock ends with this suffix — the lock lives next to the
1047
- * state file (`{prefix}/{stack}/{region}/lock.json`).
1048
- */
1049
- const LOCK_FILE_SUFFIX = "/lock.json";
1050
- /** `us-east-1` / `ap-northeast-1` / `us-gov-west-1` — a region-shaped segment. */
1051
- const REGION_SEGMENT = /^[a-z]{2}(-[a-z]+)+-\d+$/;
1052
- /**
1053
- * List every state file in the bucket — the WHOLE bucket, not just the
1054
- * default `cdkd/` prefix. Other commands accept `--state-prefix`, so live
1055
- * stack state may exist under ANY prefix in this bucket; scoping this
1056
- * listing to the default prefix would let reference scans and teardown
1057
- * guards silently miss those stacks and delete live data.
1058
- */
1059
- async function listAllStateKeys(stateBackend) {
1060
- return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(STATE_FILE_SUFFIX));
1061
- }
1062
- /**
1063
- * List every stack lock file in the bucket — same whole-bucket rule as
1064
- * {@link listAllStateKeys}. A lock under ANY prefix means a deploy /
1065
- * destroy may be in flight for that stack.
1066
- */
1067
- async function listAllLockKeys(stateBackend) {
1068
- return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(LOCK_FILE_SUFFIX));
1069
- }
1070
- /**
1071
- * `{prefix}/{stack}/{region}/state.json` → `stack (region)`; legacy
1072
- * `{prefix}/{stack}/state.json` → `stack`. The prefix is arbitrary (custom
1073
- * `--state-prefix` values included), so the stack/region pair is derived
1074
- * from the key's TAIL: the last segment is treated as a region only when
1075
- * it is region-shaped. Pass {@link LOCK_FILE_SUFFIX} to describe lock keys.
1076
- */
1077
- function describeStateKey(key, suffix = STATE_FILE_SUFFIX) {
1078
- const segments = key.slice(0, -suffix.length).split("/");
1079
- const last = segments[segments.length - 1] ?? key;
1080
- const secondLast = segments[segments.length - 2];
1081
- if (secondLast !== void 0 && REGION_SEGMENT.test(last)) return `${secondLast} (${last})`;
1082
- return last;
1083
- }
1084
-
1085
1184
  //#endregion
1086
1185
  //#region src/cli/commands/bootstrap-destroy.ts
1087
1186
  /**
1088
- * `cdkd bootstrap --destroy` — teardown of cdkd-created account resources
1089
- * (issue #1010, the reverse of `cdkd bootstrap` / `ensureAssetStorage`).
1090
- *
1091
- * Scope per invocation is ONE region's asset storage (`--region`, same
1092
- * option the create side uses): empty + delete the asset bucket, force-
1093
- * delete the container-asset ECR repo, then delete the region's bootstrap
1094
- * marker LAST — the mirror image of the create side's marker-written-last
1095
- * ordering, so a crash mid-teardown leaves the region still consistently
1096
- * opted in (marker present → deploys keep hard-erroring at the missing
1097
- * resources with a re-bootstrap hint, never a silent legacy fallback).
1098
- *
1099
- * The asset bucket / repo NAMES are read from the region's bootstrap
1100
- * marker, NOT recomputed from the naming convention — the marker is the
1101
- * source of truth for names (design §4.1), which keeps this teardown
1102
- * compatible with custom asset-storage names (issue #1011).
1103
- *
1104
- * The state bucket is NOT deleted by default: it is the account's source
1105
- * of truth. `--include-state-bucket` opts in, and even then the deletion
1106
- * is refused while ANY stack state exists in the bucket or any OTHER
1107
- * region is still opted in to asset storage (deleting their markers would
1108
- * silently flip those regions back to legacy mode).
1109
- */
1110
- /** S3 key prefix for state files — same fixed value the create side uses. */
1111
- const STATE_PREFIX = "cdkd";
1112
- /**
1113
1187
  * Interactive confirmation for the teardown. Follows the repo's
1114
1188
  * destructive-prompt convention (see `recreate-confirm-prompt.ts`, the
1115
1189
  * same pattern family as `confirm-prompt.ts`): print the deletion plan as
@@ -1157,7 +1231,7 @@ async function scanStateReferences(stateBackend, names) {
1157
1231
  for (const key of stateKeys) {
1158
1232
  const body = await stateBackend.getRawObject(key);
1159
1233
  if (body === null) continue;
1160
- if (names.some((name) => body.includes(name))) referencing.push(describeStateKey(key));
1234
+ if (names.some((name) => body.includes(name))) referencing.push(describeStateKey(key, STATE_FILE_SUFFIX, DEFAULT_STATE_PREFIX));
1161
1235
  }
1162
1236
  return referencing;
1163
1237
  }
@@ -1332,7 +1406,7 @@ async function bootstrapDestroyCommand(options) {
1332
1406
  ...options.profile && { profile: options.profile }
1333
1407
  }), {
1334
1408
  bucket: bucketName,
1335
- prefix: STATE_PREFIX
1409
+ prefix: DEFAULT_STATE_PREFIX
1336
1410
  }, {
1337
1411
  region: effective.region,
1338
1412
  ...options.profile && { profile: options.profile }
@@ -1361,7 +1435,7 @@ async function bootstrapDestroyCommand(options) {
1361
1435
  });
1362
1436
  const stateBackend = new S3StateBackend(markerS3Client, {
1363
1437
  bucket: bucketName,
1364
- prefix: STATE_PREFIX
1438
+ prefix: DEFAULT_STATE_PREFIX
1365
1439
  }, {
1366
1440
  region,
1367
1441
  ...options.profile && { profile: options.profile }
@@ -1413,7 +1487,7 @@ async function bootstrapDestroyCommand(options) {
1413
1487
  if (options.includeStateBucket) {
1414
1488
  const stateKeys = await listAllStateKeys(stateBackend);
1415
1489
  if (stateKeys.length > 0) {
1416
- const listing = stateKeys.map((k) => ` - ${describeStateKey(k)} [${k}]`).join("\n");
1490
+ const listing = stateKeys.map((k) => ` - ${describeStateKey(k, STATE_FILE_SUFFIX, DEFAULT_STATE_PREFIX)} [${k}]`).join("\n");
1417
1491
  throw new CdkdError(`Refusing to delete state bucket '${bucketName}': ${stateKeys.length} stack(s) still have state in it:\n${listing}\nDestroy every stack ('cdkd destroy' / 'cdkd state destroy') before deleting the state bucket.`, "STATE_BUCKET_NOT_EMPTY");
1418
1492
  }
1419
1493
  if (markerSiblings.otherRegions.length > 0) throw new CdkdError(`Refusing to delete state bucket '${bucketName}': region(s) ${markerSiblings.otherRegions.join(", ")} are still opted in to cdkd asset storage (their bootstrap markers live in this bucket). Run 'cdkd bootstrap --destroy --region <r>' for each first.`, "STATE_BUCKET_HOLDS_MARKERS");
@@ -1509,7 +1583,7 @@ async function bootstrapCommand(options) {
1509
1583
  ...options.profile && { profile: options.profile }
1510
1584
  }), {
1511
1585
  bucket: bucketName,
1512
- prefix: "cdkd"
1586
+ prefix: DEFAULT_STATE_PREFIX
1513
1587
  }, {
1514
1588
  region: effective.region,
1515
1589
  ...options.profile && { profile: options.profile }
@@ -1610,7 +1684,7 @@ async function bootstrapCommand(options) {
1610
1684
  });
1611
1685
  const stateBackend = new S3StateBackend(markerS3Client, {
1612
1686
  bucket: bucketName,
1613
- prefix: "cdkd"
1687
+ prefix: DEFAULT_STATE_PREFIX
1614
1688
  }, {
1615
1689
  region,
1616
1690
  ...options.profile && { profile: options.profile }
@@ -62373,6 +62447,7 @@ async function scrubCommand(stacks, options) {
62373
62447
  else throw new Error(`Multiple stacks found: ${allStacks.map(describeStack).join(", ")}. Specify stack name(s) or use --all`);
62374
62448
  if (targetStacks.length === 0) throw new Error("No stacks matched.");
62375
62449
  await synthesizer.expandMacrosForStacks(targetStacks, synthOptions);
62450
+ targetStacks = orderScrubTargets(targetStacks);
62376
62451
  const stateConfig = {
62377
62452
  bucket: stateBucket,
62378
62453
  prefix: options.statePrefix
@@ -62388,6 +62463,7 @@ async function scrubCommand(stacks, options) {
62388
62463
  const lockManager = new LockManager(stateS3.s3, stateConfig);
62389
62464
  let totalStacksScrubbed = 0;
62390
62465
  let totalStacksWithUnscrubbableKeys = 0;
62466
+ let totalStacksWithUnverifiableReads = 0;
62391
62467
  const failures = [];
62392
62468
  for (const stack of targetStacks) {
62393
62469
  const stackRegion = stack.region || region;
@@ -62396,7 +62472,8 @@ async function scrubCommand(stacks, options) {
62396
62472
  scrubbed = await scrubStack(stack, stackRegion, stateBackend, lockManager, {
62397
62473
  dryRun: options.dryRun ?? false,
62398
62474
  roleArn: options.roleArn,
62399
- logger
62475
+ logger,
62476
+ appStacks: allStacks
62400
62477
  });
62401
62478
  } catch (err) {
62402
62479
  failures.push({ stackName: stack.stackName });
@@ -62407,13 +62484,17 @@ async function scrubCommand(stacks, options) {
62407
62484
  if (scrubbed.recordsChanged > 0) {
62408
62485
  totalStacksScrubbed++;
62409
62486
  logger.info(`${options.dryRun ? "Would scrub" : "Scrubbed"} ${scrubbed.recordsChanged} resource record(s) in ${stack.stackName}`);
62410
- } else if (scrubbed.secretBearingKeys === 0) logger.info(`No plaintext secrets found in ${stack.stackName}`);
62487
+ } else if (scrubbed.secretBearingKeys === 0 && scrubbed.unverifiableReads === 0) logger.info(`No plaintext secrets found in ${stack.stackName}`);
62488
+ if (scrubbed.unverifiableReads > 0) {
62489
+ totalStacksWithUnverifiableReads++;
62490
+ logger.warn(`${scrubbed.unverifiableReads} cross-stack read(s) in ${stack.stackName} could NOT be verified — cdkd declines them by design (see the warnings above), so this stack is not reported clean.`);
62491
+ }
62411
62492
  if (scrubbed.secretBearingKeys > 0) {
62412
62493
  totalStacksWithUnscrubbableKeys++;
62413
62494
  logger.warn(`${scrubbed.secretBearingKeys} output KEY(s) in ${stack.stackName} hold plaintext and CANNOT be scrubbed — rename the Export.Name and redeploy (see the warning above).`);
62414
62495
  }
62415
62496
  }
62416
- if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0) {
62497
+ if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0 && totalStacksWithUnverifiableReads === 0) {
62417
62498
  if (failures.length === 0) {
62418
62499
  logger.info("\nNo plaintext secrets found in any target stack state. Nothing to scrub.");
62419
62500
  return;
@@ -62423,17 +62504,18 @@ async function scrubCommand(stacks, options) {
62423
62504
  }
62424
62505
  const failureNote = failures.length > 0 ? ` ${failures.length} stack(s) could NOT be scrubbed at all and are NOT covered by this summary — see the errors above.` : "";
62425
62506
  const keyNote = totalStacksWithUnscrubbableKeys > 0 ? ` ${totalStacksWithUnscrubbableKeys} stack(s) hold plaintext in an output KEY, which cdkd scrub cannot rewrite — rename that output's Export.Name and redeploy.` : "";
62507
+ const unverifiableNote = totalStacksWithUnverifiableReads > 0 ? ` ${totalStacksWithUnverifiableReads} stack(s) carry a cross-stack read cdkd declines to perform, so their imported values could NOT be checked — export a non-secret value (e.g. the secret's ARN) from the producer, or reference it from within its own account.` : "";
62426
62508
  if (options.dryRun) {
62427
- if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote}${failureNote} ROTATE any exposed secret in Secrets Manager.`);
62428
- else logger.info(`\nPlan: nothing can be scrubbed.${keyNote}${failureNote} ROTATE any exposed secret.`);
62509
+ if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote}${unverifiableNote}${failureNote} ROTATE any exposed secret in Secrets Manager.`);
62510
+ else logger.info(`\nPlan: nothing can be scrubbed.${keyNote}${unverifiableNote}${failureNote} ROTATE any exposed secret.`);
62429
62511
  if (failures.length > 0) throw scrubStacksFailedError(failures);
62430
62512
  if (options.fail) throw new ScrubNeededError();
62431
62513
  return;
62432
62514
  }
62433
- if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The plaintext is no longer stored there, but a value that was ever persisted should be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating).${keyNote}${failureNote}`);
62434
- else logger.info(`\nNothing could be rewritten.${keyNote}${failureNote} ROTATE any exposed secret.`);
62515
+ if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The plaintext is no longer stored there, but a value that was ever persisted should be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating).${keyNote}${unverifiableNote}${failureNote}`);
62516
+ else logger.info(`\nNothing could be rewritten.${keyNote}${unverifiableNote}${failureNote} ROTATE any exposed secret.`);
62435
62517
  if (failures.length > 0) throw scrubStacksFailedError(failures);
62436
- if (options.fail && totalStacksWithUnscrubbableKeys > 0) throw new ScrubNeededError();
62518
+ if (options.fail && (totalStacksWithUnscrubbableKeys > 0 || totalStacksWithUnverifiableReads > 0)) throw new ScrubNeededError();
62437
62519
  }
62438
62520
  /**
62439
62521
  * Every secret plaintext this run recorded, from ANY position in the template
@@ -62972,6 +63054,571 @@ async function pinCrossRegionSecrets(bag, stackName, ctx) {
62972
63054
  return await walk(bag, "");
62973
63055
  }
62974
63056
  /**
63057
+ * Order scrub targets PRODUCER-BEFORE-CONSUMER (issue
63058
+ * [#2133](https://github.com/go-to-k/cdkd/issues/2133) review).
63059
+ *
63060
+ * `cdkd scrub --all` used to walk the assembly's artifact order, which is not
63061
+ * topological — `getAllStacks` iterates the manifest, and only `cdkd deploy`
63062
+ * sorts. That ordering decides whether a legacy multi-stack app can be scrubbed
63063
+ * in ONE run: a consumer importing a secret learns its expression only by
63064
+ * reading the producer's state, and that state holds the expression only once
63065
+ * the PRODUCER has been scrubbed. Reached in the other order the consumer gets
63066
+ * a plaintext, has no needle, and is refused
63067
+ * ({@link plaintextProducerCrossStackReadError}) until the user re-runs. Both
63068
+ * orders are SAFE — the refusal is what makes them safe — but only one of them
63069
+ * finishes the job in a single command.
63070
+ *
63071
+ * Edges come from BOTH sources `cdkd deploy` uses: CDK's own manifest
63072
+ * dependencies (`dependencyNames`, emitted for a strong reference through
63073
+ * `exportValue`) and {@link inferCrossStackStackDeps}, which scans the
63074
+ * synthesized templates for a RAW `cdk.Fn.importValue` / `Fn::GetStackOutput`
63075
+ * that CDK records no dependency for. Edges to stacks outside the target set
63076
+ * are ignored: a producer that is not being scrubbed cannot be ordered.
63077
+ *
63078
+ * Cycles cannot happen through `Fn::ImportValue` on a deployable app, but a
63079
+ * cycle in the INFERRED graph is possible (two stacks reading each other's
63080
+ * outputs weakly), so the sort degrades to input order for whatever it cannot
63081
+ * place rather than dropping or duplicating a stack. Ties keep input order, so
63082
+ * a single-stack or dependency-free `--all` run is byte-identical to before.
63083
+ */
63084
+ function orderScrubTargets(targets) {
63085
+ if (targets.length < 2) return [...targets];
63086
+ const inSet = new Set(targets.map((t) => t.stackName));
63087
+ const inferred = inferCrossStackStackDeps(targets);
63088
+ const producersOf = /* @__PURE__ */ new Map();
63089
+ for (const target of targets) {
63090
+ const producers = /* @__PURE__ */ new Set();
63091
+ for (const dep of target.dependencyNames ?? []) if (inSet.has(dep) && dep !== target.stackName) producers.add(dep);
63092
+ for (const dep of inferred.get(target.stackName) ?? []) if (inSet.has(dep) && dep !== target.stackName) producers.add(dep);
63093
+ producersOf.set(target.stackName, producers);
63094
+ }
63095
+ const ordered = [];
63096
+ const emitted = /* @__PURE__ */ new Set();
63097
+ const pending = [...targets];
63098
+ while (pending.length > 0) {
63099
+ let index = pending.findIndex((t) => [...producersOf.get(t.stackName) ?? []].every((p) => emitted.has(p)));
63100
+ if (index === -1) index = 0;
63101
+ const [next] = pending.splice(index, 1);
63102
+ if (!next) break;
63103
+ ordered.push(next);
63104
+ emitted.add(next.stackName);
63105
+ }
63106
+ return ordered;
63107
+ }
63108
+ /**
63109
+ * A memoizing READ view over the state backend, for the resolutions ONE
63110
+ * `scrubStack` call performs (issue #2133 review).
63111
+ *
63112
+ * `resolveImportValue` has no lookup cache of its own: each reference costs a
63113
+ * `listStacks()` plus up to N `getState()` calls, and scrub resolves every
63114
+ * cross-stack reference TWICE — once in the pre-pass (which must run before the
63115
+ * best-effort catch can swallow a failure) and once in the main resolution
63116
+ * (which must run because the pre-pass deliberately does not rewrite the bag).
63117
+ * The view collapses both into one scan and one `getState` per (stack, region).
63118
+ *
63119
+ * `Object.create` rather than a hand-written wrapper object: the resolver also
63120
+ * reads `stateBackend.prefix` and could reach for more, and a literal cast to
63121
+ * `S3StateBackend` would turn the next such call into `undefined is not a
63122
+ * function`. Prototype delegation keeps every other member — and
63123
+ * `instanceof S3StateBackend` — intact.
63124
+ *
63125
+ * Deliberately NOT used for this stack's own `getState` / `saveState`: those go
63126
+ * through the real backend, so the read-modify-write still sees S3 rather than
63127
+ * a cache, and its ETag precondition still means what it says.
63128
+ */
63129
+ function memoizeCrossStackStateReads(backend) {
63130
+ const view = Object.create(backend);
63131
+ let listed;
63132
+ const states = /* @__PURE__ */ new Map();
63133
+ view.listStacks = () => {
63134
+ listed ??= backend.listStacks();
63135
+ return listed;
63136
+ };
63137
+ view.getState = (stackName, stateRegion) => {
63138
+ const key = `${stackName}\u0000${stateRegion}`;
63139
+ let pending = states.get(key);
63140
+ if (!pending) {
63141
+ pending = backend.getState(stackName, stateRegion);
63142
+ states.set(key, pending);
63143
+ }
63144
+ return pending;
63145
+ };
63146
+ return view;
63147
+ }
63148
+ /**
63149
+ * Is this template output one the deploy would NOT have written (issue #2133
63150
+ * review)?
63151
+ *
63152
+ * The outputs passes deliberately run over EVERY declared output regardless of
63153
+ * its `Condition`, so a secret carried by a (possibly spuriously) suppressed
63154
+ * output still becomes a needle for the resource records that share it. That
63155
+ * argument justifies RESOLVING a suppressed output, and nothing more — it does
63156
+ * not justify REFUSING the whole stack when such a resolution fails, because a
63157
+ * suppressed output produced no `state.outputs` key and so has nothing at risk.
63158
+ * A dev-environment scrub would otherwise be refused outright by a prod-only
63159
+ * `Value: {Fn::ImportValue: ProdOnlyExport}` whose producer does not exist in
63160
+ * dev. So: resolve it best-effort, but disarm the refusal.
63161
+ *
63162
+ * An UNKNOWN condition counts as suppressed, matching `resolveIf`'s
63163
+ * unknown-selects-FALSE rule and erring toward not refusing.
63164
+ *
63165
+ * STATE OVERRULES THE CONDITION (issue #2133 review). `conditions` degrades to
63166
+ * `{}` whenever `evaluateConditions` throws, and even when it succeeds it is
63167
+ * evaluated from TEMPLATE PARAMETER DEFAULTS — scrub takes no `--parameters` —
63168
+ * so an output the deploy really did write reads as suppressed on any stack
63169
+ * deployed with non-default parameters, which silently disarms the refusal for
63170
+ * that position. `state.outputs` is the record of what the deploy ACTUALLY
63171
+ * wrote (keyed by output NAME, plus the export-name alias), so a key present
63172
+ * there is proof the output was not suppressed, whatever this run's
63173
+ * best-effort condition evaluation concluded.
63174
+ */
63175
+ function isOutputSuppressed(name, output, conditions, stateOutputs) {
63176
+ const condition = output.Condition;
63177
+ if (typeof condition !== "string") return false;
63178
+ if (conditions[condition] === true) return false;
63179
+ return !(name in stateOutputs);
63180
+ }
63181
+ /**
63182
+ * The intrinsics that read ANOTHER stack's state (issue
63183
+ * [#2133](https://github.com/go-to-k/cdkd/issues/2133)).
63184
+ *
63185
+ * Both require `context.stateBackend` — `resolveImportValue` throws without it,
63186
+ * and `Fn::GetStackOutput` reaches the same requirement through
63187
+ * `getSameAccountStackState` — which is exactly what scrub's three resolve
63188
+ * contexts did not supply, so neither could ever resolve here.
63189
+ */
63190
+ const CROSS_STACK_INTRINSIC_KEYS = ["Fn::ImportValue", "Fn::GetStackOutput"];
63191
+ /**
63192
+ * The intrinsic keys {@link IntrinsicFunctionResolver}'s `resolveValue`
63193
+ * dispatches on, IN ITS ORDER (issue #2133 review).
63194
+ *
63195
+ * The pre-pass below must decide which key of a multi-key node the RESOLVER
63196
+ * would act on, because a node carrying a higher-precedence sibling never
63197
+ * reaches its `Fn::ImportValue` at all. Testing the cross-stack keys first — as
63198
+ * the first cut did — makes the pre-pass perform a READ the main resolution
63199
+ * never performs, and a read that fails is a refusal over a reference the
63200
+ * deploy never made. Isolation (`{ [key]: node[key] }`) does not answer this:
63201
+ * it stops the pre-pass resolving something ELSE, it does not stop it resolving
63202
+ * something EXTRA.
63203
+ *
63204
+ * `Condition` is deliberately absent. The resolver's `{Condition: <name>}` arm
63205
+ * is gated on `context.conditionResolver`, which only `evaluateConditions`
63206
+ * threads; in every context this pre-pass runs under, such a node is an
63207
+ * ordinary object property literally named `Condition`.
63208
+ *
63209
+ * Fenced against the resolver's own dispatch order by
63210
+ * `tests/unit/cli/commands/scrub-import-value-secret.test.ts`, so a new
63211
+ * intrinsic added there cannot silently leave this copy behind.
63212
+ */
63213
+ const RESOLVER_INTRINSIC_PRECEDENCE = [
63214
+ "Ref",
63215
+ "Fn::GetAtt",
63216
+ "Fn::Join",
63217
+ "Fn::Sub",
63218
+ "Fn::Select",
63219
+ "Fn::Split",
63220
+ "Fn::If",
63221
+ "Fn::Equals",
63222
+ "Fn::And",
63223
+ "Fn::Or",
63224
+ "Fn::Not",
63225
+ "Fn::ImportValue",
63226
+ "Fn::GetStackOutput",
63227
+ "Fn::FindInMap",
63228
+ "Fn::Base64",
63229
+ "Fn::GetAZs",
63230
+ "Fn::Cidr"
63231
+ ];
63232
+ /** The key `resolveValue` would dispatch on for `node`, or `undefined`. */
63233
+ function effectiveIntrinsicKey(node) {
63234
+ return RESOLVER_INTRINSIC_PRECEDENCE.find((key) => key in node);
63235
+ }
63236
+ /**
63237
+ * The refusal a cross-stack read raises when scrub cannot PERFORM it (issue
63238
+ * [#2133](https://github.com/go-to-k/cdkd/issues/2133)).
63239
+ *
63240
+ * The value behind such a reference MAY be a secret — a producer stack exporting
63241
+ * a `{{resolve:...}}` expression is exactly the shape issue #1934 persists — and
63242
+ * scrub learns its plaintext ONLY by performing the read. A read that fails
63243
+ * therefore leaves the command with no needle for that value, so a stored
63244
+ * plaintext survives and the stack is reported clean. Refusing is the same bar
63245
+ * issue #2109 sets for the cross-region case: reporting success over a document
63246
+ * scrub could not examine is the one outcome that must not survive.
63247
+ *
63248
+ * MASKED for the reason `unresolvableForeignScrubSecretError` states — the bag
63249
+ * this resolved through is one `pinCrossRegionSecrets` may already have
63250
+ * substituted a foreign plaintext into, so a resolver error echoing its input
63251
+ * can carry one, and the map is the same one this pass records into.
63252
+ */
63253
+ function unresolvableCrossStackReadError(origin, stackName, intrinsic, path, cause, secrets) {
63254
+ return new ScrubRefusalError(`Scrub of ${stackName} could not resolve the ${intrinsic} in ${origin}${path ? ` at ${path}` : ""}: ${maskSecretsInText(cause instanceof Error ? cause.message : String(cause), secrets)}. Refusing rather than continuing: the value that reference carries may be a secret, and scrub would then have no plaintext to look for — reporting the stack clean over state that still holds it. Deploy the producer stack (or correct the reference) and re-run 'cdkd scrub'.`, "SCRUB_CROSS_STACK_READ_UNRESOLVED");
63255
+ }
63256
+ /**
63257
+ * The refusal a cross-stack read raises when it SUCCEEDED but the PRODUCER's
63258
+ * own state record still stores the PLAINTEXT (issue
63259
+ * [#2133](https://github.com/go-to-k/cdkd/issues/2133) review).
63260
+ *
63261
+ * That producer state is precisely the population `cdkd scrub` exists for:
63262
+ * state an older binary wrote. Reading it hands the consumer a bare string,
63263
+ * nothing lands in `recordedSecretValues`, `totalSecrets` stays 0 and the
63264
+ * consumer is reported "No plaintext secrets found" while its record still
63265
+ * holds that plaintext. That is the same silent success the issue exists to
63266
+ * kill, reached one step later.
63267
+ *
63268
+ * There is no correct value to write here instead. What scrub must persist for
63269
+ * an imported secret is the PRODUCER's `{{resolve:...}}` expression, and that
63270
+ * expression exists only in the producer's state — the consumer's template
63271
+ * carries `{"Fn::ImportValue": ...}`, which is a REFERENCE, not an expression.
63272
+ * Writing the reference into a state leaf would fabricate an expression that
63273
+ * `Fn::ImportValue` and the exports index re-apply verbatim to further
63274
+ * consumers, the #1934 BREAK class this file already refuses one class over.
63275
+ * So: refuse, and name the fix that actually works — scrub the producer first,
63276
+ * which turns its stored plaintext into the expression this read then resolves.
63277
+ */
63278
+ function plaintextProducerCrossStackReadError(origin, stackName, intrinsic, path, producerStack, exportKey, verdict, secrets) {
63279
+ const loggedExportKey = maskSecretsInText(exportKey, secrets);
63280
+ const loggedProducerStack = maskSecretsInText(producerStack, secrets);
63281
+ const templateClaim = verdict === "declared" ? `declares '${loggedExportKey}' from a {{resolve:...}} expression` : `publishes at least one output from a {{resolve:...}} expression (this run could not match '${loggedExportKey}' to a declared output, so it cannot say which)`;
63282
+ return new ScrubRefusalError(`Scrub of ${stackName} resolved the ${intrinsic} in ${origin}${path ? ` at ${path}` : ""} to a PLAINTEXT value: the producer stack '${loggedProducerStack}' ${templateClaim}, but its own state still stores the resolved plaintext rather than that expression. scrub has no expression to write in this stack's place, so it cannot redact the imported secret and must not report this stack clean. Scrub the producer first ('cdkd scrub ${loggedProducerStack}'), then re-run 'cdkd scrub ${stackName}' — the read will then return the expression and this stack is scrubbed normally.`, "SCRUB_CROSS_STACK_PRODUCER_PLAINTEXT");
63283
+ }
63284
+ /**
63285
+ * Can the producer's STORED value be a legacy plaintext secret at all (issue
63286
+ * #2133 review)?
63287
+ *
63288
+ * `false` for a value that carries no text: `null` / `undefined`, the empty
63289
+ * string, and every non-string scalar. A resolved `{{resolve:...}}` reference
63290
+ * is always a STRING — `resolveDynamicReferences` substitutes text — so a
63291
+ * number or a boolean cannot be one, and an empty string holds nothing to
63292
+ * redact. Without this, `carriesDynamicReference` being false for those shapes
63293
+ * made a `widened` verdict refuse the consumer over a value with nothing behind
63294
+ * it, in a refusal that no re-run could clear (scrubbing the producer cannot
63295
+ * turn an empty value into an expression).
63296
+ *
63297
+ * Containers are NOT included: an array or object can nest a plaintext string,
63298
+ * and `carriesDynamicReference` already walks them, so the classification for
63299
+ * those is left exactly as it was.
63300
+ */
63301
+ function storedValueCarriesNoPlaintext(stored) {
63302
+ if (stored === null || stored === void 0) return true;
63303
+ if (typeof stored === "string") return stored === "";
63304
+ return typeof stored !== "object";
63305
+ }
63306
+ /**
63307
+ * Is `key` an export the producer's TEMPLATE declares from a `{{resolve:...}}`
63308
+ * expression (issue #2133 review)?
63309
+ *
63310
+ * This is HALF the discriminator, and the half the direct state read below
63311
+ * cannot supply. From the consumer's side, "the producer stored a plaintext
63312
+ * SECRET" and "the producer stored an ordinary non-secret value" are
63313
+ * indistinguishable — a bucket name and a leaked password are both bare
63314
+ * strings — so a refusal keyed on the producer's STORED value alone would fire
63315
+ * on every multi-stack app that imports anything at all. An export whose
63316
+ * declared `Value` contains no `{{resolve:` anywhere cannot be secret-bearing,
63317
+ * so its plaintext is nothing to redact.
63318
+ *
63319
+ * What it deliberately does NOT answer is whether the producer's own STATE has
63320
+ * been scrubbed down to that expression yet, which is the other half and the
63321
+ * one the three earlier cuts of this file each got wrong. See the discriminator
63322
+ * in {@link makeCrossStackPrePass}.
63323
+ *
63324
+ * `key` is the state.outputs KEY the read matched, which the deploy engine
63325
+ * writes under BOTH the output name and the literal `Export.Name` — hence both
63326
+ * are matched. When no output matches (an INTRINSIC `Export.Name`, which scrub
63327
+ * cannot reproduce from here) the test widens to every output of the producer,
63328
+ * which over-approximates in the SAFE direction: it can refuse a stack that
63329
+ * imports a non-secret export from a producer that has some other secret
63330
+ * output, and it never lets a secret-bearing one through. The widening is
63331
+ * REPORTED rather than folded into a boolean, because the refusal message
63332
+ * asserts what the producer declares and only the `declared` verdict has
63333
+ * checked that for THIS key.
63334
+ *
63335
+ * Residual, stated rather than hidden: a producer that publishes a secret
63336
+ * WITHOUT a literal `{{resolve:` in its template — one re-exporting a value it
63337
+ * imported from a third stack, or reading one through `Ref` to a parameter —
63338
+ * is not detected, and neither is a producer outside this app (whose template
63339
+ * scrub never sees). Those keep the pre-#2133 outcome for that one reference.
63340
+ */
63341
+ function producerPublishesSecretExpression(template, key) {
63342
+ const outputs = template.Outputs ?? {};
63343
+ const matched = [];
63344
+ for (const [name, output] of Object.entries(outputs)) {
63345
+ const exportName = output?.Export?.Name;
63346
+ if (name === key || typeof exportName === "string" && exportName === key) matched.push(output?.Value);
63347
+ }
63348
+ const widened = matched.length === 0;
63349
+ if (!(widened ? Object.values(outputs).map((o) => o?.Value) : matched).some((value) => JSON.stringify(value ?? null).includes("{{resolve:"))) return "no";
63350
+ return widened ? "widened" : "declared";
63351
+ }
63352
+ /**
63353
+ * Build the CROSS-STACK pre-pass for ONE stack's scrub (issue
63354
+ * [#2133](https://github.com/go-to-k/cdkd/issues/2133)).
63355
+ *
63356
+ * WHY THIS RUNS AT ALL, given the wiring in `scrubStack` already lets the main
63357
+ * resolution perform these reads. The wiring is what makes an
63358
+ * `Fn::ImportValue`-sourced secret RESOLVE; this pass is what makes a FAILED
63359
+ * one LOUD, and what catches a SUCCEEDING one that produced no needle. A
63360
+ * cross-stack read that throws inside `resolver.resolve` lands in the caller's
63361
+ * best-effort `catch { logger.debug(...) }`, and that catch cannot tell "a
63362
+ * `Ref` to a resource that is not in state" — which it exists for — from "the
63363
+ * producer's state could not be read", which silently costs the command its
63364
+ * needle. Lifting the second class OUT of the catch is the same placement issue
63365
+ * #2109 uses for its own refusals, and it keeps the catch doing its job:
63366
+ * everything it still swallows is now provably NOT a cross-stack read.
63367
+ *
63368
+ * THREE OUTCOMES per reference, and the split is the whole design:
63369
+ * - the read FAILS for a reason a user can fix (producer not deployed, wrong
63370
+ * reference, an `AccessDenied` on the producer's state or on
63371
+ * `cloudformation:ListExports`, an unassumable `RoleArn`) — REFUSE the stack.
63372
+ * A re-run after the fix scrubs it, so refusing costs reachability only until
63373
+ * the cause is addressed.
63374
+ * - the read is DECLINED BY DESIGN (`CrossAccountSecretRefusalError`) — record
63375
+ * an UNVERIFIABLE finding, scrub the rest of the stack, and let the run exit
63376
+ * non-zero. No user action can make that read succeed, so refusing the whole
63377
+ * stack would strand every OTHER secret in it forever — a reachability
63378
+ * regression against the pre-#2133 best-effort behaviour, in the command that
63379
+ * exists to remediate. This is the `secretBearingKeys` treatment (issue
63380
+ * #1919), applied to the same shape of finding: real, reported, unremediable.
63381
+ * The match is on that SUBCLASS, never on `IntrinsicResolutionRefusalError`:
63382
+ * five of its six throw sites are USER-FIXABLE and are reachable from here
63383
+ * whenever a cross-stack node's ARGUMENT is an `Fn::Sub` / `Fn::GetAtt` /
63384
+ * `Fn::Split` (`resolveSub` re-raises the class), so matching the base class
63385
+ * downgraded a fixable refusal to a finding and let scrub print
63386
+ * `No plaintext secrets found` and exit 0 over surviving plaintext.
63387
+ * - the read SUCCEEDS but the PRODUCER's own state record still holds the
63388
+ * plaintext, for an export the producer's template declares from a
63389
+ * `{{resolve:...}}` expression — REFUSE, see
63390
+ * {@link plaintextProducerCrossStackReadError}.
63391
+ *
63392
+ * Each node is resolved on its OWN — `{ [key]: node[key] }` rather than the
63393
+ * enclosing object — so a bag carrying an unrelated sibling key cannot make
63394
+ * this pass resolve something else, and so a failure names the cross-stack read
63395
+ * rather than whatever the whole node resolves to. Which key of a multi-key
63396
+ * node is even eligible is decided by {@link RESOLVER_INTRINSIC_PRECEDENCE},
63397
+ * mirroring the resolver — so for the CHOICE OF KEY the pass never reads
63398
+ * through an intrinsic the main resolution would ignore.
63399
+ *
63400
+ * That is the whole of the invariant, and it is deliberately narrower than
63401
+ * "this pass performs no read the main resolution would not" (issue #2133
63402
+ * review — the earlier, overstated wording). `resolveValue` walks a plain
63403
+ * object's properties SEQUENTIALLY and aborts the whole bag at the first one
63404
+ * that throws, and it THROWS on an unknown intrinsic (`Fn::ToJsonString`,
63405
+ * `Fn::ForEach`) before descending into its argument at all; this pass resolves
63406
+ * each node on its own and keeps walking in both cases. So it can read a
63407
+ * reference the main resolution never reaches — after a sibling `Ref` to a
63408
+ * resource absent from state, which is the routine case, or beneath an
63409
+ * intrinsic cdkd cannot resolve. The DIRECTION is safe (the deploy made those
63410
+ * reads, so refusing over one names a real unscrubbed producer rather than a
63411
+ * fabricated one) but the sentence is load-bearing for the refusal, so it is
63412
+ * stated as what it is rather than as the stronger claim.
63413
+ *
63414
+ * The bag is NOT rewritten. The main resolution re-resolves these references
63415
+ * itself, and the S3 reads behind them are shared with this pass by the
63416
+ * memoizing backend view `scrubStack` builds (issue #2133 review — before that
63417
+ * every reference cost `listStacks()` plus up to N `getState()` TWICE). What
63418
+ * not rewriting buys back is the whole class of hazard a substituted value
63419
+ * carries: a resolved cross-stack value that is itself intrinsic- or
63420
+ * reference-shaped would be re-interpreted by the stack's own resolver, which
63421
+ * is the shape `SCRUB_SECRET_RESOLUTION_REINTRODUCED_TOKEN` guards against one
63422
+ * class over. The needles this records survive the main resolution failing for
63423
+ * an unrelated reason, since `recordedSecretValues` is filled in place.
63424
+ *
63425
+ * `Fn::If` is walked the way {@link IntrinsicFunctionResolver} walks it — the
63426
+ * SELECTED branch only, with an unknown condition selecting the FALSE branch,
63427
+ * mirroring `resolveIf`. Reading a reference the main resolution would never
63428
+ * touch would refuse a stack over a conditional import the deploy never made.
63429
+ * A MALFORMED `Fn::If` (args not a 3-tuple) is walked with refusals DISARMED
63430
+ * rather than skipped: which branch is live is unknowable, so a read inside it
63431
+ * may be one the deploy never made — the exact hazard the selected-branch
63432
+ * mirror exists to prevent — but any needle it does yield is still worth having.
63433
+ */
63434
+ function makeCrossStackPrePass(deps) {
63435
+ const { stackName, resolver, producerTemplates, findings, logger } = deps;
63436
+ /**
63437
+ * Is this a refusal NO RE-RUN can clear (issue #2133 review)?
63438
+ *
63439
+ * The predicate is the SUBCLASS, not `IntrinsicResolutionRefusalError`. That
63440
+ * class has six throw sites and only ONE — `resolveGetStackOutput`'s
63441
+ * cross-account arm — is permanent; the other five (a stale placeholder ARN,
63442
+ * a fabricated account id, an unenriched `Fn::GetAtt`, `--strict-getatt`, a
63443
+ * malformed `Fn::Split`) are all fixed by editing the template or deploying
63444
+ * the producer. They are REACHABLE here, because `resolveSub` re-raises the
63445
+ * class, so any cross-stack node whose ARGUMENT is an `Fn::Sub` /
63446
+ * `Fn::GetAtt` / `Fn::Split` can carry one up to this catch. Matching the
63447
+ * base class downgraded every one of them to a finding logged as "cdkd
63448
+ * declines this read by design, so no re-run can change it" — false — and
63449
+ * scrub then printed `No plaintext secrets found` and exited 0 without
63450
+ * `--fail` while the consumer's state.json still held the imported plaintext.
63451
+ *
63452
+ * The CAUSE CHAIN rather than a bare `instanceof`: the refusal reaches this
63453
+ * catch through `resolver.resolve`, which wraps in several places, and a
63454
+ * top-link-only test silently reclassifies a wrapped permanent refusal as
63455
+ * user-fixable — i.e. refuses the whole stack over something no user can fix.
63456
+ */
63457
+ const isByDesignRefusal = (err) => err instanceof Error && errorCauseChain(err).some((link) => link instanceof CrossAccountSecretRefusalError);
63458
+ return async function resolveCrossStackReads(bag, context, origin, opts) {
63459
+ const canRefuse = opts?.canRefuse ?? true;
63460
+ const secrets = context.recordedSecretValues ?? /* @__PURE__ */ new Map();
63461
+ /**
63462
+ * The producer this reference actually resolved through, and the KEY it
63463
+ * matched — read back out of the resolver's own recording bags.
63464
+ *
63465
+ * Keyed on the intrinsic UNDER TEST rather than "imports first, then output
63466
+ * reads": `resolveImportValue` / `resolveGetStackOutput` each resolve their
63467
+ * ARGUMENTS before recording their own read, so a nested cross-stack read
63468
+ * inside the argument lands in the OTHER family's bag (or earlier in the
63469
+ * same one). Preferring imports unconditionally made an
63470
+ * `Fn::GetStackOutput` whose `StackName` contains an `Fn::ImportValue`
63471
+ * report the INNER producer.
63472
+ *
63473
+ * `.at(-1)` for the same reason within one family: the outer read is
63474
+ * recorded LAST, after its argument has resolved.
63475
+ *
63476
+ * An empty bag means the value did NOT come from a cdkd state record, and
63477
+ * the two arms that produce one are NOT unreadable for the same reason
63478
+ * (issue #2133 review — one rationale used to cover both, and it was false
63479
+ * of the second):
63480
+ *
63481
+ * - the CloudFormation `ListExports` / `DescribeStacks` FALLBACKS record
63482
+ * nothing deliberately, a CFn-managed producer being a weak reference.
63483
+ * Here "no expression to restore" is literally true: the value came from
63484
+ * CloudFormation, which cdkd never redacted, so no `{{resolve:...}}`
63485
+ * expression exists anywhere for scrub to write in its place.
63486
+ * - the CROSS-ACCOUNT `Fn::GetStackOutput` arm records nothing for a
63487
+ * different reason, and a post-#1934 producer in the other account DOES
63488
+ * store the expression — which is precisely why
63489
+ * `CrossAccountSecretRefusalError` exists, and why it fires BEFORE this
63490
+ * point whenever the stored value carries one. What can reach here is a
63491
+ * value that carried none, and scrub cannot classify it any further: the
63492
+ * producer's `state.json` lives in another account and the only
63493
+ * credentials in hand are the consumer's, so reading it is the
63494
+ * wrong-account lookup that refusal exists to prevent.
63495
+ *
63496
+ * The OUTCOME is the same today either way — a cross-account producer is
63497
+ * never in `appStacks`, so the `!producerTemplate` return below fires first
63498
+ * regardless — but a future change that widened `producerTemplates` would
63499
+ * be relying on this sentence, so it must not claim the second arm has no
63500
+ * expression behind it.
63501
+ *
63502
+ * Residual: an OUTER read that fell through to a CloudFormation fallback
63503
+ * while its ARGUMENT performed a same-family cdkd read leaves the inner
63504
+ * entry last, so the check below consults the wrong producer. Bounded by
63505
+ * the key test — the inner key must also exist in the inner producer's
63506
+ * outputs, which it does — and it can only produce a false REFUSAL naming a
63507
+ * real unscrubbed producer, never a false clean.
63508
+ */
63509
+ const recordedProducer = (key, probe) => {
63510
+ if (key === "Fn::GetStackOutput") {
63511
+ const read = probe.recordedOutputReads?.at(-1);
63512
+ return read ? {
63513
+ stack: read.sourceStack,
63514
+ region: read.sourceRegion,
63515
+ key: read.outputName
63516
+ } : void 0;
63517
+ }
63518
+ const imported = probe.recordedImports?.at(-1);
63519
+ return imported ? {
63520
+ stack: imported.sourceStack,
63521
+ region: imported.sourceRegion,
63522
+ key: imported.exportName
63523
+ } : void 0;
63524
+ };
63525
+ /**
63526
+ * The producer's STORED value for `key` — what its `state.json` actually
63527
+ * holds, as opposed to what the read RETURNED.
63528
+ *
63529
+ * `undefined` when the producer's record cannot be read or does not carry
63530
+ * the key, which is treated as "cannot classify" by the caller rather than
63531
+ * as evidence of anything. Both are near-unreachable on the arms that get
63532
+ * here (the read just succeeded against that record, through the SAME
63533
+ * memoized `getState` promise this call resolves), so the read costs no
63534
+ * extra AWS call.
63535
+ */
63536
+ const storedProducerValue = async (producer, backend) => {
63537
+ if (!backend) return void 0;
63538
+ let loaded;
63539
+ try {
63540
+ loaded = await backend.getState(producer.stack, producer.region);
63541
+ } catch (err) {
63542
+ logger.debug(`Scrub of ${stackName}: could not re-read producer '${producer.stack}' (${producer.region}) to classify its stored value: ${maskSecretsInText(err instanceof Error ? err.message : String(err), secrets)}`);
63543
+ return;
63544
+ }
63545
+ const outputs = loaded?.state?.outputs;
63546
+ if (!outputs || !(producer.key in outputs)) return void 0;
63547
+ return { stored: outputs[producer.key] };
63548
+ };
63549
+ const readOne = async (key, node, path, nodeCanRefuse) => {
63550
+ const probe = {
63551
+ ...context,
63552
+ recordedImports: [],
63553
+ recordedOutputReads: []
63554
+ };
63555
+ try {
63556
+ await resolver.resolve({ [key]: node[key] }, probe);
63557
+ } catch (err) {
63558
+ if (!nodeCanRefuse) {
63559
+ logger.debug(`Scrub of ${stackName}: ${key} in ${origin}${path ? ` at ${path}` : ""} could not be resolved, and this position cannot refuse: ${maskSecretsInText(err instanceof Error ? err.message : String(err), secrets)}`);
63560
+ return;
63561
+ }
63562
+ if (isByDesignRefusal(err)) {
63563
+ const detail = `${key} in ${origin}${path ? ` at ${path}` : ""}: ${maskSecretsInText(err instanceof Error ? err.message : String(err), secrets)}`;
63564
+ findings.unverifiable.push(detail);
63565
+ logger.warn(`Scrub of ${stackName} cannot verify ${detail} — cdkd declines this read by design, so no re-run can change it. The rest of the stack is still scrubbed; this stack is NOT reported clean.`);
63566
+ return;
63567
+ }
63568
+ throw unresolvableCrossStackReadError(origin, stackName, key, path, err, secrets);
63569
+ }
63570
+ const producer = recordedProducer(key, probe);
63571
+ if (!producer) return;
63572
+ const producerTemplate = producerTemplates.get(producer.stack);
63573
+ if (!producerTemplate) return;
63574
+ const verdict = producerPublishesSecretExpression(producerTemplate, producer.key);
63575
+ if (verdict === "no") return;
63576
+ const stored = await storedProducerValue(producer, context.stateBackend);
63577
+ if (!stored) return;
63578
+ if (carriesDynamicReference(stored.stored)) return;
63579
+ if (storedValueCarriesNoPlaintext(stored.stored)) {
63580
+ logger.debug(`Scrub of ${stackName}: ${key} in ${origin}${path ? ` at ${path}` : ""} resolved through producer '${producer.stack}', whose stored value for that key carries no text — nothing to redact, so this is not classified as a plaintext producer.`);
63581
+ return;
63582
+ }
63583
+ if (!nodeCanRefuse) {
63584
+ logger.debug(`Scrub of ${stackName}: ${key} in ${origin}${path ? ` at ${path}` : ""} resolved to a plaintext from unscrubbed producer '${producer.stack}', and this position cannot refuse.`);
63585
+ return;
63586
+ }
63587
+ throw plaintextProducerCrossStackReadError(origin, stackName, key, path, producer.stack, producer.key, verdict, secrets);
63588
+ };
63589
+ const walk = async (v, path, nodeCanRefuse) => {
63590
+ if (Array.isArray(v)) {
63591
+ for (let i = 0; i < v.length; i++) await walk(v[i], `${path}[${i}]`, nodeCanRefuse);
63592
+ return;
63593
+ }
63594
+ if (v === null || typeof v !== "object") return;
63595
+ const node = v;
63596
+ const key = effectiveIntrinsicKey(node);
63597
+ if (key === void 0) {
63598
+ for (const [k, val] of Object.entries(node)) await walk(val, path ? `${path}.${k}` : k, nodeCanRefuse);
63599
+ return;
63600
+ }
63601
+ if (key === "Fn::If") {
63602
+ const args = node["Fn::If"];
63603
+ if (Array.isArray(args) && args.length === 3) {
63604
+ const [conditionName, ifTrue, ifFalse] = args;
63605
+ const selected = typeof conditionName === "string" && context.conditions?.[conditionName] === true ? ifTrue : ifFalse;
63606
+ await walk(selected, `${path}['Fn::If']`, nodeCanRefuse);
63607
+ return;
63608
+ }
63609
+ await walk(args, `${path}['Fn::If']`, false);
63610
+ return;
63611
+ }
63612
+ if (CROSS_STACK_INTRINSIC_KEYS.includes(key)) {
63613
+ await readOne(key, node, path, nodeCanRefuse);
63614
+ return;
63615
+ }
63616
+ await walk(node[key], `${path}['${key}']`, nodeCanRefuse);
63617
+ };
63618
+ await walk(bag, "", canRefuse);
63619
+ };
63620
+ }
63621
+ /**
62975
63622
  * Scrub one stack's state. Re-resolves the template's per-resource properties to
62976
63623
  * learn the resolved secret VALUES, then replaces those values in the state
62977
63624
  * record with their `{{resolve:...}}` expressions. Returns counts; performs no
@@ -62990,6 +63637,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
62990
63637
  if (acquired) await lockManager.acquireLockWithRetry(stack.stackName, region, void 0, "scrub");
62991
63638
  const perResourceSecrets = /* @__PURE__ */ new Map();
62992
63639
  const outputSecrets = /* @__PURE__ */ new Map();
63640
+ const prePassFindings = { unverifiable: [] };
62993
63641
  try {
62994
63642
  const loaded = await stateBackend.getState(stack.stackName, region);
62995
63643
  if (!loaded) {
@@ -62997,7 +63645,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
62997
63645
  return {
62998
63646
  recordsChanged: 0,
62999
63647
  secretsFound: 0,
63000
- secretBearingKeys: 0
63648
+ secretBearingKeys: 0,
63649
+ unverifiableReads: 0
63001
63650
  };
63002
63651
  }
63003
63652
  const state = loaded.state;
@@ -63014,13 +63663,26 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63014
63663
  } catch (err) {
63015
63664
  logger.debug(`Parameter resolution skipped for ${stack.stackName}: ${err instanceof Error ? err.message : String(err)}`);
63016
63665
  }
63666
+ const resolverStateBackend = memoizeCrossStackStateReads(stateBackend);
63667
+ const resolverContext = (recordedSecretValues) => ({
63668
+ template: stack.template,
63669
+ resources: state.resources,
63670
+ ...Object.keys(parameters).length > 0 && { parameters },
63671
+ ...Object.keys(conditions).length > 0 && { conditions },
63672
+ stackName: stack.stackName,
63673
+ stateBackend: resolverStateBackend,
63674
+ ...recordedSecretValues && { recordedSecretValues },
63675
+ bestEffort: true
63676
+ });
63677
+ const resolveCrossStackReads = makeCrossStackPrePass({
63678
+ stackName: stack.stackName,
63679
+ resolver,
63680
+ producerTemplates: new Map((opts.appStacks ?? []).map((s) => [s.stackName, s.template])),
63681
+ findings: prePassFindings,
63682
+ logger
63683
+ });
63017
63684
  try {
63018
- conditions = await resolver.evaluateConditions({
63019
- template: stack.template,
63020
- resources: state.resources,
63021
- ...Object.keys(parameters).length > 0 && { parameters },
63022
- bestEffort: true
63023
- });
63685
+ conditions = await resolver.evaluateConditions(resolverContext(outputSecrets));
63024
63686
  } catch (err) {
63025
63687
  logger.debug(`Condition evaluation skipped for ${stack.stackName}: ${err instanceof Error ? err.message : String(err)}`);
63026
63688
  }
@@ -63029,6 +63691,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63029
63691
  const templateResource = templateResources[logicalId];
63030
63692
  if (!templateResource?.Properties) continue;
63031
63693
  const recordedSecretValues = /* @__PURE__ */ new Map();
63694
+ perResourceSecrets.set(logicalId, recordedSecretValues);
63032
63695
  const resolveInput = await pinCrossRegionSecrets(templateResource.Properties, stack.stackName, {
63033
63696
  stackRegion: region,
63034
63697
  producerRegions,
@@ -63037,20 +63700,13 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63037
63700
  recordedSecretValues,
63038
63701
  origin: `resource '${logicalId}'`
63039
63702
  });
63703
+ const resourceContext = resolverContext(recordedSecretValues);
63704
+ await resolveCrossStackReads(resolveInput, resourceContext, `resource '${logicalId}'`);
63040
63705
  try {
63041
- await resolver.resolve(resolveInput, {
63042
- template: stack.template,
63043
- resources: state.resources,
63044
- ...Object.keys(parameters).length > 0 && { parameters },
63045
- ...Object.keys(conditions).length > 0 && { conditions },
63046
- stackName: stack.stackName,
63047
- recordedSecretValues,
63048
- bestEffort: true
63049
- });
63706
+ await resolver.resolve(resolveInput, resourceContext);
63050
63707
  } catch (err) {
63051
63708
  logger.debug(`Resolution of ${logicalId} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}`);
63052
63709
  }
63053
- if (recordedSecretValues.size > 0) perResourceSecrets.set(logicalId, recordedSecretValues);
63054
63710
  perResourceTemplateProps.set(logicalId, templateResource.Properties);
63055
63711
  }
63056
63712
  const templateOutputs = stack.template.Outputs ?? {};
@@ -63071,16 +63727,10 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63071
63727
  recordedSecretValues: outputSecrets,
63072
63728
  origin: `Export.Name of output '${name}'`
63073
63729
  });
63730
+ const nameContext = resolverContext(outputSecrets);
63731
+ await resolveCrossStackReads(nameSource, nameContext, `Export.Name of output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
63074
63732
  try {
63075
- exportName = await resolver.resolve(nameSource, {
63076
- template: stack.template,
63077
- resources: state.resources,
63078
- ...Object.keys(parameters).length > 0 && { parameters },
63079
- ...Object.keys(conditions).length > 0 && { conditions },
63080
- stackName: stack.stackName,
63081
- recordedSecretValues: outputSecrets,
63082
- bestEffort: true
63083
- });
63733
+ exportName = await resolver.resolve(nameSource, nameContext);
63084
63734
  } catch (err) {
63085
63735
  outputsSourceUntrusted = true;
63086
63736
  logger.warn(`Export.Name of output ${name} could not be resolved during scrub (${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}) — redacting this stack's outputs by value match instead of by template position, since state may be keyed under a name this run cannot reproduce.`);
@@ -63111,16 +63761,10 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63111
63761
  recordedSecretValues: outputSecrets,
63112
63762
  origin: `output '${name}'`
63113
63763
  });
63764
+ const valueContext = resolverContext(outputSecrets);
63765
+ await resolveCrossStackReads(valueSource, valueContext, `output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
63114
63766
  try {
63115
- await resolver.resolve(valueSource, {
63116
- template: stack.template,
63117
- resources: state.resources,
63118
- ...Object.keys(parameters).length > 0 && { parameters },
63119
- ...Object.keys(conditions).length > 0 && { conditions },
63120
- stackName: stack.stackName,
63121
- recordedSecretValues: outputSecrets,
63122
- bestEffort: true
63123
- });
63767
+ await resolver.resolve(valueSource, valueContext);
63124
63768
  } catch (err) {
63125
63769
  logger.debug(`Resolution of output ${name} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}`);
63126
63770
  }
@@ -63137,7 +63781,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63137
63781
  if (totalSecrets === 0) return {
63138
63782
  recordsChanged: 0,
63139
63783
  secretsFound: 0,
63140
- secretBearingKeys: 0
63784
+ secretBearingKeys: secretBearingKeys.length,
63785
+ unverifiableReads: prePassFindings.unverifiable.length
63141
63786
  };
63142
63787
  let recordsChanged = 0;
63143
63788
  const newResources = {};
@@ -63167,7 +63812,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63167
63812
  return {
63168
63813
  recordsChanged,
63169
63814
  secretsFound: totalSecrets,
63170
- secretBearingKeys: secretBearingKeys.length
63815
+ secretBearingKeys: secretBearingKeys.length,
63816
+ unverifiableReads: prePassFindings.unverifiable.length
63171
63817
  };
63172
63818
  } catch (err) {
63173
63819
  throw maskSecretsInError(err, allRecordedSecrets(outputSecrets, perResourceSecrets));
@@ -73031,7 +73677,7 @@ async function scanReferencedAssets(stateBackend, marker, logger) {
73031
73677
  try {
73032
73678
  parsed = JSON.parse(body);
73033
73679
  } catch (error) {
73034
- const described = describeStateKey(key);
73680
+ const described = describeStateKey(key, STATE_FILE_SUFFIX, DEFAULT_STATE_PREFIX);
73035
73681
  const regionMatch = /^(\S+) \((\S+)\)$/.exec(described);
73036
73682
  throw new CdkdError(`State file '${key}' is not valid JSON — aborting: gc must know every referenced asset before deleting anything, and this file's references are unreadable. Repair or remove the corrupt state file ('${regionMatch ? `cdkd state show ${regionMatch[1]} --stack-region ${regionMatch[2]}` : `cdkd state show ${described}`}' to inspect), then re-run.`, "GC_STATE_UNREADABLE", error);
73037
73683
  }
@@ -73236,7 +73882,7 @@ async function gcCommand(options) {
73236
73882
  });
73237
73883
  const stateBackend = new S3StateBackend(markerS3Client, {
73238
73884
  bucket: bucketName,
73239
- prefix: "cdkd"
73885
+ prefix: DEFAULT_STATE_PREFIX
73240
73886
  }, {
73241
73887
  region: effective.region,
73242
73888
  ...options.profile && { profile: options.profile }
@@ -73282,7 +73928,7 @@ async function gcCommand(options) {
73282
73928
  const marker = parseBootstrapMarker(markerBody, resolvedMarkerKey);
73283
73929
  const lockKeys = await listAllLockKeys(stateBackend);
73284
73930
  if (lockKeys.length > 0) {
73285
- const listing = lockKeys.map((k) => ` - ${describeStateKey(k, LOCK_FILE_SUFFIX)} [${k}]`).join("\n");
73931
+ const listing = lockKeys.map((k) => ` - ${describeStateKey(k, LOCK_FILE_SUFFIX, DEFAULT_STATE_PREFIX)} [${k}]`).join("\n");
73286
73932
  throw new CdkdError(`Refusing to gc while ${lockKeys.length} stack(s) hold an active lock (a deploy in flight may have published assets whose state write has not landed yet):\n${listing}\nWait for the operation(s) to finish — or clear a stale lock with 'cdkd force-unlock <stack>' — then re-run 'cdkd gc'.`, "GC_LOCKED");
73287
73933
  }
73288
73934
  const refs = await scanReferencedAssets(stateBackend, marker, logger);
@@ -85084,7 +85730,7 @@ async function promptConfirm(message) {
85084
85730
  * callers get the standard exit-code-2 routing.
85085
85731
  */
85086
85732
  function createMigrateCommand() {
85087
- return new Command("migrate").description("Adopt a plain (non-CDK) CloudFormation stack into a cdkd-managed CDK app. Generates new CDK code via upstream `cdk migrate`, builds a logical-ID mapping between the source CFn template and the synth template, writes cdkd state, and (optionally) retires the source CFn stack. AWS resources are never modified.").argument("[stack]", "Source CFn stack name. Alias for --from-cfn-stack.").addOption(new Option("--from-cfn-stack <name>", "Source CloudFormation stack name to adopt. Required (or pass positionally).")).addOption(new Option("--output-dir <dir>", "Directory to write the generated CDK app to. Defaults to <cwd>/<CfnStackName>.")).addOption(new Option("--language <choice>", "Generated code language. v1: typescript only.").choices(["typescript"]).default("typescript")).addOption(new Option("--region <region>", "AWS region. Defaults to AWS_REGION env / profile.")).addOption(new Option("--account <id>", "AWS account ID. Auto-detected via STS when omitted.")).addOption(new Option("--retire-cfn-stack", "After cdkd state is written, inject DeletionPolicy=Retain on every resource in the source CFn stack and DeleteStack. AWS resources stay; the CFn stack record is gone. Off by default.").default(false)).addOption(new Option("--filter <key=value>", "Pass-through to `cdk migrate --filter` for resource subsetting. Repeatable.").argParser((value, previous) => [...previous ?? [], value]).default([])).addOption(new Option("--skip-install", "Skip `npm install` after codegen.").default(false)).addOption(new Option("--skip-synth", "Skip `cdk synth` (does NOT write cdkd state). Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("--dry-run", "Print the import plan without writing state or retiring the CFn stack. Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("-y, --yes", "Auto-confirm the import + retirement prompts.").default(false)).addOption(new Option("--cdk-bin <path>", "Override the `cdk` binary path.")).addOption(new Option("--resource-mapping <file>", `Path to a JSON file of {sourceLogicalId: synthLogicalId} overrides. Same shape as the auto-written ${RESOURCE_MAPPING_FILENAME}.`)).addOption(new Option("--state-bucket <name>", "cdkd state bucket. Defaults to cdkd-state-<accountId>.")).addOption(new Option("--state-prefix <prefix>", "cdkd state prefix inside the bucket.").default("cdkd")).addOption(new Option("--profile <name>", "AWS profile name.")).addOption(new Option("--role-arn <arn>", "IAM role to assume before any AWS call.")).addOption(new Option("--verbose", "Enable debug-level logging.").default(false)).action(withErrorHandling(migrateCommandAction));
85733
+ return new Command("migrate").description("Adopt a plain (non-CDK) CloudFormation stack into a cdkd-managed CDK app. Generates new CDK code via upstream `cdk migrate`, builds a logical-ID mapping between the source CFn template and the synth template, writes cdkd state, and (optionally) retires the source CFn stack. AWS resources are never modified.").argument("[stack]", "Source CFn stack name. Alias for --from-cfn-stack.").addOption(new Option("--from-cfn-stack <name>", "Source CloudFormation stack name to adopt. Required (or pass positionally).")).addOption(new Option("--output-dir <dir>", "Directory to write the generated CDK app to. Defaults to <cwd>/<CfnStackName>.")).addOption(new Option("--language <choice>", "Generated code language. v1: typescript only.").choices(["typescript"]).default("typescript")).addOption(new Option("--region <region>", "AWS region. Defaults to AWS_REGION env / profile.")).addOption(new Option("--account <id>", "AWS account ID. Auto-detected via STS when omitted.")).addOption(new Option("--retire-cfn-stack", "After cdkd state is written, inject DeletionPolicy=Retain on every resource in the source CFn stack and DeleteStack. AWS resources stay; the CFn stack record is gone. Off by default.").default(false)).addOption(new Option("--filter <key=value>", "Pass-through to `cdk migrate --filter` for resource subsetting. Repeatable.").argParser((value, previous) => [...previous ?? [], value]).default([])).addOption(new Option("--skip-install", "Skip `npm install` after codegen.").default(false)).addOption(new Option("--skip-synth", "Skip `cdk synth` (does NOT write cdkd state). Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("--dry-run", "Print the import plan without writing state or retiring the CFn stack. Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("-y, --yes", "Auto-confirm the import + retirement prompts.").default(false)).addOption(new Option("--cdk-bin <path>", "Override the `cdk` binary path.")).addOption(new Option("--resource-mapping <file>", `Path to a JSON file of {sourceLogicalId: synthLogicalId} overrides. Same shape as the auto-written ${RESOURCE_MAPPING_FILENAME}.`)).addOption(new Option("--state-bucket <name>", "cdkd state bucket. Defaults to cdkd-state-<accountId>.")).addOption(new Option("--state-prefix <prefix>", "cdkd state prefix inside the bucket.").default(DEFAULT_STATE_PREFIX)).addOption(new Option("--profile <name>", "AWS profile name.")).addOption(new Option("--role-arn <arn>", "IAM role to assume before any AWS call.")).addOption(new Option("--verbose", "Enable debug-level logging.").default(false)).action(withErrorHandling(migrateCommandAction));
85088
85734
  }
85089
85735
 
85090
85736
  //#endregion
@@ -85107,7 +85753,7 @@ function createMigrateCommand() {
85107
85753
  */
85108
85754
  function buildProgram() {
85109
85755
  const program = new Command();
85110
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.30");
85756
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.32");
85111
85757
  program.hook("preAction", (_thisCommand, actionCommand) => {
85112
85758
  const { profile } = actionCommand.optsWithGlobals();
85113
85759
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;