@go-to-k/cdkd 0.284.28 → 0.284.30

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, $n as LocalInvokeBuildError$1, $t as rewriteTemplateAssetReferences, A as MULTI_REGION_RECREATE_BLOCKED_TYPES, An as warnDeprecatedNoPrefixCliFlag, At as scrubResourceRecord, B as collectDeclaredOutputNames, Bn as AssemblyReader, Bt as withRetry, C as ccRoutedFinalSnapshotError, Cn as resolveAutoAssetStorage, Ct as STATE_SOURCED_READBACK_RULES, D as unsupportedFinalSnapshotError, Dn as resolveStateBucketWithDefaultAndSource, Dt as isSingleDynamicReferenceToken, E as refusesFinalSnapshot, En as resolveStateBucketWithDefault, Et as dynamicReferenceTokens, F as cyan, Fn as uploadCfnTemplate, Ft as s3BucketWebsiteUrl, G as stateKeySecretExposure, Gn as getAwsClients, Gt as rebuildClientForBucketRegion, H as exportAliasCollisionScrubWarning, Ht as TemplateParser, I as gray, In as expectedOwnerParam, It as applyRoleArnIfSet, J as clearOnUpdateRemoval, Jt as stringifyValue, K as IAMRoleProvider, Kn as resetAwsClients, Kt as shouldRetainResource, L as green, Ln as PARTITION_TABLE, Lt as DiffCalculator, M as renderStatefulReason, Mn as CFN_TEMPLATE_URL_LIMIT, Mt as s3BucketDomainName, N as formatResourceLine, Nn as MIGRATE_TMP_PREFIX, Nt as s3BucketDualStackDomainName, O as makeCanonicalizePropertiesFn, On as resolveUseCdkBootstrapAssets, Ot as maskSecretsInText, P as bold, Pn as findLargeInlineResources, Pt as s3BucketRegionalDomainName, Q as beginCommandInterruptScope, Qn as DeployCancelledError, Qt as loadPublishableAssetManifest, R as red, Rn as canonicalizeRegion, Rt as INTRINSIC_KEYS, S as buildFinalSnapshotIdentifier, Sn as resolveApp, St as STATE_SOURCED_CROSS_GENERATION_RULES, T as isFinalSnapshotError, Tn as resolveSkipPrefix, Tt as createSecretMasker, U as isExportAliasCollision, Un as resolveBucketRegion, Ut as LockManager, V as collectPublishedOutputNames, Vn as processStackMessages, Vt as DagBuilder, W as secretBearingStateKeyWarning, Wn as AwsClients, Wt as S3StateBackend, X as findActionableSilentDrops, Xt as buildAssetRedirectMap, Y as ProviderRegistry, Yn as CdkdError, Yt as WorkGraph, Z as findSilentDropProperties, Zt as createAssetRedirectResolver, _ as maskingRetryLogger, _n as getDockerImageBySourceHash, _r as isMarkedNonRetryable, _t as readConfigString, a as DeploymentEventsStore, an as getBootstrapMarkerKey, ar as PartialFailureError, at as isTerminationProtectionPropagationError, b as ATOMIC_FINAL_SNAPSHOT_TYPES, bn as getDefaultStateBucketName, br as markNonRetryable, bt as requireConfigObject, c as planRollback, cn as validateAssetBucketName, cr as ResourceUpdateNotSupportedError, ct as getAccountInfo, d as replayRollback, dn as buildDockerImage, dt as normalizeAwsTagsToCfn, en as escapeRegExp, er as LocalMigrateError, et as isInterruptedWaitError, f as updatePartialMessage, fn as formatDockerLoginError, ft as resolveExplicitPhysicalId, g as withResourceDeadline, gn as AssetManifestLoader, gr as withErrorHandling, gt as configStringRefusal, h as deleteSkipReason, hn as runDockerStreaming, hr as normalizeAwsError, ht as configBooleanRefusal, i as DeploymentEventsReader, in as ensureAssetStorage, ir as NestedStackChildDirectDestroyError, it as disableInstanceApiTermination, j as isStatefulRecreateTargetSync, jn as CFN_TEMPLATE_BODY_LIMIT, jt as s3BucketArn, k as extractDeploymentEventError, kn as stateBucketExistenceConfirmed, kt as redactSecretsForState, l as producerRegionsFromState, ln as validateContainerRepoName, lr as StackHasActiveImportsError, lt as refStateLookupFromResource, m as UNSPECIFIED_SKIP_REASON, mn as runDockerForeground, mt as coerceCfnBoolean, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as AssetModeResolver, nt as CloudControlProvider, o as classifyReplaySecretRegion, on as parseBootstrapMarker, or as ProvisioningError, ot as IntrinsicFunctionResolver, p as updatePartialReason, pn as getDockerCmd, pt as assertRegionMatch, q as collectInlinePolicyNamesManagedBySiblings, qn as setAwsClients, qt as AssetPublisher, r as DeployEngine, rn as BOOTSTRAP_MARKER_PREFIX, rr as MissingCdkCliError, rt as slowCcOperationTimeoutMs, s as planFailedOps, sn as readBootstrapMarkerBody, sr as ResourceTimeoutError, st as cfnRefValueFromPhysicalId, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as stripControlChars, tr as LocalStartServiceError, tt as startInterruptWatch, u as replayFailedOperations, un as buildDenyExternalAccessPolicy, ur as StackTerminationProtectionError, ut as WAFv2WebACLProvider, v as IMPLICIT_DELETE_DEPENDENCIES, vn as Synthesizer, vr as isRetryableTransientError, vt as replayWarn, w as createPreDeleteFinalSnapshot, wn as resolveCaptureObservedState, wt as TEMPLATE_SOURCED_RULES, x as PRE_DELETE_SNAPSHOT_TYPES, xn as getLegacyStateBucketName, xt as requireConfigString, y as computeImplicitDeleteEdges, yn as synthesisStatusMessage, yr as isThrottlingError, yt as requireConfigArray, z as yellow, zn as derivePartitionAndUrlSuffix, zt as describeTypeWithThrottleRetry } from "./deploy-engine-DP_MejgP.js";
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";
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-DWL4tZYx.js";
4
+ import { t as ASGProvider } from "./asg-provider-qMgJEhCM.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";
@@ -62212,6 +62212,62 @@ var ScrubNeededError = class ScrubNeededError extends CdkdError {
62212
62212
  }
62213
62213
  };
62214
62214
  /**
62215
+ * Every `cdkd scrub` REFUSAL — a reference this command cannot safely
62216
+ * re-resolve, and the per-stack failures that refusal produces under `--all`
62217
+ * (issue [#2109](https://github.com/go-to-k/cdkd/issues/2109) review).
62218
+ *
62219
+ * `exitCode = 2` rather than `CdkdError`'s default of 1, because 1 is already
62220
+ * SPOKEN FOR: `--fail` throws {@link ScrubNeededError} for "plaintext is in
62221
+ * state", and `docs/cli-reference.md` documents the pair as `1` (`--fail` found
62222
+ * plaintext) / `2` (error). A refusal is the second one, and left on the default
62223
+ * a CI gate reading the exit code alone could not tell "scrub looked and found a
62224
+ * leak" from "scrub refused to look" — the two call for opposite responses
62225
+ * (rotate the secret vs. re-spell the reference and re-run).
62226
+ */
62227
+ var ScrubRefusalError = class ScrubRefusalError extends CdkdError {
62228
+ exitCode = 2;
62229
+ constructor(message, code) {
62230
+ super(message, code);
62231
+ this.name = "ScrubRefusalError";
62232
+ Object.setPrototypeOf(this, ScrubRefusalError.prototype);
62233
+ }
62234
+ };
62235
+ /**
62236
+ * One failed stack's reason, INCLUDING its `cause` chain (issue #2109 review).
62237
+ *
62238
+ * `err.message` alone drops the actionable half of a wrapped failure: a
62239
+ * provider or AWS error is routinely a generic sentence over the link that
62240
+ * names the role, the bucket or the denied action. The chain is walked with
62241
+ * `errorCauseChain` rather than a local loop so the links PRINTED are exactly
62242
+ * the links `maskSecretsInError` masked at `scrubStack`'s boundary — a private
62243
+ * walk would eventually render a link past that function's depth cap, which
62244
+ * still carries its original, unmasked message.
62245
+ */
62246
+ function describeFailure(err) {
62247
+ if (!(err instanceof Error)) return String(err);
62248
+ return errorCauseChain(err).map((link, i) => i === 0 ? link.message : `\n Caused by: ${link.message}`).join("");
62249
+ }
62250
+ /**
62251
+ * The error a `--all` run ends with when one or more stacks could not be
62252
+ * scrubbed (issue #2109 review).
62253
+ *
62254
+ * Every stack is NAMED: the per-stack boundary keeps the run going, and a
62255
+ * single "3 stacks failed" line would make the operator re-run the command to
62256
+ * find out which. `exitCode = 2` for the same reason every other scrub refusal
62257
+ * carries it — `1` means "`--fail` found plaintext".
62258
+ *
62259
+ * The REASONS are deliberately not repeated here. Each was already logged at
62260
+ * `error` level, in run order, next to the progress lines for the stacks around
62261
+ * it; `handleError` then prints this message, so restating every reason made
62262
+ * the whole failure set appear TWICE in one terminal — directly under a summary
62263
+ * whose own note says "see the errors above". Names are kept because they are
62264
+ * what a reader needs to FIND those lines, and because the count alone is the
62265
+ * thing the paragraph above rejects.
62266
+ */
62267
+ function scrubStacksFailedError(failures) {
62268
+ return new ScrubRefusalError(`${failures.length} stack(s) could not be scrubbed: ${failures.map((f) => f.stackName).join(", ")}. Each one's reason was logged as it happened — see the 'Scrub of <stack> failed:' line above for it.`, "SCRUB_STACKS_FAILED");
62269
+ }
62270
+ /**
62215
62271
  * `cdkd scrub` — rewrite persisted state so any resolved secret dynamic
62216
62272
  * reference is stored as its UNRESOLVED expression rather than the plaintext
62217
62273
  * value (GHSA fix). "Secret" here is whatever the RESOLVER classifies as one,
@@ -62332,12 +62388,22 @@ async function scrubCommand(stacks, options) {
62332
62388
  const lockManager = new LockManager(stateS3.s3, stateConfig);
62333
62389
  let totalStacksScrubbed = 0;
62334
62390
  let totalStacksWithUnscrubbableKeys = 0;
62391
+ const failures = [];
62335
62392
  for (const stack of targetStacks) {
62336
- const scrubbed = await scrubStack(stack, stack.region || region, stateBackend, lockManager, {
62337
- dryRun: options.dryRun ?? false,
62338
- roleArn: options.roleArn,
62339
- logger
62340
- });
62393
+ const stackRegion = stack.region || region;
62394
+ let scrubbed;
62395
+ try {
62396
+ scrubbed = await scrubStack(stack, stackRegion, stateBackend, lockManager, {
62397
+ dryRun: options.dryRun ?? false,
62398
+ roleArn: options.roleArn,
62399
+ logger
62400
+ });
62401
+ } catch (err) {
62402
+ failures.push({ stackName: stack.stackName });
62403
+ logger.error(`Scrub of ${stack.stackName} failed: ${describeFailure(err)}`);
62404
+ if (err instanceof Error && err.stack) logger.debug("Stack trace:", err.stack);
62405
+ continue;
62406
+ }
62341
62407
  if (scrubbed.recordsChanged > 0) {
62342
62408
  totalStacksScrubbed++;
62343
62409
  logger.info(`${options.dryRun ? "Would scrub" : "Scrubbed"} ${scrubbed.recordsChanged} resource record(s) in ${stack.stackName}`);
@@ -62348,18 +62414,25 @@ async function scrubCommand(stacks, options) {
62348
62414
  }
62349
62415
  }
62350
62416
  if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0) {
62351
- logger.info("\nNo plaintext secrets found in any target stack state. Nothing to scrub.");
62352
- return;
62417
+ if (failures.length === 0) {
62418
+ logger.info("\nNo plaintext secrets found in any target stack state. Nothing to scrub.");
62419
+ return;
62420
+ }
62421
+ logger.info(`\nNo plaintext secrets found in the ${targetStacks.length - failures.length} stack(s) this run could examine. ${failures.length} stack(s) could NOT be scrubbed — see the errors above.`);
62422
+ throw scrubStacksFailedError(failures);
62353
62423
  }
62424
+ 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.` : "";
62354
62425
  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.` : "";
62355
62426
  if (options.dryRun) {
62356
- if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote} ROTATE any exposed secret in Secrets Manager.`);
62357
- else logger.info(`\nPlan: nothing can be scrubbed.${keyNote} ROTATE any exposed secret.`);
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.`);
62429
+ if (failures.length > 0) throw scrubStacksFailedError(failures);
62358
62430
  if (options.fail) throw new ScrubNeededError();
62359
62431
  return;
62360
62432
  }
62361
- 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}`);
62362
- else logger.info(`\nNothing could be rewritten.${keyNote} ROTATE any exposed secret.`);
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.`);
62435
+ if (failures.length > 0) throw scrubStacksFailedError(failures);
62363
62436
  if (options.fail && totalStacksWithUnscrubbableKeys > 0) throw new ScrubNeededError();
62364
62437
  }
62365
62438
  /**
@@ -62554,6 +62627,351 @@ function redactUnaccountedOutputs(positioned, stored, accountedKeys, secrets) {
62554
62627
  return repaired ?? positioned;
62555
62628
  }
62556
62629
  /**
62630
+ * The producer regions that are genuinely FOREIGN to this stack, canonicalized
62631
+ * and de-duplicated (issue #2109 review).
62632
+ *
62633
+ * The same filter {@link classifyReplaySecretRegion} applies to its own
62634
+ * `importedProducerRegions` argument, spelled here because the
62635
+ * unclassifiable-reference guard needs the answer WITHOUT an expression to hand
62636
+ * to the classifier. A same-region cross-stack read is the common case and
62637
+ * raises no cross-region question, so it must not arm a refusal.
62638
+ */
62639
+ function foreignRegionsOf(producerRegions, stackRegion) {
62640
+ const seen = /* @__PURE__ */ new Set();
62641
+ const foreign = [];
62642
+ for (const candidate of producerRegions) {
62643
+ const canonical = canonicalizeRegion(candidate);
62644
+ if (!canonical || canonical === canonicalizeRegion(stackRegion)) continue;
62645
+ if (seen.has(canonical)) continue;
62646
+ seen.add(canonical);
62647
+ foreign.push(candidate);
62648
+ }
62649
+ return foreign;
62650
+ }
62651
+ /**
62652
+ * The `cdkd scrub` resolvers: the stack's own, plus one pinned sibling per
62653
+ * FOREIGN region an ARN-named secret reference asks for (issue
62654
+ * [#2109](https://github.com/go-to-k/cdkd/issues/2109)).
62655
+ *
62656
+ * The same shape as `rollback-executor.ts`'s `ReplayResolvers`, and for the
62657
+ * same reasons. One instance per stack rather than per reference, because the
62658
+ * resolved-value cache lives on the resolver INSTANCE (issue #1933) — a
62659
+ * resolver per reference would re-fetch every secret once per reference. And
62660
+ * the pinned sibling is a PLAIN resolver, not a `producerRegionGuest`: it is
62661
+ * reached ONLY from a `named-region` verdict, which
62662
+ * {@link classifyReplaySecretRegion} returns only for an expression whose
62663
+ * SECRET_ID / parameter name starts with `arn:` and carries a region, so a
62664
+ * sibling only ever resolves an expression whose key EMBEDS the region it is
62665
+ * being resolved in. The process-global verdict store is keyed by the
62666
+ * expression STRING alone, and an ARN-form key cannot be shared by two regions,
62667
+ * so a sibling can never pin one region's verdict for another region's key. If
62668
+ * a future change ever routes a region-LESS expression here, that argument dies
62669
+ * with it and the sibling needs the guest flag.
62670
+ */
62671
+ var ScrubResolvers = class {
62672
+ /** The stack's own resolver — every `local` verdict resolves through this. */
62673
+ primary;
62674
+ pinned = /* @__PURE__ */ new Map();
62675
+ stackRegion;
62676
+ constructor(stackRegion) {
62677
+ this.stackRegion = stackRegion;
62678
+ this.primary = new IntrinsicFunctionResolver(stackRegion);
62679
+ }
62680
+ /** The resolver that must answer for `region` — `primary` when it is the stack's own. */
62681
+ forRegion(region) {
62682
+ const target = canonicalizeRegion(region);
62683
+ if (target === canonicalizeRegion(this.stackRegion)) return this.primary;
62684
+ const cached = this.pinned.get(target);
62685
+ if (cached) return cached;
62686
+ const scoped = new IntrinsicFunctionResolver(target);
62687
+ this.pinned.set(target, scoped);
62688
+ return scoped;
62689
+ }
62690
+ };
62691
+ /**
62692
+ * The refusal a region-AMBIGUOUS reference raises (issue #2109), the twin of
62693
+ * `rollback-executor.ts`'s `regionAmbiguousReplaySecretError`.
62694
+ *
62695
+ * A `CdkdError`, so it leaves `scrubStack` -> `scrubCommand` ->
62696
+ * `withErrorHandling` as a NON-ZERO EXIT with the message printed. That
62697
+ * visibility is the acceptance criterion of the issue, not a detail of it: the
62698
+ * one outcome that must not survive is scrub reporting success over a document
62699
+ * it did not scrub, and a debug log next to a `Done: scrubbed 0 stack(s)` line
62700
+ * IS that outcome.
62701
+ *
62702
+ * It refuses the whole STACK rather than the one reference, which is wider than
62703
+ * it needs to be and is the deliberate side of the trade. Scrubbing the rest
62704
+ * and reporting success would leave the operator with "no plaintext secrets
62705
+ * found" over state that still holds one — the failure this command exists to
62706
+ * prevent, arriving through the remediation. Names the reference, both regions
62707
+ * and the remedy so the refusal is actionable.
62708
+ *
62709
+ * Never the resolved value: nothing has been resolved for this reference, and
62710
+ * the expression is the same string `state.json` already stores in the clear.
62711
+ */
62712
+ function regionAmbiguousScrubSecretError(origin, stackName, secretName, foreignProducerRegions, stackRegion) {
62713
+ return new ScrubRefusalError(`Scrub of ${stackName} cannot re-resolve the secret reference '${secretName}' in ${origin}: the reference carries no region of its own, and this stack read across a region boundary (producer region(s) on record: ${foreignProducerRegions.join(", ")}), so it may have been resolved in one of those rather than in '${stackRegion}'. A secret of the same name in two regions is two independent values, so scrub would look for the WRONG plaintext — leaving the real one in state while reporting the stack clean, and using the foreign value as a needle that can rewrite an unrelated literal. Refusing instead. Spell the reference as a full ARN, which names its region and is resolved there, then re-run 'cdkd scrub'.`, "SCRUB_SECRET_REGION_AMBIGUOUS");
62714
+ }
62715
+ /**
62716
+ * The refusal a `named-region` reference raises when its OWN region cannot
62717
+ * answer for it (issue #2109).
62718
+ *
62719
+ * Continuing would hand the leaf back to the stack's own resolver, which is
62720
+ * issue #2109 verbatim — so this fails closed for the same reason the ambiguous
62721
+ * arm does. The cause is echoed because it is the actionable half (a denied
62722
+ * read in the producer region reads very differently from a missing secret) —
62723
+ * but MASKED against everything this run has recorded so far (issue #2109
62724
+ * review). No resolver error carries a plaintext today; the rule on this repo is
62725
+ * that a path which interpolates a foreign message into user-visible text masks
62726
+ * it anyway, because the day one does is the day the remediation command prints
62727
+ * the secret it exists to remove. The map is the SAME one the resolution records
62728
+ * into, so a value recorded a moment earlier in this very leaf is covered.
62729
+ */
62730
+ function unresolvableForeignScrubSecretError(origin, stackName, secretName, region, cause, secrets) {
62731
+ return new ScrubRefusalError(`Scrub of ${stackName} could not resolve the secret reference '${secretName}' in ${origin} in the region its ARN names ('${region}'): ${maskSecretsInText(cause instanceof Error ? cause.message : String(cause), secrets)}. Refusing rather than resolving it in the stack's own region, which would look for a different secret's value and report the stack clean over state that still holds the plaintext.`, "SCRUB_CROSS_REGION_SECRET_UNRESOLVED");
62732
+ }
62733
+ /**
62734
+ * The literal that OPENS a dynamic reference. Used as the CHEAP pre-filter that
62735
+ * decides whether a leaf is walked at all — see {@link pinCrossRegionSecrets}.
62736
+ */
62737
+ const DYNAMIC_REFERENCE_OPENING = "{{resolve:";
62738
+ /**
62739
+ * The openings the assembled-reference guard actually COUNTS: `{{resolve:`
62740
+ * followed by a service whose VALUE CloudFormation defines as a secret. Counted
62741
+ * against the number of WHOLE tokens of the same class the scan matched, which
62742
+ * is how an ASSEMBLED reference is detected without parsing intrinsics — see
62743
+ * {@link unclassifiableScrubSecretError}.
62744
+ *
62745
+ * WHY THE SERVICE IS PART OF THE OPENING (issue #2109 review). Counting the bare
62746
+ * `{{resolve:` made the guard fire on any leaf that merely MENTIONS the syntax
62747
+ * — measured: `Use the {{resolve: prefix for dynamic references` and
62748
+ * `{{resolve:}}` both count one opening and zero tokens. A description, an IAM
62749
+ * policy document, a UserData script or an environment variable saying that is
62750
+ * ordinary, and the refusal is permanent for the whole stack (exit 2, no bypass
62751
+ * flag) with a remedy — "spell the reference as one complete literal" — that is
62752
+ * unactionable for prose. Requiring the service makes the count a count of
62753
+ * SECRET references rather than of the characters `{{resolve:`.
62754
+ *
62755
+ * `secretsmanager` / `ssm` mirror `REPLAY_SECRET_SERVICES` in
62756
+ * `rollback-executor.ts`, whose {@link classifyReplaySecretRegion} is what
62757
+ * classifies the tokens below — the same set on both sides, so the count and
62758
+ * the classification never disagree about what is in scope. `ssm-secure` is
62759
+ * added on top: it IS a CloudFormation secret spelling, and although cdkd does
62760
+ * not resolve it today (the resolver's unsupported-service arm leaves it
62761
+ * verbatim, so it can never become a wrong-region needle), counting it costs
62762
+ * only an assembled `ssm-secure` reference in a cross-region stack and keeps
62763
+ * the guard correct the day that arm changes.
62764
+ *
62765
+ * THE TRADE, stated rather than hidden: an `Fn::Join` that splits BEFORE the
62766
+ * service name (`['{{resolve:', 'secretsmanager:db:SecretString:pw}}']`) has no
62767
+ * counted opening in either part and stops being caught by this guard. The part
62768
+ * carrying the service has no `{{resolve:` at all, so `pinCrossRegionSecrets`
62769
+ * returns it by identity too. That shape falls to the same resolver-side
62770
+ * classification issue [#2134](https://github.com/go-to-k/cdkd/issues/2134)
62771
+ * tracks.
62772
+ */
62773
+ const SECRET_REFERENCE_OPENINGS = [
62774
+ `${DYNAMIC_REFERENCE_OPENING}secretsmanager:`,
62775
+ `${DYNAMIC_REFERENCE_OPENING}ssm:`,
62776
+ `${DYNAMIC_REFERENCE_OPENING}ssm-secure:`
62777
+ ];
62778
+ /**
62779
+ * What an `Fn::Sub` placeholder opens with. A whole token that CONTAINS one is
62780
+ * the third assembled shape — see {@link unclassifiableScrubSecretError}.
62781
+ */
62782
+ const SUB_PLACEHOLDER_OPENING = "${";
62783
+ /** How many SECRET references {@link SECRET_REFERENCE_OPENINGS} `leaf` opens. */
62784
+ function countSecretReferenceOpenings(leaf) {
62785
+ let count = 0;
62786
+ for (const opening of SECRET_REFERENCE_OPENINGS) count += leaf.split(opening).length - 1;
62787
+ return count;
62788
+ }
62789
+ /** Whether a WHOLE token the scan matched is one of the secret spellings. */
62790
+ function isSecretReferenceToken(token) {
62791
+ return SECRET_REFERENCE_OPENINGS.some((opening) => token.startsWith(opening));
62792
+ }
62793
+ /**
62794
+ * The refusal an ASSEMBLED reference raises (issue #2109 review): a leaf that
62795
+ * OPENS more SECRET references than the scan found COMPLETE tokens of that
62796
+ * class in it, or a whole token that still carries an `Fn::Sub` placeholder.
62797
+ *
62798
+ * WHY THIS EXISTS, and it is the load-bearing half of this pre-pass rather than
62799
+ * an edge case. The region split runs on the RAW template leaf, BEFORE intrinsic
62800
+ * resolution, and the shared token scan is `\{\{resolve:[^}]+\}\}` — a class
62801
+ * that cannot cross a `}`. So none of the three shapes that assemble a
62802
+ * reference out of parts yields the ONE WHOLE token a literal reference does,
62803
+ * and the third one is why this guard needs two tests rather than one
62804
+ * (all four rows MEASURED):
62805
+ *
62806
+ * ```text
62807
+ * "{{resolve:secretsmanager:my-secret:SecretString:pw}}" 1 opening, 1 token literal
62808
+ * "{{resolve:secretsmanager:${Env}-db:SecretString:password}}" 1 opening, 0 tokens Fn::Sub, MID-string
62809
+ * "{{resolve:secretsmanager:" (one Fn::Join part) 1 opening, 0 tokens Fn::Join split
62810
+ * "{{resolve:secretsmanager:x:SecretString:${Field}}}" 1 opening, 1 token Fn::Sub, TRAILING
62811
+ * ```
62812
+ *
62813
+ * The first two assembled rows are caught by the COUNT. The fourth is not, and
62814
+ * the count can never catch it: `[^}]+` stops at the `}` of `${Field}` and the
62815
+ * `}}` that follows closes the match one brace short, so the scan returns
62816
+ * `"{{resolve:secretsmanager:x:SecretString:${Field}"` + `}` — a token, of the
62817
+ * right class, exactly one per opening. It is caught instead by the second
62818
+ * test: a WHOLE token that still contains `${` has not been assembled yet.
62819
+ * Neither a Secrets Manager secret name nor an SSM parameter name may contain
62820
+ * `$` or `{`, so the only false positive that test can produce is a JSON key
62821
+ * literally spelled `${...}` in a leaf that is NOT under an `Fn::Sub` — and
62822
+ * this guard is the fail-closed side of a command whose subject is a leaked
62823
+ * plaintext.
62824
+ *
62825
+ * With no token there is nothing to classify: {@link pinCrossRegionSecrets}
62826
+ * returns the leaf BY IDENTITY, and `resolveJoin` / `resolveSub` then call
62827
+ * `resolveDynamicReferences` on the PRIMARY resolver once they have joined the
62828
+ * parts — issue #2109 verbatim, with the `ambiguous` refusal never firing. The
62829
+ * trailing-placeholder row got there a different way before this guard's second
62830
+ * test: the token WAS classified, and only downstream luck kept it safe (the
62831
+ * name form then classifies `ambiguous`, the ARN form fails into
62832
+ * `SCRUB_CROSS_REGION_SECRET_UNRESOLVED` because the id it looks up still has a
62833
+ * literal `${Field}` in it). Both are pinned by the suite rather than assumed.
62834
+ * Fixing all of this inside the resolver (classify AFTER assembly) is the
62835
+ * structurally right answer and is NOT what this does: the resolver's
62836
+ * dynamic-reference path is shared with deploy and is out of this change's
62837
+ * scope. This guard buys the same SAFETY property — a reference whose region
62838
+ * cannot be established is never resolved in the stack's own region — by
62839
+ * refusing instead.
62840
+ *
62841
+ * GATED ON A FOREIGN PRODUCER REGION BEING ON RECORD, which is what keeps it
62842
+ * from refusing the world. With no foreign region on record there is no
62843
+ * cross-region question to get wrong: every reference in the stack resolves
62844
+ * locally, which is what an assembled one would have done anyway, so proceeding
62845
+ * is exactly today's behavior. Two residuals, stated rather than hidden:
62846
+ * an assembled reference that names a FOREIGN ARN inside an `Fn::Sub` in a
62847
+ * stack with NO cross-stack read on record is still resolved locally; and an
62848
+ * `Fn::Join` that splits BEFORE the service name is not counted at all (see
62849
+ * {@link SECRET_REFERENCE_OPENINGS}). Closing either needs the resolver-side
62850
+ * classification above — issue
62851
+ * [#2134](https://github.com/go-to-k/cdkd/issues/2134).
62852
+ */
62853
+ function unclassifiableScrubSecretError(origin, leafPath, stackName, foreignProducerRegions, stackRegion) {
62854
+ return new ScrubRefusalError(`Scrub of ${stackName} cannot classify a secret reference in ${origin}${leafPath ? ` at '${leafPath}'` : ""}: the value opens a '{{resolve:...}}' reference that is ASSEMBLED by an intrinsic (an Fn::Sub placeholder inside it, or an Fn::Join that splits it across parts), so the reference does not exist as a complete expression until AFTER it is resolved — and its region therefore cannot be determined before resolution. This stack read across a region boundary (producer region(s) on record: ${foreignProducerRegions.join(", ")}), so resolving it in '${stackRegion}' may look for a different region's secret: scrub would then miss the plaintext it exists to remove and report the stack clean. Refusing instead. Spell the reference as one complete literal '{{resolve:secretsmanager:<full ARN>:SecretString:...}}' — an ARN names its own region — then re-run 'cdkd scrub'.`, "SCRUB_SECRET_REFERENCE_UNCLASSIFIABLE");
62855
+ }
62856
+ /**
62857
+ * Resolve every FOREIGN-region `{{resolve:...}}` reference in one leaf through
62858
+ * a resolver pinned to the region the expression NAMES, and refuse the ones
62859
+ * whose region cannot be established (issue #2109).
62860
+ *
62861
+ * The returned string is what the primary resolver is handed, so a foreign
62862
+ * reference is already a value by the time the stack's own region sees the
62863
+ * leaf — the local endpoint is never asked about it at all. A leaf with no
62864
+ * foreign reference is returned BY IDENTITY and takes exactly the pre-#2109
62865
+ * path: `resolveDynamicReferences` has well-tested substitution semantics (it
62866
+ * collects matches from the ORIGINAL string, so a resolved plaintext that is
62867
+ * itself token-shaped is never re-resolved — issue #1917) and this change does
62868
+ * not relitigate any of it.
62869
+ *
62870
+ * That #1917 guarantee does NOT cross this seam — the primary receives a string
62871
+ * this function already substituted into — so the result is re-scanned and a
62872
+ * token the substitution INTRODUCED is refused rather than passed on. Fail
62873
+ * closed: a stopped scrub is recoverable, a lookup for a secret id spliced
62874
+ * together out of a plaintext is not.
62875
+ */
62876
+ async function resolveForeignRegionTokens(leaf, stackName, ctx, leafPath) {
62877
+ const tokens = dynamicReferenceTokens(leaf);
62878
+ const secretTokens = tokens.filter(isSecretReferenceToken);
62879
+ if ((countSecretReferenceOpenings(leaf) !== secretTokens.length || secretTokens.some((token) => token.includes(SUB_PLACEHOLDER_OPENING))) && ctx.foreignProducerRegions.length > 0) throw unclassifiableScrubSecretError(ctx.origin, leafPath, stackName, ctx.foreignProducerRegions, ctx.stackRegion);
62880
+ const verdicts = tokens.map((token) => [token, classifyReplaySecretRegion(token, ctx.stackRegion, ctx.producerRegions)]);
62881
+ for (const [, verdict] of verdicts) if (verdict.kind === "ambiguous") throw regionAmbiguousScrubSecretError(ctx.origin, stackName, verdict.secretName, verdict.foreignProducerRegions, ctx.stackRegion);
62882
+ if (!verdicts.some(([, verdict]) => verdict.kind === "named-region")) return leaf;
62883
+ const localTokens = [];
62884
+ let out = "";
62885
+ let cursor = 0;
62886
+ for (const [token, verdict] of verdicts) {
62887
+ const at = leaf.indexOf(token, cursor);
62888
+ if (at < 0) throw new ScrubRefusalError(`Scrub of ${stackName} could not locate a scanned dynamic reference in ${ctx.origin}, in the value it was scanned from. Refusing rather than resolving it in '${ctx.stackRegion}', which would be the wrong region for a reference that names another one. This is an internal invariant failure — please report it with the resource type and property path.`, "SCRUB_SECRET_TOKEN_SCAN_MISMATCH");
62889
+ out += leaf.slice(cursor, at);
62890
+ if (verdict.kind === "named-region") {
62891
+ const resolver = ctx.resolvers.forRegion(verdict.region);
62892
+ try {
62893
+ out += await resolver.resolveDynamicReferences(token, {
62894
+ template: { Resources: {} },
62895
+ resources: {},
62896
+ recordedSecretValues: ctx.recordedSecretValues
62897
+ });
62898
+ } catch (err) {
62899
+ throw unresolvableForeignScrubSecretError(ctx.origin, stackName, verdict.secretName, verdict.region, err, ctx.recordedSecretValues);
62900
+ }
62901
+ } else {
62902
+ out += token;
62903
+ localTokens.push(token);
62904
+ }
62905
+ cursor = at + token.length;
62906
+ }
62907
+ out += leaf.slice(cursor);
62908
+ const surviving = dynamicReferenceTokens(out);
62909
+ if (surviving.length !== localTokens.length || surviving.some((token, i) => token !== localTokens[i])) throw new ScrubRefusalError(`Scrub of ${stackName} refused ${ctx.origin}: resolving a cross-region secret reference produced a value that is itself dynamic-reference shaped, which the stack's own resolver would then resolve as if it were a reference of this stack's. Refusing rather than passing it on.`, "SCRUB_SECRET_RESOLUTION_REINTRODUCED_TOKEN");
62910
+ return out;
62911
+ }
62912
+ /**
62913
+ * Rewrite one template bag so every FOREIGN-region secret reference in it is
62914
+ * already resolved — in the region the expression NAMES — before the stack's
62915
+ * own resolver walks it (issue
62916
+ * [#2109](https://github.com/go-to-k/cdkd/issues/2109)).
62917
+ *
62918
+ * WHY SCRUB NEEDS THIS AT ALL. `scrubStack` learns which plaintexts to look for
62919
+ * by RE-RESOLVING the template through one resolver built from the stack's own
62920
+ * region, and `resolveSecretsManagerReference` builds its client from that
62921
+ * region and passes the SECRET_ID through as an opaque string — the AWS SDK's
62922
+ * endpoint ruleset has no ARN-derived endpoint rule, so a reference naming
62923
+ * another region's ARN is sent to this stack's regional endpoint. Both halves
62924
+ * of that go wrong at once, and the second is the one that is easy to miss: the
62925
+ * plaintext scrub exists to remove is never found (the needle is the wrong
62926
+ * region's value), so the command reports the stack CLEAN over state that still
62927
+ * holds the secret; and the foreign value is a real string, so scanning for it
62928
+ * can rewrite an unrelated stored literal that happens to coincide.
62929
+ *
62930
+ * The split is `classifyReplaySecretRegion`'s, imported from
62931
+ * `rollback-executor.ts` rather than re-spelled — one answer to "which region
62932
+ * must answer for this expression", shared by the rollback replay (#2057) and
62933
+ * by scrub. Its known OVER-refusal is inherited with it: a name-form reference
62934
+ * in a stack with ANY foreign producer region on record is refused even when it
62935
+ * is the stack's own purely-local secret, because the evidence
62936
+ * (`state.imports` / `state.outputReads`) is per-STACK and not per-reference.
62937
+ * That is the fail-closed side of a trade whose other side is a silently
62938
+ * unscrubbed state file.
62939
+ *
62940
+ * Returns the bag BY IDENTITY when nothing was foreign, which is every bag on
62941
+ * every existing code path. The caller keeps the ORIGINAL bag as its position
62942
+ * source either way — a substituted copy must never reach
62943
+ * `redactSecretsForState`, whose whole job is to read UNRESOLVED expressions
62944
+ * off it.
62945
+ */
62946
+ async function pinCrossRegionSecrets(bag, stackName, ctx) {
62947
+ const walk = async (v, path) => {
62948
+ if (typeof v === "string") {
62949
+ if (!v.includes(DYNAMIC_REFERENCE_OPENING)) return v;
62950
+ return await resolveForeignRegionTokens(v, stackName, ctx, path);
62951
+ }
62952
+ if (Array.isArray(v)) {
62953
+ const out = new Array(v.length);
62954
+ let changed = false;
62955
+ for (let i = 0; i < v.length; i++) {
62956
+ out[i] = await walk(v[i], `${path}[${i}]`);
62957
+ if (out[i] !== v[i]) changed = true;
62958
+ }
62959
+ return changed ? out : v;
62960
+ }
62961
+ if (v !== null && typeof v === "object") {
62962
+ const out = Object.create(null);
62963
+ let changed = false;
62964
+ for (const [k, val] of Object.entries(v)) {
62965
+ out[k] = await walk(val, path ? `${path}.${k}` : k);
62966
+ if (out[k] !== val) changed = true;
62967
+ }
62968
+ return changed ? out : v;
62969
+ }
62970
+ return v;
62971
+ };
62972
+ return await walk(bag, "");
62973
+ }
62974
+ /**
62557
62975
  * Scrub one stack's state. Re-resolves the template's per-resource properties to
62558
62976
  * learn the resolved secret VALUES, then replaces those values in the state
62559
62977
  * record with their `{{resolve:...}}` expressions. Returns counts; performs no
@@ -62570,6 +62988,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
62570
62988
  const { logger } = opts;
62571
62989
  const acquired = !opts.dryRun;
62572
62990
  if (acquired) await lockManager.acquireLockWithRetry(stack.stackName, region, void 0, "scrub");
62991
+ const perResourceSecrets = /* @__PURE__ */ new Map();
62992
+ const outputSecrets = /* @__PURE__ */ new Map();
62573
62993
  try {
62574
62994
  const loaded = await stateBackend.getState(stack.stackName, region);
62575
62995
  if (!loaded) {
@@ -62581,11 +63001,12 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
62581
63001
  };
62582
63002
  }
62583
63003
  const state = loaded.state;
62584
- const perResourceSecrets = /* @__PURE__ */ new Map();
62585
63004
  const perResourceTemplateProps = /* @__PURE__ */ new Map();
62586
- const outputSecrets = /* @__PURE__ */ new Map();
62587
63005
  const outputsTemplateSource = {};
62588
- const resolver = new IntrinsicFunctionResolver(region);
63006
+ const resolvers = new ScrubResolvers(region);
63007
+ const resolver = resolvers.primary;
63008
+ const producerRegions = producerRegionsFromState(state);
63009
+ const foreignProducerRegions = foreignRegionsOf(producerRegions, region);
62589
63010
  let parameters = {};
62590
63011
  let conditions = {};
62591
63012
  try {
@@ -62608,8 +63029,16 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
62608
63029
  const templateResource = templateResources[logicalId];
62609
63030
  if (!templateResource?.Properties) continue;
62610
63031
  const recordedSecretValues = /* @__PURE__ */ new Map();
63032
+ const resolveInput = await pinCrossRegionSecrets(templateResource.Properties, stack.stackName, {
63033
+ stackRegion: region,
63034
+ producerRegions,
63035
+ foreignProducerRegions,
63036
+ resolvers,
63037
+ recordedSecretValues,
63038
+ origin: `resource '${logicalId}'`
63039
+ });
62611
63040
  try {
62612
- await resolver.resolve(templateResource.Properties, {
63041
+ await resolver.resolve(resolveInput, {
62613
63042
  template: stack.template,
62614
63043
  resources: state.resources,
62615
63044
  ...Object.keys(parameters).length > 0 && { parameters },
@@ -62619,7 +63048,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
62619
63048
  bestEffort: true
62620
63049
  });
62621
63050
  } catch (err) {
62622
- logger.debug(`Resolution of ${logicalId} during scrub was partial: ${err instanceof Error ? err.message : String(err)}`);
63051
+ logger.debug(`Resolution of ${logicalId} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}`);
62623
63052
  }
62624
63053
  if (recordedSecretValues.size > 0) perResourceSecrets.set(logicalId, recordedSecretValues);
62625
63054
  perResourceTemplateProps.set(logicalId, templateResource.Properties);
@@ -62633,19 +63062,29 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
62633
63062
  for (const [name, output] of Object.entries(templateOutputs)) {
62634
63063
  const declaredExportName = output.Export?.Name;
62635
63064
  let exportName = declaredExportName;
62636
- if (declaredExportName !== void 0 && typeof declaredExportName !== "string") try {
62637
- exportName = await resolver.resolve(declaredExportName, {
62638
- template: stack.template,
62639
- resources: state.resources,
62640
- ...Object.keys(parameters).length > 0 && { parameters },
62641
- ...Object.keys(conditions).length > 0 && { conditions },
62642
- stackName: stack.stackName,
63065
+ if (declaredExportName !== void 0 && typeof declaredExportName !== "string") {
63066
+ const nameSource = await pinCrossRegionSecrets(declaredExportName, stack.stackName, {
63067
+ stackRegion: region,
63068
+ producerRegions,
63069
+ foreignProducerRegions,
63070
+ resolvers,
62643
63071
  recordedSecretValues: outputSecrets,
62644
- bestEffort: true
63072
+ origin: `Export.Name of output '${name}'`
62645
63073
  });
62646
- } catch (err) {
62647
- outputsSourceUntrusted = true;
62648
- logger.warn(`Export.Name of output ${name} could not be resolved during scrub (${err instanceof Error ? err.message : String(err)}) — 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.`);
63074
+ 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
+ });
63084
+ } catch (err) {
63085
+ outputsSourceUntrusted = true;
63086
+ 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.`);
63087
+ }
62649
63088
  }
62650
63089
  if (declaredExportName !== void 0 && typeof exportName !== "string") outputsSourceUntrusted = true;
62651
63090
  const exportNameUnresolved = declaredExportName !== void 0 && typeof exportName === "string" && isUnresolvedValue(exportName, templateUsesSub(declaredExportName));
@@ -62664,8 +63103,16 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
62664
63103
  if (!ambiguousKeys.has(name)) outputsTemplateSource[name] = value;
62665
63104
  const exportName = output.Export?.Name;
62666
63105
  if (typeof exportName === "string" && !ambiguousKeys.has(exportName)) outputsTemplateSource[exportName] = value;
63106
+ const valueSource = await pinCrossRegionSecrets(value, stack.stackName, {
63107
+ stackRegion: region,
63108
+ producerRegions,
63109
+ foreignProducerRegions,
63110
+ resolvers,
63111
+ recordedSecretValues: outputSecrets,
63112
+ origin: `output '${name}'`
63113
+ });
62667
63114
  try {
62668
- await resolver.resolve(value, {
63115
+ await resolver.resolve(valueSource, {
62669
63116
  template: stack.template,
62670
63117
  resources: state.resources,
62671
63118
  ...Object.keys(parameters).length > 0 && { parameters },
@@ -62675,7 +63122,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
62675
63122
  bestEffort: true
62676
63123
  });
62677
63124
  } catch (err) {
62678
- logger.debug(`Resolution of output ${name} during scrub was partial: ${err instanceof Error ? err.message : String(err)}`);
63125
+ logger.debug(`Resolution of output ${name} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}`);
62679
63126
  }
62680
63127
  }
62681
63128
  for (const [name, exportName] of collisions) logger.warn(exportAliasCollisionScrubWarning(name, exportName, outputSecrets));
@@ -62706,7 +63153,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
62706
63153
  if (JSON.stringify(scrubbed) !== JSON.stringify(record)) recordsChanged++;
62707
63154
  newResources[logicalId] = scrubbed;
62708
63155
  }
62709
- const newOutputs = redactUnaccountedOutputs(outputSecrets.size > 0 ? redactSecretsForState(state.outputs, outputSecrets, outputsSourceUntrusted ? void 0 : outputsTemplateSource) : state.outputs, state.outputs, accountedOutputKeys, allRecordedSecrets(outputSecrets, perResourceSecrets));
63156
+ const newOutputs = redactUnaccountedOutputs(outputSecrets.size > 0 ? redactSecretsForState(state.outputs, outputSecrets, outputsSourceUntrusted ? void 0 : outputsTemplateSource, TEMPLATE_SOURCED_RULES) : state.outputs, state.outputs, accountedOutputKeys, allRecordedSecrets(outputSecrets, perResourceSecrets));
62710
63157
  if (JSON.stringify(newOutputs) !== JSON.stringify(state.outputs)) recordsChanged++;
62711
63158
  if (recordsChanged > 0 && !opts.dryRun) {
62712
63159
  const nextState = {
@@ -62722,6 +63169,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
62722
63169
  secretsFound: totalSecrets,
62723
63170
  secretBearingKeys: secretBearingKeys.length
62724
63171
  };
63172
+ } catch (err) {
63173
+ throw maskSecretsInError(err, allRecordedSecrets(outputSecrets, perResourceSecrets));
62725
63174
  } finally {
62726
63175
  if (acquired) await lockManager.releaseLock(stack.stackName, region).catch((err) => {
62727
63176
  logger.warn(`Failed to release lock for ${stack.stackName}: ${err instanceof Error ? err.message : String(err)}`);
@@ -63632,6 +64081,30 @@ function stripWalk(value) {
63632
64081
  //#endregion
63633
64082
  //#region src/cli/commands/drift.ts
63634
64083
  /**
64084
+ * Route one outcome to the handler for its variant.
64085
+ *
64086
+ * The mechanism issue [#2135](https://github.com/go-to-k/cdkd/issues/2135) asks
64087
+ * for, and the reason every consumer in this file goes through it rather than
64088
+ * filtering on `o.kind === '...'`: `handlers` is a MAPPED TYPE over
64089
+ * `DriftOutcome['kind']`, so it must name EVERY variant. Adding a member to the
64090
+ * union turns every call site that has not been updated into a compile error
64091
+ * ("property '<kind>' is missing"), instead of leaving it to fall through to
64092
+ * whatever it happened to do before. A `.filter()` predicate cannot do that —
64093
+ * it keeps compiling and silently stops matching the new state.
64094
+ *
64095
+ * A handler that deliberately does nothing with a variant still has to be
64096
+ * written out, which is the point: the author of the next variant has to decide,
64097
+ * per consumer, what it means there.
64098
+ *
64099
+ * Exported, with {@link DriftOutcome} and {@link NotComparedCause}, so
64100
+ * `tests/unit/cli/drift-outcome.test-d.ts` can fence the mapped type itself:
64101
+ * relaxing it to a `Partial` record would silently un-fence every consumer in
64102
+ * this file while changing no runtime behaviour, so no runtime test can see it.
64103
+ */
64104
+ function matchOutcome(outcome, handlers) {
64105
+ return handlers[outcome.kind](outcome);
64106
+ }
64107
+ /**
63635
64108
  * Distinguish "drift detected" (exit 1) from "command crashed" (exit 1
63636
64109
  * via the default handler) so the drift command can fail fast and the
63637
64110
  * top-level handler doesn't add a stack trace for the expected case.
@@ -63656,18 +64129,19 @@ var DriftDetectedError = class DriftDetectedError extends CdkdError {
63656
64129
  * `{{resolve:ssm-secure:...}}` token — a pre-existing population cdkd resolves
63657
64130
  * for nobody, which can never clear on a re-run, and which this change did not
63658
64131
  * create. Exiting non-zero for them would break `cdkd drift` in CI forever over
63659
- * an unrelated defect, so the exit reads `comparisonRefused` and the report
63660
- * reads `referencesUnresolved`. See that field's note.
64132
+ * an unrelated defect, so the exit reads the `refused` cause alone while the
64133
+ * report covers both. See `NotComparedCause` and `outcomeExitSignal`.
63661
64134
  *
63662
64135
  * WHY A NON-ZERO EXIT, and why it is a PRESERVATION rather than a new signal.
63663
64136
  * Pre-#2108 that population resolved the reference in the WRONG region, the
63664
64137
  * baseline could never equal what AWS held, and the resource was reported
63665
64138
  * `drifted` -- so `cdkd drift` exited 1 and every CI gate keyed on the exit code
63666
64139
  * fired. #2108 correctly stopped comparing against a foreign region's plaintext,
63667
- * which made the same population report `clean`; without this the command would
63668
- * exit 0 and print `no drift detected` for a stack whose secret-bearing
63669
- * properties were never looked at. Round 1 of that change surfaced the refusal
63670
- * in `--json` (`notCompared`) and in the human report, but the exit code -- the
64140
+ * which made the same population report `notCompared`; without this the
64141
+ * command would exit 0 and print `no drift detected` for a stack whose
64142
+ * secret-bearing properties were never looked at. Round 1 of that change
64143
+ * surfaced the refusal in `--json` (`notCompared`) and in the human report,
64144
+ * but the exit code -- the
63671
64145
  * signal most CI gates actually read -- still said "pass".
63672
64146
  *
63673
64147
  * WHY EXIT 2 RATHER THAN 1. `2` is this repo's established "work completed but
@@ -63704,32 +64178,56 @@ var DriftComparisonRefusedError = class DriftComparisonRefusedError extends Cdkd
63704
64178
  }
63705
64179
  };
63706
64180
  /**
63707
- * Every outcome on one report that was only PARTIALLY compared -- `drifted` or
63708
- * `clean`, carrying `referencesUnresolved` (issue #2108).
64181
+ * Every outcome on one report that was only PARTIALLY compared -- the
64182
+ * `notCompared` variant, plus a `drifted` one whose `notComparedCause` is set
64183
+ * (issues #2108 / #2135).
63709
64184
  *
63710
- * ONE spelling, because there are now THREE readers that must agree: the
63711
- * `--json` `notCompared` roll-up, the human report's `PARTIALLY compared` block,
63712
- * and the EXIT CODE. A predicate spelled per reader is how the payload and the
63713
- * exit code come to disagree about the same run.
64185
+ * ONE spelling, because TWO renderings must agree about the same run: the
64186
+ * `--json` `notCompared` roll-up and the human report's `PARTIALLY compared`
64187
+ * block (whose count is also what the `N of M fully checked` line subtracts).
64188
+ * A roll-up spelled per reader is how the payload and the human summary come to
64189
+ * disagree.
64190
+ *
64191
+ * The EXIT CODE deliberately does NOT read this — see {@link outcomeExitSignal}
64192
+ * and {@link NotComparedCause} for why it is scoped to refusals alone.
63714
64193
  */
63715
64194
  function notComparedOutcomes(report) {
63716
- return report.outcomes.filter((o) => (o.kind === "drifted" || o.kind === "clean") && o.referencesUnresolved);
64195
+ return report.outcomes.flatMap((o) => matchOutcome(o, {
64196
+ drifted: (d) => d.notComparedCause === void 0 ? [] : [d],
64197
+ notCompared: (n) => [n],
64198
+ clean: () => [],
64199
+ unsupported: () => [],
64200
+ skipped: () => []
64201
+ }));
63717
64202
  }
63718
64203
  /**
63719
- * Every outcome whose comparison cdkd DELIBERATELY REFUSED (issue #2108) the
63720
- * strict subset of {@link notComparedOutcomes} that drives the EXIT CODE.
63721
- *
63722
- * Deliberately NOT the same predicate, and the asymmetry is the point: the
63723
- * REPORT covers everything that was not compared, because as information both
63724
- * populations matter equally, while the exit code covers only the population
63725
- * this change created. Driving it off `referencesUnresolved` would exit
63726
- * non-zero forever for every stack holding an `{{resolve:ssm-secure:...}}`
63727
- * reference permanent, unclearable, and unrelated to this issue. See the
63728
- * `comparisonRefused` field note, and issue
63729
- * [#2135](https://github.com/go-to-k/cdkd/issues/2135) for the structural fix.
63730
- */
63731
- function refusedComparisonOutcomes(report) {
63732
- return report.outcomes.filter((o) => (o.kind === "drifted" || o.kind === "clean") && o.comparisonRefused);
64204
+ * What one outcome contributes to the run's EXIT CODE (issues #2108 / #2135).
64205
+ *
64206
+ * The exit-code CONSUMER, kept as an exhaustive `matchOutcome` so a new outcome
64207
+ * variant cannot inherit `none` by omission reporting a resource cdkd never
64208
+ * compared as a pass is precisely the round-2 defect #2135 exists to make
64209
+ * impossible.
64210
+ *
64211
+ * `refused` is a STRICT SUBSET of what {@link notComparedOutcomes} rolls up, and
64212
+ * the asymmetry is the point: the REPORT covers everything that was not
64213
+ * compared, because as information both causes matter equally, while the exit
64214
+ * code covers only the population #2108 created. Exiting non-zero on
64215
+ * `unresolvedToken` would break `cdkd drift` in CI forever for every stack
64216
+ * holding an `{{resolve:ssm-secure:...}}` reference — permanent, unclearable,
64217
+ * and unrelated. See {@link NotComparedCause}.
64218
+ *
64219
+ * A `drifted` outcome reports `drifted` even when its own comparison was
64220
+ * refused: drift is the stronger, actionable signal and the caller ranks it
64221
+ * first, so a consumer gating on `1` loses nothing it gets today.
64222
+ */
64223
+ function outcomeExitSignal(outcome) {
64224
+ return matchOutcome(outcome, {
64225
+ drifted: () => "drifted",
64226
+ notCompared: (n) => n.notComparedCause === "refused" ? "refused" : "none",
64227
+ clean: () => "none",
64228
+ unsupported: () => "none",
64229
+ skipped: () => "none"
64230
+ });
63733
64231
  }
63734
64232
  /**
63735
64233
  * `cdkd drift [<stack>...]` command implementation.
@@ -63799,8 +64297,9 @@ async function driftCommand(stacks, options) {
63799
64297
  }
63800
64298
  if (options.json) writeJsonReport(reports);
63801
64299
  else writeHumanReport(reports);
63802
- const drifted = reports.some((r) => r.outcomes.some((o) => o.kind === "drifted"));
63803
- const anyRefused = reports.some((r) => refusedComparisonOutcomes(r).length > 0);
64300
+ const signals = reports.flatMap((r) => r.outcomes.map(outcomeExitSignal));
64301
+ const drifted = signals.includes("drifted");
64302
+ const anyRefused = signals.includes("refused");
63804
64303
  if (!options.accept && !options.revert) {
63805
64304
  if (drifted) throw new DriftDetectedError();
63806
64305
  if (anyRefused) throw new DriftComparisonRefusedError();
@@ -64504,14 +65003,17 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
64504
65003
  unionWalkObjects: useObserved,
64505
65004
  unorderedPaths
64506
65005
  }), secrets, secretResolutionFailed ? seededSecretPaths : secretPaths);
64507
- const referencesUnresolved = secretResolutionFailed || unresolvedTokens.size > 0;
64508
- const comparisonRefused = secretResolutionFailed;
64509
- if (reported.changes.length === 0) outcomes.push({
64510
- kind: "clean",
65006
+ const notComparedCause = secretResolutionFailed ? "refused" : unresolvedTokens.size > 0 ? "unresolvedToken" : void 0;
65007
+ if (reported.changes.length === 0) if (notComparedCause !== void 0) outcomes.push({
65008
+ kind: "notCompared",
64511
65009
  logicalId,
64512
65010
  resourceType: resource.resourceType,
64513
- referencesUnresolved,
64514
- comparisonRefused
65011
+ notComparedCause
65012
+ });
65013
+ else outcomes.push({
65014
+ kind: "clean",
65015
+ logicalId,
65016
+ resourceType: resource.resourceType
64515
65017
  });
64516
65018
  else outcomes.push({
64517
65019
  kind: "drifted",
@@ -64520,8 +65022,7 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
64520
65022
  ...reported,
64521
65023
  awsProperties: aws,
64522
65024
  secrets,
64523
- referencesUnresolved,
64524
- comparisonRefused
65025
+ notComparedCause
64525
65026
  });
64526
65027
  }
64527
65028
  return {
@@ -64618,7 +65119,13 @@ async function runAccept(reports, stateBackend, stateConfig, awsClients, options
64618
65119
  const lockManager = new LockManager(awsClients.s3, stateConfig);
64619
65120
  const owner = `${process.env["USER"] || "unknown"}@${process.env["HOSTNAME"] || "host"}:${process.pid}`;
64620
65121
  for (const report of reports) {
64621
- const driftedOutcomes = report.outcomes.filter((o) => o.kind === "drifted");
65122
+ const driftedOutcomes = report.outcomes.flatMap((o) => matchOutcome(o, {
65123
+ drifted: (d) => [d],
65124
+ clean: () => [],
65125
+ notCompared: () => [],
65126
+ unsupported: () => [],
65127
+ skipped: () => []
65128
+ }));
64622
65129
  if (driftedOutcomes.length === 0) continue;
64623
65130
  await lockManager.acquireLock(report.stackName, report.region, owner, "drift-accept");
64624
65131
  try {
@@ -65236,7 +65743,13 @@ async function runRevert(reports, providerRegistry, stateBackend, stateConfig, a
65236
65743
  let totalSucceeded = 0;
65237
65744
  let totalUnresolvable = 0;
65238
65745
  for (const report of reports) {
65239
- const driftedOutcomes = report.outcomes.filter((o) => o.kind === "drifted");
65746
+ const driftedOutcomes = report.outcomes.flatMap((o) => matchOutcome(o, {
65747
+ drifted: (d) => [d],
65748
+ clean: () => [],
65749
+ notCompared: () => [],
65750
+ unsupported: () => [],
65751
+ skipped: () => []
65752
+ }));
65240
65753
  if (driftedOutcomes.length === 0) continue;
65241
65754
  await lockManager.acquireLock(report.stackName, report.region, owner, "drift-revert");
65242
65755
  const narrowedByLogicalId = /* @__PURE__ */ new Map();
@@ -65387,7 +65900,13 @@ async function runRevert(reports, providerRegistry, stateBackend, stateConfig, a
65387
65900
  */
65388
65901
  function printAcceptPlan(reports) {
65389
65902
  for (const report of reports) {
65390
- const drifted = report.outcomes.filter((o) => o.kind === "drifted");
65903
+ const drifted = report.outcomes.flatMap((o) => matchOutcome(o, {
65904
+ drifted: (d) => [d],
65905
+ clean: () => [],
65906
+ notCompared: () => [],
65907
+ unsupported: () => [],
65908
+ skipped: () => []
65909
+ }));
65391
65910
  if (drifted.length === 0) continue;
65392
65911
  process.stdout.write(`\nPlan (--accept): update cdkd state for ${report.stackName} (${report.region}):\n`);
65393
65912
  for (const o of drifted) {
@@ -65410,7 +65929,13 @@ function printAcceptPlan(reports) {
65410
65929
  */
65411
65930
  function printRevertPlan(reports) {
65412
65931
  for (const report of reports) {
65413
- const drifted = report.outcomes.filter((o) => o.kind === "drifted");
65932
+ const drifted = report.outcomes.flatMap((o) => matchOutcome(o, {
65933
+ drifted: (d) => [d],
65934
+ clean: () => [],
65935
+ notCompared: () => [],
65936
+ unsupported: () => [],
65937
+ skipped: () => []
65938
+ }));
65414
65939
  if (drifted.length === 0) continue;
65415
65940
  process.stdout.write(`\nPlan (--revert): push cdkd state values back into AWS for ${report.stackName} (${report.region}):\n`);
65416
65941
  for (const o of drifted) {
@@ -65420,7 +65945,7 @@ function printRevertPlan(reports) {
65420
65945
  const stateResource = report.state.resources[o.logicalId];
65421
65946
  if (stateResource) {
65422
65947
  const preserved = findRevertPreservedTagKeys(o.changes, stateResource.observedProperties ?? stateResource.properties ?? {}, o.awsProperties);
65423
- const cannotMaskKeys = o.referencesUnresolved;
65948
+ const cannotMaskKeys = o.notComparedCause !== void 0;
65424
65949
  if (cannotMaskKeys && preserved.length > 0) process.stdout.write(` ! ${preserved.length} AWS-authored tag(s) will be preserved, but cdkd could not resolve this resource's dynamic reference(s), so their names are withheld — they come from the live readback and cannot be checked for secrets without them.\n`);
65425
65950
  if (!cannotMaskKeys && preserved.length > 0) {
65426
65951
  const tagWord = preserved.length === 1 ? "tag" : "tags";
@@ -65432,7 +65957,7 @@ function printRevertPlan(reports) {
65432
65957
  }
65433
65958
  if (stateResource && stateResource.observedProperties === void 0) {
65434
65959
  const unbaselined = findRevertUnbaselinedAwsKeys(o.changes, stateResource.properties ?? {}, o.awsProperties);
65435
- if (o.referencesUnresolved && unbaselined.length > 0) process.stdout.write(` ! ${unbaselined.length} AWS-authored value(s) will be left untouched, but cdkd could not resolve this resource's dynamic reference(s), so their paths are withheld — they come from the live readback and cannot be checked for secrets without them.\n`);
65960
+ if (o.notComparedCause !== void 0 && unbaselined.length > 0) process.stdout.write(` ! ${unbaselined.length} AWS-authored value(s) will be left untouched, but cdkd could not resolve this resource's dynamic reference(s), so their paths are withheld — they come from the live readback and cannot be checked for secrets without them.\n`);
65436
65961
  else if (unbaselined.length > 0) {
65437
65962
  const word = unbaselined.length === 1 ? "value" : "values";
65438
65963
  process.stdout.write(` ! this resource has no observed-capture baseline, so the revert pushes the raw TEMPLATE and LEAVES ${unbaselined.length} AWS-authored ${word} untouched:\n`);
@@ -65474,28 +65999,44 @@ async function confirmPrompt$5(prompt) {
65474
65999
  }
65475
66000
  function writeJsonReport(reports) {
65476
66001
  const payload = reports.map((r) => {
65477
- const drifted = r.outcomes.filter((o) => o.kind === "drifted").map((o) => ({
65478
- logicalId: o.logicalId,
65479
- type: o.resourceType,
65480
- changes: o.changes,
65481
- referencesUnresolved: o.referencesUnresolved
65482
- }));
65483
- const clean = r.outcomes.filter((o) => o.kind === "clean").map((o) => ({
65484
- logicalId: o.logicalId,
65485
- type: o.resourceType,
65486
- referencesUnresolved: o.referencesUnresolved
65487
- }));
66002
+ const drifted = [];
66003
+ const clean = [];
66004
+ const notSupported = [];
66005
+ const skipped = [];
66006
+ for (const o of r.outcomes) matchOutcome(o, {
66007
+ drifted: (d) => {
66008
+ drifted.push({
66009
+ logicalId: d.logicalId,
66010
+ type: d.resourceType,
66011
+ changes: d.changes,
66012
+ referencesUnresolved: d.notComparedCause !== void 0
66013
+ });
66014
+ },
66015
+ clean: (c) => {
66016
+ clean.push({
66017
+ logicalId: c.logicalId,
66018
+ type: c.resourceType,
66019
+ referencesUnresolved: false
66020
+ });
66021
+ },
66022
+ notCompared: () => {},
66023
+ unsupported: (u) => {
66024
+ notSupported.push({
66025
+ logicalId: u.logicalId,
66026
+ type: u.resourceType
66027
+ });
66028
+ },
66029
+ skipped: (sk) => {
66030
+ skipped.push({
66031
+ logicalId: sk.logicalId,
66032
+ type: sk.resourceType
66033
+ });
66034
+ }
66035
+ });
65488
66036
  const notCompared = notComparedOutcomes(r).map((o) => ({
65489
66037
  logicalId: o.logicalId,
65490
- type: o.resourceType
65491
- }));
65492
- const notSupported = r.outcomes.filter((o) => o.kind === "unsupported").map((o) => ({
65493
- logicalId: o.logicalId,
65494
- type: o.resourceType
65495
- }));
65496
- const skipped = r.outcomes.filter((o) => o.kind === "skipped").map((o) => ({
65497
- logicalId: o.logicalId,
65498
- type: o.resourceType
66038
+ type: o.resourceType,
66039
+ referencesUnresolved: true
65499
66040
  }));
65500
66041
  return {
65501
66042
  stack: r.stackName,
@@ -65511,10 +66052,27 @@ function writeJsonReport(reports) {
65511
66052
  }
65512
66053
  function writeHumanReport(reports) {
65513
66054
  for (const report of reports) {
65514
- const drifted = report.outcomes.filter((o) => o.kind === "drifted");
65515
- const unsupported = report.outcomes.filter((o) => o.kind === "unsupported");
65516
- const skippedCount = report.outcomes.filter((o) => o.kind === "skipped").length;
65517
- const inspected = report.outcomes.length - skippedCount;
66055
+ const drifted = [];
66056
+ const unsupported = [];
66057
+ let inspectedCount = 0;
66058
+ for (const o of report.outcomes) matchOutcome(o, {
66059
+ drifted: (d) => {
66060
+ drifted.push(d);
66061
+ inspectedCount += 1;
66062
+ },
66063
+ unsupported: (u) => {
66064
+ unsupported.push(u);
66065
+ inspectedCount += 1;
66066
+ },
66067
+ skipped: () => {},
66068
+ clean: () => {
66069
+ inspectedCount += 1;
66070
+ },
66071
+ notCompared: () => {
66072
+ inspectedCount += 1;
66073
+ }
66074
+ });
66075
+ const inspected = inspectedCount;
65518
66076
  const notCompared = notComparedOutcomes(report);
65519
66077
  const checked = inspected - notCompared.length;
65520
66078
  if (drifted.length === 0) if (notCompared.length > 0) process.stdout.write(`⚠ ${report.stackName} (${report.region}): no drift detected, but ${checked} of ${inspected} resource${inspected === 1 ? "" : "s"} fully checked (${notCompared.length} only partially compared, ${unsupported.length} unsupported)\n`);
@@ -71197,8 +71755,8 @@ async function rollbackCommand(stackArg, options) {
71197
71755
  try {
71198
71756
  await setup.lockManager.acquireLockWithRetry(stackName, region, void 0, "rollback");
71199
71757
  } catch (error) {
71200
- unforwardSigterm();
71201
71758
  process.removeListener("SIGINT", sigintHandler);
71759
+ unforwardSigterm();
71202
71760
  throw error;
71203
71761
  }
71204
71762
  try {
@@ -71343,11 +71901,14 @@ async function rollbackCommand(stackArg, options) {
71343
71901
  if (totalWarnings > 0) throw new PartialFailureError(`Rollback completed with ${totalWarnings} skipped/unrecoverable operation(s) (see warnings above).`);
71344
71902
  logger.info(`\nRollback of '${stackName}' (${region}) complete.`);
71345
71903
  } finally {
71346
- unforwardSigterm();
71347
- process.removeListener("SIGINT", sigintHandler);
71348
- await setup.lockManager.releaseLock(stackName, region).catch((err) => {
71349
- logger.warn(`Failed to release lock for '${stackName}' (${region}): ${err instanceof Error ? err.message : String(err)}`);
71350
- });
71904
+ try {
71905
+ await setup.lockManager.releaseLock(stackName, region).catch((err) => {
71906
+ logger.warn(`Failed to release lock for '${stackName}' (${region}): ${err instanceof Error ? err.message : String(err)}`);
71907
+ });
71908
+ } finally {
71909
+ process.removeListener("SIGINT", sigintHandler);
71910
+ unforwardSigterm();
71911
+ }
71351
71912
  }
71352
71913
  } finally {
71353
71914
  if (stackAwsClients) {
@@ -84546,7 +85107,7 @@ function createMigrateCommand() {
84546
85107
  */
84547
85108
  function buildProgram() {
84548
85109
  const program = new Command();
84549
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.28");
85110
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.30");
84550
85111
  program.hook("preAction", (_thisCommand, actionCommand) => {
84551
85112
  const { profile } = actionCommand.optsWithGlobals();
84552
85113
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;