@go-to-k/cdkd 0.284.31 → 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-DYdEWX-s.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-DJvcY8lj.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";
@@ -62447,6 +62447,7 @@ async function scrubCommand(stacks, options) {
62447
62447
  else throw new Error(`Multiple stacks found: ${allStacks.map(describeStack).join(", ")}. Specify stack name(s) or use --all`);
62448
62448
  if (targetStacks.length === 0) throw new Error("No stacks matched.");
62449
62449
  await synthesizer.expandMacrosForStacks(targetStacks, synthOptions);
62450
+ targetStacks = orderScrubTargets(targetStacks);
62450
62451
  const stateConfig = {
62451
62452
  bucket: stateBucket,
62452
62453
  prefix: options.statePrefix
@@ -62462,6 +62463,7 @@ async function scrubCommand(stacks, options) {
62462
62463
  const lockManager = new LockManager(stateS3.s3, stateConfig);
62463
62464
  let totalStacksScrubbed = 0;
62464
62465
  let totalStacksWithUnscrubbableKeys = 0;
62466
+ let totalStacksWithUnverifiableReads = 0;
62465
62467
  const failures = [];
62466
62468
  for (const stack of targetStacks) {
62467
62469
  const stackRegion = stack.region || region;
@@ -62470,7 +62472,8 @@ async function scrubCommand(stacks, options) {
62470
62472
  scrubbed = await scrubStack(stack, stackRegion, stateBackend, lockManager, {
62471
62473
  dryRun: options.dryRun ?? false,
62472
62474
  roleArn: options.roleArn,
62473
- logger
62475
+ logger,
62476
+ appStacks: allStacks
62474
62477
  });
62475
62478
  } catch (err) {
62476
62479
  failures.push({ stackName: stack.stackName });
@@ -62481,13 +62484,17 @@ async function scrubCommand(stacks, options) {
62481
62484
  if (scrubbed.recordsChanged > 0) {
62482
62485
  totalStacksScrubbed++;
62483
62486
  logger.info(`${options.dryRun ? "Would scrub" : "Scrubbed"} ${scrubbed.recordsChanged} resource record(s) in ${stack.stackName}`);
62484
- } 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
+ }
62485
62492
  if (scrubbed.secretBearingKeys > 0) {
62486
62493
  totalStacksWithUnscrubbableKeys++;
62487
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).`);
62488
62495
  }
62489
62496
  }
62490
- if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0) {
62497
+ if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0 && totalStacksWithUnverifiableReads === 0) {
62491
62498
  if (failures.length === 0) {
62492
62499
  logger.info("\nNo plaintext secrets found in any target stack state. Nothing to scrub.");
62493
62500
  return;
@@ -62497,17 +62504,18 @@ async function scrubCommand(stacks, options) {
62497
62504
  }
62498
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.` : "";
62499
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.` : "";
62500
62508
  if (options.dryRun) {
62501
- 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.`);
62502
- 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.`);
62503
62511
  if (failures.length > 0) throw scrubStacksFailedError(failures);
62504
62512
  if (options.fail) throw new ScrubNeededError();
62505
62513
  return;
62506
62514
  }
62507
- 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}`);
62508
- 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.`);
62509
62517
  if (failures.length > 0) throw scrubStacksFailedError(failures);
62510
- if (options.fail && totalStacksWithUnscrubbableKeys > 0) throw new ScrubNeededError();
62518
+ if (options.fail && (totalStacksWithUnscrubbableKeys > 0 || totalStacksWithUnverifiableReads > 0)) throw new ScrubNeededError();
62511
62519
  }
62512
62520
  /**
62513
62521
  * Every secret plaintext this run recorded, from ANY position in the template
@@ -63046,6 +63054,571 @@ async function pinCrossRegionSecrets(bag, stackName, ctx) {
63046
63054
  return await walk(bag, "");
63047
63055
  }
63048
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
+ /**
63049
63622
  * Scrub one stack's state. Re-resolves the template's per-resource properties to
63050
63623
  * learn the resolved secret VALUES, then replaces those values in the state
63051
63624
  * record with their `{{resolve:...}}` expressions. Returns counts; performs no
@@ -63064,6 +63637,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63064
63637
  if (acquired) await lockManager.acquireLockWithRetry(stack.stackName, region, void 0, "scrub");
63065
63638
  const perResourceSecrets = /* @__PURE__ */ new Map();
63066
63639
  const outputSecrets = /* @__PURE__ */ new Map();
63640
+ const prePassFindings = { unverifiable: [] };
63067
63641
  try {
63068
63642
  const loaded = await stateBackend.getState(stack.stackName, region);
63069
63643
  if (!loaded) {
@@ -63071,7 +63645,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63071
63645
  return {
63072
63646
  recordsChanged: 0,
63073
63647
  secretsFound: 0,
63074
- secretBearingKeys: 0
63648
+ secretBearingKeys: 0,
63649
+ unverifiableReads: 0
63075
63650
  };
63076
63651
  }
63077
63652
  const state = loaded.state;
@@ -63088,13 +63663,26 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63088
63663
  } catch (err) {
63089
63664
  logger.debug(`Parameter resolution skipped for ${stack.stackName}: ${err instanceof Error ? err.message : String(err)}`);
63090
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
+ });
63091
63684
  try {
63092
- conditions = await resolver.evaluateConditions({
63093
- template: stack.template,
63094
- resources: state.resources,
63095
- ...Object.keys(parameters).length > 0 && { parameters },
63096
- bestEffort: true
63097
- });
63685
+ conditions = await resolver.evaluateConditions(resolverContext(outputSecrets));
63098
63686
  } catch (err) {
63099
63687
  logger.debug(`Condition evaluation skipped for ${stack.stackName}: ${err instanceof Error ? err.message : String(err)}`);
63100
63688
  }
@@ -63103,6 +63691,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63103
63691
  const templateResource = templateResources[logicalId];
63104
63692
  if (!templateResource?.Properties) continue;
63105
63693
  const recordedSecretValues = /* @__PURE__ */ new Map();
63694
+ perResourceSecrets.set(logicalId, recordedSecretValues);
63106
63695
  const resolveInput = await pinCrossRegionSecrets(templateResource.Properties, stack.stackName, {
63107
63696
  stackRegion: region,
63108
63697
  producerRegions,
@@ -63111,20 +63700,13 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63111
63700
  recordedSecretValues,
63112
63701
  origin: `resource '${logicalId}'`
63113
63702
  });
63703
+ const resourceContext = resolverContext(recordedSecretValues);
63704
+ await resolveCrossStackReads(resolveInput, resourceContext, `resource '${logicalId}'`);
63114
63705
  try {
63115
- await resolver.resolve(resolveInput, {
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,
63122
- bestEffort: true
63123
- });
63706
+ await resolver.resolve(resolveInput, resourceContext);
63124
63707
  } catch (err) {
63125
63708
  logger.debug(`Resolution of ${logicalId} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}`);
63126
63709
  }
63127
- if (recordedSecretValues.size > 0) perResourceSecrets.set(logicalId, recordedSecretValues);
63128
63710
  perResourceTemplateProps.set(logicalId, templateResource.Properties);
63129
63711
  }
63130
63712
  const templateOutputs = stack.template.Outputs ?? {};
@@ -63145,16 +63727,10 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63145
63727
  recordedSecretValues: outputSecrets,
63146
63728
  origin: `Export.Name of output '${name}'`
63147
63729
  });
63730
+ const nameContext = resolverContext(outputSecrets);
63731
+ await resolveCrossStackReads(nameSource, nameContext, `Export.Name of output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
63148
63732
  try {
63149
- exportName = await resolver.resolve(nameSource, {
63150
- template: stack.template,
63151
- resources: state.resources,
63152
- ...Object.keys(parameters).length > 0 && { parameters },
63153
- ...Object.keys(conditions).length > 0 && { conditions },
63154
- stackName: stack.stackName,
63155
- recordedSecretValues: outputSecrets,
63156
- bestEffort: true
63157
- });
63733
+ exportName = await resolver.resolve(nameSource, nameContext);
63158
63734
  } catch (err) {
63159
63735
  outputsSourceUntrusted = true;
63160
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.`);
@@ -63185,16 +63761,10 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63185
63761
  recordedSecretValues: outputSecrets,
63186
63762
  origin: `output '${name}'`
63187
63763
  });
63764
+ const valueContext = resolverContext(outputSecrets);
63765
+ await resolveCrossStackReads(valueSource, valueContext, `output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
63188
63766
  try {
63189
- await resolver.resolve(valueSource, {
63190
- template: stack.template,
63191
- resources: state.resources,
63192
- ...Object.keys(parameters).length > 0 && { parameters },
63193
- ...Object.keys(conditions).length > 0 && { conditions },
63194
- stackName: stack.stackName,
63195
- recordedSecretValues: outputSecrets,
63196
- bestEffort: true
63197
- });
63767
+ await resolver.resolve(valueSource, valueContext);
63198
63768
  } catch (err) {
63199
63769
  logger.debug(`Resolution of output ${name} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}`);
63200
63770
  }
@@ -63211,7 +63781,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63211
63781
  if (totalSecrets === 0) return {
63212
63782
  recordsChanged: 0,
63213
63783
  secretsFound: 0,
63214
- secretBearingKeys: 0
63784
+ secretBearingKeys: secretBearingKeys.length,
63785
+ unverifiableReads: prePassFindings.unverifiable.length
63215
63786
  };
63216
63787
  let recordsChanged = 0;
63217
63788
  const newResources = {};
@@ -63241,7 +63812,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
63241
63812
  return {
63242
63813
  recordsChanged,
63243
63814
  secretsFound: totalSecrets,
63244
- secretBearingKeys: secretBearingKeys.length
63815
+ secretBearingKeys: secretBearingKeys.length,
63816
+ unverifiableReads: prePassFindings.unverifiable.length
63245
63817
  };
63246
63818
  } catch (err) {
63247
63819
  throw maskSecretsInError(err, allRecordedSecrets(outputSecrets, perResourceSecrets));
@@ -85181,7 +85753,7 @@ function createMigrateCommand() {
85181
85753
  */
85182
85754
  function buildProgram() {
85183
85755
  const program = new Command();
85184
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.31");
85756
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.32");
85185
85757
  program.hook("preAction", (_thisCommand, actionCommand) => {
85186
85758
  const { profile } = actionCommand.optsWithGlobals();
85187
85759
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;