@go-to-k/cdkd 0.290.7 → 0.290.9
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-JRuQeFtl.js → asg-provider-BTsQjH_E.js} +3 -3
- package/dist/{asg-provider-JRuQeFtl.js.map → asg-provider-BTsQjH_E.js.map} +1 -1
- package/dist/cli.js +2 -2
- package/dist/{deploy-engine-BMp4b4Vl.js → deploy-engine-B3yP0pHa.js} +9 -6
- package/dist/{deploy-engine-BMp4b4Vl.js.map → deploy-engine-B3yP0pHa.js.map} +1 -1
- package/dist/index.js +2 -2
- package/dist/{logger-th1myfgh.js → logger-H8n5Qjhq.js} +162 -38
- package/dist/logger-H8n5Qjhq.js.map +1 -0
- package/dist/{program-Ckl60p0E.js → program-BslZXdJR.js} +797 -59
- package/dist/{program-Ckl60p0E.js.map → program-BslZXdJR.js.map} +1 -1
- package/dist/{version-XcHj8rQZ.js → version-wXpog0Ci.js} +2 -2
- package/dist/{version-XcHj8rQZ.js.map → version-wXpog0Ci.js.map} +1 -1
- package/package.json +1 -1
- package/dist/logger-th1myfgh.js.map +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { $ as
|
|
2
|
-
import {
|
|
1
|
+
import { $ as secretBearing, $n as ensureAssetStorage, $r as setAwsClients, $t as s3BucketRegionalDomainName, A as extractDeploymentEventError, Ai as retryClassificationText, An as buildForceUnlockCommand, Ar as resolveSkipPrefix, At as parameterTypeMayLoseSecretIdentity, B as toCfnInteger, Bn as orphansAfterRollback, Br as uploadCfnTemplate, Bt as configIntegerRefusal, C as buildFinalSnapshotIdentifier, Ci as withErrorHandling, Cn as recordMaskOnlyValue, Cr as Synthesizer, Ct as IntrinsicFunctionResolver, D as refusesFinalSnapshot, Di as isTransientServerError, Dn as LockManager, Dr as resolveApp, Dt as getAccountInfo, E as isFinalSnapshotError, Ei as isThrottlingError, En as scrubResourceRecord, Er as getLegacyStateBucketName, Et as coerceParameterTypedValue, F as isStatefulRecreateTargetSync, Fn as CUSTOM_RESOURCE_RESPONSE_PREFIX, Fr as warnDeprecatedNoPrefixCliFlag, Ft as resolveExplicitPhysicalId, G as gray, Gn as WorkGraph, Gr as derivePartitionAndUrlSuffix, Gt as requireConfigObject, H as formatResourceLine, Hn as shouldRetainResource, Hr as expectedOwnerParam, Ht as readConfigString, I as renderStatefulReason, In as DEFAULT_STATE_PREFIX, Ir as CFN_TEMPLATE_BODY_LIMIT, It as assertRegionMatch, J as yellow, Jn as loadPublishableAssetManifest, Jt as producerRegionsFromState, K as green, Kn as buildAssetRedirectMap, Kr as AssemblyReader, Kt as requireConfigString, L as WARM_THROUGHPUT_MEMBERS, Ln as exportNamesCarriedFrom, Lr as CFN_TEMPLATE_URL_LIMIT, Lt as coerceCfnBoolean, M as planOrphanAdoption, Mn as forceQuitRecoveryClause, Mr as resolveStateBucketWithDefaultAndSource, Mt as isSettledInstanceState, N as MULTI_REGION_RECREATE_BLOCKED_TYPES, Nn as formatLockExpiry, Nr as resolveUseCdkBootstrapAssets, Nt as WAFv2WebACLProvider, O as unsupportedFinalSnapshotError, Oi as markNonRetryable, On as S3StateBackend, Or as resolveAutoAssetStorage, Ot as isImpossibleEmptyStoredAttribute, P as isStatefulRecreateTargetForReplace, Pn as shellQuote, Pr as stateBucketExistenceConfirmed, Pt as normalizeAwsTagsToCfn, Q as isExportAliasCollision, Qn as assertAssetBucketRegion, Qr as resetAwsClients, Qt as s3BucketDualStackDomainName, R as coerceWarmThroughput$1, Rn as importableOutputKeys, Rr as MIGRATE_TMP_PREFIX, Rt as coerceCfnInteger, S as PRE_DELETE_SNAPSHOT_TYPES, Si as normalizeAwsError, Sn as maskSecretsInText, Sr as getDockerImageBySourceHash, St as isTerminationProtectionPropagationError, T as createPreDeleteFinalSnapshot, Ti as isRetryableTransientError, Tn as redactSecretsForState, Tr as getDefaultStateBucketName, Tt as cfnRefValueFromPhysicalId, U as bold, Un as AssetPublisher, Ur as PARTITION_TABLE, Ut as replayWarn, V as toFiniteNumber, Vn as orphansCarriedFrom, Vr as CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION, Vt as configStringRefusal, W as cyan, Wn as stringifyValue, Wr as canonicalizeRegion, Wt as requireConfigArray, X as collectPublishedOutputNames, Xn as AssetModeResolver, Xr as AwsClients, Xt as s3BucketArn, Y as collectDeclaredOutputNames, Yn as rewriteTemplateAssetReferences, Yr as resolveBucketRegion, Yt as withSharedDrainBudget, Z as exportAliasCollisionScrubWarning, Zn as BOOTSTRAP_MARKER_PREFIX, Zr as getAwsClients, Zt as s3BucketDomainName, _ as isSecretBearingReferenceString, _i as StackTerminationProtectionError, _n as errorCauseChain, _r as runDockerForeground, _t as slowCcOperationTimeoutMs, a as DeploymentEventsStore, ai as DeployCancelledError, an as findSilentDropProperties, ar as validateContainerRepoName, at as clearOnUpdateRemoval, b as bindingSkippedOutputs, bn as markSameGenerationBag, br as stripControlChars, bt as deleteSkipReason, c as replayFailedOperations, ci as LocalInvokeBuildError$1, cn as DagBuilder, cr as buildDockerImage, ct as createMaskedRetryLogger, d as updatePartialReason, di as NestedStackChildDirectDestroyError, dn as STATE_SOURCED_CROSS_GENERATION_RULES, dr as describeDockerFailure, dt as beginCommandInterruptScope, en as s3BucketWebsiteUrl, er as getBootstrapMarkerKey, et as secretBearingStateKeyWarning, f as withResourceDeadline, fi as PartialFailureError, fn as STATE_SOURCED_READBACK_RULES, fr as dockerSpawnEnvWithSensitive, ft as endCommandInterruptScope, g as bagHoldsSecretExpression, gi as StackHasActiveImportsError, gn as dynamicReferenceTokens, gr as redactDockerArgvValues, gt as CloudControlProvider, h as computeImplicitDeleteEdges, hi as ResourceUpdateNotSupportedError, hn as createSecretMasker, hr as partitionSensitiveEnv, ht as startInterruptWatch, i as DeploymentEventsReader, in as findActionableSilentDrops, ir as validateAssetBucketName, it as collectInlinePolicyNamesManagedBySiblings, j as makeSiblingClaimReader, jn as buildLockContentionMessage, jr as resolveStateBucketWithDefault, jt as refStateLookupFromResource, k as makeCanonicalizePropertiesFn, ki as markRedactedCause, kn as rebuildClientForBucketRegion, kr as resolveCaptureObservedState, kt as isUnboundTemplateParameter, l as replayRollback, li as LocalStartServiceError, ln as TemplateParser, lr as describeDockerCapturedOutput, lt as maskDeep, m as IMPLICIT_DELETE_DEPENDENCIES, mi as ResourceTimeoutError, mn as carriesSecretMask, mr as getDockerCmd, mt as isInterruptedWaitError, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as DiffCalculator, nr as parseBootstrapMarker, nt as getCurrentResourceSecrets, o as planFailedOps, oi as DynamicReferenceRegionAmbiguousError, on as describeTypeWithThrottleRetry, or as buildDenyExternalAccessPolicy, ot as ProviderRegistry, p as maskingRetryLogger, pi as ProvisioningError, pn as TEMPLATE_SOURCED_RULES, pr as formatDockerLoginError, pt as interruptWatchListenerCount, q as red, qn as createAssetRedirectResolver, qr as processStackMessages, qt as classifyReplaySecretRegion, r as DeployEngine, ri as CrossAccountSecretRefusalError, rn as INTRINSIC_KEYS, rr as readBootstrapMarkerBody, rt as IAMRoleProvider, s as planRollback, si as IntrinsicResolutionRefusalError, sn as withRetry, sr as describeAwsFailure, st as wouldReturnToSdkProvider, t as DEFAULT_RESOURCE_TIMEOUT_MS, ti as CdkdError, tn as applyRoleArnIfSet, tr as isCrossRegionRedirect, tt as secretSafeKeyDisplay, u as updatePartialMessage, un as STATE_SOURCED_BASELINE_RULES, ur as describeDockerExecFailure, ut as maskerOrIdentity, v as keptWholeReasonText, vn as identityKeyFor, vr as runDockerStreaming, vt as UNSPECIFIED_SKIP_REASON, w as ccRoutedFinalSnapshotError, wi as isMarkedNonRetryable, wn as recoverMaskedOutput, wr as synthesisStatusMessage, wt as carriesDynamicReference, x as ATOMIC_FINAL_SNAPSHOT_TYPES, xn as maskSecretsInError, xr as AssetManifestLoader, xt as disableInstanceApiTermination, y as mergeNoChangeOutputs, yn as isSingleDynamicReferenceToken, yr as escapeRegExp, yt as deleteIndeterminateGuards, z as isWarmThroughputDecrease, zn as importableOutputs, zr as findLargeInlineResources, zt as configBooleanRefusal } from "./deploy-engine-B3yP0pHa.js";
|
|
2
|
+
import { C as truncateCodePoints, S as displaySafe, _ as withSkipPrefix, b as UNRENDERABLE, c as PATTERN_B_NAME_OPTIONS, f as explicitNamePropertyFor, h as getCurrentStackName, i as reserveStdoutForPayload, l as PATTERN_B_NAME_PROPERTIES, m as generateResourceNameWithFallback, n as getLogger, o as runStackBuffered, p as generateResourceName, s as getLiveRenderer, u as PATTERN_B_RESOURCE_TYPES, v as withStackName, x as displayIdent, y as STACK_REF_MAX_CODE_POINTS } from "./logger-H8n5Qjhq.js";
|
|
3
3
|
import { t as awsClientDefaults } from "./aws-client-defaults-D-iYiIJ6.js";
|
|
4
|
-
import { t as getCdkdVersion } from "./version-
|
|
5
|
-
import { a as renderDisableCommand, i as protectedReplacementAdvice, r as UNNAMEABLE_ID_CLAUSE, t as ASGProvider } from "./asg-provider-
|
|
4
|
+
import { t as getCdkdVersion } from "./version-wXpog0Ci.js";
|
|
5
|
+
import { a as renderDisableCommand, i as protectedReplacementAdvice, r as UNNAMEABLE_ID_CLAUSE, t as ASGProvider } from "./asg-provider-BTsQjH_E.js";
|
|
6
6
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
7
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
8
8
|
import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketEncryptionCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketOwnershipControlsCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, NotFound, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
|
|
@@ -64114,6 +64114,40 @@ function containsSecretDynamicReference(value) {
|
|
|
64114
64114
|
function templateHasSecretDynamicReference(template) {
|
|
64115
64115
|
return containsSecretDynamicReference(template);
|
|
64116
64116
|
}
|
|
64117
|
+
/**
|
|
64118
|
+
* Can a condition verdict reach an output's VALUE in this template?
|
|
64119
|
+
*
|
|
64120
|
+
* The no-change merge preview (issue #3101) must not carry a failed output a
|
|
64121
|
+
* condition verdict could change, because `cdkd diff` evaluates `Conditions`
|
|
64122
|
+
* best-effort and its verdict can differ from the deploy's. An output never
|
|
64123
|
+
* reads a resource's template properties (a `Ref` or `Fn::GetAtt` reads the
|
|
64124
|
+
* stored record), so a verdict reaches its value only through the output's own
|
|
64125
|
+
* `Condition`, or an `Fn::If` / `{ Condition: ... }` outside `Resources`: in an
|
|
64126
|
+
* output, or in a mapping `Fn::FindInMap` returns. `Conditions` is skipped as
|
|
64127
|
+
* well, since a reference there composes a verdict and reaches an output only
|
|
64128
|
+
* through one of those. A verdict that prunes or keeps a resource differently
|
|
64129
|
+
* changes the resource set instead; where that leaves the diff seeing no change
|
|
64130
|
+
* the deploy sees, the warning naming every carried output already says the
|
|
64131
|
+
* next deploy may write a different value for it.
|
|
64132
|
+
*
|
|
64133
|
+
* Not "the template declares `Conditions`": CDK attaches `CDKMetadataAvailable`
|
|
64134
|
+
* to its `AWS::CDK::Metadata` resource in every env-agnostic stack, and that
|
|
64135
|
+
* test refused the preview for the default app shape.
|
|
64136
|
+
*/
|
|
64137
|
+
function templateLetsConditionsReachOutputs(template) {
|
|
64138
|
+
for (const output of Object.values(template.Outputs ?? {})) if (output.Condition !== void 0) return true;
|
|
64139
|
+
return Object.entries(template).some(([section, value]) => section !== "Resources" && section !== "Conditions" && readsConditionVerdict(value));
|
|
64140
|
+
}
|
|
64141
|
+
/** An `Fn::If`, or a `{ Condition: ... }` reference, anywhere under `value`. */
|
|
64142
|
+
function readsConditionVerdict(value) {
|
|
64143
|
+
if (value === null || typeof value !== "object") return false;
|
|
64144
|
+
if (Array.isArray(value)) return value.some(readsConditionVerdict);
|
|
64145
|
+
const record = value;
|
|
64146
|
+
if (Object.prototype.hasOwnProperty.call(record, "Fn::If")) return true;
|
|
64147
|
+
const keys = Object.keys(record);
|
|
64148
|
+
if (keys.length === 1 && keys[0] === "Condition") return true;
|
|
64149
|
+
return Object.values(record).some(readsConditionVerdict);
|
|
64150
|
+
}
|
|
64117
64151
|
/** Structural equality, mirroring the deploy engine's `outputMapsEqual` leaf rule. */
|
|
64118
64152
|
function valuesEqual(a, b) {
|
|
64119
64153
|
if (a === b) return true;
|
|
@@ -64193,6 +64227,8 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
|
|
|
64193
64227
|
const outputs = Object.create(null);
|
|
64194
64228
|
const exportNames = /* @__PURE__ */ new Set();
|
|
64195
64229
|
const failedKeys = /* @__PURE__ */ new Set();
|
|
64230
|
+
const failedOutputKeys = /* @__PURE__ */ new Set();
|
|
64231
|
+
let failuresMirrorDeploy = true;
|
|
64196
64232
|
const secretSourceKeys = /* @__PURE__ */ new Set();
|
|
64197
64233
|
const declaredKeys = /* @__PURE__ */ new Set();
|
|
64198
64234
|
const templateHasSecretReference = containsSecretDynamicReference(template);
|
|
@@ -64202,6 +64238,8 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
|
|
|
64202
64238
|
outputs,
|
|
64203
64239
|
exportNames,
|
|
64204
64240
|
failedKeys,
|
|
64241
|
+
failedOutputKeys,
|
|
64242
|
+
failuresMirrorDeploy,
|
|
64205
64243
|
secretSourceKeys,
|
|
64206
64244
|
declaredKeys,
|
|
64207
64245
|
templateHasSecretReference,
|
|
@@ -64226,7 +64264,7 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
|
|
|
64226
64264
|
}
|
|
64227
64265
|
for (const [outputKey, output] of Object.entries(template.Outputs)) {
|
|
64228
64266
|
if (output.Condition !== void 0 && conditions?.[output.Condition] === false) {
|
|
64229
|
-
logger.debug(`Skipping output ${outputKey} — condition ${output.Condition} is false`);
|
|
64267
|
+
logger.debug(`Skipping output ${stripControlChars(outputKey)} — condition ${stripControlChars(String(output.Condition))} is false`);
|
|
64230
64268
|
continue;
|
|
64231
64269
|
}
|
|
64232
64270
|
if (skippedOutputKeys?.has(outputKey) && !(storedOutputs !== void 0 && Object.prototype.hasOwnProperty.call(storedOutputs, outputKey))) {
|
|
@@ -64238,15 +64276,18 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
|
|
|
64238
64276
|
try {
|
|
64239
64277
|
value = await resolveFn(structuredClone(output.Value));
|
|
64240
64278
|
} catch (error) {
|
|
64241
|
-
logger.debug(`Diff could not resolve output ${outputKey}: ${String(error)}`);
|
|
64279
|
+
logger.debug(`Diff could not resolve output ${stripControlChars(outputKey)}: ${stripControlChars(String(error))}`);
|
|
64242
64280
|
resolutionFailed = true;
|
|
64243
64281
|
recordFailure(outputKey, output);
|
|
64282
|
+
failedOutputKeys.add(outputKey);
|
|
64244
64283
|
continue;
|
|
64245
64284
|
}
|
|
64246
64285
|
if (isUnresolvedValue(value, sourceUsedSub)) {
|
|
64247
|
-
logger.debug(`Diff left output ${outputKey} unresolved (references a pending resource)`);
|
|
64286
|
+
logger.debug(`Diff left output ${stripControlChars(outputKey)} unresolved (references a pending resource)`);
|
|
64248
64287
|
resolutionFailed = true;
|
|
64249
64288
|
recordFailure(outputKey, output);
|
|
64289
|
+
if (value === void 0) failedOutputKeys.add(outputKey);
|
|
64290
|
+
else failuresMirrorDeploy = false;
|
|
64250
64291
|
continue;
|
|
64251
64292
|
}
|
|
64252
64293
|
outputs[outputKey] = value;
|
|
@@ -64257,8 +64298,9 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
|
|
|
64257
64298
|
if (typeof exportName !== "string") try {
|
|
64258
64299
|
exportName = await resolveFn(structuredClone(exportName));
|
|
64259
64300
|
} catch (error) {
|
|
64260
|
-
logger.debug(`Diff could not resolve Export.Name of ${outputKey}: ${String(error)}`);
|
|
64301
|
+
logger.debug(`Diff could not resolve Export.Name of ${stripControlChars(outputKey)}: ${stripControlChars(String(error))}`);
|
|
64261
64302
|
resolutionFailed = true;
|
|
64303
|
+
failuresMirrorDeploy = false;
|
|
64262
64304
|
continue;
|
|
64263
64305
|
}
|
|
64264
64306
|
if (typeof exportName === "string" && !isUnresolvedValue(exportName, exportSourceUsedSub)) {
|
|
@@ -64272,18 +64314,24 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
|
|
|
64272
64314
|
logger.debug(`Diff cannot decide the export alias of ${stripControlChars(outputKey)} — a literal name may contain a resolved secret and state does not hold that key`);
|
|
64273
64315
|
failedKeys.add(exportName);
|
|
64274
64316
|
resolutionFailed = true;
|
|
64317
|
+
failuresMirrorDeploy = false;
|
|
64275
64318
|
}
|
|
64276
64319
|
} else {
|
|
64277
64320
|
outputs[exportName] = value;
|
|
64278
64321
|
exportNames.add(exportName);
|
|
64279
64322
|
}
|
|
64280
|
-
} else
|
|
64323
|
+
} else {
|
|
64324
|
+
resolutionFailed = true;
|
|
64325
|
+
failuresMirrorDeploy = false;
|
|
64326
|
+
}
|
|
64281
64327
|
}
|
|
64282
64328
|
}
|
|
64283
64329
|
return {
|
|
64284
64330
|
outputs,
|
|
64285
64331
|
exportNames,
|
|
64286
64332
|
failedKeys,
|
|
64333
|
+
failedOutputKeys,
|
|
64334
|
+
failuresMirrorDeploy,
|
|
64287
64335
|
secretSourceKeys,
|
|
64288
64336
|
declaredKeys,
|
|
64289
64337
|
templateHasSecretReference,
|
|
@@ -64325,6 +64373,15 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
|
|
|
64325
64373
|
* Withholding is therefore record-level. The change is still REPORTED — only
|
|
64326
64374
|
* the value is withheld — so `--fail` and the exports story are unaffected.
|
|
64327
64375
|
*
|
|
64376
|
+
* A third input forces the same verdict: `forceLegacyRecord`, set by the
|
|
64377
|
+
* no-change merge preview (issue #3101) when a value it carried from state for
|
|
64378
|
+
* a failed output, an export alias included, is not itself a secret expression.
|
|
64379
|
+
* The first signal reads a carried key's desired side, which is then the stored
|
|
64380
|
+
* value, so evidence only its resolved value held is gone; and where a resolved
|
|
64381
|
+
* secret expression can come from (a stored attribute, a parameter, another
|
|
64382
|
+
* stack's outputs) is not enumerable. Nothing short of the carried key's own stored expression can
|
|
64383
|
+
* excuse it — the one value the per-key veto below would have excused.
|
|
64384
|
+
*
|
|
64328
64385
|
* ## The key today's template cannot account for (issue #1948)
|
|
64329
64386
|
*
|
|
64330
64387
|
* Neither signal above covers an output DELETED from the template, and an
|
|
@@ -64383,7 +64440,7 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
|
|
|
64383
64440
|
function computeOutputsDiff(current, desired, exportNames, secretSourceKeys = /* @__PURE__ */ new Set(), unaccountableScan = {}) {
|
|
64384
64441
|
const changes = [];
|
|
64385
64442
|
const currentBag = current ?? {};
|
|
64386
|
-
const legacyRecord = Object.entries(currentBag).some(([name, oldValue]) => {
|
|
64443
|
+
const legacyRecord = unaccountableScan.forceLegacyRecord === true || Object.entries(currentBag).some(([name, oldValue]) => {
|
|
64387
64444
|
if (typeof oldValue === "string" && isSecretBearingReferenceString(oldValue)) return false;
|
|
64388
64445
|
return containsSecretDynamicReference(desired[name]) || secretSourceKeys.has(name);
|
|
64389
64446
|
});
|
|
@@ -65363,8 +65420,28 @@ const STATE_RESOURCES_MALFORMED = "STATE_RESOURCES_MALFORMED";
|
|
|
65363
65420
|
* nowhere. The predicate is the plain-object test the bag's own type implies.
|
|
65364
65421
|
*/
|
|
65365
65422
|
function hasReadableResources(state) {
|
|
65366
|
-
|
|
65367
|
-
|
|
65423
|
+
return isReadableBag(state.resources);
|
|
65424
|
+
}
|
|
65425
|
+
/**
|
|
65426
|
+
* The plain-object test itself, without the `resources` field bound to it.
|
|
65427
|
+
*
|
|
65428
|
+
* Exported because {@link hasReadableResources} is not the only caller any
|
|
65429
|
+
* more: `cdkd state show` and `cdkd state resources` walk FOUR more record
|
|
65430
|
+
* containers with `Object.entries` — `outputs`, `skippedOutputs`, and each
|
|
65431
|
+
* resource's `attributes` and `properties` — and a non-object in any of them
|
|
65432
|
+
* fabricates rows exactly as the `resources` bag did (go-to-k/cdkd#3187).
|
|
65433
|
+
* Spelling the same test a second time there is what this export exists to
|
|
65434
|
+
* prevent: the two could then drift, and the one that drifted would fabricate
|
|
65435
|
+
* again while looking guarded.
|
|
65436
|
+
*
|
|
65437
|
+
* What it does NOT decide is whether a given container is malformed. That is a
|
|
65438
|
+
* per-container call, because absence means different things: an absent
|
|
65439
|
+
* `resources` bag is a defect ({@link hasReadableResources} says so), while an
|
|
65440
|
+
* absent `skippedOutputs` is the normal pre-#2740 record. Each caller pairs
|
|
65441
|
+
* this predicate with its own absence rule.
|
|
65442
|
+
*/
|
|
65443
|
+
function isReadableBag(container) {
|
|
65444
|
+
return typeof container === "object" && container !== null && !Array.isArray(container);
|
|
65368
65445
|
}
|
|
65369
65446
|
/**
|
|
65370
65447
|
* The shared explanation, in the terms the reader needs: what is wrong, what
|
|
@@ -65457,6 +65534,65 @@ function repairMalformedResourcesForReadOnly(state) {
|
|
|
65457
65534
|
function malformedStateRefusalMessage(stackName, region) {
|
|
65458
65535
|
return `${malformedStateDetail(stackName, region)} This command can WRITE state, so it refuses rather than continuing: saving over a record whose resource map could not be read would replace the evidence with a well-formed empty one and lose it permanently. Repair or remove the record first.`;
|
|
65459
65536
|
}
|
|
65537
|
+
/**
|
|
65538
|
+
* The warning a view emits when it emptied one or more {@link
|
|
65539
|
+
* RenderedStateContainer}s it could not walk (go-to-k/cdkd#3187).
|
|
65540
|
+
*
|
|
65541
|
+
* Deliberately NOT {@link malformedResourcesWarning}'s text, and the reason is
|
|
65542
|
+
* narrower than it looks. That one tells the reader not to run `cdkd deploy` /
|
|
65543
|
+
* `cdkd destroy` because an unreadable `resources` MAP is indistinguishable
|
|
65544
|
+
* from an empty stack: deploy would re-CREATE everything and destroy would
|
|
65545
|
+
* delete nothing. No container named here can produce that specific confusion —
|
|
65546
|
+
* the resource SET is still readable — so borrowing that sentence would attach
|
|
65547
|
+
* a re-create-the-world warning to a record whose resource list is intact.
|
|
65548
|
+
*
|
|
65549
|
+
* It is NOT that these containers are display-only. `properties` is read by the
|
|
65550
|
+
* deploy change calculation (`src/analyzer/diff-calculator.ts`), which imports
|
|
65551
|
+
* nothing from this module, and a non-object there compares unequal to any
|
|
65552
|
+
* desired object and yields a spurious property-change set — filed as
|
|
65553
|
+
* go-to-k/cdkd#3191. An earlier revision of this comment asserted the
|
|
65554
|
+
* display-only premise, which would have read as licence to drop a guard on
|
|
65555
|
+
* that path (review of go-to-k/cdkd#3190).
|
|
65556
|
+
*
|
|
65557
|
+
* What stays the same is the remedy: `--json` is the mode that shows the
|
|
65558
|
+
* stored value.
|
|
65559
|
+
*
|
|
65560
|
+
* ONE warning per record however many containers it names — a stack whose 500
|
|
65561
|
+
* resources all carry a hand-edited `properties` gets one line, not 500. The
|
|
65562
|
+
* caller FILTERS a fixed order (`RENDERED_CONTAINER_ORDER`), so the text is
|
|
65563
|
+
* stable across records. Not `.sort()` — that order is deliberately not
|
|
65564
|
+
* alphabetical, and its own JSDoc says so.
|
|
65565
|
+
*
|
|
65566
|
+
* Both identifiers are sanitized and then shell-quoted, and the command is
|
|
65567
|
+
* emitted LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s own note
|
|
65568
|
+
* gives.
|
|
65569
|
+
*
|
|
65570
|
+
* The container NAMES take {@link safeIdentifier} too — the SAME helper, so
|
|
65571
|
+
* they cannot drift from it — but NOT `shellQuote`, because they appear in the
|
|
65572
|
+
* PROSE and never inside the command this text tells the reader to run; the
|
|
65573
|
+
* command carries the two identifiers and nothing else.
|
|
65574
|
+
*
|
|
65575
|
+
* Sanitizing them is not dead code written for a case that cannot happen. The
|
|
65576
|
+
* union above is closed at COMPILE time, so without this the guarantee would
|
|
65577
|
+
* live in a comment, and the day a caller derives a name from a record instead
|
|
65578
|
+
* of from a literal an unsanitized element could forge a line, render as empty
|
|
65579
|
+
* quotes naming nothing, or run to kilobytes and push the remedy command off
|
|
65580
|
+
* the reader's screen. `safeIdentifier` closes all three and is the identity on
|
|
65581
|
+
* all four literals, so no user-visible text moves.
|
|
65582
|
+
*
|
|
65583
|
+
* What it does NOT close, stated rather than reassured away: a forged name made
|
|
65584
|
+
* only of printable ASCII still renders verbatim inside its quotes and could
|
|
65585
|
+
* read as prose. That residual is bounded to the PROSE — the command the reader
|
|
65586
|
+
* pastes is built from the two shell-quoted identifiers alone — and closing it
|
|
65587
|
+
* would mean JSON-quoting a name in the one place the text is meant to read as
|
|
65588
|
+
* English (review of go-to-k/cdkd#3190).
|
|
65589
|
+
*/
|
|
65590
|
+
function malformedRenderedContainersWarning(stackName, region, containers) {
|
|
65591
|
+
const stack = safeIdentifier(stackName);
|
|
65592
|
+
const reg = safeIdentifier(region);
|
|
65593
|
+
const names = containers.map((name) => `'${safeIdentifier(name)}'`).join(", ");
|
|
65594
|
+
return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has a non-object ${names} — the record is malformed or truncated. 'Object.entries' walks a string or a list as readily as a map, so rendering one INVENTS a row per character or element. Continuing with it EMPTY: this view shows no rows there, which is not the same as the record holding none. A per-resource container is named once however many resources hold one. See the stored values with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
|
|
65595
|
+
}
|
|
65460
65596
|
/** The warning a caller of {@link repairMalformedResourcesForReadOnly} emits. */
|
|
65461
65597
|
function malformedResourcesWarning(stackName, region) {
|
|
65462
65598
|
return `${malformedStateDetail(stackName, region)} Continuing with an EMPTY resource set: this command's output describes zero resources, which is not the same as the stack having none.`;
|
|
@@ -65720,7 +65856,7 @@ async function computeStackDiff(currentState, template, region, stackName, state
|
|
|
65720
65856
|
const tokenParameterNames = new Set(Object.entries(parameters ?? {}).filter(([, value]) => carriesDynamicReference(value)).map(([name]) => name));
|
|
65721
65857
|
for (const name of tokenParameterNames) {
|
|
65722
65858
|
const declaredType = (template.Parameters?.[name])?.Type;
|
|
65723
|
-
if (typeof declaredType === "string" && parameterTypeMayLoseSecretIdentity(declaredType)) logger.warn(`Stack ${stackName}: parameter '${name}' is declared 'Type: ${declaredType}' and is fed a secret dynamic reference. 'cdkd deploy' refuses this when the coercion actually destroys the plaintext (a comma-bearing secret in a list-typed parameter) — declare it 'Type: String' — and this diff compares the unresolved reference, shaped by the declared type only where that keeps every part of it a string.`);
|
|
65859
|
+
if (typeof declaredType === "string" && parameterTypeMayLoseSecretIdentity(declaredType)) logger.warn(`Stack ${stripControlChars(stackName)}: parameter '${stripControlChars(name)}' is declared 'Type: ${stripControlChars(declaredType)}' and is fed a secret dynamic reference. 'cdkd deploy' refuses this when the coercion actually destroys the plaintext (a comma-bearing secret in a list-typed parameter) — declare it 'Type: String' — and this diff compares the unresolved reference, shaped by the declared type only where that keeps every part of it a string.`);
|
|
65724
65860
|
}
|
|
65725
65861
|
try {
|
|
65726
65862
|
const userParameters = {};
|
|
@@ -65784,16 +65920,36 @@ async function computeStackDiff(currentState, template, region, stackName, state
|
|
|
65784
65920
|
const changedLogicalIds = new Set([...changes.values()].filter((c) => c.changeType !== "NO_CHANGE").map((c) => c.logicalId));
|
|
65785
65921
|
const bindingSkipped = bindingSkippedOutputs(outputsDigestSource, currentState.skippedOutputs, changedLogicalIds);
|
|
65786
65922
|
const resolved = await resolveTemplateOutputs(effectiveTemplate, resolveFn, conditions, currentState.outputs, bindingSkipped);
|
|
65787
|
-
|
|
65788
|
-
|
|
65789
|
-
if (computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys, {
|
|
65790
|
-
declaredKeys: resolved.declaredKeys,
|
|
65791
|
-
templateHasSecretReference: resolved.templateHasSecretReference || inheritSecretBearingTemplate === true
|
|
65792
|
-
}).filter((change) => !resolved.failedKeys.has(change.name)).length > 0) logger.warn(`Outputs of stack ${stackName} may have changed, but one or more could not be resolved against current state — omitting the Outputs section from this diff. It is usually an output referencing a resource this deploy has yet to create.`);
|
|
65793
|
-
} else outputChanges = computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys, {
|
|
65923
|
+
const templateHasSecretReference = resolved.templateHasSecretReference || inheritSecretBearingTemplate === true;
|
|
65924
|
+
const diffOutputsAgainst = (desired, exportNames, forceLegacyRecord = false) => computeOutputsDiff(currentState.outputs, desired, exportNames, resolved.secretSourceKeys, {
|
|
65794
65925
|
declaredKeys: resolved.declaredKeys,
|
|
65795
|
-
templateHasSecretReference
|
|
65926
|
+
templateHasSecretReference,
|
|
65927
|
+
forceLegacyRecord
|
|
65796
65928
|
});
|
|
65929
|
+
let outputChanges = [];
|
|
65930
|
+
if (!resolved.resolutionFailed) outputChanges = diffOutputsAgainst(resolved.outputs, resolved.exportNames);
|
|
65931
|
+
else {
|
|
65932
|
+
const resourcesChange = diffCalculator.hasChanges(changes);
|
|
65933
|
+
const merge = resolved.failuresMirrorDeploy && conditions !== void 0 && !templateLetsConditionsReachOutputs(template) && !resourcesChange ? mergeNoChangeOutputs({
|
|
65934
|
+
persisted: currentState.outputs ?? {},
|
|
65935
|
+
resolved: withFailedOutputsUndefined(resolved.outputs, resolved.failedOutputKeys),
|
|
65936
|
+
declaredOutputs: effectiveTemplate.Outputs,
|
|
65937
|
+
previousExportNames: new Set(importableOutputKeys(currentState)),
|
|
65938
|
+
resolvedExportNames: [...resolved.exportNames]
|
|
65939
|
+
}) : void 0;
|
|
65940
|
+
if (merge?.kind === "merged") {
|
|
65941
|
+
const storedOutputs = currentState.outputs ?? {};
|
|
65942
|
+
const withheld = merge.carriedKeys.some((key) => !isSecretBearingReferenceString(storedOutputs[key]));
|
|
65943
|
+
outputChanges = diffOutputsAgainst(merge.outputs, new Set(merge.exportNames), withheld);
|
|
65944
|
+
const failed = [...resolved.failedOutputKeys];
|
|
65945
|
+
const isStored = (key) => Object.prototype.hasOwnProperty.call(storedOutputs, key);
|
|
65946
|
+
const compared = failed.filter(isStored);
|
|
65947
|
+
const unstored = failed.filter((key) => !isStored(key));
|
|
65948
|
+
const names = (keys) => keys.map(stripControlChars).join(", ");
|
|
65949
|
+
const explainWithheld = withheld && outputChanges.some((change) => change.oldValueRedacted === true);
|
|
65950
|
+
logger.warn(`Outputs of stack ${stripControlChars(stackName)}: ${failed.length} output(s) could not be resolved for this diff.` + (compared.length > 0 ? ` Compared at their stored values: ${names(compared)}.` : "") + (unstored.length > 0 ? ` No stored value under their own names: ${names(unstored)}.` : "") + ` The next deploy may write a value for any of them it can resolve.` + (explainWithheld ? ` Previous values in this Outputs section are withheld because a value carried from state for a failed output is not a secret reference, so this diff cannot rule out legacy plaintext in state. That reason no longer applies once every failed output resolves, though other legacy-plaintext checks still can withhold them.` : ""));
|
|
65951
|
+
} else if (diffOutputsAgainst(resolved.outputs, resolved.exportNames).filter((change) => !resolved.failedKeys.has(change.name)).length > 0) logger.warn(`Outputs of stack ${stripControlChars(stackName)} may have changed, but one or more could not be resolved against current state — omitting the Outputs section from this diff. ` + (merge?.kind === "kept" ? keptWholeReasonText(merge.reason) : resourcesChange ? `It is usually an output referencing a resource this deploy has yet to create.` : `The stack has no resource change; this diff cannot preview what the deploy does with the outputs that failed here.`));
|
|
65952
|
+
}
|
|
65797
65953
|
return {
|
|
65798
65954
|
changes,
|
|
65799
65955
|
outputChanges,
|
|
@@ -65803,6 +65959,21 @@ async function computeStackDiff(currentState, template, region, stackName, state
|
|
|
65803
65959
|
};
|
|
65804
65960
|
}
|
|
65805
65961
|
/**
|
|
65962
|
+
* The preview's bag in the shape `mergeNoChangeOutputs` reads from the deploy
|
|
65963
|
+
* engine: each failed OUTPUT present with the value `undefined`
|
|
65964
|
+
* (issue #3101). This resolver drops a failed key while the deploy keeps it,
|
|
65965
|
+
* and the merge tells "failed, keep the stored value" from "not produced,
|
|
65966
|
+
* remove it" by exactly that presence. A fresh null-prototype bag, like both
|
|
65967
|
+
* bags it stands between, so a resolved `__proto__` export alias survives the
|
|
65968
|
+
* copy as a data key.
|
|
65969
|
+
*/
|
|
65970
|
+
function withFailedOutputsUndefined(outputs, failedOutputKeys) {
|
|
65971
|
+
const bag = Object.create(null);
|
|
65972
|
+
for (const [key, value] of Object.entries(outputs)) bag[key] = value;
|
|
65973
|
+
for (const key of failedOutputKeys) bag[key] = void 0;
|
|
65974
|
+
return bag;
|
|
65975
|
+
}
|
|
65976
|
+
/**
|
|
65806
65977
|
* Resolve a nested-stack child's input `Parameters` (declared on the parent's
|
|
65807
65978
|
* `AWS::CloudFormation::Stack` row under `Properties.Parameters`) to scalar
|
|
65808
65979
|
* values against the PARENT's deployed state + already-resolved parameters.
|
|
@@ -66312,7 +66483,7 @@ function renderOutputChangeLines(outputChanges, logFn) {
|
|
|
66312
66483
|
*/
|
|
66313
66484
|
function renderDiffTree(node, isRoot, logFn) {
|
|
66314
66485
|
const hasChanges = nodeHasChanges(node);
|
|
66315
|
-
if (hasChanges || node.blocking.length > 0) logFn(isRoot ? `\nStack ${node.stackName}:` : `\nNested stack: ${node.displayName}`);
|
|
66486
|
+
if (hasChanges || node.blocking.length > 0) logFn(isRoot ? `\nStack ${stripControlChars(node.stackName)}:` : `\nNested stack: ${stripControlChars(node.displayName)}`);
|
|
66316
66487
|
if (hasChanges) {
|
|
66317
66488
|
const { create, update, delete: del } = renderChangeLines(node.changes, logFn, node.ccApiRoutes, node.adoptedOrphans);
|
|
66318
66489
|
const outputs = renderOutputChangeLines(node.outputChanges, logFn);
|
|
@@ -66877,6 +67048,7 @@ async function scrubCommand(stacks, options) {
|
|
|
66877
67048
|
let totalStacksScrubbed = 0;
|
|
66878
67049
|
let totalStacksWithUnscrubbableKeys = 0;
|
|
66879
67050
|
let totalStacksWithUnverifiableReads = 0;
|
|
67051
|
+
let totalStacksWithUnverifiableLeaves = 0;
|
|
66880
67052
|
/**
|
|
66881
67053
|
* Stacks this `--dry-run` proceeded over with an UNREADABLE resources map
|
|
66882
67054
|
* (issue go-to-k/cdkd#3018). Tracked exactly like `indexUnreadable`: an
|
|
@@ -66958,8 +67130,12 @@ async function scrubCommand(stacks, options) {
|
|
|
66958
67130
|
if (scrubbed.recordsChanged > 0) {
|
|
66959
67131
|
totalStacksScrubbed++;
|
|
66960
67132
|
logger.info(`${options.dryRun ? "Would scrub" : "Scrubbed"} ${scrubbed.recordsChanged} resource record(s) in ${stack.stackName}`);
|
|
66961
|
-
} else if (scrubbed.secretBearingKeys === 0 && scrubbed.unverifiableReads === 0 && indexConverged === 0) logger.info(`No plaintext secrets found in ${stack.stackName}`);
|
|
67133
|
+
} else if (scrubbed.secretBearingKeys === 0 && scrubbed.unverifiableReads === 0 && scrubbed.unverifiableLeaves === 0 && indexConverged === 0) logger.info(`No plaintext secrets found in ${stack.stackName}`);
|
|
66962
67134
|
if (scrubbed.malformedResources) malformedRecords.push(stack.stackName);
|
|
67135
|
+
if (scrubbed.unverifiableLeaves > 0) {
|
|
67136
|
+
totalStacksWithUnverifiableLeaves++;
|
|
67137
|
+
logger.warn(`${scrubbed.unverifiableLeaves} scan(s) in ${stack.stackName} were ABANDONED mid-value because a {{resolve:...}} reference could not be resolved (see the warnings above). The resolver stops at the first failing token, so a real secret after it in the same value recorded no needle — this stack is not reported clean. The count is per abandoned SCAN, not per secret: one record can contribute more than once (an output whose Export.Name and Value both abandon), and one abandoned scan can hide several unexamined leaves.`);
|
|
67138
|
+
}
|
|
66963
67139
|
if (scrubbed.unverifiableReads > 0) {
|
|
66964
67140
|
totalStacksWithUnverifiableReads++;
|
|
66965
67141
|
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.`);
|
|
@@ -66990,7 +67166,7 @@ async function scrubCommand(stacks, options) {
|
|
|
66990
67166
|
totalIndexEntriesUnexamined += unexamined.length;
|
|
66991
67167
|
logger.info(`Exports index (${indexRegion}): ${unexamined.length} entr${unexamined.length === 1 ? "y is" : "ies are"} published by a producer this run did not scrub, so nothing here read ${unexamined.length === 1 ? "its" : "their"} value. Coverage composes across the apps sharing this bucket and region — run each app's own cdkd scrub to reach its entries.`);
|
|
66992
67168
|
}
|
|
66993
|
-
if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0 && totalStacksWithUnverifiableReads === 0 && totalIndexEntriesConverged === 0 && indexUnwritten.length === 0 && indexUnreadable.length === 0 && malformedRecords.length === 0) {
|
|
67169
|
+
if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0 && totalStacksWithUnverifiableReads === 0 && totalStacksWithUnverifiableLeaves === 0 && totalIndexEntriesConverged === 0 && indexUnwritten.length === 0 && indexUnreadable.length === 0 && malformedRecords.length === 0) {
|
|
66994
67170
|
if (failures.length === 0) {
|
|
66995
67171
|
logger.info("\nNo plaintext secrets found in any target stack state. Nothing to scrub.");
|
|
66996
67172
|
return;
|
|
@@ -67000,6 +67176,7 @@ async function scrubCommand(stacks, options) {
|
|
|
67000
67176
|
}
|
|
67001
67177
|
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.` : "";
|
|
67002
67178
|
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.` : "";
|
|
67179
|
+
const leafNote = totalStacksWithUnverifiableLeaves > 0 ? ` ${totalStacksWithUnverifiableLeaves} stack(s) had a {{resolve:...}} scan ABANDONED mid-value, so part of their state was never examined (see the warnings above).` : "";
|
|
67003
67180
|
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.` : "";
|
|
67004
67181
|
const indexNote = (() => {
|
|
67005
67182
|
const parts = [];
|
|
@@ -67009,20 +67186,20 @@ async function scrubCommand(stacks, options) {
|
|
|
67009
67186
|
return parts.join("");
|
|
67010
67187
|
})();
|
|
67011
67188
|
if (options.dryRun) {
|
|
67012
|
-
if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote}${unverifiableNote}${indexNote}${failureNote} ROTATE any exposed secret in Secrets Manager.`);
|
|
67013
|
-
else logger.info(`\nPlan: no state record can be rewritten.${keyNote}${unverifiableNote}${indexNote}${failureNote} ROTATE any exposed secret.`);
|
|
67189
|
+
if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote}${unverifiableNote}${leafNote}${indexNote}${failureNote} ROTATE any exposed secret in Secrets Manager.`);
|
|
67190
|
+
else logger.info(`\nPlan: no state record can be rewritten.${keyNote}${unverifiableNote}${leafNote}${indexNote}${failureNote} ROTATE any exposed secret.`);
|
|
67014
67191
|
if (failures.length > 0) throw scrubStacksFailedError(failures);
|
|
67015
67192
|
if (indexUnwritten.length > 0 || indexUnreadable.length > 0) throw exportIndexIncompleteError(indexUnwritten, indexUnreadable, true);
|
|
67016
67193
|
if (malformedRecords.length > 0) throw malformedRecordsAuditedError(malformedRecords);
|
|
67017
67194
|
if (options.fail) throw new ScrubNeededError();
|
|
67018
67195
|
return;
|
|
67019
67196
|
}
|
|
67020
|
-
if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The CURRENT state.json no longer holds the plaintext. Where the state bucket is VERSIONED — which cdkd bootstrap enables — the pre-scrub body survives as a noncurrent version, stays readable with GetObject and a VersionId, and scrub does not purge it. So a value that was ever persisted must be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating); rotation is what makes any surviving version harmless.${keyNote}${unverifiableNote}${indexNote}${failureNote}`);
|
|
67021
|
-
else logger.info(`\nNo state record was rewritten.${keyNote}${unverifiableNote}${indexNote}${failureNote} ROTATE any exposed secret.`);
|
|
67197
|
+
if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The CURRENT state.json no longer holds the plaintext. Where the state bucket is VERSIONED — which cdkd bootstrap enables — the pre-scrub body survives as a noncurrent version, stays readable with GetObject and a VersionId, and scrub does not purge it. So a value that was ever persisted must be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating); rotation is what makes any surviving version harmless.${keyNote}${unverifiableNote}${leafNote}${indexNote}${failureNote}`);
|
|
67198
|
+
else logger.info(`\nNo state record was rewritten.${keyNote}${unverifiableNote}${leafNote}${indexNote}${failureNote} ROTATE any exposed secret.`);
|
|
67022
67199
|
if (failures.length > 0) throw scrubStacksFailedError(failures);
|
|
67023
67200
|
if (indexUnwritten.length > 0 || indexUnreadable.length > 0) throw exportIndexIncompleteError(indexUnwritten, indexUnreadable, false);
|
|
67024
67201
|
if (malformedRecords.length > 0) throw malformedRecordsAuditedError(malformedRecords);
|
|
67025
|
-
if (options.fail && (totalStacksWithUnscrubbableKeys > 0 || totalStacksWithUnverifiableReads > 0)) throw new ScrubNeededError();
|
|
67202
|
+
if (options.fail && (totalStacksWithUnscrubbableKeys > 0 || totalStacksWithUnverifiableReads > 0 || totalStacksWithUnverifiableLeaves > 0)) throw new ScrubNeededError();
|
|
67026
67203
|
}
|
|
67027
67204
|
/**
|
|
67028
67205
|
* Every secret plaintext this run recorded, from ANY position in the template
|
|
@@ -68306,6 +68483,205 @@ function isNamelessDynamicReferenceFailure(err) {
|
|
|
68306
68483
|
return err instanceof Error && errorCauseChain(err).some((link) => link instanceof Error && link.message.includes(DYNAMIC_REFERENCE_PREFIX) && NAMELESS_DYNAMIC_REFERENCE_MARKERS.some((marker) => link.message.includes(marker)));
|
|
68307
68484
|
}
|
|
68308
68485
|
/**
|
|
68486
|
+
* The per-record line each counting site prints. Deliberately ONE SHORT
|
|
68487
|
+
* SENTENCE, and that is a fix rather than a style choice: the first cut emitted
|
|
68488
|
+
* a ~440-character paragraph PER RECORD at default verbosity, and the arm it
|
|
68489
|
+
* fires on is one a single `Default`-less parameter triggers for every resource
|
|
68490
|
+
* in the stack at once. Dozens of identical paragraphs bury the record names,
|
|
68491
|
+
* which are the only part a reader cannot reconstruct.
|
|
68492
|
+
*
|
|
68493
|
+
* The explanation is emitted ONCE PER STACK instead, by
|
|
68494
|
+
* {@link abandonedScanStackNote}.
|
|
68495
|
+
*
|
|
68496
|
+
* The CAUSE is absent on both arms: the sibling log at each site already prints
|
|
68497
|
+
* it through `maskSecretsInText`, and a resolver error echoes what it was
|
|
68498
|
+
* handed — which, after `pinCrossRegionSecrets`, can be a foreign plaintext.
|
|
68499
|
+
*
|
|
68500
|
+
* RESIDUAL on the `subject` the call sites pass. They compose
|
|
68501
|
+
* `maskSecretsInText(... displaySafe(id) ...)` — sanitise, then mask — and
|
|
68502
|
+
* `displaySafe` REPLACES a control character with a space rather than deleting
|
|
68503
|
+
* it, so it cannot JOIN a split needle (the go-to-k/cdkd#2874 direction) but it
|
|
68504
|
+
* can BREAK one, and it leaves `U+200B`-`U+200D` / `U+FEFF` untouched. A secret
|
|
68505
|
+
* would have to sit inside a template logical id or Outputs key to be reachable,
|
|
68506
|
+
* which is why `secretSafeKeyDisplay` is not used here — that one is for RESOLVED
|
|
68507
|
+
* state-bag keys. Recorded rather than closed: go-to-k/cdkd#3178 security review.
|
|
68508
|
+
*/
|
|
68509
|
+
function abandonedScanWarning(subject, verdict) {
|
|
68510
|
+
return verdict === "count" ? `The {{resolve:...}} scan of ${subject} was ABANDONED — this record is NOT certified clean.` : `The {{resolve:...}} scan of ${subject} was cut short by a TEMPLATE problem — this record is NOT certified clean.`;
|
|
68511
|
+
}
|
|
68512
|
+
/**
|
|
68513
|
+
* The one-per-stack explanation behind {@link abandonedScanWarning}'s lines.
|
|
68514
|
+
*
|
|
68515
|
+
* Two arms, because they need opposite things said. The `count` arm is a
|
|
68516
|
+
* FINDING and gates; the reader needs the cause, which is verbose-only. The
|
|
68517
|
+
* `warn` arm does NOT gate, and a reader seeing "NOT certified clean" beside a
|
|
68518
|
+
* green exit has no way to tell a passing run from a broken one unless it says
|
|
68519
|
+
* so.
|
|
68520
|
+
*
|
|
68521
|
+
* The `warn` arm's remedy is deliberately two-sided. The dominant population is
|
|
68522
|
+
* a parameter with no `Default` — legal CloudFormation, no defect to fix — so
|
|
68523
|
+
* telling the reader only to "fix the template reference" describes a repair
|
|
68524
|
+
* that does not exist for them. It names what they CAN do and then says plainly
|
|
68525
|
+
* that scrub may simply not be able to certify the record.
|
|
68526
|
+
*/
|
|
68527
|
+
function abandonedScanStackNote(verdict, records) {
|
|
68528
|
+
const subject = `${records} record(s) above`;
|
|
68529
|
+
return verdict === "count" ? `${subject}: the resolver stops at the first {{resolve:...}} token it cannot fetch, so any secret after it in the same value recorded no needle and could not be rewritten. Re-run with --verbose for the cause of each.` : `${subject}: the scan stopped on something cdkd scrub cannot resolve with template defaults alone — an unresolvable Ref or Fn::GetAtt, a parameter with no Default, or a reference whose own argument still holds an unsubstituted \${...}. These do NOT fail --fail, because scrub takes no --parameters and a gate failure here could not be cleared. Give the parameter a Default, or resolve the reference, to let scrub certify these records; otherwise accept that it cannot.`;
|
|
68530
|
+
}
|
|
68531
|
+
/**
|
|
68532
|
+
* The verdict a counting site acts on: `count` (a finding — warn AND raise the
|
|
68533
|
+
* exit code), `warn` (say it, but do not gate) or `silent`.
|
|
68534
|
+
*
|
|
68535
|
+
* The middle arm exists because the EXCLUSION has a cost in
|
|
68536
|
+
* go-to-k/cdkd#3160's OWN harm direction, and two round-4 reviewers found it
|
|
68537
|
+
* independently. `resolver.resolve` walks the whole properties bag and ONE
|
|
68538
|
+
* throw aborts the rest of it — so a bag holding `{A: {Ref: NoSuchThing},
|
|
68539
|
+
* B: '{{resolve:secretsmanager:prod/db:...}}'}` has `B` abandoned by a failure
|
|
68540
|
+
* that is nothing to do with `B`. Excluding it means `B` records no needle, its
|
|
68541
|
+
* legacy plaintext is never rewritten, and the stack can still print
|
|
68542
|
+
* `No plaintext secrets found` at exit 0. Reachable accidentally (one
|
|
68543
|
+
* `Default`-less parameter empties the bag, so every `{Ref: <param>}` throws)
|
|
68544
|
+
* and, on a repo whose CI runs `--dry-run --fail`, defeatable on purpose by
|
|
68545
|
+
* adding one dangling `Ref` ahead of the secret. That loss is REAL and is not
|
|
68546
|
+
* repaired by making it visible — go-to-k/cdkd#3196 tracks scoping the resolve
|
|
68547
|
+
* per property so the sibling reference is still scanned.
|
|
68548
|
+
*
|
|
68549
|
+
* Counting it is not the answer — that is the round-2 blocker, an unclearable
|
|
68550
|
+
* red gate on a healthy stack. What the two failure modes do NOT share is
|
|
68551
|
+
* VISIBILITY: an operator can act on a warning naming the record, and a warning
|
|
68552
|
+
* gates nothing. So the excluded arm still speaks, and only the exit code is
|
|
68553
|
+
* withheld.
|
|
68554
|
+
*
|
|
68555
|
+
* **The ORDER of the three tests is load-bearing, and getting it wrong is how
|
|
68556
|
+
* round 5 nearly shipped a silent regression.** An earlier cut asked
|
|
68557
|
+
* `carriesFetchableDynamicReference` FIRST and returned `silent` on a miss,
|
|
68558
|
+
* which made a gate-downgrade test into a universal silencer. Because
|
|
68559
|
+
* `DYNAMIC_REFERENCE_INNER_CHAR` is `[^}]`, a token with a placeholder
|
|
68560
|
+
* ANYWHERE in it fails that test — including the dominant CDK spelling, where
|
|
68561
|
+
* the reference is assembled by an `Fn::Sub` over parameters that DO have
|
|
68562
|
+
* defaults:
|
|
68563
|
+
*
|
|
68564
|
+
* ```json
|
|
68565
|
+
* {"A": {"Fn::Sub": "{{resolve:ssm-secure:/deleted/${Env}/p}}"},
|
|
68566
|
+
* "B": {"Fn::Sub": "{{resolve:secretsmanager:${Db}:SecretString:password}}"}}
|
|
68567
|
+
* ```
|
|
68568
|
+
*
|
|
68569
|
+
* Those assemble fine, `A` raises a RAW `ParameterNotFound`, `B` is abandoned
|
|
68570
|
+
* and records no needle — and the earlier order printed nothing at all. That is
|
|
68571
|
+
* go-to-k/cdkd#3160's own headline repro, silenced by the fix for a different
|
|
68572
|
+
* false positive.
|
|
68573
|
+
*
|
|
68574
|
+
* So the tests are separated by what each one is EVIDENCE of:
|
|
68575
|
+
*
|
|
68576
|
+
* - `carriesDynamicReference` — did this bag hold a reference at all? If not,
|
|
68577
|
+
* nothing was lost and the pre-existing `logger.debug` is the right level.
|
|
68578
|
+
* This, and only this, decides SILENCE.
|
|
68579
|
+
* - `isTemplateShapeResolutionFailure` / `carriesFetchableDynamicReference` —
|
|
68580
|
+
* is the abandonment one an operator could act on? If not, it still gets said;
|
|
68581
|
+
* it just does not gate. These decide the EXIT CODE, never the visibility.
|
|
68582
|
+
*/
|
|
68583
|
+
function abandonedScanVerdict(source, err) {
|
|
68584
|
+
if (!carriesDynamicReference(source)) return "silent";
|
|
68585
|
+
if (isTemplateShapeResolutionFailure(err) || !carriesFetchableDynamicReference(source)) return "warn";
|
|
68586
|
+
return "count";
|
|
68587
|
+
}
|
|
68588
|
+
/**
|
|
68589
|
+
* `source` carries a `{{resolve:...}}` token cdkd could actually have FETCHED —
|
|
68590
|
+
* i.e. one whose argument holds no surviving `${...}` placeholder.
|
|
68591
|
+
*
|
|
68592
|
+
* `carriesDynamicReference` alone is too generous here, and the gap is not
|
|
68593
|
+
* theoretical: scrub resolves with `bestEffort`, under which
|
|
68594
|
+
* `rethrowStructuralSubFailure` returns EARLY, so an `Fn::Sub` over an unbound
|
|
68595
|
+
* placeholder does not throw — it warn-and-KEEPS the literal `${Field}`. The
|
|
68596
|
+
* assembled token is then a request for a JSON key literally named `${Field}`,
|
|
68597
|
+
* which fails as `Dynamic reference: key '${Field}' not found in secret '<id>'`.
|
|
68598
|
+
* That is not a fetch that failed; it is a token that was never fetchable,
|
|
68599
|
+
* because scrub has only template DEFAULTS and accepts no `--parameters`. It is
|
|
68600
|
+
* ALSO not excludable by message, since `key ... not found` is a class
|
|
68601
|
+
* go-to-k/cdkd#3160 asks to COUNT when the key was real. So the discriminator
|
|
68602
|
+
* has to be the TOKEN, not the error.
|
|
68603
|
+
*
|
|
68604
|
+
* `some`, not `every`: a bag holding one placeholder-bearing token and one
|
|
68605
|
+
* fully substituted token still counts, because the second one genuinely was
|
|
68606
|
+
* fetchable and its scan genuinely stopped.
|
|
68607
|
+
*
|
|
68608
|
+
* WHAT THIS DOES AND DOES NOT BUY, since round 5 shipped a cut that confused
|
|
68609
|
+
* the two. A `false` here downgrades the GATE only — the record is still named
|
|
68610
|
+
* at default verbosity by the `warn` arm. It must never decide SILENCE: the
|
|
68611
|
+
* inner class is `[^}]`, so a placeholder ANYWHERE in a token fails this test,
|
|
68612
|
+
* and the reference assembled by an `Fn::Sub` over DEFAULTED parameters is the
|
|
68613
|
+
* dominant CDK spelling rather than an edge case.
|
|
68614
|
+
*
|
|
68615
|
+
* STATED RESIDUALS, both UNDER-counts against the gate, neither a silence:
|
|
68616
|
+
*
|
|
68617
|
+
* - A two-argument `Fn::Sub` whose placeholder IS bound
|
|
68618
|
+
* (`{'Fn::Sub': ['{{resolve:ssm:${N}}}', {N: 'x'}]}`) substitutes fine and
|
|
68619
|
+
* yields a fetchable token, but the raw bag still spells `${`, so a real
|
|
68620
|
+
* fetch failure there warns instead of gating.
|
|
68621
|
+
* - The same for a single-argument `Fn::Sub` over a defaulted parameter, and
|
|
68622
|
+
* for a placeholder mid-token, which yields no token at all.
|
|
68623
|
+
*
|
|
68624
|
+
* And one OVER-count, recorded for symmetry: this predicate is BAG-scoped while
|
|
68625
|
+
* the failure is TOKEN-scoped, so a bag holding a placeholder-bearing token
|
|
68626
|
+
* BESIDE a genuinely fetchable one returns `true`, and a `key '${Field}' not
|
|
68627
|
+
* found` raised for the first is gated as if it came from the second. Safety
|
|
68628
|
+
* is in the right direction (a gate that fires, not a plaintext under `clean`),
|
|
68629
|
+
* and same-bag co-location bounds it.
|
|
68630
|
+
*
|
|
68631
|
+
* go-to-k/cdkd#3181 is the fix that removes the need for any of these proxies.
|
|
68632
|
+
*/
|
|
68633
|
+
function carriesFetchableDynamicReference(source) {
|
|
68634
|
+
if (typeof source === "string") return dynamicReferenceTokens(source).some((token) => !token.includes("${"));
|
|
68635
|
+
if (Array.isArray(source)) return source.some(carriesFetchableDynamicReference);
|
|
68636
|
+
if (source !== null && typeof source === "object") return Object.values(source).some(carriesFetchableDynamicReference);
|
|
68637
|
+
return false;
|
|
68638
|
+
}
|
|
68639
|
+
/**
|
|
68640
|
+
* `err` is cdkd's OWN refusal to resolve a template SHAPE — a `Ref`, an
|
|
68641
|
+
* `Fn::GetAtt`, a parameter binding — rather than a failure to fetch a
|
|
68642
|
+
* `{{resolve:...}}` reference.
|
|
68643
|
+
*
|
|
68644
|
+
* Matched on cdkd-authored prose, which is sound here for the reason the
|
|
68645
|
+
* inverse is not: every string below is built by `intrinsic-function-resolver.ts`
|
|
68646
|
+
* itself, so it changes only when this repo changes it, and the fence in
|
|
68647
|
+
* `tests/unit/cli/scrub-abandoned-scan-origin.test.ts` reds when a throw site
|
|
68648
|
+
* there stops matching. An AWS-authored message could never carry that
|
|
68649
|
+
* guarantee.
|
|
68650
|
+
*/
|
|
68651
|
+
function isTemplateShapeResolutionFailure(err) {
|
|
68652
|
+
if (!(err instanceof Error)) return false;
|
|
68653
|
+
return TEMPLATE_SHAPE_FAILURE_PATTERNS.some((pattern) => pattern.test(err.message));
|
|
68654
|
+
}
|
|
68655
|
+
/**
|
|
68656
|
+
* The cdkd-authored messages {@link isTemplateShapeResolutionFailure} excludes,
|
|
68657
|
+
* ANCHORED. A substring test cannot be used here and the reason is concrete:
|
|
68658
|
+
* `'Parameter '` alone also matches the SDK's own `Parameter /deleted not
|
|
68659
|
+
* found.` — `ParameterNotFound`, this issue's HEADLINE repro — so the loose
|
|
68660
|
+
* spelling would have excluded the very case the positional half exists to
|
|
68661
|
+
* catch. Each pattern must stay tight enough that no AWS-authored message can
|
|
68662
|
+
* satisfy it.
|
|
68663
|
+
*
|
|
68664
|
+
* Deliberately just these THREE, not every shape failure the resolver can
|
|
68665
|
+
* raise. They are the ones that fire EN MASSE on a healthy stack:
|
|
68666
|
+
* `resolveParameters` is caught wholesale by `scrubStack`, so one
|
|
68667
|
+
* `Default`-less parameter empties the whole bag and every `{Ref: <param>}`
|
|
68668
|
+
* throws; and `resolveGetAtt` refuses on the same condition as `resolveRef`,
|
|
68669
|
+
* which a branch adding a not-yet-deployed resource hits for every
|
|
68670
|
+
* `Fn::GetAtt` to it. A rarer shape failure (an `Fn::Select` over a
|
|
68671
|
+
* non-array, say) is a genuine template defect, and counting a leaf whose
|
|
68672
|
+
* scan it abandoned is not wrong — the scan really did stop. Over-counting
|
|
68673
|
+
* THERE costs a warn on a broken template; over-counting on the three below
|
|
68674
|
+
* would cost a red CI gate on a working one.
|
|
68675
|
+
*
|
|
68676
|
+
* Per-pattern throw sites, and the fence that keeps them true, are in
|
|
68677
|
+
* `tests/unit/cli/scrub-abandoned-scan-origin.test.ts`.
|
|
68678
|
+
*/
|
|
68679
|
+
const TEMPLATE_SHAPE_FAILURE_PATTERNS = [
|
|
68680
|
+
/^Ref \S+ not found$/,
|
|
68681
|
+
/^Resource \S+ not found for Fn::GetAtt$/,
|
|
68682
|
+
/^Parameter \S+ is required but no value was provided/
|
|
68683
|
+
];
|
|
68684
|
+
/**
|
|
68309
68685
|
* A secrets map with its OWN identity whose entries ARE `target`'s (issue
|
|
68310
68686
|
* #2531): every read and write goes to `target`; nothing is stored here.
|
|
68311
68687
|
*
|
|
@@ -68736,6 +69112,33 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
68736
69112
|
*/
|
|
68737
69113
|
const orphanSecrets = /* @__PURE__ */ new Map();
|
|
68738
69114
|
const prePassFindings = { unverifiable: [] };
|
|
69115
|
+
/**
|
|
69116
|
+
* Leaves whose dynamic-reference scan was ABANDONED mid-token (issue
|
|
69117
|
+
* go-to-k/cdkd#3160). A counted finding, not a refusal — see
|
|
69118
|
+
* `abandonedScanVerdict` for why these cannot refuse.
|
|
69119
|
+
*/
|
|
69120
|
+
let unverifiableLeaves = 0;
|
|
69121
|
+
let ungateableAbandonedScans = 0;
|
|
69122
|
+
/**
|
|
69123
|
+
* ONE note per stack per arm. The per-record lines are deliberately short
|
|
69124
|
+
* (see `abandonedScanWarning`), so this is where the explanation lives, and
|
|
69125
|
+
* emitting it after the loops rather than at the first occurrence is what
|
|
69126
|
+
* lets it say HOW MANY records it is explaining.
|
|
69127
|
+
*
|
|
69128
|
+
* A CLOSURE called at BOTH returns, not a block at the late one. The early
|
|
69129
|
+
* `totalSecrets === 0` return is the DOMINANT path for an abandoned scan --
|
|
69130
|
+
* an abandoned scan records no needle, which is the whole defect -- so a
|
|
69131
|
+
* single late emission left the note unreachable for exactly the case it
|
|
69132
|
+
* exists to explain. `emitted` because the two call sites are exclusive
|
|
69133
|
+
* today and a third would not be.
|
|
69134
|
+
*/
|
|
69135
|
+
let notesEmitted = false;
|
|
69136
|
+
const emitAbandonedScanNotes = () => {
|
|
69137
|
+
if (notesEmitted) return;
|
|
69138
|
+
notesEmitted = true;
|
|
69139
|
+
if (unverifiableLeaves > 0) logger.warn(abandonedScanStackNote("count", unverifiableLeaves));
|
|
69140
|
+
if (ungateableAbandonedScans > 0) logger.warn(abandonedScanStackNote("warn", ungateableAbandonedScans));
|
|
69141
|
+
};
|
|
68739
69142
|
try {
|
|
68740
69143
|
const loaded = await stateBackend.getState(stack.stackName, region);
|
|
68741
69144
|
if (!loaded) {
|
|
@@ -68745,6 +69148,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
68745
69148
|
secretsFound: 0,
|
|
68746
69149
|
secretBearingKeys: 0,
|
|
68747
69150
|
unverifiableReads: 0,
|
|
69151
|
+
unverifiableLeaves: 0,
|
|
68748
69152
|
exportNameDisplay: (name) => secretSafeKeyDisplay(name, outputSecrets)
|
|
68749
69153
|
};
|
|
68750
69154
|
}
|
|
@@ -68818,6 +69222,12 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
68818
69222
|
await resolver.resolve(resolveInput, resourceContext);
|
|
68819
69223
|
} catch (err) {
|
|
68820
69224
|
if (isRegionAmbiguousRefusal(err) || isNamelessDynamicReferenceFailure(err)) throw err;
|
|
69225
|
+
const leafVerdict = abandonedScanVerdict(resolveInput, err);
|
|
69226
|
+
if (leafVerdict !== "silent") {
|
|
69227
|
+
if (leafVerdict === "count") unverifiableLeaves++;
|
|
69228
|
+
else ungateableAbandonedScans++;
|
|
69229
|
+
logger.warn(maskSecretsInText(abandonedScanWarning(`resource '${displaySafe(logicalId)}'`, leafVerdict), recordedSecretValues));
|
|
69230
|
+
}
|
|
68821
69231
|
logger.debug(`Resolution of ${logicalId} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}`);
|
|
68822
69232
|
}
|
|
68823
69233
|
perResourceTemplateProps.set(logicalId, templateResource.Properties);
|
|
@@ -68840,6 +69250,12 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
68840
69250
|
await resolver.resolve(resolveInput, resolverContext(recordedSecretValues));
|
|
68841
69251
|
} catch (err) {
|
|
68842
69252
|
if (isRegionAmbiguousRefusal(err) || isNamelessDynamicReferenceFailure(err)) throw err;
|
|
69253
|
+
const leafVerdict = abandonedScanVerdict(resolveInput, err);
|
|
69254
|
+
if (leafVerdict !== "silent") {
|
|
69255
|
+
if (leafVerdict === "count") unverifiableLeaves++;
|
|
69256
|
+
else ungateableAbandonedScans++;
|
|
69257
|
+
logger.warn(maskSecretsInText(abandonedScanWarning(`orphan record '${displaySafe(record.logicalId)}'`, leafVerdict), recordedSecretValues));
|
|
69258
|
+
}
|
|
68843
69259
|
logger.debug(`Resolution of orphan record ${record.logicalId} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}`);
|
|
68844
69260
|
}
|
|
68845
69261
|
}
|
|
@@ -68863,6 +69279,12 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
68863
69279
|
exportName = await resolver.resolve(nameSource, nameContext);
|
|
68864
69280
|
} catch (err) {
|
|
68865
69281
|
if (isRegionAmbiguousRefusal(err) || isNamelessDynamicReferenceFailure(err)) throw err;
|
|
69282
|
+
const leafVerdict = abandonedScanVerdict(nameSource, err);
|
|
69283
|
+
if (leafVerdict !== "silent") {
|
|
69284
|
+
if (leafVerdict === "count") unverifiableLeaves++;
|
|
69285
|
+
else ungateableAbandonedScans++;
|
|
69286
|
+
logger.warn(maskSecretsInText(abandonedScanWarning(`the Export.Name of output '${displaySafe(name)}'`, leafVerdict), outputSecrets));
|
|
69287
|
+
}
|
|
68866
69288
|
nameFailed = true;
|
|
68867
69289
|
nameError = err;
|
|
68868
69290
|
}
|
|
@@ -68901,6 +69323,12 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
68901
69323
|
await resolver.resolve(valueSource, valueContext);
|
|
68902
69324
|
} catch (err) {
|
|
68903
69325
|
if (isRegionAmbiguousRefusal(err) || isNamelessDynamicReferenceFailure(err)) throw err;
|
|
69326
|
+
const leafVerdict = abandonedScanVerdict(valueSource, err);
|
|
69327
|
+
if (leafVerdict !== "silent") {
|
|
69328
|
+
if (leafVerdict === "count") unverifiableLeaves++;
|
|
69329
|
+
else ungateableAbandonedScans++;
|
|
69330
|
+
logger.warn(maskSecretsInText(abandonedScanWarning(`output '${displaySafe(name)}'`, leafVerdict), outputSecrets));
|
|
69331
|
+
}
|
|
68904
69332
|
logger.debug(`Resolution of output ${name} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}`);
|
|
68905
69333
|
}
|
|
68906
69334
|
}
|
|
@@ -68914,15 +69342,19 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
68914
69342
|
logger.warn(secretBearingStateKeyWarning(stack.stackName, display));
|
|
68915
69343
|
}
|
|
68916
69344
|
const totalSecrets = outputSecrets.size + [...perResourceSecrets.values()].reduce((n, m) => n + m.size, 0) + [...orphanSecrets.values()].reduce((n, m) => n + m.size, 0);
|
|
68917
|
-
if (totalSecrets === 0)
|
|
68918
|
-
|
|
68919
|
-
|
|
68920
|
-
|
|
68921
|
-
|
|
68922
|
-
|
|
68923
|
-
|
|
68924
|
-
|
|
68925
|
-
|
|
69345
|
+
if (totalSecrets === 0) {
|
|
69346
|
+
emitAbandonedScanNotes();
|
|
69347
|
+
return {
|
|
69348
|
+
recordsChanged: 0,
|
|
69349
|
+
secretsFound: 0,
|
|
69350
|
+
secretBearingKeys: secretBearingKeys.length,
|
|
69351
|
+
unverifiableReads: prePassFindings.unverifiable.length,
|
|
69352
|
+
unverifiableLeaves,
|
|
69353
|
+
...malformedResources ? { malformedResources } : {},
|
|
69354
|
+
outputs: state.outputs,
|
|
69355
|
+
exportNameDisplay: (name) => secretSafeKeyDisplay(name, outputSecrets)
|
|
69356
|
+
};
|
|
69357
|
+
}
|
|
68926
69358
|
let recordsChanged = 0;
|
|
68927
69359
|
const newResources = {};
|
|
68928
69360
|
for (const [logicalId, record] of Object.entries(state.resources ?? {})) {
|
|
@@ -68951,6 +69383,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
68951
69383
|
};
|
|
68952
69384
|
});
|
|
68953
69385
|
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, orphanSecrets));
|
|
69386
|
+
emitAbandonedScanNotes();
|
|
68954
69387
|
if (JSON.stringify(newOutputs) !== JSON.stringify(state.outputs)) recordsChanged++;
|
|
68955
69388
|
if (recordsChanged > 0 && !opts.dryRun) {
|
|
68956
69389
|
const { skippedOutputs: _droppedByScrub, ...carriedState } = state;
|
|
@@ -68968,6 +69401,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
68968
69401
|
secretsFound: totalSecrets,
|
|
68969
69402
|
secretBearingKeys: secretBearingKeys.length,
|
|
68970
69403
|
unverifiableReads: prePassFindings.unverifiable.length,
|
|
69404
|
+
unverifiableLeaves,
|
|
68971
69405
|
...malformedResources ? { malformedResources } : {},
|
|
68972
69406
|
outputs: newOutputs,
|
|
68973
69407
|
exportNameDisplay: (name) => secretSafeKeyDisplay(name, outputSecrets)
|
|
@@ -75430,7 +75864,13 @@ async function buildCdkdStateStackTree(rootStackName, region, stateBackend, pref
|
|
|
75430
75864
|
}
|
|
75431
75865
|
async function walkCdkdStateStackTree(stackName, region, state, stateBackend) {
|
|
75432
75866
|
const nestedChildren = /* @__PURE__ */ new Map();
|
|
75433
|
-
|
|
75867
|
+
if (!hasReadableResources(state)) return {
|
|
75868
|
+
stackName,
|
|
75869
|
+
region,
|
|
75870
|
+
state,
|
|
75871
|
+
nestedChildren
|
|
75872
|
+
};
|
|
75873
|
+
for (const [logicalId, entry] of Object.entries(state.resources)) {
|
|
75434
75874
|
if (entry?.resourceType !== "AWS::CloudFormation::Stack") continue;
|
|
75435
75875
|
const childStackName = `${stackName}~${logicalId}`;
|
|
75436
75876
|
const childResult = await stateBackend.getState(childStackName, region);
|
|
@@ -77556,14 +77996,74 @@ function isoTimestampOrNull(value) {
|
|
|
77556
77996
|
* PROMPT is strictly worse than one in an error, because the sentence it
|
|
77557
77997
|
* corrupts is the one the operator answers `y` to.
|
|
77558
77998
|
*
|
|
77559
|
-
*
|
|
77560
|
-
*
|
|
77561
|
-
*
|
|
77562
|
-
*
|
|
77563
|
-
* `()`
|
|
77999
|
+
* BOTH halves go through `displayIdent`, not the bare `safe()` allowlist below
|
|
78000
|
+
* (issue #3164). The allowlist closes line FORGERY and nothing else: it is the
|
|
78001
|
+
* identity on printable ASCII, and this function's ` (` / `)` is cdkd's OWN
|
|
78002
|
+
* annotation of the line, so a planted 2-segment legacy key
|
|
78003
|
+
* `cdkd/ProdStack (us-east-1)/state.json` yields a region-LESS ref whose
|
|
78004
|
+
* `stackName` is literally `ProdStack (us-east-1)` and renders BYTE-EQUAL to
|
|
78005
|
+
* the genuine `ProdStack` in `us-east-1`. A `while read -r ref` cleanup loop —
|
|
78006
|
+
* the consumer the sanitization exists for — cannot tell the two apart, so it
|
|
78007
|
+
* acts on the reference the operator believes is real and the planted record
|
|
78008
|
+
* survives the sweep. The join in the two prompt callers is a second boundary
|
|
78009
|
+
* of the same kind: they `join(', ')`, which a name carrying `, ` forges an
|
|
78010
|
+
* extra entry in -- and quoting closes that, since a space is outside
|
|
78011
|
+
* `PLAIN_IDENT`.
|
|
78012
|
+
*
|
|
78013
|
+
* A BARE `,` is NOT closed, and an earlier revision of this comment was wrong
|
|
78014
|
+
* to call it harmless: the formatter supplies the space that completes the
|
|
78015
|
+
* separator, so `ProdStack,` renders `ProdStack, (us-east-1)` and a two-target
|
|
78016
|
+
* list reads as THREE entries. Only `state refresh-observed` prints a count
|
|
78017
|
+
* beside its list; `state orphan`'s banner prints none, so there nothing on
|
|
78018
|
+
* screen contradicts the forged entry -- the worse of the two. Removing `,`
|
|
78019
|
+
* from `PLAIN_IDENT` would close it and was tried; it regresses a legitimate
|
|
78020
|
+
* IAM role ARN, whose role-name segment allows `[\w+=,.@-]`. Recorded on
|
|
78021
|
+
* go-to-k/cdkd#3179 rather than traded for that.
|
|
78022
|
+
*
|
|
78023
|
+
* `displayIdent` makes the boundary VISIBLE by JSON-quoting anything that is
|
|
78024
|
+
* not a plain identifier, so the row above reads `"ProdStack (us-east-1)"` and
|
|
78025
|
+
* no longer collides. It is applied to the REGION half too: a region is an S3
|
|
78026
|
+
* key segment (or, for a legacy record, the state body via `readLegacyRegion`),
|
|
78027
|
+
* so it is attacker-chosen in exactly the same way, and a planted region alone
|
|
78028
|
+
* renders `Decoy (x) (us-east-1)` — the same spoof from the right-hand side.
|
|
78029
|
+
*
|
|
78030
|
+
* Every legitimate row is byte-identical, because `displayIdent` is the
|
|
78031
|
+
* identity on a plain identifier and CloudFormation stack names
|
|
78032
|
+
* (`Parent~Child` included) and AWS region codes all are — so no fixture, no
|
|
78033
|
+
* script's grep and no round-trip into `cdkd state show` changes. It keeps
|
|
78034
|
+
* `safe()`'s `UNRENDERABLE` fallback for a value sanitising leaves empty, since
|
|
78035
|
+
* an empty `()` would read as "no region" rather than "a region cdkd will not
|
|
78036
|
+
* print".
|
|
78037
|
+
*
|
|
78038
|
+
* The NAME half passes `STACK_REF_MAX_CODE_POINTS` rather than taking
|
|
78039
|
+
* `displayIdent`'s 255 default, and that is load-bearing for the paragraph
|
|
78040
|
+
* above rather than a tuning knob: a cdkd state-record name is not a
|
|
78041
|
+
* CloudFormation stack name, because `deriveChildStackName` appends
|
|
78042
|
+
* `~<logicalId>` per nesting level, so a legitimate deep nested-stack child
|
|
78043
|
+
* exceeds 255 and would be rendered CUT — a byte change on a legitimate row, in
|
|
78044
|
+
* the middle of a line `while read -r ref` consumes. The REGION half keeps the
|
|
78045
|
+
* default; an AWS region code is at most 25 characters.
|
|
78046
|
+
*
|
|
78047
|
+
* The consequence is taken at ALL SIX callers, the confirmation prompt
|
|
78048
|
+
* included, rather than at the listing alone: guarding one site and leaving
|
|
78049
|
+
* five is the per-site spelling `safe()`'s own comment below records having
|
|
78050
|
+
* failed twice. In the prompt a quoted spoof is the most valuable of the six —
|
|
78051
|
+
* that sentence is the one an operator answers `y` to.
|
|
78052
|
+
*
|
|
78053
|
+
* What the quoting is NOT: shell-safe. A JSON string literal is visually
|
|
78054
|
+
* indistinguishable from a shell DOUBLE-quoted argument, in which `$(...)`,
|
|
78055
|
+
* backticks and `!` still expand — so `"Prod$(touch /tmp/pwn)" (us-east-1)`
|
|
78056
|
+
* must not be pasted into a command line, and this rendering makes no claim
|
|
78057
|
+
* that it may be. The repo's answer for a value that has to survive a command
|
|
78058
|
+
* line is elsewhere and is SUPPRESSION, not quoting: `buildForceUnlockCommand`
|
|
78059
|
+
* declines to print a command at all unless sanitisation was the identity, and
|
|
78060
|
+
* `rollback-executor.ts`'s `PASTEABLE_LOGICAL_ID` records why identity alone is
|
|
78061
|
+
* still not enough (`~user` and `=x` expand). What this function provides is a
|
|
78062
|
+
* visible BOUNDARY for a value a human is reading, which is a different job.
|
|
77564
78063
|
*/
|
|
77565
78064
|
function formatStackRefSafe(ref) {
|
|
77566
|
-
|
|
78065
|
+
const name = displayIdent(ref.stackName, { maxCodePoints: STACK_REF_MAX_CODE_POINTS });
|
|
78066
|
+
return ref.region ? `${name} (${displayIdent(ref.region)})` : name;
|
|
77567
78067
|
}
|
|
77568
78068
|
/**
|
|
77569
78069
|
* One spelling of "this value came from an S3 key or a state record, and is
|
|
@@ -77588,6 +78088,29 @@ function formatStackRefSafe(ref) {
|
|
|
77588
78088
|
* no-op on every legitimate input while an S3 key admits any UTF-8. Free-form
|
|
77589
78089
|
* text (a parser's own message, an AWS error) takes the denylist class
|
|
77590
78090
|
* instead; `display-safe.ts`'s header draws that line.
|
|
78091
|
+
*
|
|
78092
|
+
* It is the WEAKER of this file's two spellings and stays so deliberately.
|
|
78093
|
+
* `formatStackRefSafe` above uses `displayIdent` instead, because it renders
|
|
78094
|
+
* cdkd's own ` (region)` annotation right beside the value and the allowlist
|
|
78095
|
+
* cannot stop a value from carrying that annotation itself (issue #3164).
|
|
78096
|
+
* Widening this helper is tracked as go-to-k/cdkd#3179 rather than done here,
|
|
78097
|
+
* for reasons that are per-SITE rather than uniform, so read that issue's
|
|
78098
|
+
* table rather than generalising from this paragraph: most of these sites are
|
|
78099
|
+
* REFUSALS, where the surrounding `'...'` is at least SOME boundary -- but not
|
|
78100
|
+
* all of them are, and ` Region: ${safe(...)}` in the `--long` view below has
|
|
78101
|
+
* no surrounding anything -- while `Run 'cdkd deploy <stack>'` sites are
|
|
78102
|
+
* COMMAND HINTS, where quoting is not this repo's answer at all:
|
|
78103
|
+
* `buildForceUnlockCommand` SUPPRESSES the whole command instead.
|
|
78104
|
+
*
|
|
78105
|
+
* `grep safe(` does NOT enumerate the class, in BOTH directions. Some sites
|
|
78106
|
+
* spell `displaySafe(..., { asciiOnly: true })` inline rather than calling this
|
|
78107
|
+
* helper (`warnOnLiveForeignLock` renders its own `stack (region)` that way,
|
|
78108
|
+
* inside the same `state orphan` output); others sanitise NOTHING
|
|
78109
|
+
* (`stateRefreshObservedCommand` interpolates raw `listStacks` values into its
|
|
78110
|
+
* refusals, including a third `cdkd deploy` hint, and `stateDestroyCommand`
|
|
78111
|
+
* writes raw names into its `--all` confirmation list); and `describeStateKey`
|
|
78112
|
+
* (`state-file-keys.ts`) renders the same shape from raw key segments.
|
|
78113
|
+
* go-to-k/cdkd#3179 enumerates them all; a grep of this helper does not.
|
|
77591
78114
|
*/
|
|
77592
78115
|
function safe$1(value) {
|
|
77593
78116
|
return displaySafe(value, { asciiOnly: true }) || "<unrenderable>";
|
|
@@ -77841,7 +78364,9 @@ async function stateResourcesCommand(stackName, options) {
|
|
|
77841
78364
|
if (!ref.region) throw new Error(`Stack '${safe$1(stackName)}' has only a legacy state record without a region. Run 'cdkd deploy ${safe$1(stackName)}' (or any cdkd write) to migrate it to the region-scoped layout, then re-run this command.`);
|
|
77842
78365
|
const stateResult = await setup.stateBackend.getState(stackName, ref.region);
|
|
77843
78366
|
if (!stateResult) throw new Error(`No state found for stack '${safe$1(stackName)}' (${safe$1(ref.region)}) in s3://${setup.bucket}/${setup.prefix}/. Run 'cdkd state list' to see available stacks.`);
|
|
77844
|
-
|
|
78367
|
+
if (repairMalformedResourcesForReadOnly(stateResult.state)) logger.warn(malformedResourcesWarning(stackName, ref.region));
|
|
78368
|
+
if (options.long || options.json) repairRenderedContainers(stateResult.state, RESOURCES_RENDERED_CONTAINERS, stackName, ref.region, logger);
|
|
78369
|
+
const resources = stateResult.state.resources;
|
|
77845
78370
|
const details = Object.entries(resources).map(([logicalId, entry]) => {
|
|
77846
78371
|
const resource = entry ?? {};
|
|
77847
78372
|
return {
|
|
@@ -78049,9 +78574,11 @@ async function stateShowCommand(stackName, options) {
|
|
|
78049
78574
|
if (options.showNested) {
|
|
78050
78575
|
const treeWithLocks = await loadLocksForTree(await buildCdkdStateStackTree(stackName, ref.region, setup.stateBackend, stateResult.state), setup.lockManager, lockInfo);
|
|
78051
78576
|
if (options.json) {
|
|
78577
|
+
warnUnreadableTreeNodes(treeWithLocks, logger);
|
|
78052
78578
|
process.stdout.write(`${JSON.stringify(treeToShowJson(treeWithLocks), null, 2)}\n`);
|
|
78053
78579
|
return;
|
|
78054
78580
|
}
|
|
78581
|
+
repairTreeForTextRender(treeWithLocks, logger);
|
|
78055
78582
|
const lines = renderTreeWithChildren(treeWithLocks);
|
|
78056
78583
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
78057
78584
|
return;
|
|
@@ -78063,6 +78590,7 @@ async function stateShowCommand(stackName, options) {
|
|
|
78063
78590
|
}, null, 2)}\n`);
|
|
78064
78591
|
return;
|
|
78065
78592
|
}
|
|
78593
|
+
repairRecordForTextRender(stateResult.state, stackName, ref.region, logger);
|
|
78066
78594
|
process.stdout.write(`${renderStateBlock(stateResult.state, lockInfo, true).join("\n")}\n`);
|
|
78067
78595
|
} finally {
|
|
78068
78596
|
setup.dispose();
|
|
@@ -78079,6 +78607,11 @@ const SKIPPED_DIGEST_PREVIEW_LEN = 12;
|
|
|
78079
78607
|
const UNSERIALIZABLE = "(unserializable)";
|
|
78080
78608
|
/**
|
|
78081
78609
|
* The `skippedOutputs` rows a state record owes, sorted by key.
|
|
78610
|
+
*
|
|
78611
|
+
* The `?? {}` covers an ABSENT field — every pre-#2740 record, plus the writers
|
|
78612
|
+
* that drop it. A non-object one is emptied at the render entry instead
|
|
78613
|
+
* (`repairRenderedContainers`, issue go-to-k/cdkd#3187), so this walk and
|
|
78614
|
+
* {@link rendersSkippedBlock}'s cannot disagree about how many rows exist.
|
|
78082
78615
|
*/
|
|
78083
78616
|
function sortedSkippedOutputs(state) {
|
|
78084
78617
|
return Object.entries(state.skippedOutputs ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
@@ -78124,9 +78657,186 @@ function skippedOutputsLegend() {
|
|
|
78124
78657
|
];
|
|
78125
78658
|
}
|
|
78126
78659
|
/**
|
|
78660
|
+
* The order {@link repairRenderedContainers} names repaired containers in.
|
|
78661
|
+
*
|
|
78662
|
+
* Fixed here rather than taken from the order they were found in, so two
|
|
78663
|
+
* records that lost the same containers produce the same warning line and a
|
|
78664
|
+
* test can assert the text rather than a set.
|
|
78665
|
+
*/
|
|
78666
|
+
const RENDERED_CONTAINER_ORDER = [
|
|
78667
|
+
"outputs",
|
|
78668
|
+
"skippedOutputs",
|
|
78669
|
+
"attributes",
|
|
78670
|
+
"properties"
|
|
78671
|
+
];
|
|
78672
|
+
/** Every container {@link renderStateBlock}'s text walks. */
|
|
78673
|
+
const SHOW_RENDERED_CONTAINERS = new Set(RENDERED_CONTAINER_ORDER);
|
|
78674
|
+
/**
|
|
78675
|
+
* What `cdkd state resources` walks: the attribute bag alone.
|
|
78676
|
+
*
|
|
78677
|
+
* `properties` is deliberately absent — that command excludes them from every
|
|
78678
|
+
* mode (`docs/cli-state.md` says so and `stateResourcesCommand`'s own doc
|
|
78679
|
+
* repeats it), so warning about a container it never renders would report a
|
|
78680
|
+
* defect the user cannot see in the output in front of them. `outputs` and
|
|
78681
|
+
* `skippedOutputs` are absent for the same reason.
|
|
78682
|
+
*/
|
|
78683
|
+
const RESOURCES_RENDERED_CONTAINERS = /* @__PURE__ */ new Set(["attributes"]);
|
|
78684
|
+
/**
|
|
78685
|
+
* Empty every container this view walks but cannot read, and warn ONCE naming
|
|
78686
|
+
* the ones that were emptied (issue go-to-k/cdkd#3187).
|
|
78687
|
+
*
|
|
78688
|
+
* `renderStateBlock` and `stateResourcesCommand --long` walk four more
|
|
78689
|
+
* containers with `Object.entries` than the `resources` bag go-to-k/cdkd#3185
|
|
78690
|
+
* guarded, and `Object.entries` takes a string, a list, a number and a boolean
|
|
78691
|
+
* as readily as an object. Measured on the shipped bundle: `cdkd state show`
|
|
78692
|
+
* over a record whose `outputs` is a 5,000,000-character string and whose
|
|
78693
|
+
* `resources` bag is HEALTHY costs ~1616 ms and ~1010 MB RSS and emits
|
|
78694
|
+
* 5,000,002 lines; the same shape in `skippedOutputs` costs ~2436 ms and
|
|
78695
|
+
* ~2148 MB. `--show-nested` pays that per node.
|
|
78696
|
+
*
|
|
78697
|
+
* AT THE RENDER ENTRY, not at the four loops, which is the rule
|
|
78698
|
+
* `src/state/malformed-resources-bag.ts`'s header records and the measurement
|
|
78699
|
+
* behind it. Here the reason the entry wins is narrower but the same in kind:
|
|
78700
|
+
* the per-resource containers are reached from inside the resource loop, so a
|
|
78701
|
+
* guard written there is one decision repeated per resource per container, and
|
|
78702
|
+
* the `?? {}` already sitting at each of them is exactly the spelling that
|
|
78703
|
+
* looks like a guard and is not.
|
|
78704
|
+
*
|
|
78705
|
+
* **Absent is NOT malformed here, and that is a different call from the one
|
|
78706
|
+
* {@link hasReadableResources} makes.** `undefined` and `null` both mean "no
|
|
78707
|
+
* such container" for all four, and the `?? {}` at each walk has always
|
|
78708
|
+
* rendered the nullish case as empty, so none of them can fabricate a row.
|
|
78709
|
+
* An unreadable `resources` bag is judged the other way because
|
|
78710
|
+
* `Object.entries(null)` THROWS and takes the whole render with it.
|
|
78711
|
+
*
|
|
78712
|
+
* The stronger half of the reason covers TWO of the four, not all of them, and
|
|
78713
|
+
* saying so is the point: `skippedOutputs?` and `attributes?` are OPTIONAL in
|
|
78714
|
+
* `src/types/state.ts`, so cdkd itself writes records without them and warning
|
|
78715
|
+
* would be a false positive on healthy state. `outputs` and `properties` are
|
|
78716
|
+
* REQUIRED there, so a nullish one IS a malformed record and this exemption
|
|
78717
|
+
* rests on the weaker reason alone — it cannot invent a row, and it renders
|
|
78718
|
+
* identically to the empty bag the reader would otherwise see. Warning on a
|
|
78719
|
+
* nullish REQUIRED container would be defensible; it is not done because the
|
|
78720
|
+
* harm this guard exists for is fabrication (review of go-to-k/cdkd#3190).
|
|
78721
|
+
*
|
|
78722
|
+
* Callers must have repaired the `resources` bag first; both do — one line up
|
|
78723
|
+
* in `repairRecordForTextRender`, and ~30 lines up in `stateResourcesCommand`,
|
|
78724
|
+
* where the mode gate sits between them.
|
|
78725
|
+
* The bag test below is that ORDER made local rather than a fallback: an
|
|
78726
|
+
* unreadable bag is emptied by the repair, so the record renders zero resources
|
|
78727
|
+
* and owns no per-resource container to repair. It reads
|
|
78728
|
+
* {@link isReadableBag} rather than {@link hasReadableResources} on purpose —
|
|
78729
|
+
* the latter is the probe `tests/unit/cli/state-record-shape.test.ts` counts to
|
|
78730
|
+
* measure the nested walk, and a second caller here would silently inflate it.
|
|
78731
|
+
*/
|
|
78732
|
+
function repairRenderedContainers(state, walked, stackName, region, logger) {
|
|
78733
|
+
const repaired = /* @__PURE__ */ new Set();
|
|
78734
|
+
const emptyIfUnwalkable = (owner, name) => {
|
|
78735
|
+
if (!walked.has(name)) return;
|
|
78736
|
+
const container = owner[name];
|
|
78737
|
+
if (container === void 0 || container === null) return;
|
|
78738
|
+
if (isReadableBag(container)) return;
|
|
78739
|
+
owner[name] = {};
|
|
78740
|
+
repaired.add(name);
|
|
78741
|
+
};
|
|
78742
|
+
const record = state;
|
|
78743
|
+
emptyIfUnwalkable(record, "outputs");
|
|
78744
|
+
emptyIfUnwalkable(record, "skippedOutputs");
|
|
78745
|
+
if ((walked.has("attributes") || walked.has("properties")) && isReadableBag(state.resources)) for (const entry of Object.values(state.resources)) {
|
|
78746
|
+
if (!isReadableBag(entry)) continue;
|
|
78747
|
+
const fields = entry;
|
|
78748
|
+
emptyIfUnwalkable(fields, "attributes");
|
|
78749
|
+
emptyIfUnwalkable(fields, "properties");
|
|
78750
|
+
}
|
|
78751
|
+
if (repaired.size === 0) return;
|
|
78752
|
+
logger.warn(malformedRenderedContainersWarning(stackName, region, RENDERED_CONTAINER_ORDER.filter((name) => repaired.has(name))));
|
|
78753
|
+
}
|
|
78754
|
+
/**
|
|
78755
|
+
* Give the TEXT render a readable `resources` bag and readable value
|
|
78756
|
+
* containers, and warn when either was a repair rather than a no-op.
|
|
78757
|
+
*
|
|
78758
|
+
* {@link renderStateBlock} walks the bag with `Object.entries`, which takes a
|
|
78759
|
+
* string, a list, a number and a boolean as readily as an object. A planted
|
|
78760
|
+
* `"abcdef"` rendered six `[index, character]` pairs as six resources that do
|
|
78761
|
+
* not exist, under a `Resources (6):` header — and the cost grows with the
|
|
78762
|
+
* planted length, since one pair is allocated per character.
|
|
78763
|
+
*
|
|
78764
|
+
* Called AFTER every `--json` branch rather than at the load, which is this
|
|
78765
|
+
* command's one departure from the rule `src/state/malformed-resources-bag.ts`
|
|
78766
|
+
* states, and is deliberate: `cdkd state show --json` is the command
|
|
78767
|
+
* `cdkd state list --long` names as the way to SEE a record it could not count,
|
|
78768
|
+
* so repairing above that branch would hand the operator a well-formed
|
|
78769
|
+
* `"resources": {}` and delete the evidence they came for.
|
|
78770
|
+
*
|
|
78771
|
+
* That placement is only safe because ONE thing reads the bag earlier — the
|
|
78772
|
+
* `--show-nested` walker — and `walkCdkdStateStackTree` now returns a childless
|
|
78773
|
+
* node on an unreadable bag before it dereferences one. The comment at that
|
|
78774
|
+
* guard carries what the walker did with such a bag before, in both directions:
|
|
78775
|
+
* a scalar-element bag yielded no children but still allocated per element, and
|
|
78776
|
+
* a LIST of resource objects hard-failed. Do not move this call up, and do not
|
|
78777
|
+
* drop that guard: either one alone leaves the other half live.
|
|
78778
|
+
*
|
|
78779
|
+
* The guard is in the WALKER and not at its caller because the walker recurses.
|
|
78780
|
+
* A caller-side test covers the root record only, which is what an earlier cut
|
|
78781
|
+
* of this fix shipped — a healthy root naming a nested child still walked that
|
|
78782
|
+
* child's bag unguarded.
|
|
78783
|
+
*
|
|
78784
|
+
* The container pass runs SECOND and under the same `--json` placement, for one
|
|
78785
|
+
* reason each. Second, because {@link repairRenderedContainers} reads the
|
|
78786
|
+
* resource entries and the repair above is what guarantees there is a map to
|
|
78787
|
+
* read them from. Under the same placement, because `--json` emits the record
|
|
78788
|
+
* as parsed and a non-object `outputs` is evidence there exactly as a
|
|
78789
|
+
* non-object `resources` bag is — and because the payload carries the stored
|
|
78790
|
+
* value whole, it fabricates nothing to emit it.
|
|
78791
|
+
*/
|
|
78792
|
+
function repairRecordForTextRender(state, stackName, region, logger) {
|
|
78793
|
+
if (repairMalformedResourcesForReadOnly(state)) logger.warn(malformedResourcesWarning(stackName, region));
|
|
78794
|
+
repairRenderedContainers(state, SHOW_RENDERED_CONTAINERS, stackName, region, logger);
|
|
78795
|
+
}
|
|
78796
|
+
/**
|
|
78797
|
+
* The same repair for every node {@link renderTreeWithChildren} will render,
|
|
78798
|
+
* the root included. Each node carries its own record, so a CHILD with a
|
|
78799
|
+
* malformed bag fabricates rows exactly as the root does; one warning per
|
|
78800
|
+
* repaired record names which stack it came from.
|
|
78801
|
+
*
|
|
78802
|
+
* Both identifiers are strings by construction rather than record fields: the
|
|
78803
|
+
* walker builds a child's name as `` `${parent}~${logicalId}` `` and threads the
|
|
78804
|
+
* region it walked against, having already refused any child whose recorded
|
|
78805
|
+
* region disagrees.
|
|
78806
|
+
*/
|
|
78807
|
+
function repairTreeForTextRender(node, logger) {
|
|
78808
|
+
repairRecordForTextRender(node.state, node.stackName, node.region, logger);
|
|
78809
|
+
for (const child of node.children) repairTreeForTextRender(child, logger);
|
|
78810
|
+
}
|
|
78811
|
+
/**
|
|
78812
|
+
* Say which nodes the walk could not read, WITHOUT repairing any of them.
|
|
78813
|
+
*
|
|
78814
|
+
* `--show-nested --json` is the one mode that must not repair — it exists to
|
|
78815
|
+
* show the operator the stored record — but "cannot repair" is not "must stay
|
|
78816
|
+
* silent". An unreadable bag makes the walk return that node childless, and a
|
|
78817
|
+
* childless node is byte-indistinguishable from a genuine leaf: a consumer
|
|
78818
|
+
* enumerating `children` reads a CUT subtree as a complete one. The text views
|
|
78819
|
+
* do not have this problem, because a cut node renders `Resources (0):` where
|
|
78820
|
+
* the operator expected rows.
|
|
78821
|
+
*
|
|
78822
|
+
* `logger.warn` writes to stderr, and `stateShowCommand` has already called
|
|
78823
|
+
* `reserveStdoutForPayload()` for this branch, so this cannot corrupt the JSON.
|
|
78824
|
+
*/
|
|
78825
|
+
function warnUnreadableTreeNodes(node, logger) {
|
|
78826
|
+
if (!hasReadableResources(node.state)) logger.warn(malformedResourcesWarning(node.stackName, node.region));
|
|
78827
|
+
for (const child of node.children) warnUnreadableTreeNodes(child, logger);
|
|
78828
|
+
}
|
|
78829
|
+
/**
|
|
78127
78830
|
* Render one stack's state record as a human-readable multi-line block —
|
|
78128
78831
|
* the shared body used by both the single-stack default output and each
|
|
78129
78832
|
* nested child rendered under `--show-nested`.
|
|
78833
|
+
*
|
|
78834
|
+
* Every caller must have run {@link repairRecordForTextRender} over the
|
|
78835
|
+
* record first (`repairTreeForTextRender` does it per node for the tree): the
|
|
78836
|
+
* resource walk below is what fabricates rows from a non-object bag, and the
|
|
78837
|
+
* `state.ts names renderStateBlock in CODE exactly 1 + 3 times` case in
|
|
78838
|
+
* `tests/unit/cli/state-record-shape.test.ts` refuses a fourth call site so
|
|
78839
|
+
* that choice cannot be made silently.
|
|
78130
78840
|
*/
|
|
78131
78841
|
function renderStateBlock(state, lockInfo, withLegend) {
|
|
78132
78842
|
const lines = [];
|
|
@@ -78157,7 +78867,7 @@ function renderStateBlock(state, lockInfo, withLegend) {
|
|
|
78157
78867
|
}
|
|
78158
78868
|
if (withLegend) lines.push(...skippedOutputsLegend());
|
|
78159
78869
|
}
|
|
78160
|
-
const resourceEntries = Object.entries(state.resources
|
|
78870
|
+
const resourceEntries = Object.entries(state.resources).map(([logicalId, entry]) => [logicalId, entry ?? {}]).sort(([a], [b]) => a.localeCompare(b));
|
|
78161
78871
|
lines.push("");
|
|
78162
78872
|
lines.push(`Resources (${resourceEntries.length}):`);
|
|
78163
78873
|
for (const [logicalId, resource] of resourceEntries) {
|
|
@@ -79238,11 +79948,39 @@ function snapshotNote(resourceType, effectiveProvisionedBy, skipFinalSnapshot) {
|
|
|
79238
79948
|
* one calls `displaySafe()` directly and takes the DENYLIST -- the same class
|
|
79239
79949
|
* `formatError` picks for a `cause`, and for the same reason. `grep displaySafe(`
|
|
79240
79950
|
* answers how many; a count written here was wrong on its first revision.
|
|
79951
|
+
*
|
|
79952
|
+
* Scope is answered by grepping BOTH `safe(` and `safeStack(` -- the latter is
|
|
79953
|
+
* not matched by the former, and a sentence naming only one understates the
|
|
79954
|
+
* population by twelve.
|
|
79241
79955
|
*/
|
|
79242
79956
|
function safe(value) {
|
|
79243
79957
|
return displayIdent(value);
|
|
79244
79958
|
}
|
|
79245
79959
|
/**
|
|
79960
|
+
* `safe()` for a cdkd STATE-RECORD STACK NAME specifically, which is the one
|
|
79961
|
+
* value class in this file whose legitimate grammar runs past `displayIdent`'s
|
|
79962
|
+
* 255-code-point default (issue #3164).
|
|
79963
|
+
*
|
|
79964
|
+
* A stack name here is not a CloudFormation stack name: `deriveChildStackName`
|
|
79965
|
+
* appends `~<logicalId>` per nesting level, and with CDK's ~60-character
|
|
79966
|
+
* generated nested-stack logical ids a legitimate child passes 255 around the
|
|
79967
|
+
* fourth level. Cutting one is a byte change on a LEGITIMATE value, and this
|
|
79968
|
+
* file is the worst place for it -- three of its renders are
|
|
79969
|
+
* `re-run 'cdkd rollback <stack>'` COPY-PASTE hints and one is the
|
|
79970
|
+
* confirmation prompt, so a cut name hands an operator an unrunnable command
|
|
79971
|
+
* mid-incident.
|
|
79972
|
+
*
|
|
79973
|
+
* It is a NAMED helper rather than a `maxCodePoints` argument repeated per
|
|
79974
|
+
* site, because a per-site spelling of exactly this rule is what issue #3164
|
|
79975
|
+
* exists to stop: the first cut of that fix widened ONE of this file's twelve
|
|
79976
|
+
* stack-name renders and left eleven cut. Every value that is NOT a stack name --
|
|
79977
|
+
* a region (at most 25 characters), a logical id, a resource type, a change
|
|
79978
|
+
* type -- keeps `safe()` and its tighter default.
|
|
79979
|
+
*/
|
|
79980
|
+
function safeStack(value) {
|
|
79981
|
+
return displayIdent(value, { maxCodePoints: STACK_REF_MAX_CODE_POINTS });
|
|
79982
|
+
}
|
|
79983
|
+
/**
|
|
79246
79984
|
* Human label for a planned rollback action (plan preview). `skipFinalSnapshot`
|
|
79247
79985
|
* is threaded in because the classifier is pure (it cannot see CLI flags) and
|
|
79248
79986
|
* the Snapshot label would otherwise promise a final snapshot the run is about
|
|
@@ -79321,7 +80059,7 @@ async function rollbackCommand(stackArg, options) {
|
|
|
79321
80059
|
return;
|
|
79322
80060
|
}
|
|
79323
80061
|
if (scoped.length > 1) {
|
|
79324
|
-
const list = scoped.map((c) => ` - ${
|
|
80062
|
+
const list = scoped.map((c) => ` - ${safeStack(c.stackName)} (${safe(c.region)})`).join("\n");
|
|
79325
80063
|
throw new Error(`Multiple stacks have a rollback journal. Pick one:\n${list}\nRe-run 'cdkd rollback <stack>' (add --stack-region if the same name spans regions).`);
|
|
79326
80064
|
}
|
|
79327
80065
|
ref = scoped[0];
|
|
@@ -79354,8 +80092,8 @@ async function rollbackCommand(stackArg, options) {
|
|
|
79354
80092
|
try {
|
|
79355
80093
|
const stateData = await setup.stateBackend.getState(stackName, region);
|
|
79356
80094
|
const journal = await setup.stateBackend.loadRollbackJournal(stackName, region);
|
|
79357
|
-
if (!journal || journal.segments.length === 0) throw new Error(`Nothing to roll back for '${
|
|
79358
|
-
if (!stateData) throw new Error(`Rollback journal exists for '${
|
|
80095
|
+
if (!journal || journal.segments.length === 0) throw new Error(`Nothing to roll back for '${safeStack(stackName)}' (${safe(region)}). Run 'cdkd deploy' to (re)deploy, or 'cdkd destroy' to clean up.`);
|
|
80096
|
+
if (!stateData) throw new Error(`Rollback journal exists for '${safeStack(stackName)}' (${safe(region)}) but its state.json is missing (keys: ${safe(setup.prefix)}/${safeStack(stackName)}/${safe(region)}/state.json and .../rollback-journal.json). State appears corrupted — inspect the bucket manually.`);
|
|
79359
80097
|
const baseState = stateData.state;
|
|
79360
80098
|
refuseMalformedState(baseState, stackName, region);
|
|
79361
80099
|
const stateResources = { ...baseState.resources };
|
|
@@ -79363,7 +80101,7 @@ async function rollbackCommand(stackArg, options) {
|
|
|
79363
80101
|
const orphanLogicalIds = new Set(options.orphan ?? []);
|
|
79364
80102
|
const newestSegment = journal.segments[journal.segments.length - 1];
|
|
79365
80103
|
if (newestSegment.roleArn && !options.roleArn) logger.info(`Note: the failed deploy ran with --role-arn ${safe(newestSegment.roleArn)}; this rollback is running with ambient credentials (pass --role-arn to match).`);
|
|
79366
|
-
logger.info(`\nRollback plan for '${
|
|
80104
|
+
logger.info(`\nRollback plan for '${safeStack(stackName)}' (${safe(region)}):`);
|
|
79367
80105
|
const planStateView = { ...stateResources };
|
|
79368
80106
|
for (let s = journal.segments.length - 1; s >= 0; s--) {
|
|
79369
80107
|
const segment = journal.segments[s];
|
|
@@ -79381,7 +80119,7 @@ async function rollbackCommand(stackArg, options) {
|
|
|
79381
80119
|
}
|
|
79382
80120
|
logger.info("");
|
|
79383
80121
|
if (!skipConfirmation) {
|
|
79384
|
-
if (!await confirm(`Roll back '${
|
|
80122
|
+
if (!await confirm(`Roll back '${safeStack(stackName)}' (${safe(region)})?`)) {
|
|
79385
80123
|
logger.info("Rollback cancelled");
|
|
79386
80124
|
return;
|
|
79387
80125
|
}
|
|
@@ -79419,7 +80157,7 @@ async function rollbackCommand(stackArg, options) {
|
|
|
79419
80157
|
const fresh = await setup.stateBackend.getState(stackName, region);
|
|
79420
80158
|
currentEtag = await setup.stateBackend.saveState(stackName, region, next(), { ...fresh?.etag !== void 0 && { expectedEtag: fresh.etag } });
|
|
79421
80159
|
} catch (retryError) {
|
|
79422
|
-
logger.warn(`Failed to persist state after a rollback operation: ${displaySafe(retryError instanceof Error ? retryError.message : String(retryError))}. The resource was reverted in AWS; re-run 'cdkd rollback ${
|
|
80160
|
+
logger.warn(`Failed to persist state after a rollback operation: ${displaySafe(retryError instanceof Error ? retryError.message : String(retryError))}. The resource was reverted in AWS; re-run 'cdkd rollback ${safeStack(stackName)}' to reconcile state.`);
|
|
79423
80161
|
}
|
|
79424
80162
|
}
|
|
79425
80163
|
};
|
|
@@ -79498,16 +80236,16 @@ async function rollbackCommand(stackArg, options) {
|
|
|
79498
80236
|
const survivingOrphans = orphansAfterRollback(baseState, mintedOrphans).orphans ?? [];
|
|
79499
80237
|
if (journal.segments.length === 0 && oldestInitialDeploy && Object.keys(stateResources).length === 0 && survivingOrphans.length === 0) {
|
|
79500
80238
|
await setup.stateBackend.deleteState(stackName, region);
|
|
79501
|
-
logger.info(`State for '${
|
|
80239
|
+
logger.info(`State for '${safeStack(stackName)}' (${safe(region)}) removed (stack fully rolled back).`);
|
|
79502
80240
|
}
|
|
79503
|
-
if (interrupted) throw new PartialFailureError(`Rollback interrupted. Journal preserved — re-run 'cdkd rollback ${
|
|
79504
|
-
if (totalFailures > 0) throw new PartialFailureError(`Rollback completed with ${totalFailures} failed operation(s). Journal preserved — re-run 'cdkd rollback ${
|
|
80241
|
+
if (interrupted) throw new PartialFailureError(`Rollback interrupted. Journal preserved — re-run 'cdkd rollback ${safeStack(stackName)}' to finish.`);
|
|
80242
|
+
if (totalFailures > 0) throw new PartialFailureError(`Rollback completed with ${totalFailures} failed operation(s). Journal preserved — re-run 'cdkd rollback ${safeStack(stackName)}' to retry.`);
|
|
79505
80243
|
if (totalWarnings > 0) throw new PartialFailureError(`Rollback completed with ${totalWarnings} skipped/unrecoverable operation(s) (see warnings above).`);
|
|
79506
|
-
logger.info(`\nRollback of '${
|
|
80244
|
+
logger.info(`\nRollback of '${safeStack(stackName)}' (${safe(region)}) complete.`);
|
|
79507
80245
|
} finally {
|
|
79508
80246
|
try {
|
|
79509
80247
|
await setup.lockManager.releaseLock(stackName, region).catch((err) => {
|
|
79510
|
-
logger.warn(`Failed to release lock for '${
|
|
80248
|
+
logger.warn(`Failed to release lock for '${safeStack(stackName)}' (${safe(region)}): ${displaySafe(err instanceof Error ? err.message : String(err))}`);
|
|
79511
80249
|
});
|
|
79512
80250
|
} finally {
|
|
79513
80251
|
process.removeListener("SIGINT", sigintHandler);
|
|
@@ -92998,4 +93736,4 @@ function buildProgram() {
|
|
|
92998
93736
|
|
|
92999
93737
|
//#endregion
|
|
93000
93738
|
export { buildProgram };
|
|
93001
|
-
//# sourceMappingURL=program-
|
|
93739
|
+
//# sourceMappingURL=program-BslZXdJR.js.map
|