@go-to-k/cdkd 0.284.27 → 0.284.29
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/README.md +6 -0
- package/dist/{asg-provider-CpNMUO45.js → asg-provider-DzbiUvJi.js} +2 -2
- package/dist/{asg-provider-CpNMUO45.js.map → asg-provider-DzbiUvJi.js.map} +1 -1
- package/dist/cli.js +789 -53
- package/dist/cli.js.map +1 -1
- package/dist/{deploy-engine-Bidb8N3p.js → deploy-engine-CE-tOz2d.js} +10 -4
- package/dist/deploy-engine-CE-tOz2d.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-Bidb8N3p.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as
|
|
2
|
+
import { $ as endCommandInterruptScope, $t as createAssetRedirectResolver, A as MULTI_REGION_RECREATE_BLOCKED_TYPES, An as resolveUseCdkBootstrapAssets, At as maskSecretsInText, B as collectDeclaredOutputNames, Bn as canonicalizeRegion, Bt as INTRINSIC_KEYS, C as ccRoutedFinalSnapshotError, Cn as getLegacyStateBucketName, Ct as STATE_SOURCED_READBACK_RULES, D as unsupportedFinalSnapshotError, Dn as resolveSkipPrefix, Dt as errorCauseChain, E as refusesFinalSnapshot, En as resolveCaptureObservedState, Et as dynamicReferenceTokens, F as cyan, Fn as MIGRATE_TMP_PREFIX, Ft as s3BucketDualStackDomainName, G as stateKeySecretExposure, Gn as resolveBucketRegion, Gt as LockManager, H as exportAliasCollisionScrubWarning, Hn as AssemblyReader, Ht as withRetry, I as gray, In as findLargeInlineResources, It as s3BucketRegionalDomainName, J as clearOnUpdateRemoval, Jn as resetAwsClients, Jt as shouldRetainResource, K as IAMRoleProvider, Kn as AwsClients, Kt as S3StateBackend, L as green, Ln as uploadCfnTemplate, Lt as s3BucketWebsiteUrl, M as renderStatefulReason, Mn as warnDeprecatedNoPrefixCliFlag, Mt as scrubResourceRecord, N as formatResourceLine, Nn as CFN_TEMPLATE_BODY_LIMIT, Nt as s3BucketArn, O as makeCanonicalizePropertiesFn, On as resolveStateBucketWithDefault, Ot as isSingleDynamicReferenceToken, P as bold, Pn as CFN_TEMPLATE_URL_LIMIT, Pt as s3BucketDomainName, Q as beginCommandInterruptScope, Qt as buildAssetRedirectMap, R as red, Rn as expectedOwnerParam, Rt as applyRoleArnIfSet, S as buildFinalSnapshotIdentifier, Sn as getDefaultStateBucketName, Sr as markNonRetryable, St as STATE_SOURCED_CROSS_GENERATION_RULES, T as isFinalSnapshotError, Tn as resolveAutoAssetStorage, Tt as createSecretMasker, U as isExportAliasCollision, Un as processStackMessages, Ut as DagBuilder, V as collectPublishedOutputNames, Vn as derivePartitionAndUrlSuffix, Vt as describeTypeWithThrottleRetry, W as secretBearingStateKeyWarning, Wt as TemplateParser, X as findActionableSilentDrops, Xt as stringifyValue, Y as ProviderRegistry, Yn as setAwsClients, Yt as AssetPublisher, Z as findSilentDropProperties, Zn as CdkdError, Zt as WorkGraph, _ as maskingRetryLogger, _n as runDockerStreaming, _r as normalizeAwsError, _t as readConfigString, a as DeploymentEventsStore, an as BOOTSTRAP_MARKER_PREFIX, ar as MissingCdkCliError, at as isTerminationProtectionPropagationError, b as ATOMIC_FINAL_SNAPSHOT_TYPES, bn as Synthesizer, br as isRetryableTransientError, bt as requireConfigObject, c as planRollback, cn as parseBootstrapMarker, cr as ProvisioningError, ct as getAccountInfo, d as replayRollback, dn as validateContainerRepoName, dr as StackHasActiveImportsError, dt as normalizeAwsTagsToCfn, en as loadPublishableAssetManifest, er as DeployCancelledError, et as isInterruptedWaitError, f as updatePartialMessage, fn as buildDenyExternalAccessPolicy, fr as StackTerminationProtectionError, ft as resolveExplicitPhysicalId, g as withResourceDeadline, gn as runDockerForeground, gt as configStringRefusal, h as deleteSkipReason, hn as getDockerCmd, ht as configBooleanRefusal, i as DeploymentEventsReader, in as AssetModeResolver, it as disableInstanceApiTermination, j as isStatefulRecreateTargetSync, jn as stateBucketExistenceConfirmed, jt as redactSecretsForState, k as extractDeploymentEventError, kn as resolveStateBucketWithDefaultAndSource, kt as maskSecretsInError, l as producerRegionsFromState, ln as readBootstrapMarkerBody, lr as ResourceTimeoutError, lt as refStateLookupFromResource, m as UNSPECIFIED_SKIP_REASON, mn as formatDockerLoginError, mt as coerceCfnBoolean, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as escapeRegExp, nr as LocalMigrateError, nt as CloudControlProvider, o as classifyReplaySecretRegion, on as ensureAssetStorage, or as NestedStackChildDirectDestroyError, ot as IntrinsicFunctionResolver, p as updatePartialReason, pn as buildDockerImage, pt as assertRegionMatch, q as collectInlinePolicyNamesManagedBySiblings, qn as getAwsClients, qt as rebuildClientForBucketRegion, r as DeployEngine, rn as stripControlChars, rr as LocalStartServiceError, rt as slowCcOperationTimeoutMs, s as planFailedOps, sn as getBootstrapMarkerKey, sr as PartialFailureError, st as cfnRefValueFromPhysicalId, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as rewriteTemplateAssetReferences, tr as LocalInvokeBuildError$1, tt as startInterruptWatch, u as replayFailedOperations, un as validateAssetBucketName, ur as ResourceUpdateNotSupportedError, ut as WAFv2WebACLProvider, v as IMPLICIT_DELETE_DEPENDENCIES, vn as AssetManifestLoader, vr as withErrorHandling, vt as replayWarn, w as createPreDeleteFinalSnapshot, wn as resolveApp, wt as TEMPLATE_SOURCED_RULES, x as PRE_DELETE_SNAPSHOT_TYPES, xn as synthesisStatusMessage, xr as isThrottlingError, xt as requireConfigString, y as computeImplicitDeleteEdges, yn as getDockerImageBySourceHash, yr as isMarkedNonRetryable, yt as requireConfigArray, z as yellow, zn as PARTITION_TABLE, zt as DiffCalculator } from "./deploy-engine-CE-tOz2d.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-DzbiUvJi.js";
|
|
5
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
6
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
7
7
|
import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketEncryptionCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketOwnershipControlsCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
|
|
@@ -62212,6 +62212,62 @@ var ScrubNeededError = class ScrubNeededError extends CdkdError {
|
|
|
62212
62212
|
}
|
|
62213
62213
|
};
|
|
62214
62214
|
/**
|
|
62215
|
+
* Every `cdkd scrub` REFUSAL — a reference this command cannot safely
|
|
62216
|
+
* re-resolve, and the per-stack failures that refusal produces under `--all`
|
|
62217
|
+
* (issue [#2109](https://github.com/go-to-k/cdkd/issues/2109) review).
|
|
62218
|
+
*
|
|
62219
|
+
* `exitCode = 2` rather than `CdkdError`'s default of 1, because 1 is already
|
|
62220
|
+
* SPOKEN FOR: `--fail` throws {@link ScrubNeededError} for "plaintext is in
|
|
62221
|
+
* state", and `docs/cli-reference.md` documents the pair as `1` (`--fail` found
|
|
62222
|
+
* plaintext) / `2` (error). A refusal is the second one, and left on the default
|
|
62223
|
+
* a CI gate reading the exit code alone could not tell "scrub looked and found a
|
|
62224
|
+
* leak" from "scrub refused to look" — the two call for opposite responses
|
|
62225
|
+
* (rotate the secret vs. re-spell the reference and re-run).
|
|
62226
|
+
*/
|
|
62227
|
+
var ScrubRefusalError = class ScrubRefusalError extends CdkdError {
|
|
62228
|
+
exitCode = 2;
|
|
62229
|
+
constructor(message, code) {
|
|
62230
|
+
super(message, code);
|
|
62231
|
+
this.name = "ScrubRefusalError";
|
|
62232
|
+
Object.setPrototypeOf(this, ScrubRefusalError.prototype);
|
|
62233
|
+
}
|
|
62234
|
+
};
|
|
62235
|
+
/**
|
|
62236
|
+
* One failed stack's reason, INCLUDING its `cause` chain (issue #2109 review).
|
|
62237
|
+
*
|
|
62238
|
+
* `err.message` alone drops the actionable half of a wrapped failure: a
|
|
62239
|
+
* provider or AWS error is routinely a generic sentence over the link that
|
|
62240
|
+
* names the role, the bucket or the denied action. The chain is walked with
|
|
62241
|
+
* `errorCauseChain` rather than a local loop so the links PRINTED are exactly
|
|
62242
|
+
* the links `maskSecretsInError` masked at `scrubStack`'s boundary — a private
|
|
62243
|
+
* walk would eventually render a link past that function's depth cap, which
|
|
62244
|
+
* still carries its original, unmasked message.
|
|
62245
|
+
*/
|
|
62246
|
+
function describeFailure(err) {
|
|
62247
|
+
if (!(err instanceof Error)) return String(err);
|
|
62248
|
+
return errorCauseChain(err).map((link, i) => i === 0 ? link.message : `\n Caused by: ${link.message}`).join("");
|
|
62249
|
+
}
|
|
62250
|
+
/**
|
|
62251
|
+
* The error a `--all` run ends with when one or more stacks could not be
|
|
62252
|
+
* scrubbed (issue #2109 review).
|
|
62253
|
+
*
|
|
62254
|
+
* Every stack is NAMED: the per-stack boundary keeps the run going, and a
|
|
62255
|
+
* single "3 stacks failed" line would make the operator re-run the command to
|
|
62256
|
+
* find out which. `exitCode = 2` for the same reason every other scrub refusal
|
|
62257
|
+
* carries it — `1` means "`--fail` found plaintext".
|
|
62258
|
+
*
|
|
62259
|
+
* The REASONS are deliberately not repeated here. Each was already logged at
|
|
62260
|
+
* `error` level, in run order, next to the progress lines for the stacks around
|
|
62261
|
+
* it; `handleError` then prints this message, so restating every reason made
|
|
62262
|
+
* the whole failure set appear TWICE in one terminal — directly under a summary
|
|
62263
|
+
* whose own note says "see the errors above". Names are kept because they are
|
|
62264
|
+
* what a reader needs to FIND those lines, and because the count alone is the
|
|
62265
|
+
* thing the paragraph above rejects.
|
|
62266
|
+
*/
|
|
62267
|
+
function scrubStacksFailedError(failures) {
|
|
62268
|
+
return new ScrubRefusalError(`${failures.length} stack(s) could not be scrubbed: ${failures.map((f) => f.stackName).join(", ")}. Each one's reason was logged as it happened — see the 'Scrub of <stack> failed:' line above for it.`, "SCRUB_STACKS_FAILED");
|
|
62269
|
+
}
|
|
62270
|
+
/**
|
|
62215
62271
|
* `cdkd scrub` — rewrite persisted state so any resolved secret dynamic
|
|
62216
62272
|
* reference is stored as its UNRESOLVED expression rather than the plaintext
|
|
62217
62273
|
* value (GHSA fix). "Secret" here is whatever the RESOLVER classifies as one,
|
|
@@ -62332,12 +62388,22 @@ async function scrubCommand(stacks, options) {
|
|
|
62332
62388
|
const lockManager = new LockManager(stateS3.s3, stateConfig);
|
|
62333
62389
|
let totalStacksScrubbed = 0;
|
|
62334
62390
|
let totalStacksWithUnscrubbableKeys = 0;
|
|
62391
|
+
const failures = [];
|
|
62335
62392
|
for (const stack of targetStacks) {
|
|
62336
|
-
const
|
|
62337
|
-
|
|
62338
|
-
|
|
62339
|
-
|
|
62340
|
-
|
|
62393
|
+
const stackRegion = stack.region || region;
|
|
62394
|
+
let scrubbed;
|
|
62395
|
+
try {
|
|
62396
|
+
scrubbed = await scrubStack(stack, stackRegion, stateBackend, lockManager, {
|
|
62397
|
+
dryRun: options.dryRun ?? false,
|
|
62398
|
+
roleArn: options.roleArn,
|
|
62399
|
+
logger
|
|
62400
|
+
});
|
|
62401
|
+
} catch (err) {
|
|
62402
|
+
failures.push({ stackName: stack.stackName });
|
|
62403
|
+
logger.error(`Scrub of ${stack.stackName} failed: ${describeFailure(err)}`);
|
|
62404
|
+
if (err instanceof Error && err.stack) logger.debug("Stack trace:", err.stack);
|
|
62405
|
+
continue;
|
|
62406
|
+
}
|
|
62341
62407
|
if (scrubbed.recordsChanged > 0) {
|
|
62342
62408
|
totalStacksScrubbed++;
|
|
62343
62409
|
logger.info(`${options.dryRun ? "Would scrub" : "Scrubbed"} ${scrubbed.recordsChanged} resource record(s) in ${stack.stackName}`);
|
|
@@ -62348,18 +62414,25 @@ async function scrubCommand(stacks, options) {
|
|
|
62348
62414
|
}
|
|
62349
62415
|
}
|
|
62350
62416
|
if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0) {
|
|
62351
|
-
|
|
62352
|
-
|
|
62417
|
+
if (failures.length === 0) {
|
|
62418
|
+
logger.info("\nNo plaintext secrets found in any target stack state. Nothing to scrub.");
|
|
62419
|
+
return;
|
|
62420
|
+
}
|
|
62421
|
+
logger.info(`\nNo plaintext secrets found in the ${targetStacks.length - failures.length} stack(s) this run could examine. ${failures.length} stack(s) could NOT be scrubbed — see the errors above.`);
|
|
62422
|
+
throw scrubStacksFailedError(failures);
|
|
62353
62423
|
}
|
|
62424
|
+
const failureNote = failures.length > 0 ? ` ${failures.length} stack(s) could NOT be scrubbed at all and are NOT covered by this summary — see the errors above.` : "";
|
|
62354
62425
|
const keyNote = totalStacksWithUnscrubbableKeys > 0 ? ` ${totalStacksWithUnscrubbableKeys} stack(s) hold plaintext in an output KEY, which cdkd scrub cannot rewrite — rename that output's Export.Name and redeploy.` : "";
|
|
62355
62426
|
if (options.dryRun) {
|
|
62356
|
-
if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote} ROTATE any exposed secret in Secrets Manager.`);
|
|
62357
|
-
else logger.info(`\nPlan: nothing can be scrubbed.${keyNote} ROTATE any exposed secret.`);
|
|
62427
|
+
if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote}${failureNote} ROTATE any exposed secret in Secrets Manager.`);
|
|
62428
|
+
else logger.info(`\nPlan: nothing can be scrubbed.${keyNote}${failureNote} ROTATE any exposed secret.`);
|
|
62429
|
+
if (failures.length > 0) throw scrubStacksFailedError(failures);
|
|
62358
62430
|
if (options.fail) throw new ScrubNeededError();
|
|
62359
62431
|
return;
|
|
62360
62432
|
}
|
|
62361
|
-
if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The plaintext is no longer stored there, but a value that was ever persisted should be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating).${keyNote}`);
|
|
62362
|
-
else logger.info(`\nNothing could be rewritten.${keyNote} ROTATE any exposed secret.`);
|
|
62433
|
+
if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The plaintext is no longer stored there, but a value that was ever persisted should be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating).${keyNote}${failureNote}`);
|
|
62434
|
+
else logger.info(`\nNothing could be rewritten.${keyNote}${failureNote} ROTATE any exposed secret.`);
|
|
62435
|
+
if (failures.length > 0) throw scrubStacksFailedError(failures);
|
|
62363
62436
|
if (options.fail && totalStacksWithUnscrubbableKeys > 0) throw new ScrubNeededError();
|
|
62364
62437
|
}
|
|
62365
62438
|
/**
|
|
@@ -62554,6 +62627,351 @@ function redactUnaccountedOutputs(positioned, stored, accountedKeys, secrets) {
|
|
|
62554
62627
|
return repaired ?? positioned;
|
|
62555
62628
|
}
|
|
62556
62629
|
/**
|
|
62630
|
+
* The producer regions that are genuinely FOREIGN to this stack, canonicalized
|
|
62631
|
+
* and de-duplicated (issue #2109 review).
|
|
62632
|
+
*
|
|
62633
|
+
* The same filter {@link classifyReplaySecretRegion} applies to its own
|
|
62634
|
+
* `importedProducerRegions` argument, spelled here because the
|
|
62635
|
+
* unclassifiable-reference guard needs the answer WITHOUT an expression to hand
|
|
62636
|
+
* to the classifier. A same-region cross-stack read is the common case and
|
|
62637
|
+
* raises no cross-region question, so it must not arm a refusal.
|
|
62638
|
+
*/
|
|
62639
|
+
function foreignRegionsOf(producerRegions, stackRegion) {
|
|
62640
|
+
const seen = /* @__PURE__ */ new Set();
|
|
62641
|
+
const foreign = [];
|
|
62642
|
+
for (const candidate of producerRegions) {
|
|
62643
|
+
const canonical = canonicalizeRegion(candidate);
|
|
62644
|
+
if (!canonical || canonical === canonicalizeRegion(stackRegion)) continue;
|
|
62645
|
+
if (seen.has(canonical)) continue;
|
|
62646
|
+
seen.add(canonical);
|
|
62647
|
+
foreign.push(candidate);
|
|
62648
|
+
}
|
|
62649
|
+
return foreign;
|
|
62650
|
+
}
|
|
62651
|
+
/**
|
|
62652
|
+
* The `cdkd scrub` resolvers: the stack's own, plus one pinned sibling per
|
|
62653
|
+
* FOREIGN region an ARN-named secret reference asks for (issue
|
|
62654
|
+
* [#2109](https://github.com/go-to-k/cdkd/issues/2109)).
|
|
62655
|
+
*
|
|
62656
|
+
* The same shape as `rollback-executor.ts`'s `ReplayResolvers`, and for the
|
|
62657
|
+
* same reasons. One instance per stack rather than per reference, because the
|
|
62658
|
+
* resolved-value cache lives on the resolver INSTANCE (issue #1933) — a
|
|
62659
|
+
* resolver per reference would re-fetch every secret once per reference. And
|
|
62660
|
+
* the pinned sibling is a PLAIN resolver, not a `producerRegionGuest`: it is
|
|
62661
|
+
* reached ONLY from a `named-region` verdict, which
|
|
62662
|
+
* {@link classifyReplaySecretRegion} returns only for an expression whose
|
|
62663
|
+
* SECRET_ID / parameter name starts with `arn:` and carries a region, so a
|
|
62664
|
+
* sibling only ever resolves an expression whose key EMBEDS the region it is
|
|
62665
|
+
* being resolved in. The process-global verdict store is keyed by the
|
|
62666
|
+
* expression STRING alone, and an ARN-form key cannot be shared by two regions,
|
|
62667
|
+
* so a sibling can never pin one region's verdict for another region's key. If
|
|
62668
|
+
* a future change ever routes a region-LESS expression here, that argument dies
|
|
62669
|
+
* with it and the sibling needs the guest flag.
|
|
62670
|
+
*/
|
|
62671
|
+
var ScrubResolvers = class {
|
|
62672
|
+
/** The stack's own resolver — every `local` verdict resolves through this. */
|
|
62673
|
+
primary;
|
|
62674
|
+
pinned = /* @__PURE__ */ new Map();
|
|
62675
|
+
stackRegion;
|
|
62676
|
+
constructor(stackRegion) {
|
|
62677
|
+
this.stackRegion = stackRegion;
|
|
62678
|
+
this.primary = new IntrinsicFunctionResolver(stackRegion);
|
|
62679
|
+
}
|
|
62680
|
+
/** The resolver that must answer for `region` — `primary` when it is the stack's own. */
|
|
62681
|
+
forRegion(region) {
|
|
62682
|
+
const target = canonicalizeRegion(region);
|
|
62683
|
+
if (target === canonicalizeRegion(this.stackRegion)) return this.primary;
|
|
62684
|
+
const cached = this.pinned.get(target);
|
|
62685
|
+
if (cached) return cached;
|
|
62686
|
+
const scoped = new IntrinsicFunctionResolver(target);
|
|
62687
|
+
this.pinned.set(target, scoped);
|
|
62688
|
+
return scoped;
|
|
62689
|
+
}
|
|
62690
|
+
};
|
|
62691
|
+
/**
|
|
62692
|
+
* The refusal a region-AMBIGUOUS reference raises (issue #2109), the twin of
|
|
62693
|
+
* `rollback-executor.ts`'s `regionAmbiguousReplaySecretError`.
|
|
62694
|
+
*
|
|
62695
|
+
* A `CdkdError`, so it leaves `scrubStack` -> `scrubCommand` ->
|
|
62696
|
+
* `withErrorHandling` as a NON-ZERO EXIT with the message printed. That
|
|
62697
|
+
* visibility is the acceptance criterion of the issue, not a detail of it: the
|
|
62698
|
+
* one outcome that must not survive is scrub reporting success over a document
|
|
62699
|
+
* it did not scrub, and a debug log next to a `Done: scrubbed 0 stack(s)` line
|
|
62700
|
+
* IS that outcome.
|
|
62701
|
+
*
|
|
62702
|
+
* It refuses the whole STACK rather than the one reference, which is wider than
|
|
62703
|
+
* it needs to be and is the deliberate side of the trade. Scrubbing the rest
|
|
62704
|
+
* and reporting success would leave the operator with "no plaintext secrets
|
|
62705
|
+
* found" over state that still holds one — the failure this command exists to
|
|
62706
|
+
* prevent, arriving through the remediation. Names the reference, both regions
|
|
62707
|
+
* and the remedy so the refusal is actionable.
|
|
62708
|
+
*
|
|
62709
|
+
* Never the resolved value: nothing has been resolved for this reference, and
|
|
62710
|
+
* the expression is the same string `state.json` already stores in the clear.
|
|
62711
|
+
*/
|
|
62712
|
+
function regionAmbiguousScrubSecretError(origin, stackName, secretName, foreignProducerRegions, stackRegion) {
|
|
62713
|
+
return new ScrubRefusalError(`Scrub of ${stackName} cannot re-resolve the secret reference '${secretName}' in ${origin}: the reference carries no region of its own, and this stack read across a region boundary (producer region(s) on record: ${foreignProducerRegions.join(", ")}), so it may have been resolved in one of those rather than in '${stackRegion}'. A secret of the same name in two regions is two independent values, so scrub would look for the WRONG plaintext — leaving the real one in state while reporting the stack clean, and using the foreign value as a needle that can rewrite an unrelated literal. Refusing instead. Spell the reference as a full ARN, which names its region and is resolved there, then re-run 'cdkd scrub'.`, "SCRUB_SECRET_REGION_AMBIGUOUS");
|
|
62714
|
+
}
|
|
62715
|
+
/**
|
|
62716
|
+
* The refusal a `named-region` reference raises when its OWN region cannot
|
|
62717
|
+
* answer for it (issue #2109).
|
|
62718
|
+
*
|
|
62719
|
+
* Continuing would hand the leaf back to the stack's own resolver, which is
|
|
62720
|
+
* issue #2109 verbatim — so this fails closed for the same reason the ambiguous
|
|
62721
|
+
* arm does. The cause is echoed because it is the actionable half (a denied
|
|
62722
|
+
* read in the producer region reads very differently from a missing secret) —
|
|
62723
|
+
* but MASKED against everything this run has recorded so far (issue #2109
|
|
62724
|
+
* review). No resolver error carries a plaintext today; the rule on this repo is
|
|
62725
|
+
* that a path which interpolates a foreign message into user-visible text masks
|
|
62726
|
+
* it anyway, because the day one does is the day the remediation command prints
|
|
62727
|
+
* the secret it exists to remove. The map is the SAME one the resolution records
|
|
62728
|
+
* into, so a value recorded a moment earlier in this very leaf is covered.
|
|
62729
|
+
*/
|
|
62730
|
+
function unresolvableForeignScrubSecretError(origin, stackName, secretName, region, cause, secrets) {
|
|
62731
|
+
return new ScrubRefusalError(`Scrub of ${stackName} could not resolve the secret reference '${secretName}' in ${origin} in the region its ARN names ('${region}'): ${maskSecretsInText(cause instanceof Error ? cause.message : String(cause), secrets)}. Refusing rather than resolving it in the stack's own region, which would look for a different secret's value and report the stack clean over state that still holds the plaintext.`, "SCRUB_CROSS_REGION_SECRET_UNRESOLVED");
|
|
62732
|
+
}
|
|
62733
|
+
/**
|
|
62734
|
+
* The literal that OPENS a dynamic reference. Used as the CHEAP pre-filter that
|
|
62735
|
+
* decides whether a leaf is walked at all — see {@link pinCrossRegionSecrets}.
|
|
62736
|
+
*/
|
|
62737
|
+
const DYNAMIC_REFERENCE_OPENING = "{{resolve:";
|
|
62738
|
+
/**
|
|
62739
|
+
* The openings the assembled-reference guard actually COUNTS: `{{resolve:`
|
|
62740
|
+
* followed by a service whose VALUE CloudFormation defines as a secret. Counted
|
|
62741
|
+
* against the number of WHOLE tokens of the same class the scan matched, which
|
|
62742
|
+
* is how an ASSEMBLED reference is detected without parsing intrinsics — see
|
|
62743
|
+
* {@link unclassifiableScrubSecretError}.
|
|
62744
|
+
*
|
|
62745
|
+
* WHY THE SERVICE IS PART OF THE OPENING (issue #2109 review). Counting the bare
|
|
62746
|
+
* `{{resolve:` made the guard fire on any leaf that merely MENTIONS the syntax
|
|
62747
|
+
* — measured: `Use the {{resolve: prefix for dynamic references` and
|
|
62748
|
+
* `{{resolve:}}` both count one opening and zero tokens. A description, an IAM
|
|
62749
|
+
* policy document, a UserData script or an environment variable saying that is
|
|
62750
|
+
* ordinary, and the refusal is permanent for the whole stack (exit 2, no bypass
|
|
62751
|
+
* flag) with a remedy — "spell the reference as one complete literal" — that is
|
|
62752
|
+
* unactionable for prose. Requiring the service makes the count a count of
|
|
62753
|
+
* SECRET references rather than of the characters `{{resolve:`.
|
|
62754
|
+
*
|
|
62755
|
+
* `secretsmanager` / `ssm` mirror `REPLAY_SECRET_SERVICES` in
|
|
62756
|
+
* `rollback-executor.ts`, whose {@link classifyReplaySecretRegion} is what
|
|
62757
|
+
* classifies the tokens below — the same set on both sides, so the count and
|
|
62758
|
+
* the classification never disagree about what is in scope. `ssm-secure` is
|
|
62759
|
+
* added on top: it IS a CloudFormation secret spelling, and although cdkd does
|
|
62760
|
+
* not resolve it today (the resolver's unsupported-service arm leaves it
|
|
62761
|
+
* verbatim, so it can never become a wrong-region needle), counting it costs
|
|
62762
|
+
* only an assembled `ssm-secure` reference in a cross-region stack and keeps
|
|
62763
|
+
* the guard correct the day that arm changes.
|
|
62764
|
+
*
|
|
62765
|
+
* THE TRADE, stated rather than hidden: an `Fn::Join` that splits BEFORE the
|
|
62766
|
+
* service name (`['{{resolve:', 'secretsmanager:db:SecretString:pw}}']`) has no
|
|
62767
|
+
* counted opening in either part and stops being caught by this guard. The part
|
|
62768
|
+
* carrying the service has no `{{resolve:` at all, so `pinCrossRegionSecrets`
|
|
62769
|
+
* returns it by identity too. That shape falls to the same resolver-side
|
|
62770
|
+
* classification issue [#2134](https://github.com/go-to-k/cdkd/issues/2134)
|
|
62771
|
+
* tracks.
|
|
62772
|
+
*/
|
|
62773
|
+
const SECRET_REFERENCE_OPENINGS = [
|
|
62774
|
+
`${DYNAMIC_REFERENCE_OPENING}secretsmanager:`,
|
|
62775
|
+
`${DYNAMIC_REFERENCE_OPENING}ssm:`,
|
|
62776
|
+
`${DYNAMIC_REFERENCE_OPENING}ssm-secure:`
|
|
62777
|
+
];
|
|
62778
|
+
/**
|
|
62779
|
+
* What an `Fn::Sub` placeholder opens with. A whole token that CONTAINS one is
|
|
62780
|
+
* the third assembled shape — see {@link unclassifiableScrubSecretError}.
|
|
62781
|
+
*/
|
|
62782
|
+
const SUB_PLACEHOLDER_OPENING = "${";
|
|
62783
|
+
/** How many SECRET references {@link SECRET_REFERENCE_OPENINGS} `leaf` opens. */
|
|
62784
|
+
function countSecretReferenceOpenings(leaf) {
|
|
62785
|
+
let count = 0;
|
|
62786
|
+
for (const opening of SECRET_REFERENCE_OPENINGS) count += leaf.split(opening).length - 1;
|
|
62787
|
+
return count;
|
|
62788
|
+
}
|
|
62789
|
+
/** Whether a WHOLE token the scan matched is one of the secret spellings. */
|
|
62790
|
+
function isSecretReferenceToken(token) {
|
|
62791
|
+
return SECRET_REFERENCE_OPENINGS.some((opening) => token.startsWith(opening));
|
|
62792
|
+
}
|
|
62793
|
+
/**
|
|
62794
|
+
* The refusal an ASSEMBLED reference raises (issue #2109 review): a leaf that
|
|
62795
|
+
* OPENS more SECRET references than the scan found COMPLETE tokens of that
|
|
62796
|
+
* class in it, or a whole token that still carries an `Fn::Sub` placeholder.
|
|
62797
|
+
*
|
|
62798
|
+
* WHY THIS EXISTS, and it is the load-bearing half of this pre-pass rather than
|
|
62799
|
+
* an edge case. The region split runs on the RAW template leaf, BEFORE intrinsic
|
|
62800
|
+
* resolution, and the shared token scan is `\{\{resolve:[^}]+\}\}` — a class
|
|
62801
|
+
* that cannot cross a `}`. So none of the three shapes that assemble a
|
|
62802
|
+
* reference out of parts yields the ONE WHOLE token a literal reference does,
|
|
62803
|
+
* and the third one is why this guard needs two tests rather than one
|
|
62804
|
+
* (all four rows MEASURED):
|
|
62805
|
+
*
|
|
62806
|
+
* ```text
|
|
62807
|
+
* "{{resolve:secretsmanager:my-secret:SecretString:pw}}" 1 opening, 1 token literal
|
|
62808
|
+
* "{{resolve:secretsmanager:${Env}-db:SecretString:password}}" 1 opening, 0 tokens Fn::Sub, MID-string
|
|
62809
|
+
* "{{resolve:secretsmanager:" (one Fn::Join part) 1 opening, 0 tokens Fn::Join split
|
|
62810
|
+
* "{{resolve:secretsmanager:x:SecretString:${Field}}}" 1 opening, 1 token Fn::Sub, TRAILING
|
|
62811
|
+
* ```
|
|
62812
|
+
*
|
|
62813
|
+
* The first two assembled rows are caught by the COUNT. The fourth is not, and
|
|
62814
|
+
* the count can never catch it: `[^}]+` stops at the `}` of `${Field}` and the
|
|
62815
|
+
* `}}` that follows closes the match one brace short, so the scan returns
|
|
62816
|
+
* `"{{resolve:secretsmanager:x:SecretString:${Field}"` + `}` — a token, of the
|
|
62817
|
+
* right class, exactly one per opening. It is caught instead by the second
|
|
62818
|
+
* test: a WHOLE token that still contains `${` has not been assembled yet.
|
|
62819
|
+
* Neither a Secrets Manager secret name nor an SSM parameter name may contain
|
|
62820
|
+
* `$` or `{`, so the only false positive that test can produce is a JSON key
|
|
62821
|
+
* literally spelled `${...}` in a leaf that is NOT under an `Fn::Sub` — and
|
|
62822
|
+
* this guard is the fail-closed side of a command whose subject is a leaked
|
|
62823
|
+
* plaintext.
|
|
62824
|
+
*
|
|
62825
|
+
* With no token there is nothing to classify: {@link pinCrossRegionSecrets}
|
|
62826
|
+
* returns the leaf BY IDENTITY, and `resolveJoin` / `resolveSub` then call
|
|
62827
|
+
* `resolveDynamicReferences` on the PRIMARY resolver once they have joined the
|
|
62828
|
+
* parts — issue #2109 verbatim, with the `ambiguous` refusal never firing. The
|
|
62829
|
+
* trailing-placeholder row got there a different way before this guard's second
|
|
62830
|
+
* test: the token WAS classified, and only downstream luck kept it safe (the
|
|
62831
|
+
* name form then classifies `ambiguous`, the ARN form fails into
|
|
62832
|
+
* `SCRUB_CROSS_REGION_SECRET_UNRESOLVED` because the id it looks up still has a
|
|
62833
|
+
* literal `${Field}` in it). Both are pinned by the suite rather than assumed.
|
|
62834
|
+
* Fixing all of this inside the resolver (classify AFTER assembly) is the
|
|
62835
|
+
* structurally right answer and is NOT what this does: the resolver's
|
|
62836
|
+
* dynamic-reference path is shared with deploy and is out of this change's
|
|
62837
|
+
* scope. This guard buys the same SAFETY property — a reference whose region
|
|
62838
|
+
* cannot be established is never resolved in the stack's own region — by
|
|
62839
|
+
* refusing instead.
|
|
62840
|
+
*
|
|
62841
|
+
* GATED ON A FOREIGN PRODUCER REGION BEING ON RECORD, which is what keeps it
|
|
62842
|
+
* from refusing the world. With no foreign region on record there is no
|
|
62843
|
+
* cross-region question to get wrong: every reference in the stack resolves
|
|
62844
|
+
* locally, which is what an assembled one would have done anyway, so proceeding
|
|
62845
|
+
* is exactly today's behavior. Two residuals, stated rather than hidden:
|
|
62846
|
+
* an assembled reference that names a FOREIGN ARN inside an `Fn::Sub` in a
|
|
62847
|
+
* stack with NO cross-stack read on record is still resolved locally; and an
|
|
62848
|
+
* `Fn::Join` that splits BEFORE the service name is not counted at all (see
|
|
62849
|
+
* {@link SECRET_REFERENCE_OPENINGS}). Closing either needs the resolver-side
|
|
62850
|
+
* classification above — issue
|
|
62851
|
+
* [#2134](https://github.com/go-to-k/cdkd/issues/2134).
|
|
62852
|
+
*/
|
|
62853
|
+
function unclassifiableScrubSecretError(origin, leafPath, stackName, foreignProducerRegions, stackRegion) {
|
|
62854
|
+
return new ScrubRefusalError(`Scrub of ${stackName} cannot classify a secret reference in ${origin}${leafPath ? ` at '${leafPath}'` : ""}: the value opens a '{{resolve:...}}' reference that is ASSEMBLED by an intrinsic (an Fn::Sub placeholder inside it, or an Fn::Join that splits it across parts), so the reference does not exist as a complete expression until AFTER it is resolved — and its region therefore cannot be determined before resolution. This stack read across a region boundary (producer region(s) on record: ${foreignProducerRegions.join(", ")}), so resolving it in '${stackRegion}' may look for a different region's secret: scrub would then miss the plaintext it exists to remove and report the stack clean. Refusing instead. Spell the reference as one complete literal '{{resolve:secretsmanager:<full ARN>:SecretString:...}}' — an ARN names its own region — then re-run 'cdkd scrub'.`, "SCRUB_SECRET_REFERENCE_UNCLASSIFIABLE");
|
|
62855
|
+
}
|
|
62856
|
+
/**
|
|
62857
|
+
* Resolve every FOREIGN-region `{{resolve:...}}` reference in one leaf through
|
|
62858
|
+
* a resolver pinned to the region the expression NAMES, and refuse the ones
|
|
62859
|
+
* whose region cannot be established (issue #2109).
|
|
62860
|
+
*
|
|
62861
|
+
* The returned string is what the primary resolver is handed, so a foreign
|
|
62862
|
+
* reference is already a value by the time the stack's own region sees the
|
|
62863
|
+
* leaf — the local endpoint is never asked about it at all. A leaf with no
|
|
62864
|
+
* foreign reference is returned BY IDENTITY and takes exactly the pre-#2109
|
|
62865
|
+
* path: `resolveDynamicReferences` has well-tested substitution semantics (it
|
|
62866
|
+
* collects matches from the ORIGINAL string, so a resolved plaintext that is
|
|
62867
|
+
* itself token-shaped is never re-resolved — issue #1917) and this change does
|
|
62868
|
+
* not relitigate any of it.
|
|
62869
|
+
*
|
|
62870
|
+
* That #1917 guarantee does NOT cross this seam — the primary receives a string
|
|
62871
|
+
* this function already substituted into — so the result is re-scanned and a
|
|
62872
|
+
* token the substitution INTRODUCED is refused rather than passed on. Fail
|
|
62873
|
+
* closed: a stopped scrub is recoverable, a lookup for a secret id spliced
|
|
62874
|
+
* together out of a plaintext is not.
|
|
62875
|
+
*/
|
|
62876
|
+
async function resolveForeignRegionTokens(leaf, stackName, ctx, leafPath) {
|
|
62877
|
+
const tokens = dynamicReferenceTokens(leaf);
|
|
62878
|
+
const secretTokens = tokens.filter(isSecretReferenceToken);
|
|
62879
|
+
if ((countSecretReferenceOpenings(leaf) !== secretTokens.length || secretTokens.some((token) => token.includes(SUB_PLACEHOLDER_OPENING))) && ctx.foreignProducerRegions.length > 0) throw unclassifiableScrubSecretError(ctx.origin, leafPath, stackName, ctx.foreignProducerRegions, ctx.stackRegion);
|
|
62880
|
+
const verdicts = tokens.map((token) => [token, classifyReplaySecretRegion(token, ctx.stackRegion, ctx.producerRegions)]);
|
|
62881
|
+
for (const [, verdict] of verdicts) if (verdict.kind === "ambiguous") throw regionAmbiguousScrubSecretError(ctx.origin, stackName, verdict.secretName, verdict.foreignProducerRegions, ctx.stackRegion);
|
|
62882
|
+
if (!verdicts.some(([, verdict]) => verdict.kind === "named-region")) return leaf;
|
|
62883
|
+
const localTokens = [];
|
|
62884
|
+
let out = "";
|
|
62885
|
+
let cursor = 0;
|
|
62886
|
+
for (const [token, verdict] of verdicts) {
|
|
62887
|
+
const at = leaf.indexOf(token, cursor);
|
|
62888
|
+
if (at < 0) throw new ScrubRefusalError(`Scrub of ${stackName} could not locate a scanned dynamic reference in ${ctx.origin}, in the value it was scanned from. Refusing rather than resolving it in '${ctx.stackRegion}', which would be the wrong region for a reference that names another one. This is an internal invariant failure — please report it with the resource type and property path.`, "SCRUB_SECRET_TOKEN_SCAN_MISMATCH");
|
|
62889
|
+
out += leaf.slice(cursor, at);
|
|
62890
|
+
if (verdict.kind === "named-region") {
|
|
62891
|
+
const resolver = ctx.resolvers.forRegion(verdict.region);
|
|
62892
|
+
try {
|
|
62893
|
+
out += await resolver.resolveDynamicReferences(token, {
|
|
62894
|
+
template: { Resources: {} },
|
|
62895
|
+
resources: {},
|
|
62896
|
+
recordedSecretValues: ctx.recordedSecretValues
|
|
62897
|
+
});
|
|
62898
|
+
} catch (err) {
|
|
62899
|
+
throw unresolvableForeignScrubSecretError(ctx.origin, stackName, verdict.secretName, verdict.region, err, ctx.recordedSecretValues);
|
|
62900
|
+
}
|
|
62901
|
+
} else {
|
|
62902
|
+
out += token;
|
|
62903
|
+
localTokens.push(token);
|
|
62904
|
+
}
|
|
62905
|
+
cursor = at + token.length;
|
|
62906
|
+
}
|
|
62907
|
+
out += leaf.slice(cursor);
|
|
62908
|
+
const surviving = dynamicReferenceTokens(out);
|
|
62909
|
+
if (surviving.length !== localTokens.length || surviving.some((token, i) => token !== localTokens[i])) throw new ScrubRefusalError(`Scrub of ${stackName} refused ${ctx.origin}: resolving a cross-region secret reference produced a value that is itself dynamic-reference shaped, which the stack's own resolver would then resolve as if it were a reference of this stack's. Refusing rather than passing it on.`, "SCRUB_SECRET_RESOLUTION_REINTRODUCED_TOKEN");
|
|
62910
|
+
return out;
|
|
62911
|
+
}
|
|
62912
|
+
/**
|
|
62913
|
+
* Rewrite one template bag so every FOREIGN-region secret reference in it is
|
|
62914
|
+
* already resolved — in the region the expression NAMES — before the stack's
|
|
62915
|
+
* own resolver walks it (issue
|
|
62916
|
+
* [#2109](https://github.com/go-to-k/cdkd/issues/2109)).
|
|
62917
|
+
*
|
|
62918
|
+
* WHY SCRUB NEEDS THIS AT ALL. `scrubStack` learns which plaintexts to look for
|
|
62919
|
+
* by RE-RESOLVING the template through one resolver built from the stack's own
|
|
62920
|
+
* region, and `resolveSecretsManagerReference` builds its client from that
|
|
62921
|
+
* region and passes the SECRET_ID through as an opaque string — the AWS SDK's
|
|
62922
|
+
* endpoint ruleset has no ARN-derived endpoint rule, so a reference naming
|
|
62923
|
+
* another region's ARN is sent to this stack's regional endpoint. Both halves
|
|
62924
|
+
* of that go wrong at once, and the second is the one that is easy to miss: the
|
|
62925
|
+
* plaintext scrub exists to remove is never found (the needle is the wrong
|
|
62926
|
+
* region's value), so the command reports the stack CLEAN over state that still
|
|
62927
|
+
* holds the secret; and the foreign value is a real string, so scanning for it
|
|
62928
|
+
* can rewrite an unrelated stored literal that happens to coincide.
|
|
62929
|
+
*
|
|
62930
|
+
* The split is `classifyReplaySecretRegion`'s, imported from
|
|
62931
|
+
* `rollback-executor.ts` rather than re-spelled — one answer to "which region
|
|
62932
|
+
* must answer for this expression", shared by the rollback replay (#2057) and
|
|
62933
|
+
* by scrub. Its known OVER-refusal is inherited with it: a name-form reference
|
|
62934
|
+
* in a stack with ANY foreign producer region on record is refused even when it
|
|
62935
|
+
* is the stack's own purely-local secret, because the evidence
|
|
62936
|
+
* (`state.imports` / `state.outputReads`) is per-STACK and not per-reference.
|
|
62937
|
+
* That is the fail-closed side of a trade whose other side is a silently
|
|
62938
|
+
* unscrubbed state file.
|
|
62939
|
+
*
|
|
62940
|
+
* Returns the bag BY IDENTITY when nothing was foreign, which is every bag on
|
|
62941
|
+
* every existing code path. The caller keeps the ORIGINAL bag as its position
|
|
62942
|
+
* source either way — a substituted copy must never reach
|
|
62943
|
+
* `redactSecretsForState`, whose whole job is to read UNRESOLVED expressions
|
|
62944
|
+
* off it.
|
|
62945
|
+
*/
|
|
62946
|
+
async function pinCrossRegionSecrets(bag, stackName, ctx) {
|
|
62947
|
+
const walk = async (v, path) => {
|
|
62948
|
+
if (typeof v === "string") {
|
|
62949
|
+
if (!v.includes(DYNAMIC_REFERENCE_OPENING)) return v;
|
|
62950
|
+
return await resolveForeignRegionTokens(v, stackName, ctx, path);
|
|
62951
|
+
}
|
|
62952
|
+
if (Array.isArray(v)) {
|
|
62953
|
+
const out = new Array(v.length);
|
|
62954
|
+
let changed = false;
|
|
62955
|
+
for (let i = 0; i < v.length; i++) {
|
|
62956
|
+
out[i] = await walk(v[i], `${path}[${i}]`);
|
|
62957
|
+
if (out[i] !== v[i]) changed = true;
|
|
62958
|
+
}
|
|
62959
|
+
return changed ? out : v;
|
|
62960
|
+
}
|
|
62961
|
+
if (v !== null && typeof v === "object") {
|
|
62962
|
+
const out = Object.create(null);
|
|
62963
|
+
let changed = false;
|
|
62964
|
+
for (const [k, val] of Object.entries(v)) {
|
|
62965
|
+
out[k] = await walk(val, path ? `${path}.${k}` : k);
|
|
62966
|
+
if (out[k] !== val) changed = true;
|
|
62967
|
+
}
|
|
62968
|
+
return changed ? out : v;
|
|
62969
|
+
}
|
|
62970
|
+
return v;
|
|
62971
|
+
};
|
|
62972
|
+
return await walk(bag, "");
|
|
62973
|
+
}
|
|
62974
|
+
/**
|
|
62557
62975
|
* Scrub one stack's state. Re-resolves the template's per-resource properties to
|
|
62558
62976
|
* learn the resolved secret VALUES, then replaces those values in the state
|
|
62559
62977
|
* record with their `{{resolve:...}}` expressions. Returns counts; performs no
|
|
@@ -62570,6 +62988,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
62570
62988
|
const { logger } = opts;
|
|
62571
62989
|
const acquired = !opts.dryRun;
|
|
62572
62990
|
if (acquired) await lockManager.acquireLockWithRetry(stack.stackName, region, void 0, "scrub");
|
|
62991
|
+
const perResourceSecrets = /* @__PURE__ */ new Map();
|
|
62992
|
+
const outputSecrets = /* @__PURE__ */ new Map();
|
|
62573
62993
|
try {
|
|
62574
62994
|
const loaded = await stateBackend.getState(stack.stackName, region);
|
|
62575
62995
|
if (!loaded) {
|
|
@@ -62581,11 +63001,12 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
62581
63001
|
};
|
|
62582
63002
|
}
|
|
62583
63003
|
const state = loaded.state;
|
|
62584
|
-
const perResourceSecrets = /* @__PURE__ */ new Map();
|
|
62585
63004
|
const perResourceTemplateProps = /* @__PURE__ */ new Map();
|
|
62586
|
-
const outputSecrets = /* @__PURE__ */ new Map();
|
|
62587
63005
|
const outputsTemplateSource = {};
|
|
62588
|
-
const
|
|
63006
|
+
const resolvers = new ScrubResolvers(region);
|
|
63007
|
+
const resolver = resolvers.primary;
|
|
63008
|
+
const producerRegions = producerRegionsFromState(state);
|
|
63009
|
+
const foreignProducerRegions = foreignRegionsOf(producerRegions, region);
|
|
62589
63010
|
let parameters = {};
|
|
62590
63011
|
let conditions = {};
|
|
62591
63012
|
try {
|
|
@@ -62608,8 +63029,16 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
62608
63029
|
const templateResource = templateResources[logicalId];
|
|
62609
63030
|
if (!templateResource?.Properties) continue;
|
|
62610
63031
|
const recordedSecretValues = /* @__PURE__ */ new Map();
|
|
63032
|
+
const resolveInput = await pinCrossRegionSecrets(templateResource.Properties, stack.stackName, {
|
|
63033
|
+
stackRegion: region,
|
|
63034
|
+
producerRegions,
|
|
63035
|
+
foreignProducerRegions,
|
|
63036
|
+
resolvers,
|
|
63037
|
+
recordedSecretValues,
|
|
63038
|
+
origin: `resource '${logicalId}'`
|
|
63039
|
+
});
|
|
62611
63040
|
try {
|
|
62612
|
-
await resolver.resolve(
|
|
63041
|
+
await resolver.resolve(resolveInput, {
|
|
62613
63042
|
template: stack.template,
|
|
62614
63043
|
resources: state.resources,
|
|
62615
63044
|
...Object.keys(parameters).length > 0 && { parameters },
|
|
@@ -62619,7 +63048,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
62619
63048
|
bestEffort: true
|
|
62620
63049
|
});
|
|
62621
63050
|
} catch (err) {
|
|
62622
|
-
logger.debug(`Resolution of ${logicalId} during scrub was partial: ${err instanceof Error ? err.message : String(err)}`);
|
|
63051
|
+
logger.debug(`Resolution of ${logicalId} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}`);
|
|
62623
63052
|
}
|
|
62624
63053
|
if (recordedSecretValues.size > 0) perResourceSecrets.set(logicalId, recordedSecretValues);
|
|
62625
63054
|
perResourceTemplateProps.set(logicalId, templateResource.Properties);
|
|
@@ -62633,19 +63062,29 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
62633
63062
|
for (const [name, output] of Object.entries(templateOutputs)) {
|
|
62634
63063
|
const declaredExportName = output.Export?.Name;
|
|
62635
63064
|
let exportName = declaredExportName;
|
|
62636
|
-
if (declaredExportName !== void 0 && typeof declaredExportName !== "string")
|
|
62637
|
-
|
|
62638
|
-
|
|
62639
|
-
|
|
62640
|
-
|
|
62641
|
-
|
|
62642
|
-
stackName: stack.stackName,
|
|
63065
|
+
if (declaredExportName !== void 0 && typeof declaredExportName !== "string") {
|
|
63066
|
+
const nameSource = await pinCrossRegionSecrets(declaredExportName, stack.stackName, {
|
|
63067
|
+
stackRegion: region,
|
|
63068
|
+
producerRegions,
|
|
63069
|
+
foreignProducerRegions,
|
|
63070
|
+
resolvers,
|
|
62643
63071
|
recordedSecretValues: outputSecrets,
|
|
62644
|
-
|
|
63072
|
+
origin: `Export.Name of output '${name}'`
|
|
62645
63073
|
});
|
|
62646
|
-
|
|
62647
|
-
|
|
62648
|
-
|
|
63074
|
+
try {
|
|
63075
|
+
exportName = await resolver.resolve(nameSource, {
|
|
63076
|
+
template: stack.template,
|
|
63077
|
+
resources: state.resources,
|
|
63078
|
+
...Object.keys(parameters).length > 0 && { parameters },
|
|
63079
|
+
...Object.keys(conditions).length > 0 && { conditions },
|
|
63080
|
+
stackName: stack.stackName,
|
|
63081
|
+
recordedSecretValues: outputSecrets,
|
|
63082
|
+
bestEffort: true
|
|
63083
|
+
});
|
|
63084
|
+
} catch (err) {
|
|
63085
|
+
outputsSourceUntrusted = true;
|
|
63086
|
+
logger.warn(`Export.Name of output ${name} could not be resolved during scrub (${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}) — redacting this stack's outputs by value match instead of by template position, since state may be keyed under a name this run cannot reproduce.`);
|
|
63087
|
+
}
|
|
62649
63088
|
}
|
|
62650
63089
|
if (declaredExportName !== void 0 && typeof exportName !== "string") outputsSourceUntrusted = true;
|
|
62651
63090
|
const exportNameUnresolved = declaredExportName !== void 0 && typeof exportName === "string" && isUnresolvedValue(exportName, templateUsesSub(declaredExportName));
|
|
@@ -62664,8 +63103,16 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
62664
63103
|
if (!ambiguousKeys.has(name)) outputsTemplateSource[name] = value;
|
|
62665
63104
|
const exportName = output.Export?.Name;
|
|
62666
63105
|
if (typeof exportName === "string" && !ambiguousKeys.has(exportName)) outputsTemplateSource[exportName] = value;
|
|
63106
|
+
const valueSource = await pinCrossRegionSecrets(value, stack.stackName, {
|
|
63107
|
+
stackRegion: region,
|
|
63108
|
+
producerRegions,
|
|
63109
|
+
foreignProducerRegions,
|
|
63110
|
+
resolvers,
|
|
63111
|
+
recordedSecretValues: outputSecrets,
|
|
63112
|
+
origin: `output '${name}'`
|
|
63113
|
+
});
|
|
62667
63114
|
try {
|
|
62668
|
-
await resolver.resolve(
|
|
63115
|
+
await resolver.resolve(valueSource, {
|
|
62669
63116
|
template: stack.template,
|
|
62670
63117
|
resources: state.resources,
|
|
62671
63118
|
...Object.keys(parameters).length > 0 && { parameters },
|
|
@@ -62675,7 +63122,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
62675
63122
|
bestEffort: true
|
|
62676
63123
|
});
|
|
62677
63124
|
} catch (err) {
|
|
62678
|
-
logger.debug(`Resolution of output ${name} during scrub was partial: ${err instanceof Error ? err.message : String(err)}`);
|
|
63125
|
+
logger.debug(`Resolution of output ${name} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}`);
|
|
62679
63126
|
}
|
|
62680
63127
|
}
|
|
62681
63128
|
for (const [name, exportName] of collisions) logger.warn(exportAliasCollisionScrubWarning(name, exportName, outputSecrets));
|
|
@@ -62706,7 +63153,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
62706
63153
|
if (JSON.stringify(scrubbed) !== JSON.stringify(record)) recordsChanged++;
|
|
62707
63154
|
newResources[logicalId] = scrubbed;
|
|
62708
63155
|
}
|
|
62709
|
-
const newOutputs = redactUnaccountedOutputs(outputSecrets.size > 0 ? redactSecretsForState(state.outputs, outputSecrets, outputsSourceUntrusted ? void 0 : outputsTemplateSource) : state.outputs, state.outputs, accountedOutputKeys, allRecordedSecrets(outputSecrets, perResourceSecrets));
|
|
63156
|
+
const newOutputs = redactUnaccountedOutputs(outputSecrets.size > 0 ? redactSecretsForState(state.outputs, outputSecrets, outputsSourceUntrusted ? void 0 : outputsTemplateSource, TEMPLATE_SOURCED_RULES) : state.outputs, state.outputs, accountedOutputKeys, allRecordedSecrets(outputSecrets, perResourceSecrets));
|
|
62710
63157
|
if (JSON.stringify(newOutputs) !== JSON.stringify(state.outputs)) recordsChanged++;
|
|
62711
63158
|
if (recordsChanged > 0 && !opts.dryRun) {
|
|
62712
63159
|
const nextState = {
|
|
@@ -62722,6 +63169,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
62722
63169
|
secretsFound: totalSecrets,
|
|
62723
63170
|
secretBearingKeys: secretBearingKeys.length
|
|
62724
63171
|
};
|
|
63172
|
+
} catch (err) {
|
|
63173
|
+
throw maskSecretsInError(err, allRecordedSecrets(outputSecrets, perResourceSecrets));
|
|
62725
63174
|
} finally {
|
|
62726
63175
|
if (acquired) await lockManager.releaseLock(stack.stackName, region).catch((err) => {
|
|
62727
63176
|
logger.warn(`Failed to release lock for ${stack.stackName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -63648,6 +64097,90 @@ var DriftDetectedError = class DriftDetectedError extends CdkdError {
|
|
|
63648
64097
|
}
|
|
63649
64098
|
};
|
|
63650
64099
|
/**
|
|
64100
|
+
* Detection found NO drift, but cdkd DELIBERATELY REFUSED to compare at least
|
|
64101
|
+
* one resource's secret-bearing properties (issue #2108).
|
|
64102
|
+
*
|
|
64103
|
+
* SCOPED TO REFUSALS, not to everything the report calls `notCompared`. The
|
|
64104
|
+
* roll-up also contains resources whose only problem is a surviving
|
|
64105
|
+
* `{{resolve:ssm-secure:...}}` token — a pre-existing population cdkd resolves
|
|
64106
|
+
* for nobody, which can never clear on a re-run, and which this change did not
|
|
64107
|
+
* create. Exiting non-zero for them would break `cdkd drift` in CI forever over
|
|
64108
|
+
* an unrelated defect, so the exit reads `comparisonRefused` and the report
|
|
64109
|
+
* reads `referencesUnresolved`. See that field's note.
|
|
64110
|
+
*
|
|
64111
|
+
* WHY A NON-ZERO EXIT, and why it is a PRESERVATION rather than a new signal.
|
|
64112
|
+
* Pre-#2108 that population resolved the reference in the WRONG region, the
|
|
64113
|
+
* baseline could never equal what AWS held, and the resource was reported
|
|
64114
|
+
* `drifted` -- so `cdkd drift` exited 1 and every CI gate keyed on the exit code
|
|
64115
|
+
* fired. #2108 correctly stopped comparing against a foreign region's plaintext,
|
|
64116
|
+
* which made the same population report `clean`; without this the command would
|
|
64117
|
+
* exit 0 and print `no drift detected` for a stack whose secret-bearing
|
|
64118
|
+
* properties were never looked at. Round 1 of that change surfaced the refusal
|
|
64119
|
+
* in `--json` (`notCompared`) and in the human report, but the exit code -- the
|
|
64120
|
+
* signal most CI gates actually read -- still said "pass".
|
|
64121
|
+
*
|
|
64122
|
+
* WHY EXIT 2 RATHER THAN 1. `2` is this repo's established "work completed but
|
|
64123
|
+
* something was SKIPPED" code (`cdkd destroy` / `cdkd deploy` / `cdkd rollback`,
|
|
64124
|
+
* and this command's own `--revert` partial failure), and the three outcomes are
|
|
64125
|
+
* genuinely distinct: nothing drifted and everything was compared (0), drift was
|
|
64126
|
+
* detected (1), nothing drifted but the comparison was incomplete (2). Drift
|
|
64127
|
+
* WINS when both hold -- a drifted resource is the stronger, actionable signal
|
|
64128
|
+
* and keeps exiting 1 exactly as it does today.
|
|
64129
|
+
*
|
|
64130
|
+
* SCOPE: detection-only mode. `--accept` / `--revert` keep their documented exit
|
|
64131
|
+
* codes (an `--accept` that refuses a secret-bearing property still exits 0, and
|
|
64132
|
+
* a partially-failed `--revert` still exits 2 through `PartialFailureError`) --
|
|
64133
|
+
* those modes report per-resource refusals on their own paths, and changing them
|
|
64134
|
+
* would alter what a remediation run means, which is not what this closes.
|
|
64135
|
+
*
|
|
64136
|
+
* WHAT DOES NOT TRIGGER IT: a stack whose properties reference
|
|
64137
|
+
* `{{resolve:ssm-secure:...}}` still exits 0. Those properties genuinely are not
|
|
64138
|
+
* compared and the report says so, but nothing REFUSED them — cdkd has never
|
|
64139
|
+
* resolved that spelling — so the condition is permanent, unclearable by any
|
|
64140
|
+
* action the user can take, and predates this change. It is reported as
|
|
64141
|
+
* information and kept out of the exit code.
|
|
64142
|
+
*
|
|
64143
|
+
* Carries no message of its own for the same reason {@link DriftDetectedError}
|
|
64144
|
+
* does -- the report was already printed.
|
|
64145
|
+
*/
|
|
64146
|
+
var DriftComparisonRefusedError = class DriftComparisonRefusedError extends CdkdError {
|
|
64147
|
+
silent = true;
|
|
64148
|
+
exitCode = 2;
|
|
64149
|
+
constructor() {
|
|
64150
|
+
super("drift comparison refused", "DRIFT_COMPARISON_REFUSED");
|
|
64151
|
+
this.name = "DriftComparisonRefusedError";
|
|
64152
|
+
Object.setPrototypeOf(this, DriftComparisonRefusedError.prototype);
|
|
64153
|
+
}
|
|
64154
|
+
};
|
|
64155
|
+
/**
|
|
64156
|
+
* Every outcome on one report that was only PARTIALLY compared -- `drifted` or
|
|
64157
|
+
* `clean`, carrying `referencesUnresolved` (issue #2108).
|
|
64158
|
+
*
|
|
64159
|
+
* ONE spelling, because there are now THREE readers that must agree: the
|
|
64160
|
+
* `--json` `notCompared` roll-up, the human report's `PARTIALLY compared` block,
|
|
64161
|
+
* and the EXIT CODE. A predicate spelled per reader is how the payload and the
|
|
64162
|
+
* exit code come to disagree about the same run.
|
|
64163
|
+
*/
|
|
64164
|
+
function notComparedOutcomes(report) {
|
|
64165
|
+
return report.outcomes.filter((o) => (o.kind === "drifted" || o.kind === "clean") && o.referencesUnresolved);
|
|
64166
|
+
}
|
|
64167
|
+
/**
|
|
64168
|
+
* Every outcome whose comparison cdkd DELIBERATELY REFUSED (issue #2108) — the
|
|
64169
|
+
* strict subset of {@link notComparedOutcomes} that drives the EXIT CODE.
|
|
64170
|
+
*
|
|
64171
|
+
* Deliberately NOT the same predicate, and the asymmetry is the point: the
|
|
64172
|
+
* REPORT covers everything that was not compared, because as information both
|
|
64173
|
+
* populations matter equally, while the exit code covers only the population
|
|
64174
|
+
* this change created. Driving it off `referencesUnresolved` would exit
|
|
64175
|
+
* non-zero forever for every stack holding an `{{resolve:ssm-secure:...}}`
|
|
64176
|
+
* reference — permanent, unclearable, and unrelated to this issue. See the
|
|
64177
|
+
* `comparisonRefused` field note, and issue
|
|
64178
|
+
* [#2135](https://github.com/go-to-k/cdkd/issues/2135) for the structural fix.
|
|
64179
|
+
*/
|
|
64180
|
+
function refusedComparisonOutcomes(report) {
|
|
64181
|
+
return report.outcomes.filter((o) => (o.kind === "drifted" || o.kind === "clean") && o.comparisonRefused);
|
|
64182
|
+
}
|
|
64183
|
+
/**
|
|
63651
64184
|
* `cdkd drift [<stack>...]` command implementation.
|
|
63652
64185
|
*
|
|
63653
64186
|
* Three operating modes (mutually exclusive):
|
|
@@ -63716,8 +64249,10 @@ async function driftCommand(stacks, options) {
|
|
|
63716
64249
|
if (options.json) writeJsonReport(reports);
|
|
63717
64250
|
else writeHumanReport(reports);
|
|
63718
64251
|
const drifted = reports.some((r) => r.outcomes.some((o) => o.kind === "drifted"));
|
|
64252
|
+
const anyRefused = reports.some((r) => refusedComparisonOutcomes(r).length > 0);
|
|
63719
64253
|
if (!options.accept && !options.revert) {
|
|
63720
64254
|
if (drifted) throw new DriftDetectedError();
|
|
64255
|
+
if (anyRefused) throw new DriftComparisonRefusedError();
|
|
63721
64256
|
return;
|
|
63722
64257
|
}
|
|
63723
64258
|
if (!drifted) {
|
|
@@ -63938,6 +64473,164 @@ function collectDynamicReferencePaths(value, into, path = "") {
|
|
|
63938
64473
|
if (value !== null && typeof value === "object") for (const [k, v] of Object.entries(value)) collectDynamicReferencePaths(v, into, path === "" ? k : `${path}.${k}`);
|
|
63939
64474
|
}
|
|
63940
64475
|
/**
|
|
64476
|
+
* The drift command's resolvers for ONE stack: the stack's own, plus one pinned
|
|
64477
|
+
* sibling per FOREIGN region an ARN-named reference asks for (issue
|
|
64478
|
+
* [#2108](https://github.com/go-to-k/cdkd/issues/2108)).
|
|
64479
|
+
*
|
|
64480
|
+
* The structural twin of the rollback replay's `ReplayResolvers`
|
|
64481
|
+
* (`src/deployment/rollback-executor.ts`), and deliberately a SEPARATE class
|
|
64482
|
+
* rather than an export of that one: the two are ~20 lines of caching around a
|
|
64483
|
+
* constructor, and exporting the replay's would put a rollback-internal name on
|
|
64484
|
+
* the drift command's contract for no behavioural gain. What is SHARED is the
|
|
64485
|
+
* part with a decision in it — `classifyReplaySecretRegion`, imported.
|
|
64486
|
+
*
|
|
64487
|
+
* One instance per STACK, not per resource and not per op — the resolved-value
|
|
64488
|
+
* cache lives on the resolver INSTANCE since issue
|
|
64489
|
+
* [#1933](https://github.com/go-to-k/cdkd/issues/1933), so a resolver per
|
|
64490
|
+
* resource would re-fetch every referenced secret once per resource. The pinned
|
|
64491
|
+
* siblings are cached for the same reason: a 50-resource stack sharing one
|
|
64492
|
+
* foreign ARN must pay one `GetSecretValue`, not fifty.
|
|
64493
|
+
*
|
|
64494
|
+
* A pinned sibling is a PLAIN resolver, deliberately NOT the resolver class's
|
|
64495
|
+
* own `producerRegionGuest`, and the argument is `ReplayResolvers`' verbatim:
|
|
64496
|
+
* {@link forRegion} is reached ONLY from a `named-region` verdict, which
|
|
64497
|
+
* `classifyReplaySecretRegion` returns only when the SECRET_ID / parameter name
|
|
64498
|
+
* starts with `arn:` and carries a region. So a pinned sibling only ever
|
|
64499
|
+
* resolves an expression whose KEY EMBEDS THE REGION IT IS BEING RESOLVED IN,
|
|
64500
|
+
* and the process-global `recordedSecretExpressions` store — keyed by the
|
|
64501
|
+
* expression string alone — cannot have two regions sharing that key. If a
|
|
64502
|
+
* future change ever routes a region-LESS expression here, the argument dies
|
|
64503
|
+
* with it and the sibling needs the guest flag.
|
|
64504
|
+
*/
|
|
64505
|
+
var DriftSecretResolvers = class {
|
|
64506
|
+
/** The stack's own resolver — every `local` verdict resolves through this. */
|
|
64507
|
+
primary;
|
|
64508
|
+
pinned = /* @__PURE__ */ new Map();
|
|
64509
|
+
stackRegion;
|
|
64510
|
+
constructor(stackRegion) {
|
|
64511
|
+
this.stackRegion = stackRegion;
|
|
64512
|
+
this.primary = new IntrinsicFunctionResolver(stackRegion);
|
|
64513
|
+
}
|
|
64514
|
+
/** The resolver that must answer for `region` — `primary` when it is the stack's own. */
|
|
64515
|
+
forRegion(region) {
|
|
64516
|
+
const target = canonicalizeRegion(region);
|
|
64517
|
+
if (target === canonicalizeRegion(this.stackRegion)) return this.primary;
|
|
64518
|
+
const cached = this.pinned.get(target);
|
|
64519
|
+
if (cached) return cached;
|
|
64520
|
+
const scoped = new IntrinsicFunctionResolver(target);
|
|
64521
|
+
this.pinned.set(target, scoped);
|
|
64522
|
+
return scoped;
|
|
64523
|
+
}
|
|
64524
|
+
};
|
|
64525
|
+
/**
|
|
64526
|
+
* A DELIBERATE refusal to resolve a state-recorded dynamic reference (issue
|
|
64527
|
+
* #2108) — as opposed to a failure to READ one.
|
|
64528
|
+
*
|
|
64529
|
+
* The distinction is load-bearing for what the user is told: "could not
|
|
64530
|
+
* resolve" sends a reader hunting for a missing `secretsmanager:GetSecretValue`
|
|
64531
|
+
* grant, while a refusal is cdkd declining to resolve a reference it CAN read
|
|
64532
|
+
* but cannot attribute to a region. Both call sites that catch
|
|
64533
|
+
* `resolveStateSecretExpressions` pick their wording from this class.
|
|
64534
|
+
*
|
|
64535
|
+
* DEFINED POSITIVELY, and that is the whole point of the class existing rather
|
|
64536
|
+
* than a second `err.code === ...` comparison at each site. Both sites used to
|
|
64537
|
+
* enumerate the one code they knew (`DRIFT_SECRET_REGION_AMBIGUOUS`) while
|
|
64538
|
+
* `DRIFT_SECRET_TOKEN_SCAN_MISMATCH` — whose own message says "Refusing rather
|
|
64539
|
+
* than resolving" — silently took the read-failure wording. Enumerating bad
|
|
64540
|
+
* shapes loses that race every time a third refusal is added; a refusal that
|
|
64541
|
+
* has to declare itself one cannot be added without answering the question.
|
|
64542
|
+
*
|
|
64543
|
+
* The `code` stays per-refusal: it is what the message and any future
|
|
64544
|
+
* programmatic consumer key on. What this class adds is the CATEGORY.
|
|
64545
|
+
*/
|
|
64546
|
+
var DriftSecretRefusalError = class DriftSecretRefusalError extends CdkdError {
|
|
64547
|
+
/** Marker read by {@link isDriftSecretRefusal}; see the class note. */
|
|
64548
|
+
driftSecretRefusal = true;
|
|
64549
|
+
constructor(message, code) {
|
|
64550
|
+
super(message, code);
|
|
64551
|
+
this.name = "DriftSecretRefusalError";
|
|
64552
|
+
Object.setPrototypeOf(this, DriftSecretRefusalError.prototype);
|
|
64553
|
+
}
|
|
64554
|
+
};
|
|
64555
|
+
/**
|
|
64556
|
+
* Whether an error thrown out of `resolveStateSecretExpressions` is a
|
|
64557
|
+
* deliberate REFUSAL rather than a failed read. See
|
|
64558
|
+
* {@link DriftSecretRefusalError}.
|
|
64559
|
+
*/
|
|
64560
|
+
function isDriftSecretRefusal(err) {
|
|
64561
|
+
return err instanceof DriftSecretRefusalError;
|
|
64562
|
+
}
|
|
64563
|
+
/**
|
|
64564
|
+
* The refusal a region-AMBIGUOUS drift reference throws (issue #2108).
|
|
64565
|
+
*
|
|
64566
|
+
* A plain throw: BOTH call sites already wrap `resolveStateSecretExpressions`
|
|
64567
|
+
* in a per-resource catch that degrades instead of aborting the command, and
|
|
64568
|
+
* the degradation each one performs is exactly the fail-closed behaviour this
|
|
64569
|
+
* refusal wants.
|
|
64570
|
+
*
|
|
64571
|
+
* - DETECTION falls back to the UNRESOLVED baseline, whose `{{resolve:...}}`
|
|
64572
|
+
* leaves `calculateResourceDrift` skips — so the secret-bearing property is
|
|
64573
|
+
* NOT COMPARED rather than compared against a foreign region's plaintext.
|
|
64574
|
+
* That is strictly better than the phantom drift this issue reports, and the
|
|
64575
|
+
* offline `seededSecretPaths` still masks the position.
|
|
64576
|
+
* - REVERT counts the resource as unresolvable and returns BEFORE
|
|
64577
|
+
* `provider.update`, so nothing is written. Refusing is strictly better than
|
|
64578
|
+
* the alternative it replaces: resolving a producer-region reference against
|
|
64579
|
+
* the consumer's region does not fail, it succeeds with the WRONG credential
|
|
64580
|
+
* and writes it to a live resource.
|
|
64581
|
+
*
|
|
64582
|
+
* Names the reference, the regions and the remedy. Never a resolved value:
|
|
64583
|
+
* nothing has been resolved when this is thrown (the refusal runs over the
|
|
64584
|
+
* WHOLE leaf before any reference in it is fetched), and the expression is the
|
|
64585
|
+
* same string `state.json` already stores in the clear.
|
|
64586
|
+
*/
|
|
64587
|
+
function regionAmbiguousDriftSecretError(logicalId, propertyPath, secretName, foreignProducerRegions, consumerRegion) {
|
|
64588
|
+
const where = propertyPath === "" ? "" : ` property '${propertyPath}'`;
|
|
64589
|
+
return new DriftSecretRefusalError(`${logicalId}${where}: the secret reference '${secretName}' carries no region of its own, and this stack read across a region boundary (producer region(s) on record: ${foreignProducerRegions.join(", ")}), so it may have been resolved in one of those rather than in '${consumerRegion}'. A secret of the same name in two regions is two independent values, so cdkd would compare against — and with --revert WRITE — the WRONG secret. Refusing instead. Spell the reference as a full ARN, which names its region and is resolved there, then re-run 'cdkd drift'.`, "DRIFT_SECRET_REGION_AMBIGUOUS");
|
|
64590
|
+
}
|
|
64591
|
+
/**
|
|
64592
|
+
* Re-resolve one LEAF string, sending each `{{resolve:...}}` reference in it to
|
|
64593
|
+
* the region {@link classifyReplaySecretRegion} says must answer (issue #2108).
|
|
64594
|
+
*
|
|
64595
|
+
* Mirrors the rollback replay's `resolveLeafByRegion` down to the ordering, and
|
|
64596
|
+
* for the same reasons:
|
|
64597
|
+
*
|
|
64598
|
+
* - Refuses FIRST, over the whole leaf, before any reference is fetched. A leaf
|
|
64599
|
+
* can splice several references together, and resolving the safe ones first
|
|
64600
|
+
* would leave half a credential fetched — and CACHED, and recorded as a
|
|
64601
|
+
* redaction needle — for a resource that is about to be refused anyway.
|
|
64602
|
+
* - With no foreign-region reference (every leaf on every pre-#2108 code path)
|
|
64603
|
+
* the leaf goes to `resolveDynamicReferences` WHOLE, byte-for-byte as before.
|
|
64604
|
+
* That method collects its matches from the ORIGINAL string, so a resolved
|
|
64605
|
+
* plaintext that is itself token-shaped is never re-resolved (issue #1917),
|
|
64606
|
+
* and this change does not want to relitigate any of it.
|
|
64607
|
+
* - With one, the leaf is rebuilt segment by segment so each reference can be
|
|
64608
|
+
* resolved by its OWN region's resolver, since `resolveDynamicReferences`
|
|
64609
|
+
* resolves every token in the string it is handed with the one resolver it
|
|
64610
|
+
* is called on. Each token is resolved ALONE and concatenated, so no resolved
|
|
64611
|
+
* value is re-scanned for tokens either.
|
|
64612
|
+
*
|
|
64613
|
+
* `dynamicReferenceTokens` returns the tokens in order and non-overlapping, so
|
|
64614
|
+
* walking the leaf with a moving `indexOf` cursor reproduces their positions
|
|
64615
|
+
* exactly, duplicates included.
|
|
64616
|
+
*/
|
|
64617
|
+
async function resolveDriftLeafByRegion(leaf, propertyPath, logicalId, consumerRegion, producerRegions, resolvers, ctx) {
|
|
64618
|
+
const verdicts = dynamicReferenceTokens(leaf).map((token) => [token, classifyReplaySecretRegion(token, consumerRegion, producerRegions)]);
|
|
64619
|
+
for (const [, verdict] of verdicts) if (verdict.kind === "ambiguous") throw regionAmbiguousDriftSecretError(logicalId, propertyPath, verdict.secretName, verdict.foreignProducerRegions, consumerRegion);
|
|
64620
|
+
if (!verdicts.some(([, verdict]) => verdict.kind === "named-region")) return await resolvers.primary.resolveDynamicReferences(leaf, ctx);
|
|
64621
|
+
let out = "";
|
|
64622
|
+
let cursor = 0;
|
|
64623
|
+
for (const [token, verdict] of verdicts) {
|
|
64624
|
+
const at = leaf.indexOf(token, cursor);
|
|
64625
|
+
if (at < 0) throw new DriftSecretRefusalError(`${logicalId}${propertyPath === "" ? "" : ` property '${propertyPath}'`}: could not locate a scanned dynamic reference in the value it was scanned from. Refusing rather than resolving it in '${consumerRegion}', which would be the wrong region for a reference that names another one. This is an internal invariant failure — please report it with the resource type and property path.`, "DRIFT_SECRET_TOKEN_SCAN_MISMATCH");
|
|
64626
|
+
out += leaf.slice(cursor, at);
|
|
64627
|
+
const resolver = verdict.kind === "named-region" ? resolvers.forRegion(verdict.region) : resolvers.primary;
|
|
64628
|
+
out += await resolver.resolveDynamicReferences(token, ctx);
|
|
64629
|
+
cursor = at + token.length;
|
|
64630
|
+
}
|
|
64631
|
+
return out + leaf.slice(cursor);
|
|
64632
|
+
}
|
|
64633
|
+
/**
|
|
63941
64634
|
* Re-resolve the SECRET dynamic references (`{{resolve:secretsmanager:...}}`,
|
|
63942
64635
|
* and an `{{resolve:ssm:...}}` naming a `SecureString`) held by a bag read back
|
|
63943
64636
|
* out of cdkd STATE (issue #1914).
|
|
@@ -63975,10 +64668,15 @@ function collectDynamicReferencePaths(value, into, path = "") {
|
|
|
63975
64668
|
*
|
|
63976
64669
|
* Returns the input by identity when it holds no dynamic reference, so the
|
|
63977
64670
|
* non-secret path is unchanged down to object identity.
|
|
64671
|
+
*
|
|
64672
|
+
* Issue [#2108](https://github.com/go-to-k/cdkd/issues/2108): every reference
|
|
64673
|
+
* is now routed to the region that must ANSWER for it rather than to the
|
|
64674
|
+
* consumer's, through {@link DriftSecretResolvers} — see
|
|
64675
|
+
* {@link resolveDriftLeafByRegion}.
|
|
63978
64676
|
*/
|
|
63979
|
-
async function resolveStateSecretExpressions(props,
|
|
64677
|
+
async function resolveStateSecretExpressions(props, resolvers, secrets, options) {
|
|
63980
64678
|
if (!containsDynamicReference(props)) return props;
|
|
63981
|
-
const { secretPaths, onUnresolved } = options;
|
|
64679
|
+
const { secretPaths, onUnresolved, producerRegions, logicalId, consumerRegion } = options;
|
|
63982
64680
|
const ctx = {
|
|
63983
64681
|
template: { Resources: {} },
|
|
63984
64682
|
resources: {},
|
|
@@ -63987,7 +64685,7 @@ async function resolveStateSecretExpressions(props, resolver, secrets, options =
|
|
|
63987
64685
|
const walk = async (v, path) => {
|
|
63988
64686
|
if (typeof v === "string") {
|
|
63989
64687
|
if (!v.includes("{{resolve:")) return v;
|
|
63990
|
-
const resolved = await
|
|
64688
|
+
const resolved = await resolveDriftLeafByRegion(v, path, logicalId, consumerRegion, producerRegions, resolvers, ctx);
|
|
63991
64689
|
const survivors = survivingDynamicReferences(resolved).filter((t) => v.includes(t));
|
|
63992
64690
|
if (survivors.length > 0) {
|
|
63993
64691
|
if (survivors.some(isSecretBySpelling)) secretPaths?.add(path);
|
|
@@ -64148,7 +64846,8 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
|
|
|
64148
64846
|
const outcomes = [];
|
|
64149
64847
|
const state = result.state;
|
|
64150
64848
|
const logger = getLogger();
|
|
64151
|
-
const
|
|
64849
|
+
const secretResolvers = new DriftSecretResolvers(region);
|
|
64850
|
+
const producerRegions = producerRegionsFromState(state);
|
|
64152
64851
|
const entries = Object.entries(state.resources ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
64153
64852
|
for (const [logicalId, resource] of entries) {
|
|
64154
64853
|
if (providerRegistry.shouldSkipResource(resource.resourceType)) continue;
|
|
@@ -64220,18 +64919,25 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
|
|
|
64220
64919
|
let comparisonBaseline = baseline;
|
|
64221
64920
|
let secretResolutionFailed = false;
|
|
64222
64921
|
try {
|
|
64223
|
-
comparisonBaseline = await resolveStateSecretExpressions(baseline,
|
|
64922
|
+
comparisonBaseline = await resolveStateSecretExpressions(baseline, secretResolvers, secrets, {
|
|
64224
64923
|
secretPaths,
|
|
64225
|
-
onUnresolved: noteUnresolved
|
|
64924
|
+
onUnresolved: noteUnresolved,
|
|
64925
|
+
logicalId,
|
|
64926
|
+
consumerRegion: region,
|
|
64927
|
+
producerRegions
|
|
64226
64928
|
});
|
|
64227
|
-
if (useObserved) await resolveStateSecretExpressions(resource.properties ?? {},
|
|
64929
|
+
if (useObserved) await resolveStateSecretExpressions(resource.properties ?? {}, secretResolvers, secrets, {
|
|
64228
64930
|
secretPaths,
|
|
64229
|
-
onUnresolved: noteUnresolved
|
|
64931
|
+
onUnresolved: noteUnresolved,
|
|
64932
|
+
logicalId,
|
|
64933
|
+
consumerRegion: region,
|
|
64934
|
+
producerRegions
|
|
64230
64935
|
});
|
|
64231
64936
|
} catch (err) {
|
|
64232
64937
|
secretResolutionFailed = true;
|
|
64233
64938
|
comparisonBaseline = baseline;
|
|
64234
|
-
|
|
64939
|
+
const refused = isDriftSecretRefusal(err);
|
|
64940
|
+
logger.warn(`${logicalId} (${resource.resourceType}): ` + (refused ? "refused to resolve a dynamic reference this resource's state records, so its secret-bearing properties are NOT compared — " : "could not resolve the dynamic reference(s) this resource's state records, so its secret-bearing properties are NOT compared — ") + `${maskSecretsInText(err instanceof Error ? err.message : String(err), secrets)}`);
|
|
64235
64941
|
secrets.clear();
|
|
64236
64942
|
}
|
|
64237
64943
|
if (unresolvedTokens.size > 0) logger.warn(`${logicalId} (${resource.resourceType}): cdkd cannot resolve ${maskSecretsInText([...unresolvedTokens].join(", "), secrets)} — those properties are NOT compared. A revert leaves a property whose WHOLE value is one of these tokens untouched; where a token is EMBEDDED in a longer string, the revert writes that string with the token literal, exactly as 'cdkd deploy' does — so a resolved value AWS holds there WOULD be overwritten. cdkd resolves 'secretsmanager' and 'ssm' references only.`);
|
|
@@ -64247,10 +64953,14 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
|
|
|
64247
64953
|
unionWalkObjects: useObserved,
|
|
64248
64954
|
unorderedPaths
|
|
64249
64955
|
}), secrets, secretResolutionFailed ? seededSecretPaths : secretPaths);
|
|
64956
|
+
const referencesUnresolved = secretResolutionFailed || unresolvedTokens.size > 0;
|
|
64957
|
+
const comparisonRefused = secretResolutionFailed;
|
|
64250
64958
|
if (reported.changes.length === 0) outcomes.push({
|
|
64251
64959
|
kind: "clean",
|
|
64252
64960
|
logicalId,
|
|
64253
|
-
resourceType: resource.resourceType
|
|
64961
|
+
resourceType: resource.resourceType,
|
|
64962
|
+
referencesUnresolved,
|
|
64963
|
+
comparisonRefused
|
|
64254
64964
|
});
|
|
64255
64965
|
else outcomes.push({
|
|
64256
64966
|
kind: "drifted",
|
|
@@ -64259,7 +64969,8 @@ async function runDriftForStack(stackName, region, stateBackend, providerRegistr
|
|
|
64259
64969
|
...reported,
|
|
64260
64970
|
awsProperties: aws,
|
|
64261
64971
|
secrets,
|
|
64262
|
-
referencesUnresolved
|
|
64972
|
+
referencesUnresolved,
|
|
64973
|
+
comparisonRefused
|
|
64263
64974
|
});
|
|
64264
64975
|
}
|
|
64265
64976
|
return {
|
|
@@ -64978,7 +65689,8 @@ async function runRevert(reports, providerRegistry, stateBackend, stateConfig, a
|
|
|
64978
65689
|
if (driftedOutcomes.length === 0) continue;
|
|
64979
65690
|
await lockManager.acquireLock(report.stackName, report.region, owner, "drift-revert");
|
|
64980
65691
|
const narrowedByLogicalId = /* @__PURE__ */ new Map();
|
|
64981
|
-
const
|
|
65692
|
+
const revertSecretResolvers = new DriftSecretResolvers(report.region);
|
|
65693
|
+
const revertProducerRegions = producerRegionsFromState(report.state);
|
|
64982
65694
|
try {
|
|
64983
65695
|
await runWithConcurrency(driftedOutcomes.map((outcome) => async () => {
|
|
64984
65696
|
const stateResource = report.state.resources[outcome.logicalId];
|
|
@@ -64999,11 +65711,21 @@ async function runRevert(reports, providerRegistry, stateBackend, stateConfig, a
|
|
|
64999
65711
|
};
|
|
65000
65712
|
let desiredProperties;
|
|
65001
65713
|
try {
|
|
65002
|
-
desiredProperties = await resolveStateSecretExpressions(revertBaseline,
|
|
65003
|
-
|
|
65714
|
+
desiredProperties = await resolveStateSecretExpressions(revertBaseline, revertSecretResolvers, secrets, {
|
|
65715
|
+
onUnresolved: noteUnresolved,
|
|
65716
|
+
logicalId: outcome.logicalId,
|
|
65717
|
+
consumerRegion: report.region,
|
|
65718
|
+
producerRegions: revertProducerRegions
|
|
65719
|
+
});
|
|
65720
|
+
if (stateResource.observedProperties !== void 0) await resolveStateSecretExpressions(stateResource.properties ?? {}, revertSecretResolvers, secrets, {
|
|
65721
|
+
onUnresolved: noteUnresolved,
|
|
65722
|
+
logicalId: outcome.logicalId,
|
|
65723
|
+
consumerRegion: report.region,
|
|
65724
|
+
producerRegions: revertProducerRegions
|
|
65725
|
+
});
|
|
65004
65726
|
} catch (err) {
|
|
65005
65727
|
totalUnresolvable++;
|
|
65006
|
-
logger.error(` ✗ ${report.stackName}/${outcome.logicalId} (${outcome.resourceType}): could not re-resolve the dynamic reference(s) this resource's state records —
|
|
65728
|
+
logger.error(` ✗ ${report.stackName}/${outcome.logicalId} (${outcome.resourceType}): ` + (isDriftSecretRefusal(err) ? `refused to re-resolve a dynamic reference this resource's state records — ` : `could not re-resolve the dynamic reference(s) this resource's state records — `) + `${maskSecretsInText(err instanceof Error ? err.message : String(err), secrets)}`);
|
|
65007
65729
|
return;
|
|
65008
65730
|
}
|
|
65009
65731
|
const unrevertablePaths = outcome.changes.map((c) => c.path).filter((path) => (path.split(".", 1)[0] ?? "").includes("***"));
|
|
@@ -65204,9 +65926,15 @@ function writeJsonReport(reports) {
|
|
|
65204
65926
|
const drifted = r.outcomes.filter((o) => o.kind === "drifted").map((o) => ({
|
|
65205
65927
|
logicalId: o.logicalId,
|
|
65206
65928
|
type: o.resourceType,
|
|
65207
|
-
changes: o.changes
|
|
65929
|
+
changes: o.changes,
|
|
65930
|
+
referencesUnresolved: o.referencesUnresolved
|
|
65208
65931
|
}));
|
|
65209
65932
|
const clean = r.outcomes.filter((o) => o.kind === "clean").map((o) => ({
|
|
65933
|
+
logicalId: o.logicalId,
|
|
65934
|
+
type: o.resourceType,
|
|
65935
|
+
referencesUnresolved: o.referencesUnresolved
|
|
65936
|
+
}));
|
|
65937
|
+
const notCompared = notComparedOutcomes(r).map((o) => ({
|
|
65210
65938
|
logicalId: o.logicalId,
|
|
65211
65939
|
type: o.resourceType
|
|
65212
65940
|
}));
|
|
@@ -65224,7 +65952,8 @@ function writeJsonReport(reports) {
|
|
|
65224
65952
|
drifted,
|
|
65225
65953
|
clean,
|
|
65226
65954
|
notSupported,
|
|
65227
|
-
skipped
|
|
65955
|
+
skipped,
|
|
65956
|
+
notCompared
|
|
65228
65957
|
};
|
|
65229
65958
|
});
|
|
65230
65959
|
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
@@ -65234,8 +65963,11 @@ function writeHumanReport(reports) {
|
|
|
65234
65963
|
const drifted = report.outcomes.filter((o) => o.kind === "drifted");
|
|
65235
65964
|
const unsupported = report.outcomes.filter((o) => o.kind === "unsupported");
|
|
65236
65965
|
const skippedCount = report.outcomes.filter((o) => o.kind === "skipped").length;
|
|
65237
|
-
const
|
|
65238
|
-
|
|
65966
|
+
const inspected = report.outcomes.length - skippedCount;
|
|
65967
|
+
const notCompared = notComparedOutcomes(report);
|
|
65968
|
+
const checked = inspected - notCompared.length;
|
|
65969
|
+
if (drifted.length === 0) if (notCompared.length > 0) process.stdout.write(`⚠ ${report.stackName} (${report.region}): no drift detected, but ${checked} of ${inspected} resource${inspected === 1 ? "" : "s"} fully checked (${notCompared.length} only partially compared, ${unsupported.length} unsupported)\n`);
|
|
65970
|
+
else process.stdout.write(`✓ ${report.stackName} (${report.region}): no drift detected (${checked} resource${checked === 1 ? "" : "s"} checked, ${unsupported.length} unsupported)\n`);
|
|
65239
65971
|
else {
|
|
65240
65972
|
const word = drifted.length === 1 ? "resource" : "resources";
|
|
65241
65973
|
process.stdout.write(`\n⚠ ${report.stackName} (${report.region}): drift detected on ${drifted.length} ${word}\n\n`);
|
|
@@ -65248,6 +65980,10 @@ function writeHumanReport(reports) {
|
|
|
65248
65980
|
process.stdout.write("\n");
|
|
65249
65981
|
}
|
|
65250
65982
|
}
|
|
65983
|
+
if (notCompared.length > 0) {
|
|
65984
|
+
process.stdout.write(`\n ${notCompared.length} resource(s) only PARTIALLY compared — cdkd could not, or refused to, resolve a dynamic reference their state records, so their secret-bearing properties were NOT compared:\n`);
|
|
65985
|
+
for (const o of notCompared) process.stdout.write(` ! ${o.logicalId} (${o.resourceType})\n`);
|
|
65986
|
+
}
|
|
65251
65987
|
if (unsupported.length > 0) {
|
|
65252
65988
|
process.stdout.write(`\n ${unsupported.length} resource(s) reported as drift unknown — provider does not yet support drift detection:\n`);
|
|
65253
65989
|
for (const o of unsupported) process.stdout.write(` ? ${o.logicalId} (${o.resourceType})\n`);
|
|
@@ -65276,7 +66012,7 @@ function stackRegionOption$2() {
|
|
|
65276
66012
|
* Create the `drift` command.
|
|
65277
66013
|
*/
|
|
65278
66014
|
function createDriftCommand() {
|
|
65279
|
-
const cmd = new Command("drift").description("Detect drift between cdkd state and AWS reality. Exits 0 when
|
|
66015
|
+
const cmd = new Command("drift").description("Detect drift between cdkd state and AWS reality. Exits 0 when nothing drifted, 1 when drift is detected, and 2 when nothing drifted but cdkd REFUSED to compare a resource, because a dynamic reference its state records could not be attributed to a region. Pass --accept to update cdkd state from AWS, or --revert to push cdkd state values back into AWS.").argument("[stacks...]", "Stack name(s) to check (physical CloudFormation names)").option("--all", "Check every stack in the state bucket", false).option("--json", "Output as JSON", false).option("--accept", "Update cdkd state with the AWS-current values for every drifted property (state ← AWS). Mutually exclusive with --revert.", false).option("--revert", "Push cdkd state values back into AWS via provider.update for every drifted resource (AWS ← state). Mutually exclusive with --accept.", false).option("--dry-run", "Print the planned mutations without acquiring a lock or hitting AWS / S3. Honored by --accept and --revert.", false).option("--concurrency <number>", "Maximum concurrent provider.update calls during --revert", (value) => parseInt(value, 10), 4).addOption(stackRegionOption$2()).action(withErrorHandling(driftCommand));
|
|
65280
66016
|
[...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
|
|
65281
66017
|
cmd.addOption(deprecatedRegionOption);
|
|
65282
66018
|
return cmd;
|
|
@@ -84259,7 +84995,7 @@ function createMigrateCommand() {
|
|
|
84259
84995
|
*/
|
|
84260
84996
|
function buildProgram() {
|
|
84261
84997
|
const program = new Command();
|
|
84262
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.
|
|
84998
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.29");
|
|
84263
84999
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
84264
85000
|
const { profile } = actionCommand.optsWithGlobals();
|
|
84265
85001
|
if (profile !== void 0) process.env["AWS_PROFILE"] = profile;
|