@go-to-k/cdkd 0.288.2 → 0.288.4

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.
@@ -1,8 +1,8 @@
1
- import { $ as createMaskedRetryLogger, $n as runDockerStreaming, $r as StackTerminationProtectionError, $t as carriesSecretMask, A as WARM_THROUGHPUT_MEMBERS, An as rewriteTemplateAssetReferences, At as replayWarn, B as yellow, Bn as validateContainerRepoName, Bt as s3BucketWebsiteUrl, C as unsupportedFinalSnapshotError, Cn as shouldRetainResource, Cr as displaySafe, Ct as normalizeAwsTagsToCfn, D as isStatefulRecreateTargetForReplace, Dn as buildAssetRedirectMap, Dr as derivePartitionAndUrlSuffix, Dt as configBooleanRefusal, E as MULTI_REGION_RECREATE_BLOCKED_TYPES, En as WorkGraph, Er as canonicalizeRegion, Et as coerceCfnBoolean, F as bold, Fn as getBootstrapMarkerKey, Fr as setAwsClients, Ft as producerRegionsFromState, G as secretBearingStateKeyWarning, Gn as describeDockerExecFailure, Gr as LocalStartServiceError, Gt as findSilentDropProperties, H as collectPublishedOutputNames, Hn as describeAwsFailure, Hr as DynamicReferenceRegionAmbiguousError, Ht as DiffCalculator, I as cyan, In as isCrossRegionRedirect, It as s3BucketArn, J as IAMRoleProvider, Jn as formatDockerLoginError, Jr as PartialFailureError, Jt as DagBuilder, K as stateKeySecretExposure, Kn as describeDockerFailure, Kt as describeTypeWithThrottleRetry, L as gray, Ln as parseBootstrapMarker, Lr as CdkdError, Lt as s3BucketDomainName, M as isWarmThroughputDecrease, Mn as BOOTSTRAP_MARKER_PREFIX, Mr as AwsClients, Mt as requireConfigObject, N as toFiniteNumber, Nn as assertAssetBucketRegion, Nr as getAwsClients, Nt as requireConfigString, O as isStatefulRecreateTargetSync, On as createAssetRedirectResolver, Or as AssemblyReader, Ot as configStringRefusal, P as formatResourceLine, Pn as ensureAssetStorage, Pr as resetAwsClients, Pt as classifyReplaySecretRegion, Q as wouldReturnToSdkProvider, Qn as runDockerForeground, Qr as StackHasActiveImportsError, Qt as TEMPLATE_SOURCED_RULES, R as green, Rn as readBootstrapMarkerBody, Rt as s3BucketDualStackDomainName, S as refusesFinalSnapshot, Sn as importableOutputs, Sr as CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION, St as WAFv2WebACLProvider, T as extractDeploymentEventError, Tn as stringifyValue, Tr as PARTITION_TABLE, Tt as assertRegionMatch, U as exportAliasCollisionScrubWarning, Un as buildDockerImage, Ur as IntrinsicResolutionRefusalError, Ut as INTRINSIC_KEYS, V as collectDeclaredOutputNames, Vn as buildDenyExternalAccessPolicy, Vr as DeployCancelledError, Vt as applyRoleArnIfSet, W as isExportAliasCollision, Wn as describeDockerCapturedOutput, Wr as LocalInvokeBuildError$1, Wt as findActionableSilentDrops, X as clearOnUpdateRemoval, Xn as partitionSensitiveEnv, Xr as ResourceTimeoutError, Xt as STATE_SOURCED_CROSS_GENERATION_RULES, Y as collectInlinePolicyNamesManagedBySiblings, Yn as getDockerCmd, Yr as ProvisioningError, Yt as TemplateParser, Z as ProviderRegistry, Zn as redactDockerArgvValues, Zr as ResourceUpdateNotSupportedError, Zt as STATE_SOURCED_READBACK_RULES, _ as PRE_DELETE_SNAPSHOT_TYPES, _n as shellQuote, _r as CFN_TEMPLATE_BODY_LIMIT, _t as coerceParameterTypedValue, a as DeploymentEventsStore, ai as withErrorHandling, an as maskSecretsInText, ar as synthesisStatusMessage, at as isInterruptedWaitError, b as createPreDeleteFinalSnapshot, bn as exportNamesCarriedFrom, br as findLargeInlineResources, bt as parameterTypeMayLoseSecretIdentity, c as replayFailedOperations, ci as isThrottlingError, cn as redactSecretsForState, cr as resolveApp, ct as slowCcOperationTimeoutMs, d as updatePartialReason, di as markRedactedCause, dn as S3StateBackend, dr as resolveSkipPrefix, dt as deleteSkipReason, en as createSecretMasker, er as escapeRegExp, et as maskDeep, f as withResourceDeadline, fi as retryClassificationText, fn as rebuildClientForBucketRegion, fr as resolveStateBucketWithDefault, ft as disableInstanceApiTermination, g as ATOMIC_FINAL_SNAPSHOT_TYPES, gn as forceQuitRecoveryClause, gr as warnDeprecatedNoPrefixCliFlag, gt as cfnRefValueFromPhysicalId, h as computeImplicitDeleteEdges, hn as buildLockContentionMessage, hr as stateBucketExistenceConfirmed, ht as carriesDynamicReference, i as DeploymentEventsReader, ii as normalizeAwsError, in as maskSecretsInError, ir as Synthesizer, it as interruptWatchListenerCount, j as coerceWarmThroughput$1, jn as AssetModeResolver, jr as resolveBucketRegion, jt as requireConfigArray, k as renderStatefulReason, kn as loadPublishableAssetManifest, kr as processStackMessages, kt as readConfigString, l as replayRollback, li as isTransientServerError, ln as scrubResourceRecord, lr as resolveAutoAssetStorage, lt as UNSPECIFIED_SKIP_REASON, m as IMPLICIT_DELETE_DEPENDENCIES, mn as buildForceUnlockCommand, mr as resolveUseCdkBootstrapAssets, mt as IntrinsicFunctionResolver, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as errorCauseChain, nr as AssetManifestLoader, nt as beginCommandInterruptScope, o as planFailedOps, oi as isMarkedNonRetryable, on as recordMaskOnlyValue, or as getDefaultStateBucketName, ot as startInterruptWatch, p as maskingRetryLogger, pn as UNRENDERABLE, pr as resolveStateBucketWithDefaultAndSource, pt as isTerminationProtectionPropagationError, q as getCurrentResourceSecrets, qn as dockerSpawnEnvWithSensitive, qr as NestedStackChildDirectDestroyError, qt as withRetry, r as DeployEngine, rn as isSingleDynamicReferenceToken, rr as getDockerImageBySourceHash, rt as endCommandInterruptScope, s as planRollback, si as isRetryableTransientError, sn as recoverMaskedOutput, sr as getLegacyStateBucketName, st as CloudControlProvider, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as dynamicReferenceTokens, tr as stripControlChars, tt as maskerOrIdentity, u as updatePartialMessage, ui as markNonRetryable, un as LockManager, ur as resolveCaptureObservedState, ut as deleteIndeterminateGuards, v as buildFinalSnapshotIdentifier, vn as CUSTOM_RESOURCE_RESPONSE_PREFIX, vr as CFN_TEMPLATE_URL_LIMIT, vt as getAccountInfo, w as makeCanonicalizePropertiesFn, wn as AssetPublisher, wr as expectedOwnerParam, wt as resolveExplicitPhysicalId, x as isFinalSnapshotError, xn as importableOutputKeys, xr as uploadCfnTemplate, xt as refStateLookupFromResource, y as ccRoutedFinalSnapshotError, yn as DEFAULT_STATE_PREFIX, yr as MIGRATE_TMP_PREFIX, yt as isUnboundTemplateParameter, z as red, zn as validateAssetBucketName, zr as CrossAccountSecretRefusalError, zt as s3BucketRegionalDomainName } from "./deploy-engine-Cxt3gn8N.js";
1
+ import { $ as ProviderRegistry, $n as partitionSensitiveEnv, $r as ResourceTimeoutError, $t as STATE_SOURCED_CROSS_GENERATION_RULES, A as renderStatefulReason, An as buildAssetRedirectMap, Ar as derivePartitionAndUrlSuffix, At as configStringRefusal, B as red, Bn as parseBootstrapMarker, Br as CdkdError, Bt as s3BucketDomainName, C as refusesFinalSnapshot, Cn as exportNamesCarriedFrom, Cr as findLargeInlineResources, Ct as refStateLookupFromResource, D as MULTI_REGION_RECREATE_BLOCKED_TYPES, Dn as AssetPublisher, Dr as expectedOwnerParam, Dt as assertRegionMatch, E as extractDeploymentEventError, En as shouldRetainResource, Er as displaySafe, Et as resolveExplicitPhysicalId, F as formatResourceLine, Fn as BOOTSTRAP_MARKER_PREFIX, Fr as AwsClients, Ft as requireConfigString, G as isExportAliasCollision, Gn as describeAwsFailure, Gr as DynamicReferenceRegionAmbiguousError, Gt as DiffCalculator, H as collectDeclaredOutputNames, Hn as validateAssetBucketName, Hr as CrossAccountSecretRefusalError, Ht as s3BucketRegionalDomainName, I as bold, In as assertAssetBucketRegion, Ir as getAwsClients, It as classifyReplaySecretRegion, J as secretSafeKeyDisplay, Jn as describeDockerExecFailure, Jr as LocalStartServiceError, Jt as findSilentDropProperties, K as secretBearing, Kn as buildDockerImage, Kr as IntrinsicResolutionRefusalError, Kt as INTRINSIC_KEYS, L as cyan, Ln as ensureAssetStorage, Lr as resetAwsClients, Lt as producerRegionsFromState, M as coerceWarmThroughput$1, Mn as loadPublishableAssetManifest, Mr as processStackMessages, Mt as replayWarn, N as isWarmThroughputDecrease, Nn as rewriteTemplateAssetReferences, Nt as requireConfigArray, O as isStatefulRecreateTargetForReplace, On as stringifyValue, Or as PARTITION_TABLE, Ot as coerceCfnBoolean, P as toFiniteNumber, Pn as AssetModeResolver, Pr as resolveBucketRegion, Pt as requireConfigObject, Q as clearOnUpdateRemoval, Qn as getDockerCmd, Qr as ProvisioningError, Qt as TemplateParser, R as gray, Rn as getBootstrapMarkerKey, Rr as setAwsClients, Rt as withSharedDrainBudget, S as isFinalSnapshotError, Sn as DEFAULT_STATE_PREFIX, Sr as MIGRATE_TMP_PREFIX, St as parameterTypeMayLoseSecretIdentity, T as makeCanonicalizePropertiesFn, Tn as importableOutputs, Tr as CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION, Tt as normalizeAwsTagsToCfn, U as collectPublishedOutputNames, Un as validateContainerRepoName, Ut as s3BucketWebsiteUrl, V as yellow, Vn as readBootstrapMarkerBody, Vt as s3BucketDualStackDomainName, W as exportAliasCollisionScrubWarning, Wn as buildDenyExternalAccessPolicy, Wr as DeployCancelledError, Wt as applyRoleArnIfSet, X as IAMRoleProvider, Xn as dockerSpawnEnvWithSensitive, Xr as NestedStackChildDirectDestroyError, Xt as withRetry, Y as getCurrentResourceSecrets, Yn as describeDockerFailure, Yt as describeTypeWithThrottleRetry, Z as collectInlinePolicyNamesManagedBySiblings, Zn as formatDockerLoginError, Zr as PartialFailureError, Zt as DagBuilder, _ as ATOMIC_FINAL_SNAPSHOT_TYPES, _n as buildForceUnlockCommand, _r as resolveUseCdkBootstrapAssets, _t as carriesDynamicReference, a as DeploymentEventsStore, an as errorCauseChain, ar as AssetManifestLoader, at as endCommandInterruptScope, b as ccRoutedFinalSnapshotError, bn as shellQuote, br as CFN_TEMPLATE_BODY_LIMIT, bt as getAccountInfo, c as replayFailedOperations, ci as withErrorHandling, cn as maskSecretsInText, cr as synthesisStatusMessage, ct as startInterruptWatch, d as updatePartialReason, di as isThrottlingError, dn as redactSecretsForState, dr as resolveApp, dt as UNSPECIFIED_SKIP_REASON, ei as ResourceUpdateNotSupportedError, en as STATE_SOURCED_READBACK_RULES, er as redactDockerArgvValues, et as wouldReturnToSdkProvider, f as withResourceDeadline, fi as isTransientServerError, fn as scrubResourceRecord, fr as resolveAutoAssetStorage, ft as deleteIndeterminateGuards, g as bindingSkippedOutputs, gn as UNRENDERABLE, gr as resolveStateBucketWithDefaultAndSource, gt as IntrinsicFunctionResolver, h as computeImplicitDeleteEdges, hi as retryClassificationText, hn as rebuildClientForBucketRegion, hr as resolveStateBucketWithDefault, ht as isTerminationProtectionPropagationError, i as DeploymentEventsReader, in as dynamicReferenceTokens, ir as stripControlChars, it as beginCommandInterruptScope, j as WARM_THROUGHPUT_MEMBERS, jn as createAssetRedirectResolver, jr as AssemblyReader, jt as readConfigString, k as isStatefulRecreateTargetSync, kn as WorkGraph, kr as canonicalizeRegion, kt as configBooleanRefusal, l as replayRollback, li as isMarkedNonRetryable, ln as recordMaskOnlyValue, lr as getDefaultStateBucketName, lt as CloudControlProvider, m as IMPLICIT_DELETE_DEPENDENCIES, mi as markRedactedCause, mn as S3StateBackend, mr as resolveSkipPrefix, mt as disableInstanceApiTermination, n as DEFAULT_RESOURCE_WARN_AFTER_MS, ni as StackTerminationProtectionError, nn as carriesSecretMask, nr as runDockerStreaming, nt as maskDeep, o as planFailedOps, on as isSingleDynamicReferenceToken, or as getDockerImageBySourceHash, ot as interruptWatchListenerCount, p as maskingRetryLogger, pi as markNonRetryable, pn as LockManager, pr as resolveCaptureObservedState, pt as deleteSkipReason, q as secretBearingStateKeyWarning, qn as describeDockerCapturedOutput, qr as LocalInvokeBuildError$1, qt as findActionableSilentDrops, r as DeployEngine, rn as createSecretMasker, rr as escapeRegExp, rt as maskerOrIdentity, s as planRollback, si as normalizeAwsError, sn as maskSecretsInError, sr as Synthesizer, st as isInterruptedWaitError, t as DEFAULT_RESOURCE_TIMEOUT_MS, ti as StackHasActiveImportsError, tn as TEMPLATE_SOURCED_RULES, tr as runDockerForeground, tt as createMaskedRetryLogger, u as updatePartialMessage, ui as isRetryableTransientError, un as recoverMaskedOutput, ur as getLegacyStateBucketName, ut as slowCcOperationTimeoutMs, v as PRE_DELETE_SNAPSHOT_TYPES, vn as buildLockContentionMessage, vr as stateBucketExistenceConfirmed, vt as cfnRefValueFromPhysicalId, w as unsupportedFinalSnapshotError, wn as importableOutputKeys, wr as uploadCfnTemplate, wt as WAFv2WebACLProvider, x as createPreDeleteFinalSnapshot, xn as CUSTOM_RESOURCE_RESPONSE_PREFIX, xr as CFN_TEMPLATE_URL_LIMIT, xt as isUnboundTemplateParameter, y as buildFinalSnapshotIdentifier, yn as forceQuitRecoveryClause, yr as warnDeprecatedNoPrefixCliFlag, yt as coerceParameterTypedValue, z as green, zn as isCrossRegionRedirect, zt as s3BucketArn } from "./deploy-engine-BiPxTgKT.js";
2
2
  import { _ as withStackName, c as PATTERN_B_NAME_OPTIONS, f as generateResourceName, g as withSkipPrefix, i as reserveStdoutForPayload, l as PATTERN_B_NAME_PROPERTIES, m as getCurrentStackName, n as getLogger, o as runStackBuffered, p as generateResourceNameWithFallback, s as getLiveRenderer, u as PATTERN_B_RESOURCE_TYPES } from "./logger-Dw-3y48G.js";
3
3
  import { t as awsClientDefaults } from "./aws-client-defaults-D-iYiIJ6.js";
4
- import { t as getCdkdVersion } from "./version-BRByznpy.js";
5
- import { r as protectedReplacementAdvice, t as ASGProvider } from "./asg-provider-DUlRn84u.js";
4
+ import { t as getCdkdVersion } from "./version-BmM4QJ9t.js";
5
+ import { r as protectedReplacementAdvice, t as ASGProvider } from "./asg-provider-TexsJufw.js";
6
6
  import { AsyncLocalStorage } from "node:async_hooks";
7
7
  import { createHash, randomBytes, randomUUID } from "node:crypto";
8
8
  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, NotFound, 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";
@@ -3470,6 +3470,67 @@ function mapsEqual(a, b) {
3470
3470
  }
3471
3471
  return true;
3472
3472
  }
3473
+ /**
3474
+ * Describe a `JSON.parse` failure WITHOUT echoing the body (issue
3475
+ * [#2667](https://github.com/go-to-k/cdkd/issues/2667) review).
3476
+ *
3477
+ * V8 embeds a window of the INPUT in its parse error, so interpolating
3478
+ * `err.message` republishes whatever bytes sat near the syntax error. Measured
3479
+ * on node v24.19.0:
3480
+ *
3481
+ * ```
3482
+ * JSON.parse('{"exports":{"K":{"value":hunter2SECRET}}}')
3483
+ * -> Unexpected token 'h', ..."K":{"value":hunter2SEC"... is not valid JSON
3484
+ * ```
3485
+ *
3486
+ * This index holds resolved Output VALUES, and for the unscrubbed state
3487
+ * `cdkd scrub` targets those are exactly the plaintext — which then reaches a
3488
+ * `logger.error` and the command's failure message, i.e. `--dry-run --fail` CI
3489
+ * logs. Same class as the `import.ts` parse-snippet leak issue
3490
+ * [#2829](https://github.com/go-to-k/cdkd/issues/2829) closed.
3491
+ *
3492
+ * What survives is the SIZE, plus the OFFSET WHEN V8 STATES ONE — neither is a
3493
+ * function of the body's content, and both help an operator holding the object
3494
+ * locate the damage. The offset is extracted by pattern rather than passed
3495
+ * through, so a message worded differently yields no offset instead of leaking
3496
+ * the rest of the sentence. The two numbers are in DIFFERENT UNITS and the
3497
+ * message says so: V8 counts `at position` in UTF-16 code units while the size
3498
+ * is UTF-8 bytes, so `'{ n\u00f6t json'` reports position 2 and 11 bytes. Measured on node v24.19.0: the `Expected ...`
3499
+ * family carries `at position N` and the `Unexpected token 'X', ...snippet...`
3500
+ * family — the one that embeds the body, i.e. the case this exists for — does
3501
+ * NOT, so the size is what is always present and the offset is a bonus. Both
3502
+ * arms are driven by tests.
3503
+ */
3504
+ function describeParseFailure(err, bytes) {
3505
+ const raw = err instanceof Error ? err.message : String(err);
3506
+ const at = /at position (\d+)/.exec(raw);
3507
+ return `invalid JSON${at ? ` at code-unit position ${at[1]}` : ""}; ${bytes} byte(s) read`;
3508
+ }
3509
+ /**
3510
+ * UTF-8 byte length of a decoded body, for {@link describeParseFailure}.
3511
+ *
3512
+ * `body.length` counts UTF-16 CODE UNITS, so reporting it as "byte(s)" is
3513
+ * wrong for any non-ASCII index — an export name outside the BMP counts 2
3514
+ * where the object holds 4 (issue #2667 review). The number is meant to help
3515
+ * an operator match the message against the object S3 holds, so it has to be
3516
+ * the same unit S3 reports.
3517
+ */
3518
+ function utf8ByteLength(body) {
3519
+ return Buffer.byteLength(body, "utf8");
3520
+ }
3521
+ /**
3522
+ * Describe an `indexVersion` that is not a usable number, WITHOUT rendering it.
3523
+ *
3524
+ * `String(x)` renders a body-controlled value verbatim for two of the three
3525
+ * non-number shapes — measured on node v24.19.0: a JSON string yields itself
3526
+ * and a one-element array yields its element, while only an object degrades to
3527
+ * `[object Object]`. The field is attacker-controlled in the same sense the
3528
+ * rest of the body is, so the message reports its TYPE and, for a number, its
3529
+ * value — the only shape whose value is safe because it is a number.
3530
+ */
3531
+ function describeIndexVersion(value) {
3532
+ return typeof value === "number" ? String(value) : `a non-numeric value (${typeof value})`;
3533
+ }
3473
3534
  const DEFAULT_OPTIONS = {
3474
3535
  maxWriteRetries: 5,
3475
3536
  initialBackoffMs: 100,
@@ -3597,12 +3658,57 @@ var ExportIndexStore = class {
3597
3658
  await this.enqueueWrite("update", () => this.applyStackUpdate(stackName, producerRegion, outputs));
3598
3659
  }
3599
3660
  /**
3661
+ * A snapshot of the persisted index, read WITHOUT the rebuild that
3662
+ * {@link lookup} triggers on a missing or corrupt object.
3663
+ *
3664
+ * Every S3 call THIS METHOD makes is a GET, so a caller that must perform no
3665
+ * S3 write (`cdkd scrub --dry-run`) can read the entries here — with ONE
3666
+ * documented exception, which such a caller has to know about: when another
3667
+ * caller on the same instance already has a `doLoad` in flight, the first
3668
+ * line below awaits IT, and `doLoad` rebuilds (a PUT) on a missing or
3669
+ * corrupt object. Unreachable from `cdkd scrub`, which never calls `lookup`
3670
+ * and drives one store per region serially — but the guarantee is the thing
3671
+ * a future no-write caller would rely on, so it is stated with its bound
3672
+ * rather than unconditionally.
3673
+ *
3674
+ * THREE OUTCOMES, and only ONE of them is `undefined`. A MISSING object
3675
+ * returns `undefined`: there is nothing to audit and nothing to repair. A
3676
+ * body that does not PARSE, and an `indexVersion` this binary cannot
3677
+ * interpret, both THROW — because the caller's question is "what does this
3678
+ * index hold", and for those two the honest answer is "this call could not
3679
+ * find out". Collapsing the corrupt case into `undefined` reads as an empty
3680
+ * index, so a caller reports nothing over bytes it never interpreted, which
3681
+ * for `cdkd scrub` is a green gate over a file that may still hold the
3682
+ * plaintext (issue [#2667](https://github.com/go-to-k/cdkd/issues/2667)) —
3683
+ * the exact shape that issue exists to close, one level down. `doLoad`'s own
3684
+ * corrupt arm is unaffected: it REBUILDS, which is a write, and is why this
3685
+ * read cannot share it.
3686
+ *
3687
+ * The returned map is a COPY, and a `patchEntry` on this same instance
3688
+ * afterwards writes under the etag this read cached rather than re-reading.
3689
+ */
3690
+ async readPersistedEntries() {
3691
+ if (this.loadState.kind === "loading") await this.loadState.promise;
3692
+ if (this.loadState.kind === "loaded") return new Map(this.loadState.entries);
3693
+ const outcome = await this.loadPersisted();
3694
+ if (outcome.kind === "missing") return void 0;
3695
+ if (outcome.kind === "corrupt") throw new Error(`Exports index at ${this.indexKey()} could not be parsed (${describeParseFailure(outcome.error, outcome.bytes)}). Its contents were NOT examined.`);
3696
+ return new Map(outcome.entries);
3697
+ }
3698
+ /**
3600
3699
  * Patch a single entry into the index after a `lookup` miss fell back
3601
3700
  * to a state.json scan and found the value. Lightweight write that
3602
3701
  * does NOT require a full rebuild.
3702
+ *
3703
+ * Returns whether the index now holds `entry` — `false` when the write
3704
+ * was abandoned (a non-retryable S3 failure, or an exhausted If-Match
3705
+ * retry budget), which `runWithRetry` logs and does not throw. A caller
3706
+ * for which an unwritten entry is the outcome it must report — `cdkd
3707
+ * scrub`, where the entry keeps whatever value it held — reads this
3708
+ * return; the deploy path treats the index as a perf hint and ignores it.
3603
3709
  */
3604
- async patchEntry(exportName, entry) {
3605
- await this.enqueueWrite("patch", () => this.applyPatch(exportName, entry));
3710
+ async patchEntry(exportName, entry, opts = {}) {
3711
+ return this.enqueueWrite("patch", () => this.applyPatch(exportName, entry, opts.requireOwner));
3606
3712
  }
3607
3713
  /**
3608
3714
  * Drop all entries owned by `(stackName, producerRegion)`. Used after
@@ -3623,7 +3729,7 @@ var ExportIndexStore = class {
3623
3729
  */
3624
3730
  async enqueueWrite(label, op) {
3625
3731
  const next = this.writeChain.then(() => this.runWithRetry(label, op));
3626
- this.writeChain = next.catch(() => {});
3732
+ this.writeChain = next.then(() => {}, () => {});
3627
3733
  return next;
3628
3734
  }
3629
3735
  /**
@@ -3658,31 +3764,54 @@ var ExportIndexStore = class {
3658
3764
  };
3659
3765
  await promise;
3660
3766
  }
3767
+ /**
3768
+ * GET the index object and, when it parses, install it as `loadState`.
3769
+ * Performs no PUT: the missing / corrupt REBUILD lives one level up in
3770
+ * {@link doLoad}, so {@link readPersistedEntries} can share this parse
3771
+ * without writing. A `indexVersion` above this binary's still throws
3772
+ * here rather than reporting `corrupt` — a rebuild would overwrite an
3773
+ * index a newer binary wrote with this binary's view of it.
3774
+ */
3775
+ async loadPersisted() {
3776
+ const raw = await this.readIndexRaw();
3777
+ if (raw === null) return { kind: "missing" };
3778
+ const { body, etag } = raw;
3779
+ let parsed;
3780
+ try {
3781
+ parsed = JSON.parse(body);
3782
+ } catch (err) {
3783
+ return {
3784
+ kind: "corrupt",
3785
+ error: err,
3786
+ bytes: utf8ByteLength(body)
3787
+ };
3788
+ }
3789
+ if (typeof parsed.indexVersion !== "number" || parsed.indexVersion > 1) throw new Error(`Exports index uses indexVersion ${describeIndexVersion(parsed.indexVersion)} which is newer than this cdkd binary supports (max ${1}). Upgrade cdkd.`);
3790
+ const entries = /* @__PURE__ */ new Map();
3791
+ for (const [name, entry] of Object.entries(parsed.exports ?? {})) entries.set(name, entry);
3792
+ this.loadState = {
3793
+ kind: "loaded",
3794
+ etag,
3795
+ entries
3796
+ };
3797
+ return {
3798
+ kind: "loaded",
3799
+ entries
3800
+ };
3801
+ }
3661
3802
  async doLoad() {
3662
3803
  try {
3663
- const raw = await this.readIndexRaw();
3664
- if (raw === null) {
3804
+ const outcome = await this.loadPersisted();
3805
+ if (outcome.kind === "missing") {
3665
3806
  this.logger.info("Exports index missing; rebuilding from state.json files");
3666
3807
  await this.rebuild();
3667
3808
  return;
3668
3809
  }
3669
- const { body, etag } = raw;
3670
- let parsed;
3671
- try {
3672
- parsed = JSON.parse(body);
3673
- } catch (err) {
3674
- this.logger.warn(`Exports index corrupt (${err instanceof Error ? err.message : String(err)}); rebuilding from state.json files`);
3810
+ if (outcome.kind === "corrupt") {
3811
+ this.logger.warn(`Exports index corrupt (${outcome.error instanceof Error ? outcome.error.message : String(outcome.error)}); rebuilding from state.json files`);
3675
3812
  await this.rebuild();
3676
3813
  return;
3677
3814
  }
3678
- if (typeof parsed.indexVersion !== "number" || parsed.indexVersion > 1) throw new Error(`Exports index uses indexVersion ${String(parsed.indexVersion)} which is newer than this cdkd binary supports (max ${1}). Upgrade cdkd.`);
3679
- const entries = /* @__PURE__ */ new Map();
3680
- for (const [name, entry] of Object.entries(parsed.exports ?? {})) entries.set(name, entry);
3681
- this.loadState = {
3682
- kind: "loaded",
3683
- etag,
3684
- entries
3685
- };
3686
3815
  } catch (err) {
3687
3816
  this.loadState = { kind: "unloaded" };
3688
3817
  throw err;
@@ -3764,7 +3893,7 @@ var ExportIndexStore = class {
3764
3893
  }
3765
3894
  async applyStackUpdate(stackName, producerRegion, outputs) {
3766
3895
  await this.ensureLoaded();
3767
- if (this.loadState.kind !== "loaded") return;
3896
+ if (this.loadState.kind !== "loaded") return false;
3768
3897
  const next = new Map(this.loadState.entries);
3769
3898
  for (const [name, entry] of next) if (entry.producerStack === stackName && entry.producerRegion === producerRegion) next.delete(name);
3770
3899
  for (const [name, value] of Object.entries(outputs)) {
@@ -3775,8 +3904,8 @@ var ExportIndexStore = class {
3775
3904
  producerRegion
3776
3905
  });
3777
3906
  }
3778
- if (mapsEqual(this.loadState.entries, next)) return;
3779
- await this.persist(next);
3907
+ if (mapsEqual(this.loadState.entries, next)) return true;
3908
+ return this.persist(next);
3780
3909
  }
3781
3910
  /**
3782
3911
  * Two stacks publishing one export name (issue #2193). CloudFormation
@@ -3793,27 +3922,34 @@ var ExportIndexStore = class {
3793
3922
  const safeName = displaySafe(exportName);
3794
3923
  this.logger.warn(`Export '${safeName}' is published by both '${existing.producerStack}' (${existing.producerRegion}) and '${stackName}' (${producerRegion}); the exports index keeps the latest writer, so an Fn::ImportValue on it binds to whichever deployed last. CloudFormation refuses a second producer of the same export name — rename one of the two exports. (If '${existing.producerStack}' predates cdkd's state schema v9, this is its stale entry for a plain output of that name, and its next deploy clears it.)`);
3795
3924
  }
3796
- async applyPatch(exportName, entry) {
3925
+ async applyPatch(exportName, entry, requireOwner) {
3797
3926
  await this.ensureLoaded();
3798
- if (this.loadState.kind !== "loaded") return;
3927
+ if (this.loadState.kind !== "loaded") return false;
3928
+ if (requireOwner) {
3929
+ const current = this.loadState.entries.get(exportName);
3930
+ if (!current || current.producerStack !== requireOwner.producerStack || current.producerRegion !== requireOwner.producerRegion) {
3931
+ this.logger.warn(`Exports index entry ownership changed under a patch; refusing to write it. Expected producer '${displaySafe(requireOwner.producerStack)}' (${displaySafe(requireOwner.producerRegion)}), found ${current ? `'${displaySafe(current.producerStack)}' (${displaySafe(current.producerRegion)})` : "no entry"}.`);
3932
+ return false;
3933
+ }
3934
+ }
3799
3935
  const next = new Map(this.loadState.entries);
3800
3936
  next.set(exportName, entry);
3801
- await this.persist(next);
3937
+ return this.persist(next);
3802
3938
  }
3803
3939
  async applyRemoveStack(stackName, producerRegion) {
3804
3940
  await this.ensureLoaded();
3805
- if (this.loadState.kind !== "loaded") return;
3941
+ if (this.loadState.kind !== "loaded") return false;
3806
3942
  const next = new Map(this.loadState.entries);
3807
3943
  let changed = false;
3808
3944
  for (const [name, entry] of next) if (entry.producerStack === stackName && entry.producerRegion === producerRegion) {
3809
3945
  next.delete(name);
3810
3946
  changed = true;
3811
3947
  }
3812
- if (!changed) return;
3813
- await this.persist(next);
3948
+ if (!changed) return true;
3949
+ return this.persist(next);
3814
3950
  }
3815
3951
  async persist(entries) {
3816
- if (this.loadState.kind !== "loaded") return;
3952
+ if (this.loadState.kind !== "loaded") return false;
3817
3953
  const file = {
3818
3954
  indexVersion: 1,
3819
3955
  region: this.region,
@@ -3826,12 +3962,20 @@ var ExportIndexStore = class {
3826
3962
  etag,
3827
3963
  entries
3828
3964
  };
3965
+ return true;
3829
3966
  }
3967
+ /**
3968
+ * Run `op` under the If-Match retry budget. `true` means `op` reported the
3969
+ * index now holds what it was asked to write (a PUT, or a no-op skip whose
3970
+ * result was already there); `false` means this store abandoned the write
3971
+ * and logged the warn below. It does NOT throw on either arm — `state.json`
3972
+ * is the canonical record for the deploy path, and a caller that needs the
3973
+ * failure surfaced reads the return value (see {@link patchEntry}).
3974
+ */
3830
3975
  async runWithRetry(label, op) {
3831
3976
  let lastErr;
3832
3977
  for (let attempt = 0; attempt < this.opts.maxWriteRetries; attempt++) try {
3833
- await op();
3834
- return;
3978
+ return await op();
3835
3979
  } catch (err) {
3836
3980
  lastErr = err;
3837
3981
  if (this.isPreconditionFailed(err)) {
@@ -3841,9 +3985,10 @@ var ExportIndexStore = class {
3841
3985
  continue;
3842
3986
  }
3843
3987
  this.logger.warn(`Exports index ${label} failed (non-retryable): ${err instanceof Error ? err.message : String(err)}; continuing without index update`);
3844
- return;
3988
+ return false;
3845
3989
  }
3846
3990
  this.logger.warn(`Exports index ${label} exhausted ${this.opts.maxWriteRetries} retries due to concurrent writers; continuing without index update. Last error: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
3991
+ return false;
3847
3992
  }
3848
3993
  isNoSuchKey(err) {
3849
3994
  if (!err || typeof err !== "object") return false;
@@ -31089,11 +31234,13 @@ var ApiGatewayV2Provider = class {
31089
31234
  const apiId = response.ApiId;
31090
31235
  const apiEndpoint = response.ApiEndpoint;
31091
31236
  this.logger.debug(`Successfully created API Gateway V2 Api ${logicalId}: ${apiId}`);
31237
+ const executeApiArn = await this.buildExecuteApiArn(apiId);
31092
31238
  return {
31093
31239
  physicalId: apiId,
31094
31240
  attributes: {
31095
31241
  ApiId: apiId,
31096
- ApiEndpoint: apiEndpoint
31242
+ ApiEndpoint: apiEndpoint,
31243
+ ...executeApiArn !== void 0 ? { ExecuteApiArn: executeApiArn } : {}
31097
31244
  }
31098
31245
  };
31099
31246
  } catch (error) {
@@ -31117,6 +31264,52 @@ var ApiGatewayV2Provider = class {
31117
31264
  throw new ProvisioningError(`Failed to delete API Gateway V2 Api ${logicalId}: ${error instanceof Error ? error.message : String(error)}`, resourceType, logicalId, physicalId, cause);
31118
31265
  }
31119
31266
  }
31267
+ /**
31268
+ * `arn:{partition}:execute-api:{region}:{account}:{apiId}` — the ARN AWS
31269
+ * documents for an API Gateway v2 API, which no API call returns.
31270
+ *
31271
+ * Refuses to record a FABRICATED account, exactly as
31272
+ * `SSMParameterProvider.buildParameterArn` does: an ARN naming a placeholder
31273
+ * account is worse than an absent one, because it is persisted into
31274
+ * `state.json` and outlives the deploy that produced it. The partition is
31275
+ * DERIVED through the same closed region mapping `${AWS::Partition}` uses,
31276
+ * and the region segment is canonicalized — defence in depth rather than the
31277
+ * only fold, since `getAccountInfo` already canonicalizes what it returns
31278
+ * (the issue #1795 / #1814 class: an unfolded `US-EAST-1` matches no IAM
31279
+ * policy and is persisted).
31280
+ *
31281
+ * **NEVER THROWS, and callers depend on it.** Every call site is INSIDE the
31282
+ * `try` of an operation that has already created or found the API, so an
31283
+ * escape here would report a failed create over a resource that exists —
31284
+ * an orphan. A failure means the attribute is absent, which is the state
31285
+ * every deploy before this change was in.
31286
+ *
31287
+ * Takes NO `SecretMaskingContext`, where its SSM twin takes one, and the
31288
+ * difference is not an oversight: every interpolated value here is
31289
+ * non-secret BY CONSTRUCTION — an AWS-minted api id (or, on import, the
31290
+ * CLI's own `--resource` argument), a derived partition, a region, an
31291
+ * account id — and the catch names the error CLASS rather than its message.
31292
+ * `SSMParameterProvider.buildParameterArn` needs a masker because it
31293
+ * interpolates `error.message`, which quotes a resolved parameter value
31294
+ * back. **Widening this catch to `error.message` therefore requires
31295
+ * threading the masker in the same change**; the invariant is what makes
31296
+ * the omission safe, not the current absence of a caller.
31297
+ */
31298
+ async buildExecuteApiArn(apiId) {
31299
+ try {
31300
+ const region = await this.getClient().config.region();
31301
+ const accountInfo = await getAccountInfo(region);
31302
+ if (accountInfo.fabricated) {
31303
+ this.logger.warn(`Cannot determine the AWS account (STS is unreachable, and the resolved account id is a placeholder), so the ExecuteApiArn attribute for API ${apiId} would be fabricated and is NOT recorded. An Fn::GetAtt on it will fail until a later deploy resolves the account.`);
31304
+ return;
31305
+ }
31306
+ const { partition } = derivePartitionAndUrlSuffix(accountInfo.region);
31307
+ return `arn:${partition}:execute-api:${canonicalizeRegion(accountInfo.region)}:${accountInfo.accountId}:${apiId}`;
31308
+ } catch (error) {
31309
+ this.logger.warn(`Could not build the ExecuteApiArn attribute for API ${apiId} (${error instanceof Error ? error.name : typeof error}), so it is NOT recorded. An Fn::GetAtt on it will fail until a later deploy records it.`);
31310
+ return;
31311
+ }
31312
+ }
31120
31313
  getApiAttribute(physicalId, attributeName) {
31121
31314
  if (attributeName === "ApiId") return physicalId;
31122
31315
  }
@@ -31541,10 +31734,15 @@ var ApiGatewayV2Provider = class {
31541
31734
  }
31542
31735
  const explicit = resolveExplicitPhysicalId(input, null);
31543
31736
  if (explicit) try {
31544
- await this.getClient().send(new GetApiCommand({ ApiId: explicit }));
31737
+ const got = await this.getClient().send(new GetApiCommand({ ApiId: explicit }));
31738
+ const executeApiArn = await this.buildExecuteApiArn(explicit);
31545
31739
  return {
31546
31740
  physicalId: explicit,
31547
- attributes: {}
31741
+ attributes: {
31742
+ ApiId: explicit,
31743
+ ...got.ApiEndpoint !== void 0 ? { ApiEndpoint: got.ApiEndpoint } : {},
31744
+ ...executeApiArn !== void 0 ? { ExecuteApiArn: executeApiArn } : {}
31745
+ }
31548
31746
  };
31549
31747
  } catch (err) {
31550
31748
  if (err instanceof NotFoundException$3) return null;
@@ -31623,11 +31821,25 @@ var ApiGatewayV2Provider = class {
31623
31821
  }
31624
31822
  this.logger.debug(`Updating API Gateway V2 Api ${logicalId}: ${physicalId}`);
31625
31823
  try {
31626
- if (changed) await this.getClient().send(new UpdateApiCommand(input));
31824
+ let apiEndpoint;
31825
+ if (changed) apiEndpoint = (await this.getClient().send(new UpdateApiCommand(input))).ApiEndpoint;
31627
31826
  if (corsRemoved) {
31628
31827
  this.logger.debug(`Clearing CORS configuration for Api ${logicalId}: ${physicalId}`);
31629
31828
  await this.getClient().send(new DeleteCorsConfigurationCommand({ ApiId: physicalId }));
31630
31829
  }
31830
+ if (apiEndpoint !== void 0) {
31831
+ const executeApiArn = await this.buildExecuteApiArn(physicalId);
31832
+ if (executeApiArn !== void 0) return {
31833
+ physicalId,
31834
+ wasReplaced: false,
31835
+ attributes: {
31836
+ ApiId: physicalId,
31837
+ ApiEndpoint: apiEndpoint,
31838
+ ExecuteApiArn: executeApiArn
31839
+ }
31840
+ };
31841
+ this.logger.debug(`Skipping the ExecuteApiArn heal for API ${physicalId}: the ARN could not be built. The previously recorded attributes are kept.`);
31842
+ }
31631
31843
  return {
31632
31844
  physicalId,
31633
31845
  wasReplaced: false
@@ -63333,12 +63545,30 @@ function valuesEqual(a, b) {
63333
63545
  * the expression that state stores post-redaction.
63334
63546
  *
63335
63547
  * `storedOutputs` is the bag currently in state. It is consulted for exactly
63336
- * one decision — the LITERAL `Export.Name` of a secret-bearing stack, see issue
63337
- * #1942 at that branch and never merged into the result: everything else here
63548
+ * two decisions — the LITERAL `Export.Name` of a secret-bearing stack, see issue
63549
+ * #1942 at that branch, and whether a skipped-output record still applies, see
63550
+ * issue #2740 below — and never merged into the result: everything else here
63338
63551
  * previews what the template says, and reading state anywhere else would make
63339
63552
  * the preview agree with the past instead of with the next deploy.
63340
- */
63341
- async function resolveTemplateOutputs(template, resolveFn, conditions, storedOutputs) {
63553
+ *
63554
+ * `skippedOutputKeys` (issue #2740) are the keys the LAST DEPLOY could not
63555
+ * resolve and skipped, already narrowed by the caller on BOTH halves of the
63556
+ * binding rule: the recorded digest still matches today's template, AND no
63557
+ * resource the output references is changing on this run
63558
+ * (`bindingSkippedOutputs`, over a pre-resolution snapshot and this run's
63559
+ * resource diff — see `skipped-outputs.ts` for why the snapshot is the
63560
+ * caller's and why the change map cannot come from the digest). Such a key,
63561
+ * while still absent from `storedOutputs`, is previewed as ABSENT — skipped
63562
+ * without resolving, no bag entry, no row — because that is what the next
63563
+ * deploy will leave in state: this resolver runs
63564
+ * with `skipDynamicReferences`, so a failure that happened inside a secret
63565
+ * lookup would otherwise assemble cleanly and preview an `ADD` the deploy will
63566
+ * never perform. NOT a resolution failure: the sibling outputs are previewed
63567
+ * normally, so a genuine change beside the broken output still renders and
63568
+ * `--fail` still exits 1 for it. The key appearing in state (a later deploy
63569
+ * resolved it) ends the suppression.
63570
+ */
63571
+ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOutputs, skippedOutputKeys) {
63342
63572
  const logger = getLogger().child("OutputsDiff");
63343
63573
  const outputs = Object.create(null);
63344
63574
  const exportNames = /* @__PURE__ */ new Set();
@@ -63379,6 +63609,10 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
63379
63609
  logger.debug(`Skipping output ${outputKey} — condition ${output.Condition} is false`);
63380
63610
  continue;
63381
63611
  }
63612
+ if (skippedOutputKeys?.has(outputKey) && !(storedOutputs !== void 0 && Object.prototype.hasOwnProperty.call(storedOutputs, outputKey))) {
63613
+ logger.debug(`Diff previewing output ${stripControlChars(outputKey)} as absent — the last deploy could not resolve it and its template inputs are unchanged`);
63614
+ continue;
63615
+ }
63382
63616
  const sourceUsedSub = templateUsesSub(output.Value);
63383
63617
  let value;
63384
63618
  try {
@@ -64706,6 +64940,10 @@ function tokenValueForComparison(token, declaredType) {
64706
64940
  async function computeStackDiff(currentState, template, region, stackName, stateBackend, diffCalculator, options = {}) {
64707
64941
  const { parameters, canonicalizeProperties, cfnFallback, inheritSecretBearingTemplate } = options;
64708
64942
  const intrinsicResolver = new IntrinsicFunctionResolver(region, { cfnFallback: cfnFallback ?? true });
64943
+ const outputsDigestSource = structuredClone({
64944
+ ...template,
64945
+ Resources: {}
64946
+ });
64709
64947
  let mergedParameters = parameters;
64710
64948
  let parametersBound = false;
64711
64949
  const tokenParameterNames = new Set(Object.entries(parameters ?? {}).filter(([, value]) => carriesDynamicReference(value)).map(([name]) => name));
@@ -64755,7 +64993,9 @@ async function computeStackDiff(currentState, template, region, stackName, state
64755
64993
  skipDynamicReferences: true
64756
64994
  });
64757
64995
  const changes = await diffCalculator.calculateDiff(currentState, effectiveTemplate, resolveFn, canonicalizeProperties);
64758
- const resolved = await resolveTemplateOutputs(effectiveTemplate, resolveFn, conditions, currentState.outputs);
64996
+ const changedLogicalIds = new Set([...changes.values()].filter((c) => c.changeType !== "NO_CHANGE").map((c) => c.logicalId));
64997
+ const bindingSkipped = bindingSkippedOutputs(outputsDigestSource, currentState.skippedOutputs, changedLogicalIds);
64998
+ const resolved = await resolveTemplateOutputs(effectiveTemplate, resolveFn, conditions, currentState.outputs, bindingSkipped);
64759
64999
  let outputChanges = [];
64760
65000
  if (resolved.resolutionFailed) {
64761
65001
  if (computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys, {
@@ -65399,6 +65639,24 @@ var ScrubNeededError = class ScrubNeededError extends CdkdError {
65399
65639
  * leak" from "scrub refused to look" — the two call for opposite responses
65400
65640
  * (rotate the secret vs. re-spell the reference and re-run).
65401
65641
  */
65642
+ /**
65643
+ * An internal invariant of the exports-index repair, thrown where this code
65644
+ * believes a case is unreachable (issue #2667 review).
65645
+ *
65646
+ * A distinct CLASS because the repair runs inside the per-stack `try` whose
65647
+ * `catch` attributes everything to "the exports index could not be READ": a
65648
+ * plain `Error` there is reported as an S3 read failure, sending an operator to
65649
+ * their bucket policy over a bug in cdkd. That catch rethrows this class
65650
+ * instead, so it surfaces as the per-stack failure it actually is.
65651
+ */
65652
+ var ScrubIndexInvariantError = class ScrubIndexInvariantError extends CdkdError {
65653
+ exitCode = 2;
65654
+ constructor(message) {
65655
+ super(message, "SCRUB_EXPORT_INDEX_INVARIANT");
65656
+ this.name = "ScrubIndexInvariantError";
65657
+ Object.setPrototypeOf(this, ScrubIndexInvariantError.prototype);
65658
+ }
65659
+ };
65402
65660
  var ScrubRefusalError = class ScrubRefusalError extends CdkdError {
65403
65661
  exitCode = 2;
65404
65662
  constructor(message, code) {
@@ -65443,6 +65701,140 @@ function scrubStacksFailedError(failures) {
65443
65701
  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");
65444
65702
  }
65445
65703
  /**
65704
+ * Which of `entries` this stack's post-scrub `outputs` bag disagrees with.
65705
+ *
65706
+ * OWNERSHIP is read off the entry (`producerStack` / `producerRegion`), never
65707
+ * inferred from the name: a pre-v9 state record makes `importableOutputKeys`
65708
+ * return every output key (`src/types/state.ts`), so a rebuild can key an entry
65709
+ * by a plain output name, and on a name collision the index keeps the latest
65710
+ * writer — so a name alone says nothing about whose entry it is.
65711
+ *
65712
+ * The rule is CONVERGENCE, not a match against scrub's plaintext map. The map
65713
+ * is built by LIVE RESOLUTION against Secrets Manager / SSM, so it holds the
65714
+ * secret's value AS IT IS NOW; an index entry written before a rotation holds
65715
+ * the value as it was THEN, and the two are different strings. Keying on
65716
+ * `state.outputs` instead means the rule reads the record a redeploy and
65717
+ * `ExportIndexStore.rebuild()` both write, and the plaintext map is not
65718
+ * consulted here at all.
65719
+ *
65720
+ * The lookup runs in ONE direction, and the inverse is not available: an
65721
+ * `Export.Name` alias is written into the outputs bag itself
65722
+ * (`deploy-engine.ts`, beside `resolvedExportNames.push(exportName)`), so an
65723
+ * index entry NAME is a key of that bag — while `ExportIndexEntry` carries no
65724
+ * output-key field, so nothing maps an entry back to the output it came from.
65725
+ *
65726
+ * WHAT CAN STILL GO WRONG. A name absent from `outputs` is reported and
65727
+ * nothing is written for it, so an entry in that position keeps its value —
65728
+ * `outputs` is `undefined` here whenever the stack has no state record at all,
65729
+ * which puts every one of its entries in that arm. An entry whose state value
65730
+ * carries no `{{resolve:` is left alone, so a plaintext an index entry holds
65731
+ * whose state counterpart is also plaintext is not reported by this pass and
65732
+ * not written by it. And `entries` is one region's index: an entry in another
65733
+ * region's `exports.json` is outside this call.
65734
+ */
65735
+ function planExportIndexRepair(entries, stackName, producerRegion, outputs) {
65736
+ const examined = [];
65737
+ const findings = [];
65738
+ for (const [exportName, entry] of entries) {
65739
+ if (entry.producerStack !== stackName || entry.producerRegion !== producerRegion) continue;
65740
+ examined.push(exportName);
65741
+ if (outputs === void 0 || !Object.hasOwn(outputs, exportName)) {
65742
+ findings.push({
65743
+ kind: "absent",
65744
+ exportName
65745
+ });
65746
+ continue;
65747
+ }
65748
+ const stateValue = outputs[exportName];
65749
+ if (!carriesDynamicReference(stateValue)) continue;
65750
+ if (JSON.stringify(entry.value) === JSON.stringify(stateValue)) continue;
65751
+ findings.push({
65752
+ kind: "converge",
65753
+ exportName,
65754
+ stateValue
65755
+ });
65756
+ }
65757
+ return {
65758
+ examined,
65759
+ findings
65760
+ };
65761
+ }
65762
+ /**
65763
+ * The error a run ends with when `state.json` was rewritten and the exports
65764
+ * index was not (issue #2667).
65765
+ *
65766
+ * EXPLICIT rather than a warn: an entry this run went to converge and did not
65767
+ * keeps the value it held, in a region-wide object every principal with
65768
+ * `s3:GetObject` on the state bucket can read. `ExportIndexStore.patchEntry`
65769
+ * does not throw on a non-retryable S3 failure or an exhausted If-Match budget
65770
+ * — it logs and returns `false` — so without this the command would exit 0
65771
+ * over exactly that.
65772
+ *
65773
+ * Naming the remainder is what makes the re-run instruction actionable: the
65774
+ * convergence rule reads `state.outputs`, which a completed half already
65775
+ * matches, so a second invocation of the same command decides `converge` only
65776
+ * for the entries still differing.
65777
+ */
65778
+ function exportIndexIncompleteError(unwritten, unreadable, dryRun) {
65779
+ const parts = [dryRun ? "no state written (--dry-run)" : "state.json complete"];
65780
+ if (unwritten.length > 0) parts.push(`${unwritten.length} exports index entr${unwritten.length === 1 ? "y" : "ies"} unwritten (${unwritten.map((u) => `${u.shown} in ${u.region}`).join(", ")})`);
65781
+ if (unreadable.length > 0) parts.push(`${unreadable.length} region(s) whose exports index could not be read (${unreadable.map((u) => `${u.region}: ${u.reason}`).join("; ")})`);
65782
+ return new ScrubRefusalError(`${parts.join("; ")}. Re-run the same command once the cause is cleared: each entry is written only while its value differs from the producer's state.outputs, so a re-run writes the remainder and leaves the rest alone.`, "SCRUB_EXPORT_INDEX_INCOMPLETE");
65783
+ }
65784
+ /**
65785
+ * Converge one scrubbed stack's entries in one region's exports index.
65786
+ *
65787
+ * Run as its OWN step by `scrubCommand`, after `scrubStack` returned — never
65788
+ * from inside the resolver. `scrubStack`'s resolve contexts deliberately
65789
+ * withhold `exportIndex` so `resolveImportValue`'s scan arm cannot `patchEntry`
65790
+ * as a side effect of resolution, at a point in the run nothing chose; that
65791
+ * withholding is unchanged, and this is a separate call with its own condition.
65792
+ *
65793
+ * Under `dryRun` no `patchEntry` is issued. The READ still happens:
65794
+ * `readPersistedEntries` is a GET that does not rebuild, so the audit half runs
65795
+ * without an S3 write.
65796
+ *
65797
+ * TWO PROPERTIES OF THE WRITE a reader will ask about. It runs OUTSIDE the
65798
+ * stack lock — `scrubStack` releases that in its own `finally` — because the
65799
+ * lock guards `state.json`, one key per stack, while `exports.json` is shared
65800
+ * region-wide and carries its own If-Match optimistic lock plus bounded retry;
65801
+ * that is the same concurrency control the deploy path's index write relies on.
65802
+ * And it is ONE PutObject PER ENTRY rather than one for the batch: `patchEntry`
65803
+ * is the operation that changes a value without touching membership, and a
65804
+ * remediation command pays that round trip so a failure names the entry it
65805
+ * could not write.
65806
+ */
65807
+ async function repairExportIndexForStack(store, stackName, producerRegion, outputs, opts) {
65808
+ const entries = await store.readPersistedEntries();
65809
+ if (!entries) return {
65810
+ examined: [],
65811
+ findings: [],
65812
+ unwritten: []
65813
+ };
65814
+ const plan = planExportIndexRepair(entries, stackName, producerRegion, outputs);
65815
+ const unwritten = [];
65816
+ if (opts.dryRun) return {
65817
+ ...plan,
65818
+ unwritten
65819
+ };
65820
+ for (const finding of plan.findings) {
65821
+ if (finding.kind !== "converge") continue;
65822
+ const entry = entries.get(finding.exportName);
65823
+ if (!entry) throw new ScrubIndexInvariantError("exports index repair: a planned entry is absent from the snapshot it was planned from");
65824
+ if (!await store.patchEntry(finding.exportName, {
65825
+ ...entry,
65826
+ value: finding.stateValue
65827
+ }, { requireOwner: {
65828
+ producerStack: stackName,
65829
+ producerRegion
65830
+ } })) unwritten.push(finding.exportName);
65831
+ }
65832
+ return {
65833
+ ...plan,
65834
+ unwritten
65835
+ };
65836
+ }
65837
+ /**
65446
65838
  * `cdkd scrub` — rewrite persisted state so any resolved secret dynamic
65447
65839
  * reference is stored as its UNRESOLVED expression rather than the plaintext
65448
65840
  * value (GHSA fix). "Secret" here is whatever the RESOLVER classifies as one,
@@ -65462,10 +65854,14 @@ function scrubStacksFailedError(failures) {
65462
65854
  * re-resolves each resource's properties to learn the resolved secret VALUES
65463
65855
  * (recorded, never printed or re-persisted), and replaces those values in the
65464
65856
  * state record's `properties` / `attributes` / `observedProperties` with the
65465
- * expression. No AWS resource is created, updated, or deleted; only state.json
65466
- * is rewritten. This is why it is a top-level command and not `cdkd state
65467
- * scrub` — the `cdkd state ...` family operates on the state bucket alone and
65468
- * deliberately needs no CDK code.
65857
+ * expression. No AWS resource is created, updated, or deleted. What it WRITES
65858
+ * is the state bucket: each targeted stack's state.json, and since issue
65859
+ * [#2667](https://github.com/go-to-k/cdkd/issues/2667) — the entries a scrubbed
65860
+ * stack publishes in the shared exports index at
65861
+ * `{prefix}/_index/{region}/exports.json`, as a separate step after that
65862
+ * stack's state.json write. This is why it is a top-level command and not
65863
+ * `cdkd state scrub` — the `cdkd state ...` family operates on the state bucket
65864
+ * alone and deliberately needs no CDK code.
65469
65865
  *
65470
65866
  * IMPORTANT: scrubbing does not un-expose an already-leaked secret. A value
65471
65867
  * that was stored in plaintext should be treated as compromised and ROTATED at
@@ -65567,6 +65963,20 @@ async function scrubCommand(stacks, options) {
65567
65963
  let totalStacksWithUnscrubbableKeys = 0;
65568
65964
  let totalStacksWithUnverifiableReads = 0;
65569
65965
  const failures = [];
65966
+ const exportIndexStores = /* @__PURE__ */ new Map();
65967
+ const exportIndexFor = (indexRegion) => {
65968
+ const existing = exportIndexStores.get(indexRegion);
65969
+ if (existing) return existing;
65970
+ const store = new ExportIndexStore(stateS3.s3, stateBucket, options.statePrefix, indexRegion, stateBackend);
65971
+ exportIndexStores.set(indexRegion, store);
65972
+ return store;
65973
+ };
65974
+ const examinedIndexNames = /* @__PURE__ */ new Map();
65975
+ let totalIndexEntriesConverged = 0;
65976
+ let totalIndexEntriesAbsent = 0;
65977
+ let totalIndexEntriesUnexamined = 0;
65978
+ const indexUnwritten = [];
65979
+ const indexUnreadable = [];
65570
65980
  for (const stack of targetStacks) {
65571
65981
  const stackRegion = stack.region || region;
65572
65982
  let scrubbed;
@@ -65583,10 +65993,51 @@ async function scrubCommand(stacks, options) {
65583
65993
  if (err instanceof Error && err.stack) logger.debug("Stack trace:", err.stack);
65584
65994
  continue;
65585
65995
  }
65996
+ let indexConverged = 0;
65997
+ try {
65998
+ const repair = await repairExportIndexForStack(exportIndexFor(stackRegion), stack.stackName, stackRegion, scrubbed.outputs, { dryRun: options.dryRun ?? false });
65999
+ const seen = examinedIndexNames.get(stackRegion) ?? /* @__PURE__ */ new Set();
66000
+ for (const name of repair.examined) seen.add(name);
66001
+ examinedIndexNames.set(stackRegion, seen);
66002
+ const named = (exportName) => {
66003
+ const shown = scrubbed.exportNameDisplay(exportName);
66004
+ switch (shown.kind) {
66005
+ case "safe": return `'${shown.text}'`;
66006
+ case "masked": return `(masked: "${shown.text}")`;
66007
+ case "withheld": return "(name withheld: it holds a secret this run recorded)";
66008
+ }
66009
+ };
66010
+ const unwrittenNames = new Set(repair.unwritten);
66011
+ for (const exportName of repair.unwritten) indexUnwritten.push({
66012
+ region: stackRegion,
66013
+ shown: named(exportName)
66014
+ });
66015
+ for (const finding of repair.findings) if (finding.kind === "converge") {
66016
+ indexConverged++;
66017
+ if (!options.dryRun && unwrittenNames.has(finding.exportName)) logger.warn(`Exports index entry ${named(finding.exportName)} (${stackRegion}) differs from ${stack.stackName}'s state.outputs and could NOT be written — it keeps the value it holds.`);
66018
+ else {
66019
+ totalIndexEntriesConverged++;
66020
+ logger.info(`${options.dryRun ? "Would converge" : "Converged"} exports index entry ${named(finding.exportName)} (${stackRegion}) to ${stack.stackName}'s state.outputs value.`);
66021
+ }
66022
+ } else {
66023
+ totalIndexEntriesAbsent++;
66024
+ logger.warn(`Exports index entry ${named(finding.exportName)} (${stackRegion}) is published by ${stack.stackName}, whose state.outputs has no key of that name — nothing was written for it and it keeps the value it holds. Redeploy ${stack.stackName} to rewrite the index from its own outputs.`);
66025
+ }
66026
+ } catch (err) {
66027
+ if (err instanceof ScrubIndexInvariantError) throw err;
66028
+ if (!indexUnreadable.some((u) => u.region === stackRegion)) {
66029
+ const reason = displaySafe(err instanceof Error ? err.message : String(err));
66030
+ indexUnreadable.push({
66031
+ region: stackRegion,
66032
+ reason
66033
+ });
66034
+ logger.error(`Exports index for ${stackRegion} could not be read (first seen while scrubbing ${stack.stackName}): ${reason}`);
66035
+ }
66036
+ }
65586
66037
  if (scrubbed.recordsChanged > 0) {
65587
66038
  totalStacksScrubbed++;
65588
66039
  logger.info(`${options.dryRun ? "Would scrub" : "Scrubbed"} ${scrubbed.recordsChanged} resource record(s) in ${stack.stackName}`);
65589
- } else if (scrubbed.secretBearingKeys === 0 && scrubbed.unverifiableReads === 0) logger.info(`No plaintext secrets found in ${stack.stackName}`);
66040
+ } else if (scrubbed.secretBearingKeys === 0 && scrubbed.unverifiableReads === 0 && indexConverged === 0) logger.info(`No plaintext secrets found in ${stack.stackName}`);
65590
66041
  if (scrubbed.unverifiableReads > 0) {
65591
66042
  totalStacksWithUnverifiableReads++;
65592
66043
  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.`);
@@ -65596,7 +66047,28 @@ async function scrubCommand(stacks, options) {
65596
66047
  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).`);
65597
66048
  }
65598
66049
  }
65599
- if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0 && totalStacksWithUnverifiableReads === 0) {
66050
+ for (const [indexRegion, store] of exportIndexStores) {
66051
+ if (indexUnreadable.some((u) => u.region === indexRegion)) continue;
66052
+ let entries;
66053
+ try {
66054
+ entries = await store.readPersistedEntries();
66055
+ } catch (err) {
66056
+ const reason = displaySafe(err instanceof Error ? err.message : String(err));
66057
+ indexUnreadable.push({
66058
+ region: indexRegion,
66059
+ reason
66060
+ });
66061
+ logger.error(`Exports index for ${indexRegion} could not be read for the coverage report: ${reason}`);
66062
+ continue;
66063
+ }
66064
+ if (!entries) continue;
66065
+ const examined = examinedIndexNames.get(indexRegion) ?? /* @__PURE__ */ new Set();
66066
+ const unexamined = [...entries.keys()].filter((name) => !examined.has(name));
66067
+ if (unexamined.length === 0) continue;
66068
+ totalIndexEntriesUnexamined += unexamined.length;
66069
+ logger.info(`Exports index (${indexRegion}): ${unexamined.length} entr${unexamined.length === 1 ? "y is" : "ies are"} published by a producer this run did not scrub, so nothing here read ${unexamined.length === 1 ? "its" : "their"} value. Coverage composes across the apps sharing this bucket and region — run each app's own cdkd scrub to reach its entries.`);
66070
+ }
66071
+ if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0 && totalStacksWithUnverifiableReads === 0 && totalIndexEntriesConverged === 0 && indexUnwritten.length === 0 && indexUnreadable.length === 0) {
65600
66072
  if (failures.length === 0) {
65601
66073
  logger.info("\nNo plaintext secrets found in any target stack state. Nothing to scrub.");
65602
66074
  return;
@@ -65607,16 +66079,25 @@ async function scrubCommand(stacks, options) {
65607
66079
  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.` : "";
65608
66080
  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.` : "";
65609
66081
  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.` : "";
66082
+ const indexNote = (() => {
66083
+ const parts = [];
66084
+ if (totalIndexEntriesConverged > 0) parts.push(options.dryRun ? ` ${totalIndexEntriesConverged} exports index entr${totalIndexEntriesConverged === 1 ? "y" : "ies"} differ from the producer's scrubbed state.outputs and would be converged to it (--dry-run, nothing written).` : ` ${totalIndexEntriesConverged} exports index entr${totalIndexEntriesConverged === 1 ? "y" : "ies"} converged to the producer's state.outputs value. exports.json is written with the same PutObject state.json is, so on a VERSIONED bucket the pre-repair body survives as a noncurrent version and stays readable with GetObject and a VersionId.`);
66085
+ if (totalIndexEntriesAbsent > 0) parts.push(` ${totalIndexEntriesAbsent} exports index entr${totalIndexEntriesAbsent === 1 ? "y is" : "ies are"} published by a scrubbed stack under a name its state.outputs does not carry; nothing was written for ${totalIndexEntriesAbsent === 1 ? "it" : "them"} — redeploy that producer.`);
66086
+ if (totalIndexEntriesUnexamined > 0) parts.push(` ${totalIndexEntriesUnexamined} exports index entr${totalIndexEntriesUnexamined === 1 ? "y" : "ies"} belong to producers outside this run and were NOT examined.`);
66087
+ return parts.join("");
66088
+ })();
65610
66089
  if (options.dryRun) {
65611
- 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.`);
65612
- else logger.info(`\nPlan: nothing can be scrubbed.${keyNote}${unverifiableNote}${failureNote} ROTATE any exposed secret.`);
66090
+ if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote}${unverifiableNote}${indexNote}${failureNote} ROTATE any exposed secret in Secrets Manager.`);
66091
+ else logger.info(`\nPlan: no state record can be rewritten.${keyNote}${unverifiableNote}${indexNote}${failureNote} ROTATE any exposed secret.`);
65613
66092
  if (failures.length > 0) throw scrubStacksFailedError(failures);
66093
+ if (indexUnwritten.length > 0 || indexUnreadable.length > 0) throw exportIndexIncompleteError(indexUnwritten, indexUnreadable, true);
65614
66094
  if (options.fail) throw new ScrubNeededError();
65615
66095
  return;
65616
66096
  }
65617
- if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The CURRENT state.json no longer holds the plaintext. Where the state bucket is VERSIONED — which cdkd bootstrap enables — the pre-scrub body survives as a noncurrent version, stays readable with GetObject and a VersionId, and scrub does not purge it. So a value that was ever persisted must be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating); rotation is what makes any surviving version harmless.${keyNote}${unverifiableNote}${failureNote}`);
65618
- else logger.info(`\nNothing could be rewritten.${keyNote}${unverifiableNote}${failureNote} ROTATE any exposed secret.`);
66097
+ if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The CURRENT state.json no longer holds the plaintext. Where the state bucket is VERSIONED — which cdkd bootstrap enables — the pre-scrub body survives as a noncurrent version, stays readable with GetObject and a VersionId, and scrub does not purge it. So a value that was ever persisted must be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating); rotation is what makes any surviving version harmless.${keyNote}${unverifiableNote}${indexNote}${failureNote}`);
66098
+ else logger.info(`\nNo state record was rewritten.${keyNote}${unverifiableNote}${indexNote}${failureNote} ROTATE any exposed secret.`);
65619
66099
  if (failures.length > 0) throw scrubStacksFailedError(failures);
66100
+ if (indexUnwritten.length > 0 || indexUnreadable.length > 0) throw exportIndexIncompleteError(indexUnwritten, indexUnreadable, false);
65620
66101
  if (options.fail && (totalStacksWithUnscrubbableKeys > 0 || totalStacksWithUnverifiableReads > 0)) throw new ScrubNeededError();
65621
66102
  }
65622
66103
  /**
@@ -67215,9 +67696,14 @@ function makeCrossStackPrePass(deps) {
67215
67696
  /**
67216
67697
  * Scrub one stack's state. Re-resolves the template's per-resource properties to
67217
67698
  * learn the resolved secret VALUES, then replaces those values in the state
67218
- * record with their `{{resolve:...}}` expressions. Returns counts; performs no
67219
- * AWS mutation. Acquires the stack lock for the read-modify-write unless
67220
- * `dryRun`.
67699
+ * record with their `{{resolve:...}}` expressions. Returns counts. It mutates no
67700
+ * AWS RESOURCE, but it does write S3: the state object itself, and the stack
67701
+ * lock it acquires for the read-modify-write unless `dryRun` (issue #2667 added
67702
+ * a third write, the cross-stack exports index, in `repairExportIndexForStack` —
67703
+ * a separate step outside this function). This comment is no longer the
67704
+ * "performs no AWS mutation" it used to say (go-to-k/cdkd#2878): that was FALSE
67705
+ * about the lock even before #2667, and it wrapped across a line break, which
67706
+ * is why a line-oriented grep for the claim did not return it.
67221
67707
  *
67222
67708
  * The stack OUTPUTS bag is scrubbed too, in two passes: today's declared
67223
67709
  * outputs are redacted BY POSITION against the template, and a stored key
@@ -67240,7 +67726,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
67240
67726
  recordsChanged: 0,
67241
67727
  secretsFound: 0,
67242
67728
  secretBearingKeys: 0,
67243
- unverifiableReads: 0
67729
+ unverifiableReads: 0,
67730
+ exportNameDisplay: (name) => secretSafeKeyDisplay(name, outputSecrets)
67244
67731
  };
67245
67732
  }
67246
67733
  const state = loaded.state;
@@ -67281,109 +67768,113 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
67281
67768
  logger.debug(`Condition evaluation skipped for ${stack.stackName}: ${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}`);
67282
67769
  }
67283
67770
  const templateResources = stack.template.Resources ?? {};
67284
- for (const logicalId of Object.keys(state.resources)) {
67285
- const templateResource = templateResources[logicalId];
67286
- if (!templateResource?.Properties) continue;
67287
- const recordedSecretValues = /* @__PURE__ */ new Map();
67288
- perResourceSecrets.set(logicalId, recordedSecretValues);
67289
- const resolveInput = await pinCrossRegionSecrets(templateResource.Properties, stack.stackName, {
67290
- stackRegion: region,
67291
- producerRegions,
67292
- resolvers,
67293
- recordedSecretValues,
67294
- origin: `resource '${logicalId}'`
67295
- });
67296
- const resourceContext = resolverContext(recordedSecretValues);
67297
- await resolveCrossStackReads(resolveInput, resourceContext, `resource '${logicalId}'`);
67298
- try {
67299
- await resolver.resolve(resolveInput, resourceContext);
67300
- } catch (err) {
67301
- if (isRegionAmbiguousRefusal(err)) throw err;
67302
- logger.debug(`Resolution of ${logicalId} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}`);
67303
- }
67304
- perResourceTemplateProps.set(logicalId, templateResource.Properties);
67305
- }
67306
67771
  const templateOutputs = stack.template.Outputs ?? {};
67307
67772
  const declaredOutputNames = collectDeclaredOutputNames(templateOutputs);
67308
67773
  const accountedOutputKeys = new Set(declaredOutputNames);
67309
67774
  const ambiguousKeys = /* @__PURE__ */ new Set();
67310
67775
  const collisions = [];
67311
67776
  let outputsSourceUntrusted = false;
67312
- for (const [name, output] of Object.entries(templateOutputs)) {
67313
- const declaredExportName = output.Export?.Name;
67314
- let exportName = declaredExportName;
67315
- if (declaredExportName !== void 0 && typeof declaredExportName !== "string") {
67316
- const nameSecrets = new SharedEntriesSecrets(outputSecrets);
67317
- let nameFailed = false;
67318
- let nameError;
67319
- const nameSource = await pinCrossRegionSecrets(declaredExportName, stack.stackName, {
67777
+ await withSharedDrainBudget(async () => {
67778
+ for (const logicalId of Object.keys(state.resources)) {
67779
+ const templateResource = templateResources[logicalId];
67780
+ if (!templateResource?.Properties) continue;
67781
+ const recordedSecretValues = /* @__PURE__ */ new Map();
67782
+ perResourceSecrets.set(logicalId, recordedSecretValues);
67783
+ const resolveInput = await pinCrossRegionSecrets(templateResource.Properties, stack.stackName, {
67320
67784
  stackRegion: region,
67321
67785
  producerRegions,
67322
67786
  resolvers,
67323
- recordedSecretValues: nameSecrets,
67324
- origin: `Export.Name of output '${name}'`
67787
+ recordedSecretValues,
67788
+ origin: `resource '${logicalId}'`
67325
67789
  });
67326
- const nameContext = resolverContext(nameSecrets);
67327
- await resolveCrossStackReads(nameSource, nameContext, `Export.Name of output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
67790
+ const resourceContext = resolverContext(recordedSecretValues);
67791
+ await resolveCrossStackReads(resolveInput, resourceContext, `resource '${logicalId}'`);
67328
67792
  try {
67329
- exportName = await resolver.resolve(nameSource, nameContext);
67793
+ await resolver.resolve(resolveInput, resourceContext);
67330
67794
  } catch (err) {
67331
67795
  if (isRegionAmbiguousRefusal(err)) throw err;
67332
- nameFailed = true;
67333
- nameError = err;
67796
+ logger.debug(`Resolution of ${logicalId} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}`);
67334
67797
  }
67335
- if (nameFailed) {
67798
+ perResourceTemplateProps.set(logicalId, templateResource.Properties);
67799
+ }
67800
+ for (const [name, output] of Object.entries(templateOutputs)) {
67801
+ const declaredExportName = output.Export?.Name;
67802
+ let exportName = declaredExportName;
67803
+ if (declaredExportName !== void 0 && typeof declaredExportName !== "string") {
67804
+ const nameSecrets = new SharedEntriesSecrets(outputSecrets);
67805
+ let nameFailed = false;
67806
+ let nameError;
67807
+ const nameSource = await pinCrossRegionSecrets(declaredExportName, stack.stackName, {
67808
+ stackRegion: region,
67809
+ producerRegions,
67810
+ resolvers,
67811
+ recordedSecretValues: nameSecrets,
67812
+ origin: `Export.Name of output '${name}'`
67813
+ });
67814
+ const nameContext = resolverContext(nameSecrets);
67815
+ await resolveCrossStackReads(nameSource, nameContext, `Export.Name of output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
67816
+ try {
67817
+ exportName = await resolver.resolve(nameSource, nameContext);
67818
+ } catch (err) {
67819
+ if (isRegionAmbiguousRefusal(err)) throw err;
67820
+ nameFailed = true;
67821
+ nameError = err;
67822
+ }
67823
+ if (nameFailed) {
67824
+ outputsSourceUntrusted = true;
67825
+ logger.warn(`Export.Name of output ${name} could not be resolved during scrub (${maskSecretsInText(nameError instanceof Error ? nameError.message : String(nameError), 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.`);
67826
+ }
67827
+ }
67828
+ if (declaredExportName !== void 0 && typeof exportName !== "string") outputsSourceUntrusted = true;
67829
+ const exportNameUnresolved = declaredExportName !== void 0 && typeof exportName === "string" && isUnresolvedValue(exportName, templateUsesSub(declaredExportName));
67830
+ if (typeof exportName === "string" && !exportNameUnresolved) accountedOutputKeys.add(exportName);
67831
+ if (exportNameUnresolved) {
67336
67832
  outputsSourceUntrusted = true;
67337
- logger.warn(`Export.Name of output ${name} could not be resolved during scrub (${maskSecretsInText(nameError instanceof Error ? nameError.message : String(nameError), 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.`);
67833
+ logger.warn(`Export.Name of output ${name} did not fully resolve during scrub — 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.`);
67834
+ } else if (typeof exportName === "string" && isExportAliasCollision(exportName, name, declaredOutputNames)) {
67835
+ ambiguousKeys.add(exportName);
67836
+ collisions.push([name, exportName]);
67338
67837
  }
67339
67838
  }
67340
- if (declaredExportName !== void 0 && typeof exportName !== "string") outputsSourceUntrusted = true;
67341
- const exportNameUnresolved = declaredExportName !== void 0 && typeof exportName === "string" && isUnresolvedValue(exportName, templateUsesSub(declaredExportName));
67342
- if (typeof exportName === "string" && !exportNameUnresolved) accountedOutputKeys.add(exportName);
67343
- if (exportNameUnresolved) {
67344
- outputsSourceUntrusted = true;
67345
- logger.warn(`Export.Name of output ${name} did not fully resolve during scrub — 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.`);
67346
- } else if (typeof exportName === "string" && isExportAliasCollision(exportName, name, declaredOutputNames)) {
67347
- ambiguousKeys.add(exportName);
67348
- collisions.push([name, exportName]);
67349
- }
67350
- }
67351
- for (const [name, output] of Object.entries(templateOutputs)) {
67352
- const value = output.Value;
67353
- if (value === void 0) continue;
67354
- if (!ambiguousKeys.has(name)) outputsTemplateSource[name] = value;
67355
- const exportName = output.Export?.Name;
67356
- if (typeof exportName === "string" && !ambiguousKeys.has(exportName)) outputsTemplateSource[exportName] = value;
67357
- const valueSource = await pinCrossRegionSecrets(value, stack.stackName, {
67358
- stackRegion: region,
67359
- producerRegions,
67360
- resolvers,
67361
- recordedSecretValues: outputSecrets,
67362
- origin: `output '${name}'`
67363
- });
67364
- const valueContext = resolverContext(outputSecrets);
67365
- await resolveCrossStackReads(valueSource, valueContext, `output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
67366
- try {
67367
- await resolver.resolve(valueSource, valueContext);
67368
- } catch (err) {
67369
- if (isRegionAmbiguousRefusal(err)) throw err;
67370
- logger.debug(`Resolution of output ${name} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}`);
67839
+ for (const [name, output] of Object.entries(templateOutputs)) {
67840
+ const value = output.Value;
67841
+ if (value === void 0) continue;
67842
+ if (!ambiguousKeys.has(name)) outputsTemplateSource[name] = value;
67843
+ const exportName = output.Export?.Name;
67844
+ if (typeof exportName === "string" && !ambiguousKeys.has(exportName)) outputsTemplateSource[exportName] = value;
67845
+ const valueSource = await pinCrossRegionSecrets(value, stack.stackName, {
67846
+ stackRegion: region,
67847
+ producerRegions,
67848
+ resolvers,
67849
+ recordedSecretValues: outputSecrets,
67850
+ origin: `output '${name}'`
67851
+ });
67852
+ const valueContext = resolverContext(outputSecrets);
67853
+ await resolveCrossStackReads(valueSource, valueContext, `output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
67854
+ try {
67855
+ await resolver.resolve(valueSource, valueContext);
67856
+ } catch (err) {
67857
+ if (isRegionAmbiguousRefusal(err)) throw err;
67858
+ logger.debug(`Resolution of output ${name} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}`);
67859
+ }
67371
67860
  }
67372
- }
67861
+ });
67373
67862
  for (const [name, exportName] of collisions) logger.warn(exportAliasCollisionScrubWarning(name, exportName, outputSecrets));
67374
67863
  const secretBearingKeys = [];
67375
67864
  for (const key of Object.keys(state.outputs ?? {})) {
67376
- const exposure = stateKeySecretExposure(key, outputSecrets);
67377
- if (!exposure) continue;
67865
+ const display = secretSafeKeyDisplay(key, outputSecrets);
67866
+ if (!secretBearing(display)) continue;
67378
67867
  secretBearingKeys.push(key);
67379
- logger.warn(secretBearingStateKeyWarning(stack.stackName, key, exposure));
67868
+ logger.warn(secretBearingStateKeyWarning(stack.stackName, display));
67380
67869
  }
67381
67870
  const totalSecrets = outputSecrets.size + [...perResourceSecrets.values()].reduce((n, m) => n + m.size, 0);
67382
67871
  if (totalSecrets === 0) return {
67383
67872
  recordsChanged: 0,
67384
67873
  secretsFound: 0,
67385
67874
  secretBearingKeys: secretBearingKeys.length,
67386
- unverifiableReads: prePassFindings.unverifiable.length
67875
+ unverifiableReads: prePassFindings.unverifiable.length,
67876
+ outputs: state.outputs,
67877
+ exportNameDisplay: (name) => secretSafeKeyDisplay(name, outputSecrets)
67387
67878
  };
67388
67879
  let recordsChanged = 0;
67389
67880
  const newResources = {};
@@ -67402,8 +67893,9 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
67402
67893
  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));
67403
67894
  if (JSON.stringify(newOutputs) !== JSON.stringify(state.outputs)) recordsChanged++;
67404
67895
  if (recordsChanged > 0 && !opts.dryRun) {
67896
+ const { skippedOutputs: _droppedByScrub, ...carriedState } = state;
67405
67897
  const nextState = {
67406
- ...state,
67898
+ ...carriedState,
67407
67899
  resources: newResources,
67408
67900
  outputs: newOutputs,
67409
67901
  lastModified: Date.now()
@@ -67414,7 +67906,9 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
67414
67906
  recordsChanged,
67415
67907
  secretsFound: totalSecrets,
67416
67908
  secretBearingKeys: secretBearingKeys.length,
67417
- unverifiableReads: prePassFindings.unverifiable.length
67909
+ unverifiableReads: prePassFindings.unverifiable.length,
67910
+ outputs: newOutputs,
67911
+ exportNameDisplay: (name) => secretSafeKeyDisplay(name, outputSecrets)
67418
67912
  };
67419
67913
  } catch (err) {
67420
67914
  throw maskSecretsInError(err, allRecordedSecrets(outputSecrets, perResourceSecrets));
@@ -69759,8 +70253,9 @@ async function runAccept(reports, stateBackend, stateConfig, awsClients, options
69759
70253
  properties: redactedBaseline
69760
70254
  };
69761
70255
  }
70256
+ const { skippedOutputs: _droppedByAccept, ...carriedState } = report.state;
69762
70257
  const newState = {
69763
- ...report.state,
70258
+ ...carriedState,
69764
70259
  resources,
69765
70260
  lastModified: Date.now()
69766
70261
  };
@@ -70580,8 +71075,9 @@ async function runRevert(reports, providerRegistry, stateBackend, stateConfig, a
70580
71075
  };
70581
71076
  }
70582
71077
  if (recordedCount === 0) continue;
71078
+ const { skippedOutputs: _droppedByRevert, ...carriedState } = report.state;
70583
71079
  const newState = {
70584
- ...report.state,
71080
+ ...carriedState,
70585
71081
  resources,
70586
71082
  lastModified: Date.now()
70587
71083
  };
@@ -74799,7 +75295,7 @@ async function runPerStackImportLoop(args) {
74799
75295
  const uploadCleanups = [];
74800
75296
  const importedStacks = [];
74801
75297
  const paramResolver = new IntrinsicFunctionResolver(rootRegion);
74802
- const { paramsByCdkdName, intrinsicSkippedByCdkdName: sessionIntrinsicSkipped } = await buildResolvedParametersPerStack({
75298
+ const { paramsByCdkdName, intrinsicSkippedByCdkdName: sessionIntrinsicSkipped } = await withSharedDrainBudget(() => buildResolvedParametersPerStack({
74803
75299
  rootStackName,
74804
75300
  rootParameters,
74805
75301
  perStackNodes: perStackPlans.map((p) => ({
@@ -74810,7 +75306,7 @@ async function runPerStackImportLoop(args) {
74810
75306
  tree,
74811
75307
  resolver: paramResolver,
74812
75308
  stateBackend: deps.stateBackend
74813
- });
75309
+ }));
74814
75310
  for (const [cdkdName, skipped] of sessionIntrinsicSkipped) logger.warn(` Child '${cdkdName}': could not resolve intrinsic-valued Parameter(s) ${skipped.join(", ")} at IMPORT time. The child template's Parameter Default values must cover these — otherwise CFn will reject the IMPORT.`);
74815
75311
  try {
74816
75312
  for (let i = 0; i < perStackPlans.length; i++) {
@@ -76371,7 +76867,7 @@ async function stateRefreshObservedCommand(stackArgs, options) {
76371
76867
  * Shared rather than constructed per resource because nothing writes to it:
76372
76868
  * `redactSecretsForState` only reads its argument.
76373
76869
  */
76374
- const NO_RECORDED_SECRETS = /* @__PURE__ */ new Map();
76870
+ const NO_RECORDED_SECRETS$1 = /* @__PURE__ */ new Map();
76375
76871
  /**
76376
76872
  * Warn before `cdkd state orphan` force-releases a lock that is still LIVE.
76377
76873
  *
@@ -76484,7 +76980,7 @@ async function refreshObservedForStack(stackName, region, stateBackend, lockMana
76484
76980
  unsupported++;
76485
76981
  return;
76486
76982
  }
76487
- resource.observedProperties = redactSecretsForState(observed, NO_RECORDED_SECRETS, resource.properties ?? {}, STATE_SOURCED_READBACK_RULES);
76983
+ resource.observedProperties = redactSecretsForState(observed, NO_RECORDED_SECRETS$1, resource.properties ?? {}, STATE_SOURCED_READBACK_RULES);
76488
76984
  refreshed++;
76489
76985
  } catch (err) {
76490
76986
  failed++;
@@ -76494,6 +76990,7 @@ async function refreshObservedForStack(stackName, region, stateBackend, lockMana
76494
76990
  await Promise.all(tasks);
76495
76991
  });
76496
76992
  state.lastModified = Date.now();
76993
+ if (refreshed > 0) delete state.skippedOutputs;
76497
76994
  const saveOptions = { expectedEtag: etag };
76498
76995
  if (migrationPending) saveOptions.migrateLegacy = true;
76499
76996
  await stateBackend.saveState(stackName, region, state, saveOptions);
@@ -76785,8 +77282,9 @@ async function rollbackCommand(stackArg, options) {
76785
77282
  };
76786
77283
  let currentEtag = stateData.etag;
76787
77284
  const saveState = async () => {
77285
+ const { skippedOutputs: _droppedByRollback, ...carriedState } = baseState;
76788
77286
  const next = () => ({
76789
- ...baseState,
77287
+ ...carriedState,
76790
77288
  version: 9,
76791
77289
  region,
76792
77290
  resources: { ...stateResources },
@@ -78830,9 +79328,10 @@ async function rewriteResourceReferences(state, orphanLogicalIds, providerRegist
78830
79328
  }
78831
79329
  const newOutputs = {};
78832
79330
  for (const [name, value] of Object.entries(state.outputs ?? {})) newOutputs[name] = await rewriteValue(value, `outputs.${name}`, `<output:${name}>`, orphanSet, fetcher, rewrites, unresolvable);
79331
+ const { skippedOutputs: _droppedByOrphanRewrite, ...carriedState } = state;
78833
79332
  return {
78834
79333
  state: {
78835
- ...state,
79334
+ ...carriedState,
78836
79335
  resources: newResources,
78837
79336
  outputs: newOutputs,
78838
79337
  lastModified: Date.now()
@@ -79771,8 +80270,7 @@ async function importCommand(stackArg, options) {
79771
80270
  }
79772
80271
  }
79773
80272
  const stackState = buildStackState(stackInfo.stackName, targetRegion, rows, templateParser, stateTemplate, existingState, selectiveMode);
79774
- await resolveImportedProperties(stackState, stateTemplate, targetRegion, stateBackend, logger);
79775
- await captureObservedForImportedResources(stackState, providerRegistry, logger);
80273
+ await captureObservedForImportedResources(stackState, providerRegistry, logger, await withSharedDrainBudget(() => resolveImportedProperties(stackState, stateTemplate, targetRegion, stateBackend, logger)));
79776
80274
  const saveOptions = {};
79777
80275
  if (existingEtag) saveOptions.expectedEtag = existingEtag;
79778
80276
  if (migrationPending) saveOptions.migrateLegacy = true;
@@ -80205,14 +80703,90 @@ function defaultOnlyParameterTemplate(template) {
80205
80703
  * already-stored, which on the v3 baseline is already resolved-shape
80206
80704
  * from a prior import / deploy, so re-resolving is a no-op.
80207
80705
  *
80706
+ * RETURNS the logical ids for which an `observedProperties` baseline must NOT
80707
+ * be captured, because this walk cannot vouch that the persisted `properties`
80708
+ * still SPELL the dynamic reference the template had.
80709
+ * `captureObservedForImportedResources` refuses to write a baseline for those
80710
+ * (issue [#2828](https://github.com/go-to-k/cdkd/issues/2828) review); the
80711
+ * redaction it performs is POSITION-based, so a bag that no longer spells the
80712
+ * reference gives it no evidence and the DECRYPTED readback is persisted in
80713
+ * the clear.
80714
+ *
80715
+ * The predicate is deliberately CONSERVATIVE — it over-refuses — and that is a
80716
+ * decision a review round paid for. Two arms:
80717
+ *
80718
+ * - **Resolution THREW** — refuse, unconditionally. A throw means the
80719
+ * resolver could not finish, so nothing about the persisted bag is
80720
+ * trustworthy evidence. This is BEHAVIOURALLY IDENTICAL to the arm this PR
80721
+ * first shipped: two attempts to sharpen it in between each shipped a leak
80722
+ * (the wrapped-complete-token shape, then the
80723
+ * reference-sourced-from-outside-the-bag shape), and saying so is the
80724
+ * strongest available form of "every sharpening leaked". The arm's own
80725
+ * comment carries both measurements.
80726
+ * - **Resolution SUCCEEDED but LOST an opener.** An `Fn::If` whose condition
80727
+ * was DOWNGRADED resolves without throwing and selects the branch AWS did
80728
+ * not take, so `properties` hold the false-branch literal and no reference
80729
+ * at all. The downgrade needs a parameter with NO `Default` — `cdkd import`
80730
+ * accepts no parameter VALUES from the user, but a `Default`-carrying
80731
+ * parameter IS bound (issue #2321's retry), so only an unbindable one leaves
80732
+ * `evaluateConditions` catching per condition and recording `false`.
80733
+ *
80734
+ * ARM 2 IS KNOWN OVER-BROAD for a PUBLIC `{{resolve:ssm:...}}` reference: a
80735
+ * `String` / `StringList` parameter is stored RESOLVED (issue #1901), so its
80736
+ * opener disappears across the resolve and the resource is refused although
80737
+ * nothing was ever secret, costing it a drift baseline on a common config
80738
+ * pattern. Threading the resolve's own `recordedSecretValues` into the
80739
+ * capture corrects it -- measured on issue
80740
+ * [#2852](https://github.com/go-to-k/cdkd/issues/2852), which carries that
80741
+ * remedy -- because a populated map makes absence from it real evidence of
80742
+ * a public parameter, exactly as the deploy path already relies on.
80743
+ *
80744
+ * The cost of a false refusal is one resource's drift baseline until its next
80745
+ * deploy; the cost of a false pass is a plaintext secret in `state.json`. So an
80746
+ * unmeasurable bag refuses too.
80747
+ *
80748
+ * WHAT CAN STILL GO WRONG. No claim is made here about what this closes, and
80749
+ * that is deliberate: successive review rounds falsified a closure claim, then
80750
+ * a closed-set list, then a "reduces" claim -- the mechanism survived every
80751
+ * round of measurement and the SENTENCES about it did not. What follows states only
80752
+ * the DANGER direction, which can become false only once the residue is gone.
80753
+ *
80754
+ * PLAINTEXT CAN STILL BE PERSISTED by this capture. Known ways, each with its
80755
+ * issue and none of them proven to be all of them:
80756
+ *
80757
+ * - the resolve drops a reference, or trades one away, in a manner the opener
80758
+ * comparison below cannot see --
80759
+ * [#2850](https://github.com/go-to-k/cdkd/issues/2850);
80760
+ * - a parameter binds to a placeholder `Default` while the DEPLOYED value was
80761
+ * the reference, so no opener exists in any template this walk is handed --
80762
+ * [#2854](https://github.com/go-to-k/cdkd/issues/2854);
80763
+ * - `redactSecretsForState` cannot PAIR the readback against the source and
80764
+ * returns the bag unchanged: a reshaped container, an identity key AWS
80765
+ * normalised, a readback key the source lacks, an array whose anchors do not
80766
+ * corroborate -- [#2852](https://github.com/go-to-k/cdkd/issues/2852), which
80767
+ * carries the measured probe table;
80768
+ * - the readback holds a secret with NO counterpart in the source at all, set
80769
+ * out of band -- [#2868](https://github.com/go-to-k/cdkd/issues/2868), filed
80770
+ * as a remit question rather than a defect in this mechanism.
80771
+ *
80772
+ * WHY NO EXHAUSTIVE LIST IS OFFERED, as mechanism rather than framing: this
80773
+ * redaction's only evidence is a POST-HOC comparison between the persisted bag
80774
+ * and the readback, so anything that comparison cannot see is trusted.
80775
+ * Establishing more needs PROVENANCE carried out of the resolver or the
80776
+ * deployer, which is where the structural remedy for all of the above lives.
80777
+ *
80778
+ * The set is returned rather than recorded on the state, because it describes
80779
+ * THIS run's resolution and nothing downstream of the save has any use for it.
80780
+ *
80208
80781
  * Exported for unit testing — internal to the command flow otherwise. The
80209
80782
  * masking this walk applies (issue #2803) is only provable against the REAL
80210
80783
  * resolver, since the plaintext reaches the message through the resolver's
80211
80784
  * own throw rather than through anything this function writes.
80212
80785
  */
80213
80786
  async function resolveImportedProperties(stackState, template, region, stateBackend, logger) {
80787
+ const unsafeObservedBaselineLogicalIds = /* @__PURE__ */ new Set();
80214
80788
  const entries = Object.entries(stackState.resources);
80215
- if (entries.length === 0) return;
80789
+ if (entries.length === 0) return unsafeObservedBaselineLogicalIds;
80216
80790
  const resolver = new IntrinsicFunctionResolver(region);
80217
80791
  let parameters = {};
80218
80792
  let conditions = {};
@@ -80247,8 +80821,9 @@ async function resolveImportedProperties(stackState, template, region, stateBack
80247
80821
  };
80248
80822
  for (const [logicalId, resource] of entries) {
80249
80823
  const recordedSecretValues = /* @__PURE__ */ new Map();
80824
+ const unresolvedProperties = resource.properties ?? {};
80825
+ let threw = false;
80250
80826
  try {
80251
- const unresolvedProperties = resource.properties ?? {};
80252
80827
  const resolved = await resolver.resolve(unresolvedProperties, {
80253
80828
  ...baseContext,
80254
80829
  recordedSecretValues
@@ -80256,7 +80831,46 @@ async function resolveImportedProperties(stackState, template, region, stateBack
80256
80831
  resource.properties = recordedSecretValues.size > 0 ? redactSecretsForState(resolved, recordedSecretValues, unresolvedProperties) : resolved;
80257
80832
  } catch (err) {
80258
80833
  logger.warn(`Failed to resolve intrinsics in Properties for imported resource '${logicalId}' (${resource.resourceType}): ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}. State will be written with the raw intrinsic shape, which may cause 'cdkd destroy' to fail on this resource — re-import once every referenced sibling is in state, or remove this resource via 'cdkd state orphan'.` + (unboundParameterNames.length > 0 ? ` This template also declares parameter(s) with no 'Default' that an import cannot bind (${unboundParameterNames.join(", ")}), and 'cdkd import' accepts no parameter values — if this property was built from one of those, re-importing a sibling will not change it: give the parameter a 'Default' in the template and re-import, or correct the recorded properties before the next 'cdkd deploy'.` : ""));
80834
+ threw = true;
80835
+ }
80836
+ if (threw) {
80837
+ unsafeObservedBaselineLogicalIds.add(logicalId);
80838
+ continue;
80259
80839
  }
80840
+ const rawOpeners = countDynamicReferenceOpeners(unresolvedProperties);
80841
+ const persistedOpeners = countDynamicReferenceOpeners(resource.properties);
80842
+ if (rawOpeners === void 0 || persistedOpeners === void 0 || persistedOpeners < rawOpeners) unsafeObservedBaselineLogicalIds.add(logicalId);
80843
+ }
80844
+ return unsafeObservedBaselineLogicalIds;
80845
+ }
80846
+ /** The opener every dynamic reference starts with, secret-bearing or not. */
80847
+ const DYNAMIC_REFERENCE_OPENER = "{{resolve:";
80848
+ /**
80849
+ * How many dynamic-reference OPENERS a property bag carries, counted over its
80850
+ * JSON serialization rather than by walking string leaves — which is the whole
80851
+ * point on the RAW side: a token assembled by `Fn::Join` is SPLIT across array
80852
+ * elements, so `{{resolve:secretsmanager:` survives as a fragment that no scan
80853
+ * for a COMPLETE token would see. `secretValueFromJson` renders exactly that
80854
+ * shape, so the split form is the common one, not the exotic one.
80855
+ *
80856
+ * `undefined` when the bag cannot be serialized. Callers treat that as "cannot
80857
+ * vouch for this bag" rather than as zero — the only safe direction, since the
80858
+ * cost of a false refusal is a drift baseline and the cost of a false pass is a
80859
+ * plaintext secret in `state.json`.
80860
+ */
80861
+ function countDynamicReferenceOpeners(bag) {
80862
+ let text;
80863
+ try {
80864
+ text = JSON.stringify(bag ?? {}) ?? "";
80865
+ } catch {
80866
+ return;
80867
+ }
80868
+ let count = 0;
80869
+ for (let from = 0;;) {
80870
+ const at = text.indexOf(DYNAMIC_REFERENCE_OPENER, from);
80871
+ if (at === -1) return count;
80872
+ count++;
80873
+ from = at + 10;
80260
80874
  }
80261
80875
  }
80262
80876
  function printSummary(rows) {
@@ -80348,6 +80962,14 @@ function collectMultiple(value, previous) {
80348
80962
  return [...previous ?? [], value];
80349
80963
  }
80350
80964
  /**
80965
+ * The empty secrets map the observed-capture redaction below passes. Shared
80966
+ * and module-level because `redactSecretsForState` only ever READS its map,
80967
+ * and because an empty one is not an oversight here but the POSITION-only
80968
+ * configuration the call site's note argues for — the same constant
80969
+ * `cdkd state refresh-observed` passes for the same reason.
80970
+ */
80971
+ const NO_RECORDED_SECRETS = /* @__PURE__ */ new Map();
80972
+ /**
80351
80973
  * Populate `observedProperties` for every resource in a freshly-built
80352
80974
  * import StackState by calling the matching provider's
80353
80975
  * `readCurrentState`. Mirrors what `cdkd deploy` does after each
@@ -80366,11 +80988,25 @@ function collectMultiple(value, previous) {
80366
80988
  * (incremental rollout — see `ResourceProvider.readCurrentState`'s
80367
80989
  * doc-comment) keep `observedProperties: undefined`; the drift comparator
80368
80990
  * falls back to `properties` for those, matching pre-v3 behavior.
80991
+ *
80992
+ * Exported for unit testing -- internal to the command flow otherwise. The
80993
+ * input-space matrix drives THIS function rather than replaying its
80994
+ * `redactSecretsForState` call by hand, so a change to the arguments it passes
80995
+ * cannot leave that fence green.
80996
+ *
80997
+ * Every captured bag is REDACTED before it lands on the record, and a resource
80998
+ * whose redaction POSITION SOURCE is unusable is skipped entirely — see the
80999
+ * call site's own note (issue
81000
+ * [#2828](https://github.com/go-to-k/cdkd/issues/2828)).
80369
81001
  */
80370
- async function captureObservedForImportedResources(stackState, providerRegistry, logger) {
81002
+ async function captureObservedForImportedResources(stackState, providerRegistry, logger, unsafeObservedBaselineLogicalIds) {
80371
81003
  const entries = Object.entries(stackState.resources);
80372
81004
  if (entries.length === 0) return;
80373
81005
  await Promise.all(entries.map(async ([logicalId, resource]) => {
81006
+ if (unsafeObservedBaselineLogicalIds.has(logicalId)) {
81007
+ logger.debug(`observedProperties capture SKIPPED for imported ${logicalId} (${resource.resourceType}): the recorded properties no longer spell the template's dynamic reference, so they cannot position a redaction — capturing an AWS readback against them could persist a resolved secret in plaintext. Drift will compare against the recorded properties for this resource until the next successful deploy.`);
81008
+ return;
81009
+ }
80374
81010
  try {
80375
81011
  const provider = providerRegistry.getProviderFor({
80376
81012
  resourceType: resource.resourceType,
@@ -80378,9 +81014,9 @@ async function captureObservedForImportedResources(stackState, providerRegistry,
80378
81014
  }).provider;
80379
81015
  if (!provider.readCurrentState) return;
80380
81016
  const observed = await provider.readCurrentState(resource.physicalId, logicalId, resource.resourceType, resource.properties ?? {}, buildReadCurrentStateContext(stackState, logicalId));
80381
- if (observed !== void 0) resource.observedProperties = observed;
81017
+ if (observed !== void 0) resource.observedProperties = redactSecretsForState(observed, NO_RECORDED_SECRETS, resource.properties ?? {}, STATE_SOURCED_READBACK_RULES);
80382
81018
  } catch (err) {
80383
- logger.debug(`observedProperties capture for imported ${logicalId} (${resource.resourceType}) failed: ${err instanceof Error ? err.message : String(err)} — drift will fall back to template properties for this resource until the next successful deploy.`);
81019
+ logger.debug(`observedProperties capture for imported ${logicalId} (${resource.resourceType}) failed: ${err instanceof Error ? err.name : typeof err} — drift will fall back to template properties for this resource until the next successful deploy.`);
80384
81020
  }
80385
81021
  }));
80386
81022
  }
@@ -80550,8 +81186,7 @@ async function importNestedStackChildrenRecursive(args) {
80550
81186
  childStackState.parentStack = parentStackName;
80551
81187
  childStackState.parentLogicalId = childLogicalId;
80552
81188
  childStackState.parentRegion = parentRegion;
80553
- await resolveImportedProperties(childStackState, childStateTemplate, childRegion, stateBackend, logger);
80554
- await captureObservedForImportedResources(childStackState, providerRegistry, logger);
81189
+ await captureObservedForImportedResources(childStackState, providerRegistry, logger, await withSharedDrainBudget(() => resolveImportedProperties(childStackState, childStateTemplate, childRegion, stateBackend, logger)));
80555
81190
  await stateBackend.saveState(childStackName, childRegion, childStackState);
80556
81191
  logger.info(`✓ Nested stack state written: ${childStackName} (${childRegion}) — ${rows.filter((r) => r.outcome === "imported").length} resource(s) imported.`);
80557
81192
  if (childTreeNode.nested.size > 0) await importNestedStackChildrenRecursive({
@@ -89782,4 +90417,4 @@ function buildProgram() {
89782
90417
 
89783
90418
  //#endregion
89784
90419
  export { buildProgram };
89785
- //# sourceMappingURL=program-BRon74Fc.js.map
90420
+ //# sourceMappingURL=program-Ci4Y5UO9.js.map