@go-to-k/cdkd 0.284.32 → 0.284.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { $ as endCommandInterruptScope, $t as buildAssetRedirectMap, A as MULTI_REGION_RECREATE_BLOCKED_TYPES, An as resolveStateBucketWithDefaultAndSource, At as maskSecretsInError, B as collectDeclaredOutputNames, Bn as PARTITION_TABLE, Bt as DiffCalculator, C as ccRoutedFinalSnapshotError, Cn as getDefaultStateBucketName, Cr as isThrottlingError, Ct as STATE_SOURCED_CROSS_GENERATION_RULES, D as unsupportedFinalSnapshotError, Dn as resolveCaptureObservedState, Dt as dynamicReferenceTokens, E as refusesFinalSnapshot, En as resolveAutoAssetStorage, Et as createSecretMasker, F as cyan, Fn as CFN_TEMPLATE_URL_LIMIT, Ft as s3BucketDomainName, G as stateKeySecretExposure, Gt as TemplateParser, H as exportAliasCollisionScrubWarning, Hn as derivePartitionAndUrlSuffix, Ht as describeTypeWithThrottleRetry, I as gray, In as MIGRATE_TMP_PREFIX, It as s3BucketDualStackDomainName, J as clearOnUpdateRemoval, Jn as getAwsClients, Jt as rebuildClientForBucketRegion, K as IAMRoleProvider, Kn as resolveBucketRegion, Kt as LockManager, L as green, Ln as findLargeInlineResources, Lt as s3BucketRegionalDomainName, M as renderStatefulReason, Mn as stateBucketExistenceConfirmed, Mt as redactSecretsForState, N as formatResourceLine, Nn as warnDeprecatedNoPrefixCliFlag, Nt as scrubResourceRecord, O as makeCanonicalizePropertiesFn, On as resolveSkipPrefix, Ot as errorCauseChain, P as bold, Pn as CFN_TEMPLATE_BODY_LIMIT, Pt as s3BucketArn, Q as beginCommandInterruptScope, Qn as CdkdError, Qt as WorkGraph, R as red, Rn as uploadCfnTemplate, Rt as s3BucketWebsiteUrl, S as buildFinalSnapshotIdentifier, Sn as synthesisStatusMessage, Sr as isRetryableTransientError, St as requireConfigString, T as isFinalSnapshotError, Tn as resolveApp, Tt as TEMPLATE_SOURCED_RULES, U as isExportAliasCollision, Un as AssemblyReader, Ut as withRetry, V as collectPublishedOutputNames, Vn as canonicalizeRegion, Vt as INTRINSIC_KEYS, W as secretBearingStateKeyWarning, Wn as processStackMessages, Wt as DagBuilder, X as findActionableSilentDrops, Xn as setAwsClients, Xt as AssetPublisher, Y as ProviderRegistry, Yn as resetAwsClients, Yt as shouldRetainResource, Z as findSilentDropProperties, Zt as stringifyValue, _ as maskingRetryLogger, _n as runDockerForeground, _t as configStringRefusal, a as DeploymentEventsStore, an as AssetModeResolver, ar as LocalStartServiceError, at as isTerminationProtectionPropagationError, b as ATOMIC_FINAL_SNAPSHOT_TYPES, bn as getDockerImageBySourceHash, br as withErrorHandling, bt as requireConfigArray, c as planRollback, cn as getBootstrapMarkerKey, cr as NestedStackChildDirectDestroyError, ct as cfnRefValueFromPhysicalId, d as replayRollback, dn as validateAssetBucketName, dr as ResourceTimeoutError, dt as WAFv2WebACLProvider, en as createAssetRedirectResolver, er as CrossAccountSecretRefusalError, et as isInterruptedWaitError, f as updatePartialMessage, fn as validateContainerRepoName, fr as ResourceUpdateNotSupportedError, ft as normalizeAwsTagsToCfn, g as withResourceDeadline, gn as getDockerCmd, gt as configBooleanRefusal, h as deleteSkipReason, hn as formatDockerLoginError, ht as coerceCfnBoolean, i as DeploymentEventsReader, in as stripControlChars, ir as LocalMigrateError, it as disableInstanceApiTermination, j as isStatefulRecreateTargetSync, jn as resolveUseCdkBootstrapAssets, jt as maskSecretsInText, k as extractDeploymentEventError, kn as resolveStateBucketWithDefault, kt as isSingleDynamicReferenceToken, l as producerRegionsFromState, ln as parseBootstrapMarker, lr as PartialFailureError, lt as getAccountInfo, m as UNSPECIFIED_SKIP_REASON, mn as buildDockerImage, mr as StackTerminationProtectionError, mt as assertRegionMatch, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as rewriteTemplateAssetReferences, nr as DeployCancelledError, nt as CloudControlProvider, o as classifyReplaySecretRegion, on as BOOTSTRAP_MARKER_PREFIX, ot as IntrinsicFunctionResolver, p as updatePartialReason, pn as buildDenyExternalAccessPolicy, pr as StackHasActiveImportsError, pt as resolveExplicitPhysicalId, q as collectInlinePolicyNamesManagedBySiblings, qn as AwsClients, qt as S3StateBackend, r as DeployEngine, rn as escapeRegExp, rr as LocalInvokeBuildError$1, rt as slowCcOperationTimeoutMs, s as planFailedOps, sn as ensureAssetStorage, sr as MissingCdkCliError, st as carriesDynamicReference, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as loadPublishableAssetManifest, tt as startInterruptWatch, u as replayFailedOperations, un as readBootstrapMarkerBody, ur as ProvisioningError, ut as refStateLookupFromResource, v as IMPLICIT_DELETE_DEPENDENCIES, vn as runDockerStreaming, vt as readConfigString, w as createPreDeleteFinalSnapshot, wn as getLegacyStateBucketName, wr as markNonRetryable, wt as STATE_SOURCED_READBACK_RULES, x as PRE_DELETE_SNAPSHOT_TYPES, xn as Synthesizer, xr as isMarkedNonRetryable, xt as requireConfigObject, y as computeImplicitDeleteEdges, yn as AssetManifestLoader, yr as normalizeAwsError, yt as replayWarn, z as yellow, zn as expectedOwnerParam, zt as applyRoleArnIfSet } from "./deploy-engine-DsUQ4GxO.js";
2
+ import { $ as endCommandInterruptScope, $t as buildAssetRedirectMap, A as MULTI_REGION_RECREATE_BLOCKED_TYPES, An as resolveStateBucketWithDefaultAndSource, At as maskSecretsInError, B as collectDeclaredOutputNames, Bn as PARTITION_TABLE, Bt as DiffCalculator, C as ccRoutedFinalSnapshotError, Cn as getDefaultStateBucketName, Cr as isThrottlingError, Ct as STATE_SOURCED_CROSS_GENERATION_RULES, D as unsupportedFinalSnapshotError, Dn as resolveCaptureObservedState, Dt as dynamicReferenceTokens, E as refusesFinalSnapshot, En as resolveAutoAssetStorage, Et as createSecretMasker, F as cyan, Fn as CFN_TEMPLATE_URL_LIMIT, Ft as s3BucketDomainName, G as stateKeySecretExposure, Gt as TemplateParser, H as exportAliasCollisionScrubWarning, Hn as derivePartitionAndUrlSuffix, Ht as describeTypeWithThrottleRetry, I as gray, In as MIGRATE_TMP_PREFIX, It as s3BucketDualStackDomainName, J as clearOnUpdateRemoval, Jn as getAwsClients, Jt as rebuildClientForBucketRegion, K as IAMRoleProvider, Kn as resolveBucketRegion, Kt as LockManager, L as green, Ln as findLargeInlineResources, Lt as s3BucketRegionalDomainName, M as renderStatefulReason, Mn as stateBucketExistenceConfirmed, Mt as redactSecretsForState, N as formatResourceLine, Nn as warnDeprecatedNoPrefixCliFlag, Nt as scrubResourceRecord, O as makeCanonicalizePropertiesFn, On as resolveSkipPrefix, Ot as errorCauseChain, P as bold, Pn as CFN_TEMPLATE_BODY_LIMIT, Pt as s3BucketArn, Q as beginCommandInterruptScope, Qn as CdkdError, Qt as WorkGraph, R as red, Rn as uploadCfnTemplate, Rt as s3BucketWebsiteUrl, S as buildFinalSnapshotIdentifier, Sn as synthesisStatusMessage, Sr as isRetryableTransientError, St as requireConfigString, T as isFinalSnapshotError, Tn as resolveApp, Tt as TEMPLATE_SOURCED_RULES, U as isExportAliasCollision, Un as AssemblyReader, Ut as withRetry, V as collectPublishedOutputNames, Vn as canonicalizeRegion, Vt as INTRINSIC_KEYS, W as secretBearingStateKeyWarning, Wn as processStackMessages, Wt as DagBuilder, X as findActionableSilentDrops, Xn as setAwsClients, Xt as AssetPublisher, Y as ProviderRegistry, Yn as resetAwsClients, Yt as shouldRetainResource, Z as findSilentDropProperties, Zt as stringifyValue, _ as maskingRetryLogger, _n as runDockerForeground, _t as configStringRefusal, a as DeploymentEventsStore, an as AssetModeResolver, ar as LocalStartServiceError, at as isTerminationProtectionPropagationError, b as ATOMIC_FINAL_SNAPSHOT_TYPES, bn as getDockerImageBySourceHash, br as withErrorHandling, bt as requireConfigArray, c as planRollback, cn as getBootstrapMarkerKey, cr as NestedStackChildDirectDestroyError, ct as cfnRefValueFromPhysicalId, d as replayRollback, dn as validateAssetBucketName, dr as ResourceTimeoutError, dt as WAFv2WebACLProvider, en as createAssetRedirectResolver, er as CrossAccountSecretRefusalError, et as isInterruptedWaitError, f as updatePartialMessage, fn as validateContainerRepoName, fr as ResourceUpdateNotSupportedError, ft as normalizeAwsTagsToCfn, g as withResourceDeadline, gn as getDockerCmd, gt as configBooleanRefusal, h as deleteSkipReason, hn as formatDockerLoginError, ht as coerceCfnBoolean, i as DeploymentEventsReader, in as stripControlChars, ir as LocalMigrateError, it as disableInstanceApiTermination, j as isStatefulRecreateTargetSync, jn as resolveUseCdkBootstrapAssets, jt as maskSecretsInText, k as extractDeploymentEventError, kn as resolveStateBucketWithDefault, kt as isSingleDynamicReferenceToken, l as producerRegionsFromState, ln as parseBootstrapMarker, lr as PartialFailureError, lt as getAccountInfo, m as UNSPECIFIED_SKIP_REASON, mn as buildDockerImage, mr as StackTerminationProtectionError, mt as assertRegionMatch, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as rewriteTemplateAssetReferences, nr as DeployCancelledError, nt as CloudControlProvider, o as classifyReplaySecretRegion, on as BOOTSTRAP_MARKER_PREFIX, ot as IntrinsicFunctionResolver, p as updatePartialReason, pn as buildDenyExternalAccessPolicy, pr as StackHasActiveImportsError, pt as resolveExplicitPhysicalId, q as collectInlinePolicyNamesManagedBySiblings, qn as AwsClients, qt as S3StateBackend, r as DeployEngine, rn as escapeRegExp, rr as LocalInvokeBuildError$1, rt as slowCcOperationTimeoutMs, s as planFailedOps, sn as ensureAssetStorage, sr as MissingCdkCliError, st as carriesDynamicReference, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as loadPublishableAssetManifest, tt as startInterruptWatch, u as replayFailedOperations, un as readBootstrapMarkerBody, ur as ProvisioningError, ut as refStateLookupFromResource, v as IMPLICIT_DELETE_DEPENDENCIES, vn as runDockerStreaming, vt as readConfigString, w as createPreDeleteFinalSnapshot, wn as getLegacyStateBucketName, wr as markNonRetryable, wt as STATE_SOURCED_READBACK_RULES, x as PRE_DELETE_SNAPSHOT_TYPES, xn as Synthesizer, xr as isMarkedNonRetryable, xt as requireConfigObject, y as computeImplicitDeleteEdges, yn as AssetManifestLoader, yr as normalizeAwsError, yt as replayWarn, z as yellow, zn as expectedOwnerParam, zt as applyRoleArnIfSet } from "./deploy-engine-BxP9k1OS.js";
3
3
  import { a as getLiveRenderer, c as PATTERN_B_RESOURCE_TYPES, d as generateResourceNameWithFallback, f as getCurrentStackName, h as withStackName, i as runStackBuffered, m as withSkipPrefix, n as getLogger, o as PATTERN_B_NAME_OPTIONS, s as PATTERN_B_NAME_PROPERTIES, u as generateResourceName } from "./logger-zRrlbaQt.js";
4
- import { t as ASGProvider } from "./asg-provider-BVzQ7mQX.js";
4
+ import { t as ASGProvider } from "./asg-provider-DZ_xdKkZ.js";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { createHash, randomBytes, randomUUID } from "node:crypto";
7
7
  import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketEncryptionCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketOwnershipControlsCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
@@ -63254,6 +63254,71 @@ function unresolvableCrossStackReadError(origin, stackName, intrinsic, path, cau
63254
63254
  return new ScrubRefusalError(`Scrub of ${stackName} could not resolve the ${intrinsic} in ${origin}${path ? ` at ${path}` : ""}: ${maskSecretsInText(cause instanceof Error ? cause.message : String(cause), secrets)}. Refusing rather than continuing: the value that reference carries may be a secret, and scrub would then have no plaintext to look for — reporting the stack clean over state that still holds it. Deploy the producer stack (or correct the reference) and re-run 'cdkd scrub'.`, "SCRUB_CROSS_STACK_READ_UNRESOLVED");
63255
63255
  }
63256
63256
  /**
63257
+ * The two sentences the plaintext-producer refusal is built from: what the
63258
+ * producer's TEMPLATE is being claimed to say, and the remedy that clears it
63259
+ * (issues [#2133](https://github.com/go-to-k/cdkd/issues/2133) review and
63260
+ * [#2146](https://github.com/go-to-k/cdkd/issues/2146) review).
63261
+ *
63262
+ * SEPARATE FROM THE ERROR BUILDER so both can be tested over verdict shapes the
63263
+ * walk does not produce today. The one below — `chained` with an EMPTY `via` —
63264
+ * is exactly such a shape: unreachable, because `chained` only ever returns
63265
+ * from a non-root frame whose `via` therefore has at least one entry, and yet
63266
+ * the arm that used to catch it was the WIDENED wording, which asserts "this run
63267
+ * could not match '<key>' to a declared output" — false of every `chained`
63268
+ * verdict, since a chained one matched the key and left through its value.
63269
+ *
63270
+ * WHICH SIGNAL KEYS WHICH ARM, since this file has now been round-tripped on it
63271
+ * once. The CLAIM keys on `kind`: only `widened` may say "could not say which",
63272
+ * and no amount of chain changes that. WHAT the claim names keys on the chain:
63273
+ * a `widened` verdict CAN have crossed a re-export, and saying "publishes at
63274
+ * least one output from a `{{resolve:...}}` expression" when the walk has just
63275
+ * established that none of its outputs does is the same over-claim in the other
63276
+ * direction.
63277
+ *
63278
+ * `via` IS DE-DUPLICATED, and may name the producer itself (issue #2146 review).
63279
+ * A chain that returns to the direct producer under a DIFFERENT output key —
63280
+ * `A -> B -> A`, or a self-import `A -> A` — is a real walk result, since the
63281
+ * visited set is keyed by `(stack, key)` rather than by stack. Left as-is it
63282
+ * rendered "producer 'A' ... RE-EXPORTING a value that 'A' declares ... (through
63283
+ * 'A')" and a remedy running `cdkd scrub A` two or three times, which reads as a
63284
+ * bug in the tool rather than as advice. The claim de-duplicates
63285
+ * ORDER-PRESERVINGLY keeping the FIRST occurrence (so the stack that declares
63286
+ * the expression stays last, where `chainRoot` reads it), and the remedy keeps
63287
+ * the LAST (so the DIRECT producer — the one actually holding the plaintext —
63288
+ * stays at the end of the order, where the user must scrub it).
63289
+ *
63290
+ * Naming the producer inside its own chain is left standing rather than
63291
+ * filtered: for `A -> B -> A` it is A that declares the expression, under an
63292
+ * output the consumer's key did not match, so dropping it would move the claim
63293
+ * onto B, which declares nothing.
63294
+ */
63295
+ function scrubRefusalWording(verdict, loggedExportKey, loggedProducerStack, loggedVia) {
63296
+ const chain = dedupePreservingOrder(loggedVia, "first");
63297
+ const chainRoot = chain[chain.length - 1];
63298
+ const through = chain.length > 1 ? ` (through ${chain.slice(0, -1).map((s) => `'${s}'`).join(", ")})` : "";
63299
+ const templateClaim = verdict.kind === "declared" ? `declares '${loggedExportKey}' from a {{resolve:...}} expression` : verdict.kind === "chained" ? chainRoot !== void 0 ? `publishes '${loggedExportKey}' by RE-EXPORTING a value that '${chainRoot}' declares from a {{resolve:...}} expression${through}` : `publishes '${loggedExportKey}' by RE-EXPORTING a value another stack of this app declares from a {{resolve:...}} expression` : chainRoot !== void 0 ? `publishes at least one output that RE-EXPORTS a value '${chainRoot}' declares from a {{resolve:...}} expression${through} (this run could not match '${loggedExportKey}' to a declared output, so it cannot say which)` : `publishes at least one output from a {{resolve:...}} expression (this run could not match '${loggedExportKey}' to a declared output, so it cannot say which)`;
63300
+ const scrubOrder = dedupePreservingOrder([...chain].reverse().concat(loggedProducerStack), "last");
63301
+ return {
63302
+ templateClaim,
63303
+ remedy: scrubOrder.length > 1 ? `Scrub the producers first, from the head of the chain (${scrubOrder.map((s) => `'cdkd scrub ${s}'`).join(", then ")})` : `Scrub the producer first ('cdkd scrub ${loggedProducerStack}')`
63304
+ };
63305
+ }
63306
+ /**
63307
+ * `values` with repeats removed, keeping either the FIRST or the LAST occurrence
63308
+ * of each and preserving the order of the ones kept.
63309
+ */
63310
+ function dedupePreservingOrder(values, keep) {
63311
+ const seen = /* @__PURE__ */ new Set();
63312
+ const out = [];
63313
+ const ordered = keep === "last" ? [...values].reverse() : values;
63314
+ for (const value of ordered) {
63315
+ if (seen.has(value)) continue;
63316
+ seen.add(value);
63317
+ out.push(value);
63318
+ }
63319
+ return keep === "last" ? out.reverse() : out;
63320
+ }
63321
+ /**
63257
63322
  * The refusal a cross-stack read raises when it SUCCEEDED but the PRODUCER's
63258
63323
  * own state record still stores the PLAINTEXT (issue
63259
63324
  * [#2133](https://github.com/go-to-k/cdkd/issues/2133) review).
@@ -63278,9 +63343,14 @@ function unresolvableCrossStackReadError(origin, stackName, intrinsic, path, cau
63278
63343
  function plaintextProducerCrossStackReadError(origin, stackName, intrinsic, path, producerStack, exportKey, verdict, secrets) {
63279
63344
  const loggedExportKey = maskSecretsInText(exportKey, secrets);
63280
63345
  const loggedProducerStack = maskSecretsInText(producerStack, secrets);
63281
- const templateClaim = verdict === "declared" ? `declares '${loggedExportKey}' from a {{resolve:...}} expression` : `publishes at least one output from a {{resolve:...}} expression (this run could not match '${loggedExportKey}' to a declared output, so it cannot say which)`;
63282
- return new ScrubRefusalError(`Scrub of ${stackName} resolved the ${intrinsic} in ${origin}${path ? ` at ${path}` : ""} to a PLAINTEXT value: the producer stack '${loggedProducerStack}' ${templateClaim}, but its own state still stores the resolved plaintext rather than that expression. scrub has no expression to write in this stack's place, so it cannot redact the imported secret and must not report this stack clean. Scrub the producer first ('cdkd scrub ${loggedProducerStack}'), then re-run 'cdkd scrub ${stackName}' — the read will then return the expression and this stack is scrubbed normally.`, "SCRUB_CROSS_STACK_PRODUCER_PLAINTEXT");
63346
+ const { templateClaim, remedy } = scrubRefusalWording(verdict, loggedExportKey, loggedProducerStack, verdict.via.map((s) => maskSecretsInText(s, secrets)));
63347
+ return new ScrubRefusalError(`Scrub of ${stackName} resolved the ${intrinsic} in ${origin}${path ? ` at ${path}` : ""} to a PLAINTEXT value: the producer stack '${loggedProducerStack}' ${templateClaim}, but its own state still stores the resolved plaintext rather than that expression. scrub has no expression to write in this stack's place, so it cannot redact the imported secret and must not report this stack clean. ${remedy}, then re-run 'cdkd scrub ${stackName}' — the read will then return the expression and this stack is scrubbed normally.`, "SCRUB_CROSS_STACK_PRODUCER_PLAINTEXT");
63283
63348
  }
63349
+ /** The `no` verdict, shared so the walk allocates nothing for the common case. */
63350
+ const NO_SECRET_EXPRESSION = {
63351
+ kind: "no",
63352
+ via: []
63353
+ };
63284
63354
  /**
63285
63355
  * Can the producer's STORED value be a legacy plaintext secret at all (issue
63286
63356
  * #2133 review)?
@@ -63332,22 +63402,239 @@ function storedValueCarriesNoPlaintext(stored) {
63332
63402
  * asserts what the producer declares and only the `declared` verdict has
63333
63403
  * checked that for THIS key.
63334
63404
  *
63335
- * Residual, stated rather than hidden: a producer that publishes a secret
63336
- * WITHOUT a literal `{{resolve:` in its template — one re-exporting a value it
63337
- * imported from a third stack, or reading one through `Ref` to a parameter —
63338
- * is not detected, and neither is a producer outside this app (whose template
63339
- * scrub never sees). Those keep the pre-#2133 outcome for that one reference.
63340
- */
63341
- function producerPublishesSecretExpression(template, key) {
63342
- const outputs = template.Outputs ?? {};
63343
- const matched = [];
63344
- for (const [name, output] of Object.entries(outputs)) {
63345
- const exportName = output?.Export?.Name;
63346
- if (name === key || typeof exportName === "string" && exportName === key) matched.push(output?.Value);
63405
+ * RE-EXPORT CHAINS ARE FOLLOWED (issue
63406
+ * [#2146](https://github.com/go-to-k/cdkd/issues/2146)). A MIDDLE stack whose
63407
+ * output is `{"Fn::ImportValue": "<upstream export>"}` declares no literal
63408
+ * `{{resolve:` of its own, so the single-template test returned `no` for it and
63409
+ * the consumer at the END of the chain was reported CLEAN over its surviving
63410
+ * plaintext — the #2133 silent success, reached through a re-export instead of
63411
+ * a direct import. (`cdkd scrub --all` masks it: `orderScrubTargets` scrubs
63412
+ * producers first, so the middle stack stores the expression by the time the
63413
+ * consumer is reached. `cdkd scrub <one stack>` does not.) This walk therefore
63414
+ * follows a matched output's `Fn::ImportValue` (through the export-name index)
63415
+ * and its `Fn::GetStackOutput` (through the literal `StackName` / `OutputName`)
63416
+ * into the next producer's template, and keeps going until it finds an
63417
+ * expression or runs out of reachable outputs.
63418
+ *
63419
+ * TERMINATION IS A VISITED SET over `(stack, key)` pairs, NOT a hop cap. Each
63420
+ * pair expands deterministically, so a second visit can never yield evidence
63421
+ * the first did not, and the pair space is finite (stacks x outputs) — so the
63422
+ * walk halts on a CYCLE (two stacks re-exporting each other; no deploy can
63423
+ * produce one, a template can express one) while losing no reach. A depth cap
63424
+ * was rejected because it is lossy in the one direction that matters: a chain
63425
+ * longer than the cap would return `no`, which is exactly the silent success
63426
+ * this walk exists to kill.
63427
+ *
63428
+ * WIDENING IS ROOT-ONLY, and that means WHERE THE FALLBACK FIRES, not what a
63429
+ * widened subject may expand into. The all-outputs fallback fires for the DIRECT
63430
+ * producer alone, where the key came from a read and may match nothing because
63431
+ * the producer's `Export.Name` is an intrinsic scrub cannot reproduce. One hop
63432
+ * up the key is a LITERAL export / output name read out of a template, so a miss
63433
+ * there means that template genuinely does not declare it, and widening THERE
63434
+ * would refuse the consumer over an unrelated secret two stacks away that no
63435
+ * scrub could ever clear.
63436
+ *
63437
+ * A widened root's subjects DO still expand into hops, deliberately, and the
63438
+ * trade is stated rather than hidden (issue #2146 review — an earlier revision
63439
+ * of this comment read as if they did not, which contradicted the code). Not
63440
+ * expanding them would make an intrinsic-`Export.Name` producer that re-exports
63441
+ * a secret undetectable — the exact silent success this walk exists to kill,
63442
+ * reintroduced for the one population the widening exists for. Expanding them
63443
+ * extends #2133's accepted over-approximation ("it can refuse a stack that
63444
+ * imports a non-secret export from a producer that has some other secret
63445
+ * output") by one hop, in the direction that repo rule calls safe; and the
63446
+ * refusal it produces now names the head of the chain and the order to scrub it
63447
+ * in, so it is at least as actionable as the root widening it extends.
63448
+ *
63449
+ * Residuals, stated rather than hidden. Each keeps the pre-#2133 outcome for
63450
+ * that one reference — a possible false CLEAN, never a refusal:
63451
+ * - a producer that publishes a secret with NO literal `{{resolve:` and no
63452
+ * followable re-export: one reading it through `Ref` to a parameter, or
63453
+ * re-exporting an export whose producer is outside this app (whose template
63454
+ * scrub never sees), and a producer outside this app at all;
63455
+ * - a NON-LITERAL `Fn::ImportValue` argument (an `Fn::Sub`-assembled export
63456
+ * name) and a NON-LITERAL or foreign `Fn::GetStackOutput` `StackName` /
63457
+ * `OutputName`: the hop cannot be resolved statically, and guessing one would
63458
+ * refuse over a reference the walk never established;
63459
+ * - a `Fn::GetStackOutput` naming a stack of this app in ANOTHER REGION:
63460
+ * {@link buildExportOwnerIndex} and `producerTemplates` are keyed by stack
63461
+ * NAME alone, so the walk reads that name's template regardless of `Region`;
63462
+ * - a secret reachable only through the TRUE branch of an `Fn::If` in a
63463
+ * producer's output: see {@link collectReExportHops}, which takes the FALSE
63464
+ * branch for a condition it cannot evaluate.
63465
+ */
63466
+ function producerPublishesSecretExpression(templates, exportOwners, producerStack, key) {
63467
+ const seen = /* @__PURE__ */ new Set([`${producerStack}\u0000${key}`]);
63468
+ const queue = [{
63469
+ stack: producerStack,
63470
+ key,
63471
+ via: []
63472
+ }];
63473
+ let widened = false;
63474
+ while (queue.length > 0) {
63475
+ const frame = queue.shift();
63476
+ if (!frame) break;
63477
+ const template = templates.get(frame.stack);
63478
+ if (!template) continue;
63479
+ const outputs = template.Outputs ?? {};
63480
+ const matched = [];
63481
+ for (const [name, output] of Object.entries(outputs)) {
63482
+ const exportName = output?.Export?.Name;
63483
+ if (name === frame.key || typeof exportName === "string" && exportName === frame.key) matched.push(output?.Value);
63484
+ }
63485
+ const isRoot = frame.via.length === 0;
63486
+ let subjects;
63487
+ if (matched.length > 0) subjects = matched;
63488
+ else if (isRoot) {
63489
+ widened = true;
63490
+ subjects = Object.values(outputs).map((o) => o?.Value);
63491
+ } else continue;
63492
+ for (const subject of subjects) {
63493
+ if (JSON.stringify(subject ?? null).includes("{{resolve:")) {
63494
+ if (widened) return {
63495
+ kind: "widened",
63496
+ via: frame.via
63497
+ };
63498
+ return isRoot ? {
63499
+ kind: "declared",
63500
+ via: []
63501
+ } : {
63502
+ kind: "chained",
63503
+ via: frame.via
63504
+ };
63505
+ }
63506
+ for (const hop of collectReExportHops(subject, templates, exportOwners)) {
63507
+ const id = `${hop.stack}\u0000${hop.key}`;
63508
+ if (seen.has(id)) continue;
63509
+ seen.add(id);
63510
+ queue.push({
63511
+ stack: hop.stack,
63512
+ key: hop.key,
63513
+ via: [...frame.via, hop.stack]
63514
+ });
63515
+ }
63516
+ }
63347
63517
  }
63348
- const widened = matched.length === 0;
63349
- if (!(widened ? Object.values(outputs).map((o) => o?.Value) : matched).some((value) => JSON.stringify(value ?? null).includes("{{resolve:"))) return "no";
63350
- return widened ? "widened" : "declared";
63518
+ return NO_SECRET_EXPRESSION;
63519
+ }
63520
+ /**
63521
+ * The re-export hops one output VALUE carries (issue #2146).
63522
+ *
63523
+ * Only STATICALLY resolvable references produce a hop, mirroring
63524
+ * {@link inferCrossStackStackDeps}: an `Fn::ImportValue` of a literal export
63525
+ * name (looked up in `exportOwners`) and an `Fn::GetStackOutput` whose
63526
+ * `StackName` and `OutputName` are both literal strings and whose stack is one
63527
+ * of this app's. An assembled name resolves to a hop the walk cannot follow,
63528
+ * which keeps the pre-#2146 outcome for that value rather than guessing.
63529
+ *
63530
+ * EVERY owner of a duplicated export name is returned, not the first (issue
63531
+ * #2146 review). See {@link buildExportOwnerIndex}: picking one silently
63532
+ * decides the verdict on insertion order, and the visited set makes following
63533
+ * all of them cheap and terminating.
63534
+ *
63535
+ * The WHOLE node is walked, not just its top level, because a re-export is
63536
+ * routinely wrapped — `Fn::Join`ing an imported password into a URL is the
63537
+ * shape the live fixture uses.
63538
+ *
63539
+ * `Fn::If` IS NOT WALKED ON BOTH ARMS, and this is load-bearing rather than
63540
+ * tidy (issue #2146 review). `makeCrossStackPrePass` deliberately mirrors
63541
+ * `resolveIf` — selected branch only — so that a conditional import of a
63542
+ * not-yet-deployed producer cannot refuse a whole stack, unbypassably, over a
63543
+ * reference the deploy never read. A hop walk that descended into both arms
63544
+ * broke that mirror and produced something worse: a producer whose output is
63545
+ * `{"Fn::If": ["IsProd", {"Fn::ImportValue": "ProdSecret"}, {"Fn::ImportValue":
63546
+ * "DevPlain"}]}`, deployed in dev with a benign stored value, yielded a
63547
+ * `chained` verdict and a refusal NO `cdkd scrub` can clear — nothing can turn
63548
+ * that producer's non-secret value into an expression, and there is no bypass
63549
+ * flag. On main the same input answered `no`, so it would have been a
63550
+ * regression this change introduced.
63551
+ *
63552
+ * WHICH branch: the FALSE one, unconditionally. `resolveIf` selects FALSE for a
63553
+ * condition it cannot evaluate, and every condition here belongs to ANOTHER
63554
+ * stack — scrub evaluates conditions for the stack it is scrubbing, from that
63555
+ * template's parameter DEFAULTS, and has neither the parameters nor the
63556
+ * evaluation for a producer's. A MALFORMED `Fn::If` (args not a 3-tuple) yields
63557
+ * NO hop at all: which branch is live is unknowable, and the pre-pass's answer
63558
+ * there (walk it with refusals DISARMED) has no counterpart in a function whose
63559
+ * only output is "refuse or not".
63560
+ *
63561
+ * Residual, and it is the direction this file accepts elsewhere: a secret
63562
+ * reachable only through the TRUE branch is not detected, so that reference
63563
+ * keeps its pre-#2133 outcome. The asymmetry with the `{{resolve:` scan in
63564
+ * {@link producerPublishesSecretExpression} — which `JSON.stringify`s the whole
63565
+ * subject and therefore sees BOTH arms — is #2133's behaviour, unchanged here
63566
+ * on purpose: narrowing it would change which stacks that older refusal fires
63567
+ * on, which is not this change's subject.
63568
+ */
63569
+ function collectReExportHops(value, templates, exportOwners) {
63570
+ const hops = [];
63571
+ const walk = (node) => {
63572
+ if (Array.isArray(node)) {
63573
+ for (const item of node) walk(item);
63574
+ return;
63575
+ }
63576
+ if (node === null || typeof node !== "object") return;
63577
+ const obj = node;
63578
+ if ("Fn::If" in obj) {
63579
+ const args = obj["Fn::If"];
63580
+ if (Array.isArray(args) && args.length === 3) walk(args[2]);
63581
+ return;
63582
+ }
63583
+ const imported = obj["Fn::ImportValue"];
63584
+ if (typeof imported === "string") for (const owner of exportOwners.get(imported) ?? []) hops.push({
63585
+ stack: owner,
63586
+ key: imported
63587
+ });
63588
+ const read = obj["Fn::GetStackOutput"];
63589
+ if (read !== null && typeof read === "object" && !Array.isArray(read)) {
63590
+ const args = read;
63591
+ const stack = args["StackName"];
63592
+ const outputName = args["OutputName"];
63593
+ if (typeof stack === "string" && typeof outputName === "string" && templates.has(stack)) hops.push({
63594
+ stack,
63595
+ key: outputName
63596
+ });
63597
+ }
63598
+ for (const child of Object.values(obj)) walk(child);
63599
+ };
63600
+ walk(value);
63601
+ return hops;
63602
+ }
63603
+ /**
63604
+ * `Export.Name` -> EVERY app stack that declares it (issue #2146), for literal
63605
+ * export names only. Built ONCE per stack scrub rather than per reference,
63606
+ * since {@link producerPublishesSecretExpression} needs it on every cross-stack
63607
+ * read the pre-pass performs.
63608
+ *
63609
+ * ALL OWNERS, not the first (issue #2146 review). `inferCrossStackStackDeps`
63610
+ * keeps one because it is choosing a deploy EDGE and either choice orders the
63611
+ * graph; this index decides whether a value is SECRET-BEARING, where following
63612
+ * the wrong twin returns `no` and reports a stack clean over surviving
63613
+ * plaintext. Two stacks declaring one export name is an AWS-side error, but
63614
+ * scrub reads TEMPLATES — an app that has never been deployed, or one deployed
63615
+ * to two regions, presents it routinely — so there IS a better answer than the
63616
+ * first writer: take the over-approximation and follow both. The visited set
63617
+ * bounds the cost and guarantees termination either way.
63618
+ *
63619
+ * Keyed by stack NAME with no region, which is the residual
63620
+ * {@link producerPublishesSecretExpression} lists: an `Fn::GetStackOutput`
63621
+ * naming this app's stack in another region reads the template of the name,
63622
+ * since `producerTemplates` has no region either.
63623
+ */
63624
+ function buildExportOwnerIndex(templates) {
63625
+ const owners = /* @__PURE__ */ new Map();
63626
+ for (const [stackName, template] of templates) {
63627
+ const outputs = template.Outputs ?? {};
63628
+ for (const output of Object.values(outputs)) {
63629
+ const name = output?.Export?.Name;
63630
+ if (typeof name !== "string" || name === "") continue;
63631
+ const existing = owners.get(name);
63632
+ if (existing) {
63633
+ if (!existing.includes(stackName)) existing.push(stackName);
63634
+ } else owners.set(name, [stackName]);
63635
+ }
63636
+ }
63637
+ return owners;
63351
63638
  }
63352
63639
  /**
63353
63640
  * Build the CROSS-STACK pre-pass for ONE stack's scrub (issue
@@ -63433,6 +63720,17 @@ function producerPublishesSecretExpression(template, key) {
63433
63720
  */
63434
63721
  function makeCrossStackPrePass(deps) {
63435
63722
  const { stackName, resolver, producerTemplates, findings, logger } = deps;
63723
+ const exportOwners = buildExportOwnerIndex(producerTemplates);
63724
+ const verdicts = /* @__PURE__ */ new Map();
63725
+ const secretExpressionVerdict = (stack, key) => {
63726
+ const id = `${stack}\u0000${key}`;
63727
+ let verdict = verdicts.get(id);
63728
+ if (!verdict) {
63729
+ verdict = producerPublishesSecretExpression(producerTemplates, exportOwners, stack, key);
63730
+ verdicts.set(id, verdict);
63731
+ }
63732
+ return verdict;
63733
+ };
63436
63734
  /**
63437
63735
  * Is this a refusal NO RE-RUN can clear (issue #2133 review)?
63438
63736
  *
@@ -63494,8 +63792,8 @@ function makeCrossStackPrePass(deps) {
63494
63792
  * wrong-account lookup that refusal exists to prevent.
63495
63793
  *
63496
63794
  * The OUTCOME is the same today either way — a cross-account producer is
63497
- * never in `appStacks`, so the `!producerTemplate` return below fires first
63498
- * regardless — but a future change that widened `producerTemplates` would
63795
+ * never in `appStacks`, so the `producerTemplates.has(...)` return below
63796
+ * fires first regardless — but a future change that widened `producerTemplates` would
63499
63797
  * be relying on this sentence, so it must not claim the second arm has no
63500
63798
  * expression behind it.
63501
63799
  *
@@ -63569,10 +63867,9 @@ function makeCrossStackPrePass(deps) {
63569
63867
  }
63570
63868
  const producer = recordedProducer(key, probe);
63571
63869
  if (!producer) return;
63572
- const producerTemplate = producerTemplates.get(producer.stack);
63573
- if (!producerTemplate) return;
63574
- const verdict = producerPublishesSecretExpression(producerTemplate, producer.key);
63575
- if (verdict === "no") return;
63870
+ if (!producerTemplates.has(producer.stack)) return;
63871
+ const verdict = secretExpressionVerdict(producer.stack, producer.key);
63872
+ if (verdict.kind === "no") return;
63576
63873
  const stored = await storedProducerValue(producer, context.stateBackend);
63577
63874
  if (!stored) return;
63578
63875
  if (carriesDynamicReference(stored.stored)) return;
@@ -85753,7 +86050,7 @@ function createMigrateCommand() {
85753
86050
  */
85754
86051
  function buildProgram() {
85755
86052
  const program = new Command();
85756
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.32");
86053
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.34");
85757
86054
  program.hook("preAction", (_thisCommand, actionCommand) => {
85758
86055
  const { profile } = actionCommand.optsWithGlobals();
85759
86056
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;