@go-to-k/cdkd 0.284.31 → 0.284.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{asg-provider-DJvcY8lj.js → asg-provider-BVzQ7mQX.js} +2 -2
- package/dist/{asg-provider-DJvcY8lj.js.map → asg-provider-BVzQ7mQX.js.map} +1 -1
- package/dist/cli.js +620 -48
- package/dist/cli.js.map +1 -1
- package/dist/{deploy-engine-DYdEWX-s.js → deploy-engine-DsUQ4GxO.js} +132 -33
- package/dist/deploy-engine-DsUQ4GxO.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/deploy-engine-DYdEWX-s.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as endCommandInterruptScope, $t as
|
|
2
|
+
import { $ as endCommandInterruptScope, $t as buildAssetRedirectMap, A as MULTI_REGION_RECREATE_BLOCKED_TYPES, An as resolveStateBucketWithDefaultAndSource, At as maskSecretsInError, B as collectDeclaredOutputNames, Bn as PARTITION_TABLE, Bt as DiffCalculator, C as ccRoutedFinalSnapshotError, Cn as getDefaultStateBucketName, Cr as isThrottlingError, Ct as STATE_SOURCED_CROSS_GENERATION_RULES, D as unsupportedFinalSnapshotError, Dn as resolveCaptureObservedState, Dt as dynamicReferenceTokens, E as refusesFinalSnapshot, En as resolveAutoAssetStorage, Et as createSecretMasker, F as cyan, Fn as CFN_TEMPLATE_URL_LIMIT, Ft as s3BucketDomainName, G as stateKeySecretExposure, Gt as TemplateParser, H as exportAliasCollisionScrubWarning, Hn as derivePartitionAndUrlSuffix, Ht as describeTypeWithThrottleRetry, I as gray, In as MIGRATE_TMP_PREFIX, It as s3BucketDualStackDomainName, J as clearOnUpdateRemoval, Jn as getAwsClients, Jt as rebuildClientForBucketRegion, K as IAMRoleProvider, Kn as resolveBucketRegion, Kt as LockManager, L as green, Ln as findLargeInlineResources, Lt as s3BucketRegionalDomainName, M as renderStatefulReason, Mn as stateBucketExistenceConfirmed, Mt as redactSecretsForState, N as formatResourceLine, Nn as warnDeprecatedNoPrefixCliFlag, Nt as scrubResourceRecord, O as makeCanonicalizePropertiesFn, On as resolveSkipPrefix, Ot as errorCauseChain, P as bold, Pn as CFN_TEMPLATE_BODY_LIMIT, Pt as s3BucketArn, Q as beginCommandInterruptScope, Qn as CdkdError, Qt as WorkGraph, R as red, Rn as uploadCfnTemplate, Rt as s3BucketWebsiteUrl, S as buildFinalSnapshotIdentifier, Sn as synthesisStatusMessage, Sr as isRetryableTransientError, St as requireConfigString, T as isFinalSnapshotError, Tn as resolveApp, Tt as TEMPLATE_SOURCED_RULES, U as isExportAliasCollision, Un as AssemblyReader, Ut as withRetry, V as collectPublishedOutputNames, Vn as canonicalizeRegion, Vt as INTRINSIC_KEYS, W as secretBearingStateKeyWarning, Wn as processStackMessages, Wt as DagBuilder, X as findActionableSilentDrops, Xn as setAwsClients, Xt as AssetPublisher, Y as ProviderRegistry, Yn as resetAwsClients, Yt as shouldRetainResource, Z as findSilentDropProperties, Zt as stringifyValue, _ as maskingRetryLogger, _n as runDockerForeground, _t as configStringRefusal, a as DeploymentEventsStore, an as AssetModeResolver, ar as LocalStartServiceError, at as isTerminationProtectionPropagationError, b as ATOMIC_FINAL_SNAPSHOT_TYPES, bn as getDockerImageBySourceHash, br as withErrorHandling, bt as requireConfigArray, c as planRollback, cn as getBootstrapMarkerKey, cr as NestedStackChildDirectDestroyError, ct as cfnRefValueFromPhysicalId, d as replayRollback, dn as validateAssetBucketName, dr as ResourceTimeoutError, dt as WAFv2WebACLProvider, en as createAssetRedirectResolver, er as CrossAccountSecretRefusalError, et as isInterruptedWaitError, f as updatePartialMessage, fn as validateContainerRepoName, fr as ResourceUpdateNotSupportedError, ft as normalizeAwsTagsToCfn, g as withResourceDeadline, gn as getDockerCmd, gt as configBooleanRefusal, h as deleteSkipReason, hn as formatDockerLoginError, ht as coerceCfnBoolean, i as DeploymentEventsReader, in as stripControlChars, ir as LocalMigrateError, it as disableInstanceApiTermination, j as isStatefulRecreateTargetSync, jn as resolveUseCdkBootstrapAssets, jt as maskSecretsInText, k as extractDeploymentEventError, kn as resolveStateBucketWithDefault, kt as isSingleDynamicReferenceToken, l as producerRegionsFromState, ln as parseBootstrapMarker, lr as PartialFailureError, lt as getAccountInfo, m as UNSPECIFIED_SKIP_REASON, mn as buildDockerImage, mr as StackTerminationProtectionError, mt as assertRegionMatch, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as rewriteTemplateAssetReferences, nr as DeployCancelledError, nt as CloudControlProvider, o as classifyReplaySecretRegion, on as BOOTSTRAP_MARKER_PREFIX, ot as IntrinsicFunctionResolver, p as updatePartialReason, pn as buildDenyExternalAccessPolicy, pr as StackHasActiveImportsError, pt as resolveExplicitPhysicalId, q as collectInlinePolicyNamesManagedBySiblings, qn as AwsClients, qt as S3StateBackend, r as DeployEngine, rn as escapeRegExp, rr as LocalInvokeBuildError$1, rt as slowCcOperationTimeoutMs, s as planFailedOps, sn as ensureAssetStorage, sr as MissingCdkCliError, st as carriesDynamicReference, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as loadPublishableAssetManifest, tt as startInterruptWatch, u as replayFailedOperations, un as readBootstrapMarkerBody, ur as ProvisioningError, ut as refStateLookupFromResource, v as IMPLICIT_DELETE_DEPENDENCIES, vn as runDockerStreaming, vt as readConfigString, w as createPreDeleteFinalSnapshot, wn as getLegacyStateBucketName, wr as markNonRetryable, wt as STATE_SOURCED_READBACK_RULES, x as PRE_DELETE_SNAPSHOT_TYPES, xn as Synthesizer, xr as isMarkedNonRetryable, xt as requireConfigObject, y as computeImplicitDeleteEdges, yn as AssetManifestLoader, yr as normalizeAwsError, yt as replayWarn, z as yellow, zn as expectedOwnerParam, zt as applyRoleArnIfSet } from "./deploy-engine-DsUQ4GxO.js";
|
|
3
3
|
import { a as getLiveRenderer, c as PATTERN_B_RESOURCE_TYPES, d as generateResourceNameWithFallback, f as getCurrentStackName, h as withStackName, i as runStackBuffered, m as withSkipPrefix, n as getLogger, o as PATTERN_B_NAME_OPTIONS, s as PATTERN_B_NAME_PROPERTIES, u as generateResourceName } from "./logger-zRrlbaQt.js";
|
|
4
|
-
import { t as ASGProvider } from "./asg-provider-
|
|
4
|
+
import { t as ASGProvider } from "./asg-provider-BVzQ7mQX.js";
|
|
5
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
6
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
7
7
|
import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketEncryptionCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketOwnershipControlsCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
|
|
@@ -62447,6 +62447,7 @@ async function scrubCommand(stacks, options) {
|
|
|
62447
62447
|
else throw new Error(`Multiple stacks found: ${allStacks.map(describeStack).join(", ")}. Specify stack name(s) or use --all`);
|
|
62448
62448
|
if (targetStacks.length === 0) throw new Error("No stacks matched.");
|
|
62449
62449
|
await synthesizer.expandMacrosForStacks(targetStacks, synthOptions);
|
|
62450
|
+
targetStacks = orderScrubTargets(targetStacks);
|
|
62450
62451
|
const stateConfig = {
|
|
62451
62452
|
bucket: stateBucket,
|
|
62452
62453
|
prefix: options.statePrefix
|
|
@@ -62462,6 +62463,7 @@ async function scrubCommand(stacks, options) {
|
|
|
62462
62463
|
const lockManager = new LockManager(stateS3.s3, stateConfig);
|
|
62463
62464
|
let totalStacksScrubbed = 0;
|
|
62464
62465
|
let totalStacksWithUnscrubbableKeys = 0;
|
|
62466
|
+
let totalStacksWithUnverifiableReads = 0;
|
|
62465
62467
|
const failures = [];
|
|
62466
62468
|
for (const stack of targetStacks) {
|
|
62467
62469
|
const stackRegion = stack.region || region;
|
|
@@ -62470,7 +62472,8 @@ async function scrubCommand(stacks, options) {
|
|
|
62470
62472
|
scrubbed = await scrubStack(stack, stackRegion, stateBackend, lockManager, {
|
|
62471
62473
|
dryRun: options.dryRun ?? false,
|
|
62472
62474
|
roleArn: options.roleArn,
|
|
62473
|
-
logger
|
|
62475
|
+
logger,
|
|
62476
|
+
appStacks: allStacks
|
|
62474
62477
|
});
|
|
62475
62478
|
} catch (err) {
|
|
62476
62479
|
failures.push({ stackName: stack.stackName });
|
|
@@ -62481,13 +62484,17 @@ async function scrubCommand(stacks, options) {
|
|
|
62481
62484
|
if (scrubbed.recordsChanged > 0) {
|
|
62482
62485
|
totalStacksScrubbed++;
|
|
62483
62486
|
logger.info(`${options.dryRun ? "Would scrub" : "Scrubbed"} ${scrubbed.recordsChanged} resource record(s) in ${stack.stackName}`);
|
|
62484
|
-
} else if (scrubbed.secretBearingKeys === 0) logger.info(`No plaintext secrets found in ${stack.stackName}`);
|
|
62487
|
+
} else if (scrubbed.secretBearingKeys === 0 && scrubbed.unverifiableReads === 0) logger.info(`No plaintext secrets found in ${stack.stackName}`);
|
|
62488
|
+
if (scrubbed.unverifiableReads > 0) {
|
|
62489
|
+
totalStacksWithUnverifiableReads++;
|
|
62490
|
+
logger.warn(`${scrubbed.unverifiableReads} cross-stack read(s) in ${stack.stackName} could NOT be verified — cdkd declines them by design (see the warnings above), so this stack is not reported clean.`);
|
|
62491
|
+
}
|
|
62485
62492
|
if (scrubbed.secretBearingKeys > 0) {
|
|
62486
62493
|
totalStacksWithUnscrubbableKeys++;
|
|
62487
62494
|
logger.warn(`${scrubbed.secretBearingKeys} output KEY(s) in ${stack.stackName} hold plaintext and CANNOT be scrubbed — rename the Export.Name and redeploy (see the warning above).`);
|
|
62488
62495
|
}
|
|
62489
62496
|
}
|
|
62490
|
-
if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0) {
|
|
62497
|
+
if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0 && totalStacksWithUnverifiableReads === 0) {
|
|
62491
62498
|
if (failures.length === 0) {
|
|
62492
62499
|
logger.info("\nNo plaintext secrets found in any target stack state. Nothing to scrub.");
|
|
62493
62500
|
return;
|
|
@@ -62497,17 +62504,18 @@ async function scrubCommand(stacks, options) {
|
|
|
62497
62504
|
}
|
|
62498
62505
|
const failureNote = failures.length > 0 ? ` ${failures.length} stack(s) could NOT be scrubbed at all and are NOT covered by this summary — see the errors above.` : "";
|
|
62499
62506
|
const keyNote = totalStacksWithUnscrubbableKeys > 0 ? ` ${totalStacksWithUnscrubbableKeys} stack(s) hold plaintext in an output KEY, which cdkd scrub cannot rewrite — rename that output's Export.Name and redeploy.` : "";
|
|
62507
|
+
const unverifiableNote = totalStacksWithUnverifiableReads > 0 ? ` ${totalStacksWithUnverifiableReads} stack(s) carry a cross-stack read cdkd declines to perform, so their imported values could NOT be checked — export a non-secret value (e.g. the secret's ARN) from the producer, or reference it from within its own account.` : "";
|
|
62500
62508
|
if (options.dryRun) {
|
|
62501
|
-
if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote}${failureNote} ROTATE any exposed secret in Secrets Manager.`);
|
|
62502
|
-
else logger.info(`\nPlan: nothing can be scrubbed.${keyNote}${failureNote} ROTATE any exposed secret.`);
|
|
62509
|
+
if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote}${unverifiableNote}${failureNote} ROTATE any exposed secret in Secrets Manager.`);
|
|
62510
|
+
else logger.info(`\nPlan: nothing can be scrubbed.${keyNote}${unverifiableNote}${failureNote} ROTATE any exposed secret.`);
|
|
62503
62511
|
if (failures.length > 0) throw scrubStacksFailedError(failures);
|
|
62504
62512
|
if (options.fail) throw new ScrubNeededError();
|
|
62505
62513
|
return;
|
|
62506
62514
|
}
|
|
62507
|
-
if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The plaintext is no longer stored there, but a value that was ever persisted should be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating).${keyNote}${failureNote}`);
|
|
62508
|
-
else logger.info(`\nNothing could be rewritten.${keyNote}${failureNote} ROTATE any exposed secret.`);
|
|
62515
|
+
if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The plaintext is no longer stored there, but a value that was ever persisted should be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating).${keyNote}${unverifiableNote}${failureNote}`);
|
|
62516
|
+
else logger.info(`\nNothing could be rewritten.${keyNote}${unverifiableNote}${failureNote} ROTATE any exposed secret.`);
|
|
62509
62517
|
if (failures.length > 0) throw scrubStacksFailedError(failures);
|
|
62510
|
-
if (options.fail && totalStacksWithUnscrubbableKeys > 0) throw new ScrubNeededError();
|
|
62518
|
+
if (options.fail && (totalStacksWithUnscrubbableKeys > 0 || totalStacksWithUnverifiableReads > 0)) throw new ScrubNeededError();
|
|
62511
62519
|
}
|
|
62512
62520
|
/**
|
|
62513
62521
|
* Every secret plaintext this run recorded, from ANY position in the template
|
|
@@ -63046,6 +63054,571 @@ async function pinCrossRegionSecrets(bag, stackName, ctx) {
|
|
|
63046
63054
|
return await walk(bag, "");
|
|
63047
63055
|
}
|
|
63048
63056
|
/**
|
|
63057
|
+
* Order scrub targets PRODUCER-BEFORE-CONSUMER (issue
|
|
63058
|
+
* [#2133](https://github.com/go-to-k/cdkd/issues/2133) review).
|
|
63059
|
+
*
|
|
63060
|
+
* `cdkd scrub --all` used to walk the assembly's artifact order, which is not
|
|
63061
|
+
* topological — `getAllStacks` iterates the manifest, and only `cdkd deploy`
|
|
63062
|
+
* sorts. That ordering decides whether a legacy multi-stack app can be scrubbed
|
|
63063
|
+
* in ONE run: a consumer importing a secret learns its expression only by
|
|
63064
|
+
* reading the producer's state, and that state holds the expression only once
|
|
63065
|
+
* the PRODUCER has been scrubbed. Reached in the other order the consumer gets
|
|
63066
|
+
* a plaintext, has no needle, and is refused
|
|
63067
|
+
* ({@link plaintextProducerCrossStackReadError}) until the user re-runs. Both
|
|
63068
|
+
* orders are SAFE — the refusal is what makes them safe — but only one of them
|
|
63069
|
+
* finishes the job in a single command.
|
|
63070
|
+
*
|
|
63071
|
+
* Edges come from BOTH sources `cdkd deploy` uses: CDK's own manifest
|
|
63072
|
+
* dependencies (`dependencyNames`, emitted for a strong reference through
|
|
63073
|
+
* `exportValue`) and {@link inferCrossStackStackDeps}, which scans the
|
|
63074
|
+
* synthesized templates for a RAW `cdk.Fn.importValue` / `Fn::GetStackOutput`
|
|
63075
|
+
* that CDK records no dependency for. Edges to stacks outside the target set
|
|
63076
|
+
* are ignored: a producer that is not being scrubbed cannot be ordered.
|
|
63077
|
+
*
|
|
63078
|
+
* Cycles cannot happen through `Fn::ImportValue` on a deployable app, but a
|
|
63079
|
+
* cycle in the INFERRED graph is possible (two stacks reading each other's
|
|
63080
|
+
* outputs weakly), so the sort degrades to input order for whatever it cannot
|
|
63081
|
+
* place rather than dropping or duplicating a stack. Ties keep input order, so
|
|
63082
|
+
* a single-stack or dependency-free `--all` run is byte-identical to before.
|
|
63083
|
+
*/
|
|
63084
|
+
function orderScrubTargets(targets) {
|
|
63085
|
+
if (targets.length < 2) return [...targets];
|
|
63086
|
+
const inSet = new Set(targets.map((t) => t.stackName));
|
|
63087
|
+
const inferred = inferCrossStackStackDeps(targets);
|
|
63088
|
+
const producersOf = /* @__PURE__ */ new Map();
|
|
63089
|
+
for (const target of targets) {
|
|
63090
|
+
const producers = /* @__PURE__ */ new Set();
|
|
63091
|
+
for (const dep of target.dependencyNames ?? []) if (inSet.has(dep) && dep !== target.stackName) producers.add(dep);
|
|
63092
|
+
for (const dep of inferred.get(target.stackName) ?? []) if (inSet.has(dep) && dep !== target.stackName) producers.add(dep);
|
|
63093
|
+
producersOf.set(target.stackName, producers);
|
|
63094
|
+
}
|
|
63095
|
+
const ordered = [];
|
|
63096
|
+
const emitted = /* @__PURE__ */ new Set();
|
|
63097
|
+
const pending = [...targets];
|
|
63098
|
+
while (pending.length > 0) {
|
|
63099
|
+
let index = pending.findIndex((t) => [...producersOf.get(t.stackName) ?? []].every((p) => emitted.has(p)));
|
|
63100
|
+
if (index === -1) index = 0;
|
|
63101
|
+
const [next] = pending.splice(index, 1);
|
|
63102
|
+
if (!next) break;
|
|
63103
|
+
ordered.push(next);
|
|
63104
|
+
emitted.add(next.stackName);
|
|
63105
|
+
}
|
|
63106
|
+
return ordered;
|
|
63107
|
+
}
|
|
63108
|
+
/**
|
|
63109
|
+
* A memoizing READ view over the state backend, for the resolutions ONE
|
|
63110
|
+
* `scrubStack` call performs (issue #2133 review).
|
|
63111
|
+
*
|
|
63112
|
+
* `resolveImportValue` has no lookup cache of its own: each reference costs a
|
|
63113
|
+
* `listStacks()` plus up to N `getState()` calls, and scrub resolves every
|
|
63114
|
+
* cross-stack reference TWICE — once in the pre-pass (which must run before the
|
|
63115
|
+
* best-effort catch can swallow a failure) and once in the main resolution
|
|
63116
|
+
* (which must run because the pre-pass deliberately does not rewrite the bag).
|
|
63117
|
+
* The view collapses both into one scan and one `getState` per (stack, region).
|
|
63118
|
+
*
|
|
63119
|
+
* `Object.create` rather than a hand-written wrapper object: the resolver also
|
|
63120
|
+
* reads `stateBackend.prefix` and could reach for more, and a literal cast to
|
|
63121
|
+
* `S3StateBackend` would turn the next such call into `undefined is not a
|
|
63122
|
+
* function`. Prototype delegation keeps every other member — and
|
|
63123
|
+
* `instanceof S3StateBackend` — intact.
|
|
63124
|
+
*
|
|
63125
|
+
* Deliberately NOT used for this stack's own `getState` / `saveState`: those go
|
|
63126
|
+
* through the real backend, so the read-modify-write still sees S3 rather than
|
|
63127
|
+
* a cache, and its ETag precondition still means what it says.
|
|
63128
|
+
*/
|
|
63129
|
+
function memoizeCrossStackStateReads(backend) {
|
|
63130
|
+
const view = Object.create(backend);
|
|
63131
|
+
let listed;
|
|
63132
|
+
const states = /* @__PURE__ */ new Map();
|
|
63133
|
+
view.listStacks = () => {
|
|
63134
|
+
listed ??= backend.listStacks();
|
|
63135
|
+
return listed;
|
|
63136
|
+
};
|
|
63137
|
+
view.getState = (stackName, stateRegion) => {
|
|
63138
|
+
const key = `${stackName}\u0000${stateRegion}`;
|
|
63139
|
+
let pending = states.get(key);
|
|
63140
|
+
if (!pending) {
|
|
63141
|
+
pending = backend.getState(stackName, stateRegion);
|
|
63142
|
+
states.set(key, pending);
|
|
63143
|
+
}
|
|
63144
|
+
return pending;
|
|
63145
|
+
};
|
|
63146
|
+
return view;
|
|
63147
|
+
}
|
|
63148
|
+
/**
|
|
63149
|
+
* Is this template output one the deploy would NOT have written (issue #2133
|
|
63150
|
+
* review)?
|
|
63151
|
+
*
|
|
63152
|
+
* The outputs passes deliberately run over EVERY declared output regardless of
|
|
63153
|
+
* its `Condition`, so a secret carried by a (possibly spuriously) suppressed
|
|
63154
|
+
* output still becomes a needle for the resource records that share it. That
|
|
63155
|
+
* argument justifies RESOLVING a suppressed output, and nothing more — it does
|
|
63156
|
+
* not justify REFUSING the whole stack when such a resolution fails, because a
|
|
63157
|
+
* suppressed output produced no `state.outputs` key and so has nothing at risk.
|
|
63158
|
+
* A dev-environment scrub would otherwise be refused outright by a prod-only
|
|
63159
|
+
* `Value: {Fn::ImportValue: ProdOnlyExport}` whose producer does not exist in
|
|
63160
|
+
* dev. So: resolve it best-effort, but disarm the refusal.
|
|
63161
|
+
*
|
|
63162
|
+
* An UNKNOWN condition counts as suppressed, matching `resolveIf`'s
|
|
63163
|
+
* unknown-selects-FALSE rule and erring toward not refusing.
|
|
63164
|
+
*
|
|
63165
|
+
* STATE OVERRULES THE CONDITION (issue #2133 review). `conditions` degrades to
|
|
63166
|
+
* `{}` whenever `evaluateConditions` throws, and even when it succeeds it is
|
|
63167
|
+
* evaluated from TEMPLATE PARAMETER DEFAULTS — scrub takes no `--parameters` —
|
|
63168
|
+
* so an output the deploy really did write reads as suppressed on any stack
|
|
63169
|
+
* deployed with non-default parameters, which silently disarms the refusal for
|
|
63170
|
+
* that position. `state.outputs` is the record of what the deploy ACTUALLY
|
|
63171
|
+
* wrote (keyed by output NAME, plus the export-name alias), so a key present
|
|
63172
|
+
* there is proof the output was not suppressed, whatever this run's
|
|
63173
|
+
* best-effort condition evaluation concluded.
|
|
63174
|
+
*/
|
|
63175
|
+
function isOutputSuppressed(name, output, conditions, stateOutputs) {
|
|
63176
|
+
const condition = output.Condition;
|
|
63177
|
+
if (typeof condition !== "string") return false;
|
|
63178
|
+
if (conditions[condition] === true) return false;
|
|
63179
|
+
return !(name in stateOutputs);
|
|
63180
|
+
}
|
|
63181
|
+
/**
|
|
63182
|
+
* The intrinsics that read ANOTHER stack's state (issue
|
|
63183
|
+
* [#2133](https://github.com/go-to-k/cdkd/issues/2133)).
|
|
63184
|
+
*
|
|
63185
|
+
* Both require `context.stateBackend` — `resolveImportValue` throws without it,
|
|
63186
|
+
* and `Fn::GetStackOutput` reaches the same requirement through
|
|
63187
|
+
* `getSameAccountStackState` — which is exactly what scrub's three resolve
|
|
63188
|
+
* contexts did not supply, so neither could ever resolve here.
|
|
63189
|
+
*/
|
|
63190
|
+
const CROSS_STACK_INTRINSIC_KEYS = ["Fn::ImportValue", "Fn::GetStackOutput"];
|
|
63191
|
+
/**
|
|
63192
|
+
* The intrinsic keys {@link IntrinsicFunctionResolver}'s `resolveValue`
|
|
63193
|
+
* dispatches on, IN ITS ORDER (issue #2133 review).
|
|
63194
|
+
*
|
|
63195
|
+
* The pre-pass below must decide which key of a multi-key node the RESOLVER
|
|
63196
|
+
* would act on, because a node carrying a higher-precedence sibling never
|
|
63197
|
+
* reaches its `Fn::ImportValue` at all. Testing the cross-stack keys first — as
|
|
63198
|
+
* the first cut did — makes the pre-pass perform a READ the main resolution
|
|
63199
|
+
* never performs, and a read that fails is a refusal over a reference the
|
|
63200
|
+
* deploy never made. Isolation (`{ [key]: node[key] }`) does not answer this:
|
|
63201
|
+
* it stops the pre-pass resolving something ELSE, it does not stop it resolving
|
|
63202
|
+
* something EXTRA.
|
|
63203
|
+
*
|
|
63204
|
+
* `Condition` is deliberately absent. The resolver's `{Condition: <name>}` arm
|
|
63205
|
+
* is gated on `context.conditionResolver`, which only `evaluateConditions`
|
|
63206
|
+
* threads; in every context this pre-pass runs under, such a node is an
|
|
63207
|
+
* ordinary object property literally named `Condition`.
|
|
63208
|
+
*
|
|
63209
|
+
* Fenced against the resolver's own dispatch order by
|
|
63210
|
+
* `tests/unit/cli/commands/scrub-import-value-secret.test.ts`, so a new
|
|
63211
|
+
* intrinsic added there cannot silently leave this copy behind.
|
|
63212
|
+
*/
|
|
63213
|
+
const RESOLVER_INTRINSIC_PRECEDENCE = [
|
|
63214
|
+
"Ref",
|
|
63215
|
+
"Fn::GetAtt",
|
|
63216
|
+
"Fn::Join",
|
|
63217
|
+
"Fn::Sub",
|
|
63218
|
+
"Fn::Select",
|
|
63219
|
+
"Fn::Split",
|
|
63220
|
+
"Fn::If",
|
|
63221
|
+
"Fn::Equals",
|
|
63222
|
+
"Fn::And",
|
|
63223
|
+
"Fn::Or",
|
|
63224
|
+
"Fn::Not",
|
|
63225
|
+
"Fn::ImportValue",
|
|
63226
|
+
"Fn::GetStackOutput",
|
|
63227
|
+
"Fn::FindInMap",
|
|
63228
|
+
"Fn::Base64",
|
|
63229
|
+
"Fn::GetAZs",
|
|
63230
|
+
"Fn::Cidr"
|
|
63231
|
+
];
|
|
63232
|
+
/** The key `resolveValue` would dispatch on for `node`, or `undefined`. */
|
|
63233
|
+
function effectiveIntrinsicKey(node) {
|
|
63234
|
+
return RESOLVER_INTRINSIC_PRECEDENCE.find((key) => key in node);
|
|
63235
|
+
}
|
|
63236
|
+
/**
|
|
63237
|
+
* The refusal a cross-stack read raises when scrub cannot PERFORM it (issue
|
|
63238
|
+
* [#2133](https://github.com/go-to-k/cdkd/issues/2133)).
|
|
63239
|
+
*
|
|
63240
|
+
* The value behind such a reference MAY be a secret — a producer stack exporting
|
|
63241
|
+
* a `{{resolve:...}}` expression is exactly the shape issue #1934 persists — and
|
|
63242
|
+
* scrub learns its plaintext ONLY by performing the read. A read that fails
|
|
63243
|
+
* therefore leaves the command with no needle for that value, so a stored
|
|
63244
|
+
* plaintext survives and the stack is reported clean. Refusing is the same bar
|
|
63245
|
+
* issue #2109 sets for the cross-region case: reporting success over a document
|
|
63246
|
+
* scrub could not examine is the one outcome that must not survive.
|
|
63247
|
+
*
|
|
63248
|
+
* MASKED for the reason `unresolvableForeignScrubSecretError` states — the bag
|
|
63249
|
+
* this resolved through is one `pinCrossRegionSecrets` may already have
|
|
63250
|
+
* substituted a foreign plaintext into, so a resolver error echoing its input
|
|
63251
|
+
* can carry one, and the map is the same one this pass records into.
|
|
63252
|
+
*/
|
|
63253
|
+
function unresolvableCrossStackReadError(origin, stackName, intrinsic, path, cause, secrets) {
|
|
63254
|
+
return new ScrubRefusalError(`Scrub of ${stackName} could not resolve the ${intrinsic} in ${origin}${path ? ` at ${path}` : ""}: ${maskSecretsInText(cause instanceof Error ? cause.message : String(cause), secrets)}. Refusing rather than continuing: the value that reference carries may be a secret, and scrub would then have no plaintext to look for — reporting the stack clean over state that still holds it. Deploy the producer stack (or correct the reference) and re-run 'cdkd scrub'.`, "SCRUB_CROSS_STACK_READ_UNRESOLVED");
|
|
63255
|
+
}
|
|
63256
|
+
/**
|
|
63257
|
+
* The refusal a cross-stack read raises when it SUCCEEDED but the PRODUCER's
|
|
63258
|
+
* own state record still stores the PLAINTEXT (issue
|
|
63259
|
+
* [#2133](https://github.com/go-to-k/cdkd/issues/2133) review).
|
|
63260
|
+
*
|
|
63261
|
+
* That producer state is precisely the population `cdkd scrub` exists for:
|
|
63262
|
+
* state an older binary wrote. Reading it hands the consumer a bare string,
|
|
63263
|
+
* nothing lands in `recordedSecretValues`, `totalSecrets` stays 0 and the
|
|
63264
|
+
* consumer is reported "No plaintext secrets found" while its record still
|
|
63265
|
+
* holds that plaintext. That is the same silent success the issue exists to
|
|
63266
|
+
* kill, reached one step later.
|
|
63267
|
+
*
|
|
63268
|
+
* There is no correct value to write here instead. What scrub must persist for
|
|
63269
|
+
* an imported secret is the PRODUCER's `{{resolve:...}}` expression, and that
|
|
63270
|
+
* expression exists only in the producer's state — the consumer's template
|
|
63271
|
+
* carries `{"Fn::ImportValue": ...}`, which is a REFERENCE, not an expression.
|
|
63272
|
+
* Writing the reference into a state leaf would fabricate an expression that
|
|
63273
|
+
* `Fn::ImportValue` and the exports index re-apply verbatim to further
|
|
63274
|
+
* consumers, the #1934 BREAK class this file already refuses one class over.
|
|
63275
|
+
* So: refuse, and name the fix that actually works — scrub the producer first,
|
|
63276
|
+
* which turns its stored plaintext into the expression this read then resolves.
|
|
63277
|
+
*/
|
|
63278
|
+
function plaintextProducerCrossStackReadError(origin, stackName, intrinsic, path, producerStack, exportKey, verdict, secrets) {
|
|
63279
|
+
const loggedExportKey = maskSecretsInText(exportKey, secrets);
|
|
63280
|
+
const loggedProducerStack = maskSecretsInText(producerStack, secrets);
|
|
63281
|
+
const templateClaim = verdict === "declared" ? `declares '${loggedExportKey}' from a {{resolve:...}} expression` : `publishes at least one output from a {{resolve:...}} expression (this run could not match '${loggedExportKey}' to a declared output, so it cannot say which)`;
|
|
63282
|
+
return new ScrubRefusalError(`Scrub of ${stackName} resolved the ${intrinsic} in ${origin}${path ? ` at ${path}` : ""} to a PLAINTEXT value: the producer stack '${loggedProducerStack}' ${templateClaim}, but its own state still stores the resolved plaintext rather than that expression. scrub has no expression to write in this stack's place, so it cannot redact the imported secret and must not report this stack clean. Scrub the producer first ('cdkd scrub ${loggedProducerStack}'), then re-run 'cdkd scrub ${stackName}' — the read will then return the expression and this stack is scrubbed normally.`, "SCRUB_CROSS_STACK_PRODUCER_PLAINTEXT");
|
|
63283
|
+
}
|
|
63284
|
+
/**
|
|
63285
|
+
* Can the producer's STORED value be a legacy plaintext secret at all (issue
|
|
63286
|
+
* #2133 review)?
|
|
63287
|
+
*
|
|
63288
|
+
* `false` for a value that carries no text: `null` / `undefined`, the empty
|
|
63289
|
+
* string, and every non-string scalar. A resolved `{{resolve:...}}` reference
|
|
63290
|
+
* is always a STRING — `resolveDynamicReferences` substitutes text — so a
|
|
63291
|
+
* number or a boolean cannot be one, and an empty string holds nothing to
|
|
63292
|
+
* redact. Without this, `carriesDynamicReference` being false for those shapes
|
|
63293
|
+
* made a `widened` verdict refuse the consumer over a value with nothing behind
|
|
63294
|
+
* it, in a refusal that no re-run could clear (scrubbing the producer cannot
|
|
63295
|
+
* turn an empty value into an expression).
|
|
63296
|
+
*
|
|
63297
|
+
* Containers are NOT included: an array or object can nest a plaintext string,
|
|
63298
|
+
* and `carriesDynamicReference` already walks them, so the classification for
|
|
63299
|
+
* those is left exactly as it was.
|
|
63300
|
+
*/
|
|
63301
|
+
function storedValueCarriesNoPlaintext(stored) {
|
|
63302
|
+
if (stored === null || stored === void 0) return true;
|
|
63303
|
+
if (typeof stored === "string") return stored === "";
|
|
63304
|
+
return typeof stored !== "object";
|
|
63305
|
+
}
|
|
63306
|
+
/**
|
|
63307
|
+
* Is `key` an export the producer's TEMPLATE declares from a `{{resolve:...}}`
|
|
63308
|
+
* expression (issue #2133 review)?
|
|
63309
|
+
*
|
|
63310
|
+
* This is HALF the discriminator, and the half the direct state read below
|
|
63311
|
+
* cannot supply. From the consumer's side, "the producer stored a plaintext
|
|
63312
|
+
* SECRET" and "the producer stored an ordinary non-secret value" are
|
|
63313
|
+
* indistinguishable — a bucket name and a leaked password are both bare
|
|
63314
|
+
* strings — so a refusal keyed on the producer's STORED value alone would fire
|
|
63315
|
+
* on every multi-stack app that imports anything at all. An export whose
|
|
63316
|
+
* declared `Value` contains no `{{resolve:` anywhere cannot be secret-bearing,
|
|
63317
|
+
* so its plaintext is nothing to redact.
|
|
63318
|
+
*
|
|
63319
|
+
* What it deliberately does NOT answer is whether the producer's own STATE has
|
|
63320
|
+
* been scrubbed down to that expression yet, which is the other half and the
|
|
63321
|
+
* one the three earlier cuts of this file each got wrong. See the discriminator
|
|
63322
|
+
* in {@link makeCrossStackPrePass}.
|
|
63323
|
+
*
|
|
63324
|
+
* `key` is the state.outputs KEY the read matched, which the deploy engine
|
|
63325
|
+
* writes under BOTH the output name and the literal `Export.Name` — hence both
|
|
63326
|
+
* are matched. When no output matches (an INTRINSIC `Export.Name`, which scrub
|
|
63327
|
+
* cannot reproduce from here) the test widens to every output of the producer,
|
|
63328
|
+
* which over-approximates in the SAFE direction: it can refuse a stack that
|
|
63329
|
+
* imports a non-secret export from a producer that has some other secret
|
|
63330
|
+
* output, and it never lets a secret-bearing one through. The widening is
|
|
63331
|
+
* REPORTED rather than folded into a boolean, because the refusal message
|
|
63332
|
+
* asserts what the producer declares and only the `declared` verdict has
|
|
63333
|
+
* checked that for THIS key.
|
|
63334
|
+
*
|
|
63335
|
+
* Residual, stated rather than hidden: a producer that publishes a secret
|
|
63336
|
+
* WITHOUT a literal `{{resolve:` in its template — one re-exporting a value it
|
|
63337
|
+
* imported from a third stack, or reading one through `Ref` to a parameter —
|
|
63338
|
+
* is not detected, and neither is a producer outside this app (whose template
|
|
63339
|
+
* scrub never sees). Those keep the pre-#2133 outcome for that one reference.
|
|
63340
|
+
*/
|
|
63341
|
+
function producerPublishesSecretExpression(template, key) {
|
|
63342
|
+
const outputs = template.Outputs ?? {};
|
|
63343
|
+
const matched = [];
|
|
63344
|
+
for (const [name, output] of Object.entries(outputs)) {
|
|
63345
|
+
const exportName = output?.Export?.Name;
|
|
63346
|
+
if (name === key || typeof exportName === "string" && exportName === key) matched.push(output?.Value);
|
|
63347
|
+
}
|
|
63348
|
+
const widened = matched.length === 0;
|
|
63349
|
+
if (!(widened ? Object.values(outputs).map((o) => o?.Value) : matched).some((value) => JSON.stringify(value ?? null).includes("{{resolve:"))) return "no";
|
|
63350
|
+
return widened ? "widened" : "declared";
|
|
63351
|
+
}
|
|
63352
|
+
/**
|
|
63353
|
+
* Build the CROSS-STACK pre-pass for ONE stack's scrub (issue
|
|
63354
|
+
* [#2133](https://github.com/go-to-k/cdkd/issues/2133)).
|
|
63355
|
+
*
|
|
63356
|
+
* WHY THIS RUNS AT ALL, given the wiring in `scrubStack` already lets the main
|
|
63357
|
+
* resolution perform these reads. The wiring is what makes an
|
|
63358
|
+
* `Fn::ImportValue`-sourced secret RESOLVE; this pass is what makes a FAILED
|
|
63359
|
+
* one LOUD, and what catches a SUCCEEDING one that produced no needle. A
|
|
63360
|
+
* cross-stack read that throws inside `resolver.resolve` lands in the caller's
|
|
63361
|
+
* best-effort `catch { logger.debug(...) }`, and that catch cannot tell "a
|
|
63362
|
+
* `Ref` to a resource that is not in state" — which it exists for — from "the
|
|
63363
|
+
* producer's state could not be read", which silently costs the command its
|
|
63364
|
+
* needle. Lifting the second class OUT of the catch is the same placement issue
|
|
63365
|
+
* #2109 uses for its own refusals, and it keeps the catch doing its job:
|
|
63366
|
+
* everything it still swallows is now provably NOT a cross-stack read.
|
|
63367
|
+
*
|
|
63368
|
+
* THREE OUTCOMES per reference, and the split is the whole design:
|
|
63369
|
+
* - the read FAILS for a reason a user can fix (producer not deployed, wrong
|
|
63370
|
+
* reference, an `AccessDenied` on the producer's state or on
|
|
63371
|
+
* `cloudformation:ListExports`, an unassumable `RoleArn`) — REFUSE the stack.
|
|
63372
|
+
* A re-run after the fix scrubs it, so refusing costs reachability only until
|
|
63373
|
+
* the cause is addressed.
|
|
63374
|
+
* - the read is DECLINED BY DESIGN (`CrossAccountSecretRefusalError`) — record
|
|
63375
|
+
* an UNVERIFIABLE finding, scrub the rest of the stack, and let the run exit
|
|
63376
|
+
* non-zero. No user action can make that read succeed, so refusing the whole
|
|
63377
|
+
* stack would strand every OTHER secret in it forever — a reachability
|
|
63378
|
+
* regression against the pre-#2133 best-effort behaviour, in the command that
|
|
63379
|
+
* exists to remediate. This is the `secretBearingKeys` treatment (issue
|
|
63380
|
+
* #1919), applied to the same shape of finding: real, reported, unremediable.
|
|
63381
|
+
* The match is on that SUBCLASS, never on `IntrinsicResolutionRefusalError`:
|
|
63382
|
+
* five of its six throw sites are USER-FIXABLE and are reachable from here
|
|
63383
|
+
* whenever a cross-stack node's ARGUMENT is an `Fn::Sub` / `Fn::GetAtt` /
|
|
63384
|
+
* `Fn::Split` (`resolveSub` re-raises the class), so matching the base class
|
|
63385
|
+
* downgraded a fixable refusal to a finding and let scrub print
|
|
63386
|
+
* `No plaintext secrets found` and exit 0 over surviving plaintext.
|
|
63387
|
+
* - the read SUCCEEDS but the PRODUCER's own state record still holds the
|
|
63388
|
+
* plaintext, for an export the producer's template declares from a
|
|
63389
|
+
* `{{resolve:...}}` expression — REFUSE, see
|
|
63390
|
+
* {@link plaintextProducerCrossStackReadError}.
|
|
63391
|
+
*
|
|
63392
|
+
* Each node is resolved on its OWN — `{ [key]: node[key] }` rather than the
|
|
63393
|
+
* enclosing object — so a bag carrying an unrelated sibling key cannot make
|
|
63394
|
+
* this pass resolve something else, and so a failure names the cross-stack read
|
|
63395
|
+
* rather than whatever the whole node resolves to. Which key of a multi-key
|
|
63396
|
+
* node is even eligible is decided by {@link RESOLVER_INTRINSIC_PRECEDENCE},
|
|
63397
|
+
* mirroring the resolver — so for the CHOICE OF KEY the pass never reads
|
|
63398
|
+
* through an intrinsic the main resolution would ignore.
|
|
63399
|
+
*
|
|
63400
|
+
* That is the whole of the invariant, and it is deliberately narrower than
|
|
63401
|
+
* "this pass performs no read the main resolution would not" (issue #2133
|
|
63402
|
+
* review — the earlier, overstated wording). `resolveValue` walks a plain
|
|
63403
|
+
* object's properties SEQUENTIALLY and aborts the whole bag at the first one
|
|
63404
|
+
* that throws, and it THROWS on an unknown intrinsic (`Fn::ToJsonString`,
|
|
63405
|
+
* `Fn::ForEach`) before descending into its argument at all; this pass resolves
|
|
63406
|
+
* each node on its own and keeps walking in both cases. So it can read a
|
|
63407
|
+
* reference the main resolution never reaches — after a sibling `Ref` to a
|
|
63408
|
+
* resource absent from state, which is the routine case, or beneath an
|
|
63409
|
+
* intrinsic cdkd cannot resolve. The DIRECTION is safe (the deploy made those
|
|
63410
|
+
* reads, so refusing over one names a real unscrubbed producer rather than a
|
|
63411
|
+
* fabricated one) but the sentence is load-bearing for the refusal, so it is
|
|
63412
|
+
* stated as what it is rather than as the stronger claim.
|
|
63413
|
+
*
|
|
63414
|
+
* The bag is NOT rewritten. The main resolution re-resolves these references
|
|
63415
|
+
* itself, and the S3 reads behind them are shared with this pass by the
|
|
63416
|
+
* memoizing backend view `scrubStack` builds (issue #2133 review — before that
|
|
63417
|
+
* every reference cost `listStacks()` plus up to N `getState()` TWICE). What
|
|
63418
|
+
* not rewriting buys back is the whole class of hazard a substituted value
|
|
63419
|
+
* carries: a resolved cross-stack value that is itself intrinsic- or
|
|
63420
|
+
* reference-shaped would be re-interpreted by the stack's own resolver, which
|
|
63421
|
+
* is the shape `SCRUB_SECRET_RESOLUTION_REINTRODUCED_TOKEN` guards against one
|
|
63422
|
+
* class over. The needles this records survive the main resolution failing for
|
|
63423
|
+
* an unrelated reason, since `recordedSecretValues` is filled in place.
|
|
63424
|
+
*
|
|
63425
|
+
* `Fn::If` is walked the way {@link IntrinsicFunctionResolver} walks it — the
|
|
63426
|
+
* SELECTED branch only, with an unknown condition selecting the FALSE branch,
|
|
63427
|
+
* mirroring `resolveIf`. Reading a reference the main resolution would never
|
|
63428
|
+
* touch would refuse a stack over a conditional import the deploy never made.
|
|
63429
|
+
* A MALFORMED `Fn::If` (args not a 3-tuple) is walked with refusals DISARMED
|
|
63430
|
+
* rather than skipped: which branch is live is unknowable, so a read inside it
|
|
63431
|
+
* may be one the deploy never made — the exact hazard the selected-branch
|
|
63432
|
+
* mirror exists to prevent — but any needle it does yield is still worth having.
|
|
63433
|
+
*/
|
|
63434
|
+
function makeCrossStackPrePass(deps) {
|
|
63435
|
+
const { stackName, resolver, producerTemplates, findings, logger } = deps;
|
|
63436
|
+
/**
|
|
63437
|
+
* Is this a refusal NO RE-RUN can clear (issue #2133 review)?
|
|
63438
|
+
*
|
|
63439
|
+
* The predicate is the SUBCLASS, not `IntrinsicResolutionRefusalError`. That
|
|
63440
|
+
* class has six throw sites and only ONE — `resolveGetStackOutput`'s
|
|
63441
|
+
* cross-account arm — is permanent; the other five (a stale placeholder ARN,
|
|
63442
|
+
* a fabricated account id, an unenriched `Fn::GetAtt`, `--strict-getatt`, a
|
|
63443
|
+
* malformed `Fn::Split`) are all fixed by editing the template or deploying
|
|
63444
|
+
* the producer. They are REACHABLE here, because `resolveSub` re-raises the
|
|
63445
|
+
* class, so any cross-stack node whose ARGUMENT is an `Fn::Sub` /
|
|
63446
|
+
* `Fn::GetAtt` / `Fn::Split` can carry one up to this catch. Matching the
|
|
63447
|
+
* base class downgraded every one of them to a finding logged as "cdkd
|
|
63448
|
+
* declines this read by design, so no re-run can change it" — false — and
|
|
63449
|
+
* scrub then printed `No plaintext secrets found` and exited 0 without
|
|
63450
|
+
* `--fail` while the consumer's state.json still held the imported plaintext.
|
|
63451
|
+
*
|
|
63452
|
+
* The CAUSE CHAIN rather than a bare `instanceof`: the refusal reaches this
|
|
63453
|
+
* catch through `resolver.resolve`, which wraps in several places, and a
|
|
63454
|
+
* top-link-only test silently reclassifies a wrapped permanent refusal as
|
|
63455
|
+
* user-fixable — i.e. refuses the whole stack over something no user can fix.
|
|
63456
|
+
*/
|
|
63457
|
+
const isByDesignRefusal = (err) => err instanceof Error && errorCauseChain(err).some((link) => link instanceof CrossAccountSecretRefusalError);
|
|
63458
|
+
return async function resolveCrossStackReads(bag, context, origin, opts) {
|
|
63459
|
+
const canRefuse = opts?.canRefuse ?? true;
|
|
63460
|
+
const secrets = context.recordedSecretValues ?? /* @__PURE__ */ new Map();
|
|
63461
|
+
/**
|
|
63462
|
+
* The producer this reference actually resolved through, and the KEY it
|
|
63463
|
+
* matched — read back out of the resolver's own recording bags.
|
|
63464
|
+
*
|
|
63465
|
+
* Keyed on the intrinsic UNDER TEST rather than "imports first, then output
|
|
63466
|
+
* reads": `resolveImportValue` / `resolveGetStackOutput` each resolve their
|
|
63467
|
+
* ARGUMENTS before recording their own read, so a nested cross-stack read
|
|
63468
|
+
* inside the argument lands in the OTHER family's bag (or earlier in the
|
|
63469
|
+
* same one). Preferring imports unconditionally made an
|
|
63470
|
+
* `Fn::GetStackOutput` whose `StackName` contains an `Fn::ImportValue`
|
|
63471
|
+
* report the INNER producer.
|
|
63472
|
+
*
|
|
63473
|
+
* `.at(-1)` for the same reason within one family: the outer read is
|
|
63474
|
+
* recorded LAST, after its argument has resolved.
|
|
63475
|
+
*
|
|
63476
|
+
* An empty bag means the value did NOT come from a cdkd state record, and
|
|
63477
|
+
* the two arms that produce one are NOT unreadable for the same reason
|
|
63478
|
+
* (issue #2133 review — one rationale used to cover both, and it was false
|
|
63479
|
+
* of the second):
|
|
63480
|
+
*
|
|
63481
|
+
* - the CloudFormation `ListExports` / `DescribeStacks` FALLBACKS record
|
|
63482
|
+
* nothing deliberately, a CFn-managed producer being a weak reference.
|
|
63483
|
+
* Here "no expression to restore" is literally true: the value came from
|
|
63484
|
+
* CloudFormation, which cdkd never redacted, so no `{{resolve:...}}`
|
|
63485
|
+
* expression exists anywhere for scrub to write in its place.
|
|
63486
|
+
* - the CROSS-ACCOUNT `Fn::GetStackOutput` arm records nothing for a
|
|
63487
|
+
* different reason, and a post-#1934 producer in the other account DOES
|
|
63488
|
+
* store the expression — which is precisely why
|
|
63489
|
+
* `CrossAccountSecretRefusalError` exists, and why it fires BEFORE this
|
|
63490
|
+
* point whenever the stored value carries one. What can reach here is a
|
|
63491
|
+
* value that carried none, and scrub cannot classify it any further: the
|
|
63492
|
+
* producer's `state.json` lives in another account and the only
|
|
63493
|
+
* credentials in hand are the consumer's, so reading it is the
|
|
63494
|
+
* wrong-account lookup that refusal exists to prevent.
|
|
63495
|
+
*
|
|
63496
|
+
* The OUTCOME is the same today either way — a cross-account producer is
|
|
63497
|
+
* never in `appStacks`, so the `!producerTemplate` return below fires first
|
|
63498
|
+
* regardless — but a future change that widened `producerTemplates` would
|
|
63499
|
+
* be relying on this sentence, so it must not claim the second arm has no
|
|
63500
|
+
* expression behind it.
|
|
63501
|
+
*
|
|
63502
|
+
* Residual: an OUTER read that fell through to a CloudFormation fallback
|
|
63503
|
+
* while its ARGUMENT performed a same-family cdkd read leaves the inner
|
|
63504
|
+
* entry last, so the check below consults the wrong producer. Bounded by
|
|
63505
|
+
* the key test — the inner key must also exist in the inner producer's
|
|
63506
|
+
* outputs, which it does — and it can only produce a false REFUSAL naming a
|
|
63507
|
+
* real unscrubbed producer, never a false clean.
|
|
63508
|
+
*/
|
|
63509
|
+
const recordedProducer = (key, probe) => {
|
|
63510
|
+
if (key === "Fn::GetStackOutput") {
|
|
63511
|
+
const read = probe.recordedOutputReads?.at(-1);
|
|
63512
|
+
return read ? {
|
|
63513
|
+
stack: read.sourceStack,
|
|
63514
|
+
region: read.sourceRegion,
|
|
63515
|
+
key: read.outputName
|
|
63516
|
+
} : void 0;
|
|
63517
|
+
}
|
|
63518
|
+
const imported = probe.recordedImports?.at(-1);
|
|
63519
|
+
return imported ? {
|
|
63520
|
+
stack: imported.sourceStack,
|
|
63521
|
+
region: imported.sourceRegion,
|
|
63522
|
+
key: imported.exportName
|
|
63523
|
+
} : void 0;
|
|
63524
|
+
};
|
|
63525
|
+
/**
|
|
63526
|
+
* The producer's STORED value for `key` — what its `state.json` actually
|
|
63527
|
+
* holds, as opposed to what the read RETURNED.
|
|
63528
|
+
*
|
|
63529
|
+
* `undefined` when the producer's record cannot be read or does not carry
|
|
63530
|
+
* the key, which is treated as "cannot classify" by the caller rather than
|
|
63531
|
+
* as evidence of anything. Both are near-unreachable on the arms that get
|
|
63532
|
+
* here (the read just succeeded against that record, through the SAME
|
|
63533
|
+
* memoized `getState` promise this call resolves), so the read costs no
|
|
63534
|
+
* extra AWS call.
|
|
63535
|
+
*/
|
|
63536
|
+
const storedProducerValue = async (producer, backend) => {
|
|
63537
|
+
if (!backend) return void 0;
|
|
63538
|
+
let loaded;
|
|
63539
|
+
try {
|
|
63540
|
+
loaded = await backend.getState(producer.stack, producer.region);
|
|
63541
|
+
} catch (err) {
|
|
63542
|
+
logger.debug(`Scrub of ${stackName}: could not re-read producer '${producer.stack}' (${producer.region}) to classify its stored value: ${maskSecretsInText(err instanceof Error ? err.message : String(err), secrets)}`);
|
|
63543
|
+
return;
|
|
63544
|
+
}
|
|
63545
|
+
const outputs = loaded?.state?.outputs;
|
|
63546
|
+
if (!outputs || !(producer.key in outputs)) return void 0;
|
|
63547
|
+
return { stored: outputs[producer.key] };
|
|
63548
|
+
};
|
|
63549
|
+
const readOne = async (key, node, path, nodeCanRefuse) => {
|
|
63550
|
+
const probe = {
|
|
63551
|
+
...context,
|
|
63552
|
+
recordedImports: [],
|
|
63553
|
+
recordedOutputReads: []
|
|
63554
|
+
};
|
|
63555
|
+
try {
|
|
63556
|
+
await resolver.resolve({ [key]: node[key] }, probe);
|
|
63557
|
+
} catch (err) {
|
|
63558
|
+
if (!nodeCanRefuse) {
|
|
63559
|
+
logger.debug(`Scrub of ${stackName}: ${key} in ${origin}${path ? ` at ${path}` : ""} could not be resolved, and this position cannot refuse: ${maskSecretsInText(err instanceof Error ? err.message : String(err), secrets)}`);
|
|
63560
|
+
return;
|
|
63561
|
+
}
|
|
63562
|
+
if (isByDesignRefusal(err)) {
|
|
63563
|
+
const detail = `${key} in ${origin}${path ? ` at ${path}` : ""}: ${maskSecretsInText(err instanceof Error ? err.message : String(err), secrets)}`;
|
|
63564
|
+
findings.unverifiable.push(detail);
|
|
63565
|
+
logger.warn(`Scrub of ${stackName} cannot verify ${detail} — cdkd declines this read by design, so no re-run can change it. The rest of the stack is still scrubbed; this stack is NOT reported clean.`);
|
|
63566
|
+
return;
|
|
63567
|
+
}
|
|
63568
|
+
throw unresolvableCrossStackReadError(origin, stackName, key, path, err, secrets);
|
|
63569
|
+
}
|
|
63570
|
+
const producer = recordedProducer(key, probe);
|
|
63571
|
+
if (!producer) return;
|
|
63572
|
+
const producerTemplate = producerTemplates.get(producer.stack);
|
|
63573
|
+
if (!producerTemplate) return;
|
|
63574
|
+
const verdict = producerPublishesSecretExpression(producerTemplate, producer.key);
|
|
63575
|
+
if (verdict === "no") return;
|
|
63576
|
+
const stored = await storedProducerValue(producer, context.stateBackend);
|
|
63577
|
+
if (!stored) return;
|
|
63578
|
+
if (carriesDynamicReference(stored.stored)) return;
|
|
63579
|
+
if (storedValueCarriesNoPlaintext(stored.stored)) {
|
|
63580
|
+
logger.debug(`Scrub of ${stackName}: ${key} in ${origin}${path ? ` at ${path}` : ""} resolved through producer '${producer.stack}', whose stored value for that key carries no text — nothing to redact, so this is not classified as a plaintext producer.`);
|
|
63581
|
+
return;
|
|
63582
|
+
}
|
|
63583
|
+
if (!nodeCanRefuse) {
|
|
63584
|
+
logger.debug(`Scrub of ${stackName}: ${key} in ${origin}${path ? ` at ${path}` : ""} resolved to a plaintext from unscrubbed producer '${producer.stack}', and this position cannot refuse.`);
|
|
63585
|
+
return;
|
|
63586
|
+
}
|
|
63587
|
+
throw plaintextProducerCrossStackReadError(origin, stackName, key, path, producer.stack, producer.key, verdict, secrets);
|
|
63588
|
+
};
|
|
63589
|
+
const walk = async (v, path, nodeCanRefuse) => {
|
|
63590
|
+
if (Array.isArray(v)) {
|
|
63591
|
+
for (let i = 0; i < v.length; i++) await walk(v[i], `${path}[${i}]`, nodeCanRefuse);
|
|
63592
|
+
return;
|
|
63593
|
+
}
|
|
63594
|
+
if (v === null || typeof v !== "object") return;
|
|
63595
|
+
const node = v;
|
|
63596
|
+
const key = effectiveIntrinsicKey(node);
|
|
63597
|
+
if (key === void 0) {
|
|
63598
|
+
for (const [k, val] of Object.entries(node)) await walk(val, path ? `${path}.${k}` : k, nodeCanRefuse);
|
|
63599
|
+
return;
|
|
63600
|
+
}
|
|
63601
|
+
if (key === "Fn::If") {
|
|
63602
|
+
const args = node["Fn::If"];
|
|
63603
|
+
if (Array.isArray(args) && args.length === 3) {
|
|
63604
|
+
const [conditionName, ifTrue, ifFalse] = args;
|
|
63605
|
+
const selected = typeof conditionName === "string" && context.conditions?.[conditionName] === true ? ifTrue : ifFalse;
|
|
63606
|
+
await walk(selected, `${path}['Fn::If']`, nodeCanRefuse);
|
|
63607
|
+
return;
|
|
63608
|
+
}
|
|
63609
|
+
await walk(args, `${path}['Fn::If']`, false);
|
|
63610
|
+
return;
|
|
63611
|
+
}
|
|
63612
|
+
if (CROSS_STACK_INTRINSIC_KEYS.includes(key)) {
|
|
63613
|
+
await readOne(key, node, path, nodeCanRefuse);
|
|
63614
|
+
return;
|
|
63615
|
+
}
|
|
63616
|
+
await walk(node[key], `${path}['${key}']`, nodeCanRefuse);
|
|
63617
|
+
};
|
|
63618
|
+
await walk(bag, "", canRefuse);
|
|
63619
|
+
};
|
|
63620
|
+
}
|
|
63621
|
+
/**
|
|
63049
63622
|
* Scrub one stack's state. Re-resolves the template's per-resource properties to
|
|
63050
63623
|
* learn the resolved secret VALUES, then replaces those values in the state
|
|
63051
63624
|
* record with their `{{resolve:...}}` expressions. Returns counts; performs no
|
|
@@ -63064,6 +63637,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63064
63637
|
if (acquired) await lockManager.acquireLockWithRetry(stack.stackName, region, void 0, "scrub");
|
|
63065
63638
|
const perResourceSecrets = /* @__PURE__ */ new Map();
|
|
63066
63639
|
const outputSecrets = /* @__PURE__ */ new Map();
|
|
63640
|
+
const prePassFindings = { unverifiable: [] };
|
|
63067
63641
|
try {
|
|
63068
63642
|
const loaded = await stateBackend.getState(stack.stackName, region);
|
|
63069
63643
|
if (!loaded) {
|
|
@@ -63071,7 +63645,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63071
63645
|
return {
|
|
63072
63646
|
recordsChanged: 0,
|
|
63073
63647
|
secretsFound: 0,
|
|
63074
|
-
secretBearingKeys: 0
|
|
63648
|
+
secretBearingKeys: 0,
|
|
63649
|
+
unverifiableReads: 0
|
|
63075
63650
|
};
|
|
63076
63651
|
}
|
|
63077
63652
|
const state = loaded.state;
|
|
@@ -63088,13 +63663,26 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63088
63663
|
} catch (err) {
|
|
63089
63664
|
logger.debug(`Parameter resolution skipped for ${stack.stackName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
63090
63665
|
}
|
|
63666
|
+
const resolverStateBackend = memoizeCrossStackStateReads(stateBackend);
|
|
63667
|
+
const resolverContext = (recordedSecretValues) => ({
|
|
63668
|
+
template: stack.template,
|
|
63669
|
+
resources: state.resources,
|
|
63670
|
+
...Object.keys(parameters).length > 0 && { parameters },
|
|
63671
|
+
...Object.keys(conditions).length > 0 && { conditions },
|
|
63672
|
+
stackName: stack.stackName,
|
|
63673
|
+
stateBackend: resolverStateBackend,
|
|
63674
|
+
...recordedSecretValues && { recordedSecretValues },
|
|
63675
|
+
bestEffort: true
|
|
63676
|
+
});
|
|
63677
|
+
const resolveCrossStackReads = makeCrossStackPrePass({
|
|
63678
|
+
stackName: stack.stackName,
|
|
63679
|
+
resolver,
|
|
63680
|
+
producerTemplates: new Map((opts.appStacks ?? []).map((s) => [s.stackName, s.template])),
|
|
63681
|
+
findings: prePassFindings,
|
|
63682
|
+
logger
|
|
63683
|
+
});
|
|
63091
63684
|
try {
|
|
63092
|
-
conditions = await resolver.evaluateConditions(
|
|
63093
|
-
template: stack.template,
|
|
63094
|
-
resources: state.resources,
|
|
63095
|
-
...Object.keys(parameters).length > 0 && { parameters },
|
|
63096
|
-
bestEffort: true
|
|
63097
|
-
});
|
|
63685
|
+
conditions = await resolver.evaluateConditions(resolverContext(outputSecrets));
|
|
63098
63686
|
} catch (err) {
|
|
63099
63687
|
logger.debug(`Condition evaluation skipped for ${stack.stackName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
63100
63688
|
}
|
|
@@ -63103,6 +63691,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63103
63691
|
const templateResource = templateResources[logicalId];
|
|
63104
63692
|
if (!templateResource?.Properties) continue;
|
|
63105
63693
|
const recordedSecretValues = /* @__PURE__ */ new Map();
|
|
63694
|
+
perResourceSecrets.set(logicalId, recordedSecretValues);
|
|
63106
63695
|
const resolveInput = await pinCrossRegionSecrets(templateResource.Properties, stack.stackName, {
|
|
63107
63696
|
stackRegion: region,
|
|
63108
63697
|
producerRegions,
|
|
@@ -63111,20 +63700,13 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63111
63700
|
recordedSecretValues,
|
|
63112
63701
|
origin: `resource '${logicalId}'`
|
|
63113
63702
|
});
|
|
63703
|
+
const resourceContext = resolverContext(recordedSecretValues);
|
|
63704
|
+
await resolveCrossStackReads(resolveInput, resourceContext, `resource '${logicalId}'`);
|
|
63114
63705
|
try {
|
|
63115
|
-
await resolver.resolve(resolveInput,
|
|
63116
|
-
template: stack.template,
|
|
63117
|
-
resources: state.resources,
|
|
63118
|
-
...Object.keys(parameters).length > 0 && { parameters },
|
|
63119
|
-
...Object.keys(conditions).length > 0 && { conditions },
|
|
63120
|
-
stackName: stack.stackName,
|
|
63121
|
-
recordedSecretValues,
|
|
63122
|
-
bestEffort: true
|
|
63123
|
-
});
|
|
63706
|
+
await resolver.resolve(resolveInput, resourceContext);
|
|
63124
63707
|
} catch (err) {
|
|
63125
63708
|
logger.debug(`Resolution of ${logicalId} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}`);
|
|
63126
63709
|
}
|
|
63127
|
-
if (recordedSecretValues.size > 0) perResourceSecrets.set(logicalId, recordedSecretValues);
|
|
63128
63710
|
perResourceTemplateProps.set(logicalId, templateResource.Properties);
|
|
63129
63711
|
}
|
|
63130
63712
|
const templateOutputs = stack.template.Outputs ?? {};
|
|
@@ -63145,16 +63727,10 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63145
63727
|
recordedSecretValues: outputSecrets,
|
|
63146
63728
|
origin: `Export.Name of output '${name}'`
|
|
63147
63729
|
});
|
|
63730
|
+
const nameContext = resolverContext(outputSecrets);
|
|
63731
|
+
await resolveCrossStackReads(nameSource, nameContext, `Export.Name of output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
|
|
63148
63732
|
try {
|
|
63149
|
-
exportName = await resolver.resolve(nameSource,
|
|
63150
|
-
template: stack.template,
|
|
63151
|
-
resources: state.resources,
|
|
63152
|
-
...Object.keys(parameters).length > 0 && { parameters },
|
|
63153
|
-
...Object.keys(conditions).length > 0 && { conditions },
|
|
63154
|
-
stackName: stack.stackName,
|
|
63155
|
-
recordedSecretValues: outputSecrets,
|
|
63156
|
-
bestEffort: true
|
|
63157
|
-
});
|
|
63733
|
+
exportName = await resolver.resolve(nameSource, nameContext);
|
|
63158
63734
|
} catch (err) {
|
|
63159
63735
|
outputsSourceUntrusted = true;
|
|
63160
63736
|
logger.warn(`Export.Name of output ${name} could not be resolved during scrub (${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}) — redacting this stack's outputs by value match instead of by template position, since state may be keyed under a name this run cannot reproduce.`);
|
|
@@ -63185,16 +63761,10 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63185
63761
|
recordedSecretValues: outputSecrets,
|
|
63186
63762
|
origin: `output '${name}'`
|
|
63187
63763
|
});
|
|
63764
|
+
const valueContext = resolverContext(outputSecrets);
|
|
63765
|
+
await resolveCrossStackReads(valueSource, valueContext, `output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
|
|
63188
63766
|
try {
|
|
63189
|
-
await resolver.resolve(valueSource,
|
|
63190
|
-
template: stack.template,
|
|
63191
|
-
resources: state.resources,
|
|
63192
|
-
...Object.keys(parameters).length > 0 && { parameters },
|
|
63193
|
-
...Object.keys(conditions).length > 0 && { conditions },
|
|
63194
|
-
stackName: stack.stackName,
|
|
63195
|
-
recordedSecretValues: outputSecrets,
|
|
63196
|
-
bestEffort: true
|
|
63197
|
-
});
|
|
63767
|
+
await resolver.resolve(valueSource, valueContext);
|
|
63198
63768
|
} catch (err) {
|
|
63199
63769
|
logger.debug(`Resolution of output ${name} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}`);
|
|
63200
63770
|
}
|
|
@@ -63211,7 +63781,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63211
63781
|
if (totalSecrets === 0) return {
|
|
63212
63782
|
recordsChanged: 0,
|
|
63213
63783
|
secretsFound: 0,
|
|
63214
|
-
secretBearingKeys:
|
|
63784
|
+
secretBearingKeys: secretBearingKeys.length,
|
|
63785
|
+
unverifiableReads: prePassFindings.unverifiable.length
|
|
63215
63786
|
};
|
|
63216
63787
|
let recordsChanged = 0;
|
|
63217
63788
|
const newResources = {};
|
|
@@ -63241,7 +63812,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63241
63812
|
return {
|
|
63242
63813
|
recordsChanged,
|
|
63243
63814
|
secretsFound: totalSecrets,
|
|
63244
|
-
secretBearingKeys: secretBearingKeys.length
|
|
63815
|
+
secretBearingKeys: secretBearingKeys.length,
|
|
63816
|
+
unverifiableReads: prePassFindings.unverifiable.length
|
|
63245
63817
|
};
|
|
63246
63818
|
} catch (err) {
|
|
63247
63819
|
throw maskSecretsInError(err, allRecordedSecrets(outputSecrets, perResourceSecrets));
|
|
@@ -85181,7 +85753,7 @@ function createMigrateCommand() {
|
|
|
85181
85753
|
*/
|
|
85182
85754
|
function buildProgram() {
|
|
85183
85755
|
const program = new Command();
|
|
85184
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.
|
|
85756
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.32");
|
|
85185
85757
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
85186
85758
|
const { profile } = actionCommand.optsWithGlobals();
|
|
85187
85759
|
if (profile !== void 0) process.env["AWS_PROFILE"] = profile;
|