@go-to-k/cdkd 0.283.23 → 0.283.25

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.
@@ -9821,6 +9821,81 @@ function s3BucketWebsiteUrl(bucketName, region) {
9821
9821
  /** Fixed marker substituted for a secret value in log / error output. */
9822
9822
  const SECRET_MASK = "***";
9823
9823
  /**
9824
+ * Every `{{resolve:...}}` expression this process has PROVEN resolves to a
9825
+ * secret, as a SET — uncollapsed by resolved value (issue #1910).
9826
+ *
9827
+ * This is the piece {@link RecordedSecretValues} structurally cannot supply.
9828
+ * That map is keyed by the resolved PLAINTEXT, so when two expressions resolve
9829
+ * to the same value it keeps only the last, and asking it whether the LOSING
9830
+ * expression was a secret answers "no" — for precisely the pair the path-based
9831
+ * redaction exists to separate. The losing leaf then falls through to the value
9832
+ * scan and is persisted holding its SIBLING's expression: a permanent spurious
9833
+ * UPDATE, and on the rollback-journal replay path a re-resolution of the wrong
9834
+ * reference against the live resource.
9835
+ *
9836
+ * EVERY secret expression is recorded, not only the `ssm` ones (issue
9837
+ * [#1916](https://github.com/go-to-k/cdkd/issues/1916)). Only `ssm` needs an
9838
+ * entry to answer "is this secret?" — a `secretsmanager` reference is secret by
9839
+ * SPELLING (see {@link isKnownSecretExpression}), decidable with no lookup and
9840
+ * no memory, while an `ssm` one is secret only when its parameter is a
9841
+ * `SecureString`, knowable only from the `GetParameter` response. But this set
9842
+ * answers a SECOND question: it is the CANDIDATE LIST
9843
+ * {@link positionByIntrinsicSkeleton} matches an intrinsic source leaf against,
9844
+ * and there the losing member of a collapsed
9845
+ * secretsmanager/secretsmanager pair must be nameable too. Holding only the ssm
9846
+ * half made the set's name a lie and left that pair unpositionable.
9847
+ *
9848
+ * One kind is deliberately still absent: an `ssm` reference whose `Type` came
9849
+ * back unclassifiable is treated as secret for THAT resolution but not pinned,
9850
+ * so the next pass re-asks AWS rather than inheriting a transient answer
9851
+ * (issue #1901). Recording it here would pin it for the process.
9852
+ *
9853
+ * It lives in THIS module rather than in the resolver even though the resolver
9854
+ * is its only writer, for two reasons. This module is the LEAF — the resolver
9855
+ * already imports it, so the store is reachable from the writer without adding
9856
+ * an edge, while the reverse (a leaf importing the resolver) would close a
9857
+ * cycle. And the READER is {@link isKnownSecretExpression} right here, so
9858
+ * homing it here means no call site has to thread it: the four sibling writers
9859
+ * #1910 fixes pass a position SOURCE and nothing else.
9860
+ *
9861
+ * Its lifetime matches the resolver's `cachedDynamicReferences` — the resolver
9862
+ * clears both together, and that shared lifetime is the point: this set IS the
9863
+ * resolver's own SecureString verdict store, which already had exactly this
9864
+ * scope, so nothing is widened by homing it here.
9865
+ *
9866
+ * Process-wide is therefore INHERITED, not claimed to be ideal. It is not
9867
+ * strictly sound across regions or accounts in one run — the same expression
9868
+ * can name a `SecureString` in one region and a plain `String` in another — but
9869
+ * `cachedDynamicReferences` caches the resolved VALUE under the same
9870
+ * assumption, so a per-region store here would fix nothing on its own. Note
9871
+ * which way the imprecision points: an entry only ever GRANTS "persist the
9872
+ * source leaf verbatim", and the leaf is the resource's own template
9873
+ * expression, so the failure mode is a public reference stored as an expression
9874
+ * (a spurious UPDATE, issue #1901's class), never a secret stored as plaintext.
9875
+ */
9876
+ const recordedSecretExpressions$1 = /* @__PURE__ */ new Set();
9877
+ /** Remember that `expression` resolves to a secret. Called by the resolver. */
9878
+ function recordSecretExpression(expression) {
9879
+ recordedSecretExpressions$1.add(expression);
9880
+ }
9881
+ /**
9882
+ * Forget a previously recorded expression — the resolver's `SecureString`
9883
+ * verdict going the other way (an ssm parameter that turns out to be a plain
9884
+ * `String` / `StringList`, i.e. public config that must stay RESOLVED in
9885
+ * state).
9886
+ */
9887
+ function forgetSecretExpression(expression) {
9888
+ recordedSecretExpressions$1.delete(expression);
9889
+ }
9890
+ /** Has `expression` been PROVEN to resolve to a secret this process? */
9891
+ function isRecordedSecretExpression(expression) {
9892
+ return recordedSecretExpressions$1.has(expression);
9893
+ }
9894
+ /** Drop every remembered verdict. Paired with the resolver's cache reset. */
9895
+ function clearRecordedSecretExpressions() {
9896
+ recordedSecretExpressions$1.clear();
9897
+ }
9898
+ /**
9824
9899
  * A resolved secret value shorter than this is NOT used as a redaction needle:
9825
9900
  * a 1-2 character plaintext (e.g. a secret whose JSON key holds `"0"`) would
9826
9901
  * match incidental characters everywhere and mangle unrelated state. Such a
@@ -9842,6 +9917,382 @@ function buildNeedleRegex(values) {
9842
9917
  if (needles.length === 0) return void 0;
9843
9918
  return new RegExp(needles.map(escapeRegExp).join("|"), "g");
9844
9919
  }
9920
+ /** The bag was produced by resolving the source: same shape, template source. */
9921
+ const TEMPLATE_DERIVED_RULES = {
9922
+ descendArrays: true,
9923
+ trustAnyExpression: false
9924
+ };
9925
+ /** An AWS readback projected from a persisted STATE bag. */
9926
+ const STATE_SOURCED_READBACK_RULES = {
9927
+ descendArrays: false,
9928
+ trustAnyExpression: true
9929
+ };
9930
+ /** An AWS readback projected from the TEMPLATE: neither relaxation applies. */
9931
+ const TEMPLATE_SOURCED_READBACK_RULES = {
9932
+ descendArrays: false,
9933
+ trustAnyExpression: false
9934
+ };
9935
+ /**
9936
+ * The bag was produced by resolving a STATE source — both relaxations apply.
9937
+ *
9938
+ * The rollback replay is the case (issue #1910): `resolveReplayProps` resolves
9939
+ * the JOURNALED bag and the provider's `effectiveProperties` come back from
9940
+ * that, so the two have identical structure and positional array descent is
9941
+ * sound exactly as it is for a template-derived bag. And the source is a
9942
+ * persisted record, which holds no PUBLIC expressions — a `String` ssm
9943
+ * reference is stored resolved — so any `{{resolve:...}}` in it is by
9944
+ * construction a secret.
9945
+ *
9946
+ * Using `STATE_SOURCED_READBACK_RULES` here instead would be wrong in the
9947
+ * quiet direction: it turns array descent OFF, so a secret nested in a
9948
+ * `Tags[]` / ECS `secrets[]` element falls to the value scan and a colliding
9949
+ * pair still collapses there.
9950
+ */
9951
+ const STATE_DERIVED_RULES = {
9952
+ descendArrays: true,
9953
+ trustAnyExpression: true
9954
+ };
9955
+ function isDynamicReferenceString(value) {
9956
+ return typeof value === "string" && value.includes("{{resolve:");
9957
+ }
9958
+ /**
9959
+ * Is this template expression one whose resolved value is a SECRET?
9960
+ *
9961
+ * Two independent answers, and both are needed:
9962
+ *
9963
+ * - A `secretsmanager` reference is secret BY DEFINITION, so spelling settles
9964
+ * it with no lookup. This arm is what makes the #1904 fix work at all: when
9965
+ * two expressions resolve to the same value the value-keyed map keeps only the
9966
+ * last, so asking the map whether the LOSING expression was a secret answers
9967
+ * "no" — precisely for the pair the fix exists to separate.
9968
+ * - An `ssm` reference is secret only when its parameter is a `SecureString`
9969
+ * (issue #1901), which is not derivable from the string, so that arm consults
9970
+ * what the resolver actually recorded.
9971
+ *
9972
+ * `secretExpressions` is what closes the ssm/ssm case (issue #1910). Derived
9973
+ * from the value-keyed map it is useless for exactly this question — the map
9974
+ * already collapsed the pair, so the losing expression is absent from
9975
+ * `secrets.values()` — which is why callers pass the resolver's own SET of
9976
+ * secret expressions instead. Callers that pass nothing fall back to the map's
9977
+ * values, i.e. to the pre-#1910 behavior: the pair still collapses, but nothing
9978
+ * leaks (both leaves are redacted, just onto one expression).
9979
+ */
9980
+ function isKnownSecretExpression(expression, secretExpressions) {
9981
+ return expression.startsWith("{{resolve:secretsmanager:") || secretExpressions.has(expression) || isRecordedSecretExpression(expression);
9982
+ }
9983
+ /**
9984
+ * Is this source leaf a SINGLE complete `{{resolve:...}}` token and nothing
9985
+ * else?
9986
+ *
9987
+ * Whole-leaf substitution is only correct for that shape. A MIXED leaf --
9988
+ * `pre{{resolve:ssm:/public}}-{{resolve:secretsmanager:x}}post`, i.e. anything
9989
+ * the resolver substituted INTO rather than replaced -- must fall to the value
9990
+ * scan, which rewrites just the secret substring. Substituting the whole leaf
9991
+ * there would re-introduce every other token in it, including a public ssm
9992
+ * reference the resolver deliberately left resolved (issue #1901).
9993
+ */
9994
+ function isSingleDynamicReferenceToken(value) {
9995
+ return /^\{\{resolve:[^{}]*\}\}$/.test(value);
9996
+ }
9997
+ function isPlainObject$2(value) {
9998
+ return value !== null && typeof value === "object" && !Array.isArray(value);
9999
+ }
10000
+ /**
10001
+ * Stands in for a source part the skeleton cannot know — an `Fn::Join` element
10002
+ * that is itself an intrinsic, or an `Fn::Sub` `${...}` variable.
10003
+ *
10004
+ * `[^}]*` rather than `.*` because a recorded expression's INNER text never
10005
+ * contains `}`: the resolver matches them with `/\{\{resolve:([^}]+)\}\}/`, so
10006
+ * the first `}` after `{{resolve:` is already the terminator. Excluding it
10007
+ * means a wildcard can never swallow one token's terminator and run into the
10008
+ * next, so a skeleton for ONE reference cannot match a candidate built from a
10009
+ * different one.
10010
+ */
10011
+ const SKELETON_WILDCARD = "[^}]*";
10012
+ /**
10013
+ * More wildcards than this and the skeleton is REFUSED outright.
10014
+ *
10015
+ * The bound is what actually closes the backtracking class; the collapse below
10016
+ * only closes one ARRANGEMENT of it. A skeleton with N wildcards makes a
10017
+ * FAILING match exponential in N — and failing is the common case, since the
10018
+ * pattern is tried against every recorded expression that is NOT this leaf's.
10019
+ * The collapse merges ADJACENT wildcards, so `${a}${b}${c}` becomes one; but
10020
+ * wildcards separated by a literal cannot merge and backtrack identically.
10021
+ * Measured against a failing candidate: `Fn::Sub '${a}x${b}x…'` at 6 wildcards
10022
+ * 17.7s and at 8 did not finish in two minutes, and a realistic
10023
+ * `Fn::Join['-', 9 x {Ref}]` against a hyphen-rich secret ARN took 855ms per
10024
+ * candidate. So the producer is the wildcard COUNT, not adjacency.
10025
+ *
10026
+ * What refusing costs is worth stating plainly rather than waving away, because
10027
+ * it is the SAME cost this module's residual note describes: a refused leaf
10028
+ * falls to the value scan, and for a colliding pair that means state holds the
10029
+ * SIBLING's reference, which `resolveReplayProps` re-resolves and applies. A
10030
+ * four-wildcard join carrying substantive literals CAN position uniquely, so
10031
+ * the bound does give something up. It is set where it is because the dominant
10032
+ * CDK shape carries exactly ONE wildcard (the secret ARN's `{Ref}`) and two or
10033
+ * three covers an account plus a region — and because the alternative at the
10034
+ * top of that trade is not "slightly better redaction" but a deploy that hangs
10035
+ * after its AWS mutations.
10036
+ */
10037
+ const MAX_SKELETON_WILDCARDS = 3;
10038
+ /**
10039
+ * A candidate longer than this REFUSES the whole positioning pass.
10040
+ *
10041
+ * The wildcard cap bounds the EXPONENT; this bounds the BASE. Cost at the cap
10042
+ * is polynomial in the candidate's length (~cubic, measured: 200 chars 4.9ms,
10043
+ * 1000 chars 192ms, and an adversarial all-separator 3000-char candidate 4.1s),
10044
+ * and candidate length is template-authored — `[^}]*` never crosses a `}`, so
10045
+ * the whole expression is one backtracking run. A real `{{resolve:...}}` is
10046
+ * 100-250 characters even with a full ARN and a JSON key, so this only excludes
10047
+ * the pathological.
10048
+ *
10049
+ * The whole pass is refused rather than that one candidate being SKIPPED, and
10050
+ * the difference is load-bearing: skipping shrinks the candidate set, so a
10051
+ * second match could go unseen and condition 2's "exactly one" would be decided
10052
+ * over a filtered list — turning a bound meant for speed into a silent
10053
+ * weakening of the fence. Refusing degrades to the value scan like every other
10054
+ * refusal here.
10055
+ */
10056
+ const MAX_SKELETON_CANDIDATE_LENGTH = 512;
10057
+ /**
10058
+ * Concatenate skeleton segments, dropping an empty one and COLLAPSING a run of
10059
+ * consecutive wildcards into one — then REFUSE (return `undefined`) when more
10060
+ * than {@link MAX_SKELETON_WILDCARDS} survive.
10061
+ *
10062
+ * The collapse is a correctness fix, not tidiness: `[^}]*[^}]*` is semantically
10063
+ * identical to `[^}]*`, but the engine has to try every split of the input
10064
+ * between them. Measured on a ~120-char candidate, before the collapse:
10065
+ * 4 adjacent wildcards 39ms, 5 ~1s, 6 20s, 8 did not finish in two minutes. Two
10066
+ * legal CFn shapes CDK can emit produce ADJACENT ones — an `Fn::Join` with an
10067
+ * EMPTY delimiter and consecutive non-string parts, and an `Fn::Sub` with
10068
+ * adjacent `${a}${b}` variables — and the collapse takes both to a single
10069
+ * wildcard. It does NOT close the class, which is why the cap exists beside it.
10070
+ *
10071
+ * This runs on the state-persist choke point, i.e. AFTER the AWS mutations and
10072
+ * BEFORE state is written, so a hang there strands real resources.
10073
+ *
10074
+ * An empty segment is dropped rather than appended because an empty DELIMITER
10075
+ * would otherwise sit between two wildcards and defeat the collapse. A
10076
+ * non-empty literal between two wildcards is what anchors the match, and those
10077
+ * are left exactly as they are — and counted.
10078
+ */
10079
+ function joinSkeletonSegments(segments) {
10080
+ const out = [];
10081
+ let wildcards = 0;
10082
+ for (const segment of segments) {
10083
+ if (segment === "") continue;
10084
+ if (segment === SKELETON_WILDCARD) {
10085
+ if (out[out.length - 1] === SKELETON_WILDCARD) continue;
10086
+ wildcards += 1;
10087
+ if (wildcards > MAX_SKELETON_WILDCARDS) return void 0;
10088
+ }
10089
+ out.push(segment);
10090
+ }
10091
+ return out.join("");
10092
+ }
10093
+ /**
10094
+ * Build an anchored pattern describing what a `{{resolve:...}}` expression at
10095
+ * this INTRINSIC source leaf must look like — literal parts kept verbatim,
10096
+ * unknowable parts wildcarded (issue #1916).
10097
+ *
10098
+ * The dominant CDK shape is an `Fn::Join`, because `secret.secretValueFromJson(...)`
10099
+ * renders the secret's ARN as a `Ref` and CDK joins the pieces:
10100
+ *
10101
+ * ```json
10102
+ * {"Fn::Join": ["", ["{{resolve:secretsmanager:my-secret-", {"Ref": "AWS::AccountId"},
10103
+ * ":SecretString:password}}"]]}
10104
+ * ```
10105
+ *
10106
+ * which yields `^\{\{resolve:secretsmanager:my\-secret\-[^}]*:SecretString:password\}\}$`
10107
+ * — enough to tell that leaf's expression from its `:AWSCURRENT`-suffixed
10108
+ * sibling, which is precisely what the value map cannot do once the two
10109
+ * resolve to the same plaintext. `Fn::Sub` has the same shape via its literal
10110
+ * template string.
10111
+ *
10112
+ * Returns `undefined` for any source this cannot describe (a delimiter that is
10113
+ * itself an intrinsic, a non-array `Fn::Join`, a non-string `Fn::Sub`
10114
+ * template, an object that is not a single-key intrinsic at all), and the
10115
+ * caller then falls back to the value scan — i.e. to the pre-#1916 behavior.
10116
+ *
10117
+ * Exported for its TEST only. The reason is now CONVENIENCE rather than
10118
+ * necessity, and the distinction is worth keeping straight: before the wildcard
10119
+ * cap existed, driving the collapse through `redactSecretsForState` did not
10120
+ * fail on a timeout — catastrophic backtracking is SYNCHRONOUS, so it wedged
10121
+ * the vitest worker and the run never ended, which is a worse CI outcome than
10122
+ * the defect and indistinguishable from a slow machine. With the cap, dropping
10123
+ * the collapse turns that same input into a REFUSAL, so the behavior IS
10124
+ * observable through the public entry point. Asserting on the returned
10125
+ * pattern's shape is still the better fence — it names the invariant (how many
10126
+ * unknown spans survived) instead of a downstream consequence — but it is a
10127
+ * choice, not the only option.
10128
+ */
10129
+ function intrinsicSkeletonPattern(source) {
10130
+ const keys = Object.keys(source);
10131
+ if (keys.length !== 1) return void 0;
10132
+ const key = keys[0];
10133
+ if (key === "Fn::Join") {
10134
+ const args = source[key];
10135
+ if (!Array.isArray(args) || args.length !== 2) return void 0;
10136
+ const [delimiter, parts] = args;
10137
+ if (typeof delimiter !== "string" || !Array.isArray(parts)) return void 0;
10138
+ const separator = escapeRegExp(delimiter);
10139
+ const segments = [];
10140
+ parts.forEach((part, index) => {
10141
+ if (index > 0) segments.push(separator);
10142
+ segments.push(typeof part === "string" ? escapeRegExp(part) : SKELETON_WILDCARD);
10143
+ });
10144
+ const body = joinSkeletonSegments(segments);
10145
+ return body === void 0 ? void 0 : new RegExp(`^${body}$`);
10146
+ }
10147
+ if (key === "Fn::Sub") {
10148
+ const args = source[key];
10149
+ const template = typeof args === "string" ? args : Array.isArray(args) ? args[0] : void 0;
10150
+ if (typeof template !== "string") return void 0;
10151
+ const segments = [];
10152
+ let cursor = 0;
10153
+ const variable = /\$\{([^}]*)\}/g;
10154
+ let hit;
10155
+ while ((hit = variable.exec(template)) !== null) {
10156
+ segments.push(escapeRegExp(template.slice(cursor, hit.index)));
10157
+ const inner = hit[1];
10158
+ segments.push(inner.startsWith("!") ? escapeRegExp(`\${${inner.slice(1)}}`) : SKELETON_WILDCARD);
10159
+ cursor = hit.index + hit[0].length;
10160
+ }
10161
+ segments.push(escapeRegExp(template.slice(cursor)));
10162
+ const body = joinSkeletonSegments(segments);
10163
+ return body === void 0 ? void 0 : new RegExp(`^${body}$`);
10164
+ }
10165
+ }
10166
+ /**
10167
+ * Position a leaf whose SOURCE is an intrinsic OBJECT, by matching the shape of
10168
+ * that intrinsic against the expressions this process recorded as secrets
10169
+ * (issue #1916).
10170
+ *
10171
+ * This is the residual {@link redactByPath} left behind. Its plain-string arm
10172
+ * persists the source leaf VERBATIM, which needs a source string to copy; when
10173
+ * the source leaf is an `Fn::Join` / `Fn::Sub` there is none, so the leaf fell
10174
+ * to the value scan — and the value map is keyed by the resolved PLAINTEXT, so
10175
+ * a colliding pair collapses there exactly as it did before #1904. That is the
10176
+ * DOMINANT CDK shape rather than an edge case: any secret reached through an L2
10177
+ * token renders the secret ARN as a `Ref`, hence an `Fn::Join`.
10178
+ *
10179
+ * Three conditions must ALL hold before an expression is persisted, and each
10180
+ * removes a different way of being wrong:
10181
+ *
10182
+ * 1. The bag leaf's WHOLE value is a recorded secret plaintext. The skeleton
10183
+ * describes ONE complete `{{resolve:...}}` token, so a leaf that merely
10184
+ * EMBEDS a secret (a join with surrounding text) is not this shape at all
10185
+ * and must keep going to the value scan, which rewrites just the substring.
10186
+ * 2. EXACTLY ONE candidate matches. Two matching candidates mean the skeleton
10187
+ * genuinely cannot separate them (`{Ref}` in the position that differs), and
10188
+ * guessing would be the collapse this fix exists to remove, one step over.
10189
+ * 3. The match is not DEMONSTRABLY another value's expression. The pass's own
10190
+ * map holds each surviving expression against the plaintext it resolved to,
10191
+ * so a candidate recorded there under a DIFFERENT plaintext is refused. That
10192
+ * is what fences a bag/source misalignment: a shape-plausible expression is
10193
+ * rejected outright when the pass can see it resolved to something else. A
10194
+ * candidate absent from that map is a collapsed LOSER, which is the case
10195
+ * this whole function exists to serve, so it is accepted.
10196
+ *
10197
+ * Every rejection degrades to the value scan, i.e. to today's behavior, so no
10198
+ * case gets worse than it is without this pass.
10199
+ *
10200
+ * One residual is worth naming rather than leaving to be rediscovered: a single
10201
+ * WRONG candidate can win only when this leaf's own expression is in NEITHER
10202
+ * store — which is exactly an `ssm` reference whose `Type` came back
10203
+ * unclassifiable (deliberately unpinned per #1901) and which then lost the
10204
+ * value collapse. `recordedSecretExpressions` is process-wide, so the winner
10205
+ * could in principle come from another resource, and condition 3 cannot refuse
10206
+ * one the pass never recorded.
10207
+ *
10208
+ * **What that costs is a WRONG REFERENCE in state, not merely a noisy diff.**
10209
+ * Persisting another leaf's expression is the pre-#1904 failure exactly:
10210
+ * `resolveReplayProps` RE-RESOLVES the persisted expression against AWS and
10211
+ * hands the result to `provider.update`, so a rollback replays the wrong secret
10212
+ * — immediately, if the two references already resolve to different values. It
10213
+ * is narrow (unclassifiable-`ssm` leaves only) but it is not cosmetic, and an
10214
+ * earlier draft of this paragraph called it "a spurious UPDATE rather than a
10215
+ * disclosure", which understated it.
10216
+ *
10217
+ * There is deliberately NO {@link isKnownSecretExpression} check here, and the
10218
+ * asymmetry with {@link redactByPath}'s plain-string arm is principled rather
10219
+ * than an oversight. That arm's candidate is the SOURCE LEAF itself — arbitrary
10220
+ * template text, which genuinely can be a PUBLIC ssm reference that must stay
10221
+ * resolved in state (issue #1901), so it has to be tested. Here the candidates
10222
+ * come only from {@link recordedSecretExpressions} and from the values of a
10223
+ * {@link RecordedSecretValues} map, both of which the resolver populates ONLY
10224
+ * on a proven-secret verdict — so the test could never answer `false`, and an
10225
+ * unfalsifiable guard reads as protection while fencing nothing. Widening
10226
+ * either candidate source is what would make it necessary again.
10227
+ */
10228
+ function positionByIntrinsicSkeleton(bag, source, secrets, secretExpressions) {
10229
+ if (bag === "" || !secrets.has(bag)) return void 0;
10230
+ const pattern = intrinsicSkeletonPattern(source);
10231
+ if (!pattern) return void 0;
10232
+ const CONFLICTING = Symbol("conflicting plaintext");
10233
+ const plaintextOf = /* @__PURE__ */ new Map();
10234
+ for (const [plaintext, expression] of secrets) plaintextOf.set(expression, plaintextOf.has(expression) ? CONFLICTING : plaintext);
10235
+ let matched;
10236
+ for (const candidate of /* @__PURE__ */ new Set([...secretExpressions, ...recordedSecretExpressions$1])) {
10237
+ if (candidate.length > MAX_SKELETON_CANDIDATE_LENGTH) return void 0;
10238
+ if (!pattern.test(candidate)) continue;
10239
+ const recordedPlaintext = plaintextOf.get(candidate);
10240
+ if (recordedPlaintext !== void 0 && recordedPlaintext !== bag) continue;
10241
+ if (matched !== void 0) return void 0;
10242
+ matched = candidate;
10243
+ }
10244
+ return matched;
10245
+ }
10246
+ /**
10247
+ * PATH-based redaction: walk `bag` alongside a SOURCE bag that still carries the
10248
+ * unresolved `{{resolve:...}}` expressions, and wherever the source leaf is such
10249
+ * a string, persist THAT string verbatim.
10250
+ *
10251
+ * This exists because value-keyed redaction cannot answer the question at all
10252
+ * when two expressions share one resolved value (issue #1904): the map is keyed
10253
+ * by the plaintext, so the two collapse and every site is rewritten to whichever
10254
+ * expression was recorded last — state then holds an expression the template
10255
+ * does not have at that leaf, and the stack takes a permanent spurious UPDATE.
10256
+ * Position is the only disambiguator, and the source bag supplies it.
10257
+ *
10258
+ * It also covers the case where there is no secrets map to consult at all
10259
+ * (issue #1900): an UNCHANGED resource is never resolved during a deploy, so its
10260
+ * `perResourceSecrets` entry is empty, and a live readback that echoes a secret
10261
+ * would be persisted in plaintext. Projecting from the resource's OWN state
10262
+ * record — which already holds the expressions — redacts it with no secret
10263
+ * fetch and no value matching.
10264
+ *
10265
+ * A source leaf that is an intrinsic OBJECT (`Fn::Join` / `Fn::Sub`) has no
10266
+ * string to copy, so it goes through {@link positionByIntrinsicSkeleton} first
10267
+ * (issue #1916): when the intrinsic's literal parts describe exactly one of the
10268
+ * recorded secret expressions, THAT is persisted. This is the dominant CDK
10269
+ * shape — an L2 secret token renders the ARN as a `Ref`, hence a join.
10270
+ *
10271
+ * The value scan is still applied wherever neither can answer: a leaf that
10272
+ * merely EMBEDS a secret inside surrounding text, an intrinsic whose skeleton
10273
+ * matches zero or several candidates, a diverged shape, a key the source lacks.
10274
+ * So the passes are complementary rather than alternatives — path where
10275
+ * position is knowable, skeleton where the position is an intrinsic, value
10276
+ * where neither is.
10277
+ */
10278
+ function redactByPath(bag, source, secrets, rules, secretExpressions) {
10279
+ if (typeof bag === "string" && isSingleDynamicReferenceToken(bag)) return bag;
10280
+ if (isDynamicReferenceString(source) && typeof bag === "string") {
10281
+ if (isSingleDynamicReferenceToken(source) && (rules.trustAnyExpression || isKnownSecretExpression(source, secretExpressions))) return source;
10282
+ return redactSecretsForState(bag, secrets);
10283
+ }
10284
+ if (typeof bag === "string" && isPlainObject$2(source)) {
10285
+ const positioned = positionByIntrinsicSkeleton(bag, source, secrets, secretExpressions);
10286
+ if (positioned !== void 0) return positioned;
10287
+ }
10288
+ if (rules.descendArrays && Array.isArray(bag) && Array.isArray(source) && bag.length === source.length) return bag.map((item, i) => redactByPath(item, source[i], secrets, rules, secretExpressions));
10289
+ if (isPlainObject$2(bag) && isPlainObject$2(source)) {
10290
+ const out = {};
10291
+ for (const [k, v] of Object.entries(bag)) out[k] = Object.hasOwn(source, k) ? redactByPath(v, source[k], secrets, rules, secretExpressions) : redactSecretsForState(v, secrets);
10292
+ return out;
10293
+ }
10294
+ return redactSecretsForState(bag, secrets);
10295
+ }
9845
10296
  /**
9846
10297
  * Deep-clone `bag`, replacing every occurrence of a recorded secret value with
9847
10298
  * the unresolved `{{resolve:...}}` expression it came from. A string whose WHOLE
@@ -9851,8 +10302,9 @@ function buildNeedleRegex(values) {
9851
10302
  * is nothing to redact, so callers can persist the original object unchanged in
9852
10303
  * the common no-secret case.
9853
10304
  */
9854
- function redactSecretsForState(bag, secrets) {
9855
- if (secrets.size === 0) return bag;
10305
+ function redactSecretsForState(bag, secrets, source, rules = TEMPLATE_DERIVED_RULES) {
10306
+ if (secrets.size === 0 && source === void 0) return bag;
10307
+ if (source !== void 0) return redactByPath(bag, source, secrets, rules, new Set(secrets.values()));
9856
10308
  const regex = buildNeedleRegex(secrets.keys());
9857
10309
  const wholeValueExpr = (s) => s === "" ? void 0 : secrets.get(s);
9858
10310
  const walk = (value) => {
@@ -9883,12 +10335,12 @@ function redactSecretsForState(bag, secrets) {
9883
10335
  * detect a no-op cheaply. Shared by the deploy engine's save choke point and
9884
10336
  * the `cdkd scrub` command so both scrub the same three fields identically.
9885
10337
  */
9886
- function scrubResourceRecord(record, secrets) {
9887
- if (secrets.size === 0) return record;
10338
+ function scrubResourceRecord(record, secrets, sourceProperties) {
10339
+ if (secrets.size === 0 && sourceProperties === void 0 && !record.observedProperties) return record;
9888
10340
  const next = { ...record };
9889
- next.properties = redactSecretsForState(record.properties, secrets);
10341
+ next.properties = redactSecretsForState(record.properties, secrets, sourceProperties);
9890
10342
  if (record.attributes) next.attributes = redactSecretsForState(record.attributes, secrets);
9891
- if (record.observedProperties) next.observedProperties = redactSecretsForState(record.observedProperties, secrets);
10343
+ if (record.observedProperties) next.observedProperties = redactSecretsForState(record.observedProperties, secrets, sourceProperties ?? next.properties, sourceProperties === void 0 ? STATE_SOURCED_READBACK_RULES : TEMPLATE_SOURCED_READBACK_RULES);
9892
10344
  return next;
9893
10345
  }
9894
10346
  /**
@@ -11494,28 +11946,51 @@ const cachedAvailabilityZones = {};
11494
11946
  */
11495
11947
  const cachedDynamicReferences = {};
11496
11948
  /**
11497
- * The `{{resolve:ssm:...}}` expressions this process has PROVEN point at a
11498
- * `SecureString` parameter (issue #1901).
11499
- *
11500
- * A plain `ssm` reference is not a secret by SPELLING the way `secretsmanager`
11501
- * is — whether it resolves to public config or to a decrypted secret depends on
11502
- * the parameter's `Type`, which is only knowable from the `GetParameter`
11503
- * response. So secret-ness is discovered on the first resolution and remembered
11504
- * here, keyed by the full `{{resolve:...}}` expression, for the same lifetime as
11505
- * {@link cachedDynamicReferences}.
11506
- *
11507
- * Two consumers need it AFTER the lookup that populated it: the cache-hit arm
11508
- * (which must re-record the value as a secret for the current resolution pass)
11509
- * and the diff / no-op path (which must leave a SecureString reference
11510
- * unresolved without paying a lookup at all once the type is known). A
11511
- * reference NOT in this set is only "not known to be secure" — never "proven
11512
- * public" — so every arm that would leak still asks AWS for the type first.
11513
- *
11514
- * Only the TYPE is remembered, never the decrypted value: on the diff path the
11515
- * lookup is made with `WithDecryption: false`, so the plaintext is never
11516
- * fetched at all there.
11517
- */
11518
- const secureStringSsmReferences = /* @__PURE__ */ new Set();
11949
+ * The `{{resolve:...}}` expressions this process has PROVEN resolve to a
11950
+ * SECRET, reached through `secret-redaction.ts`'s `recordedSecretExpressions`
11951
+ * store. Lifetime is {@link cachedDynamicReferences}'s — `resetAccountInfoCache`
11952
+ * clears both, so a stale verdict can never decide secret-ness for a reference
11953
+ * whose resolved value that call just asked to forget.
11954
+ *
11955
+ * `ssm` is the kind that NEEDS the memory (issue #1901). A plain `ssm`
11956
+ * reference is not a secret by SPELLING the way `secretsmanager` is — whether
11957
+ * it resolves to public config or to a decrypted secret depends on the
11958
+ * parameter's `Type`, which is only knowable from the `GetParameter` response.
11959
+ * So secret-ness is discovered on the first resolution and remembered, keyed by
11960
+ * the full `{{resolve:...}}` expression. Two consumers need it AFTER the lookup
11961
+ * that populated it: the cache-hit arm (which must re-record the value as a
11962
+ * secret for the current resolution pass) and the diff / no-op path (which must
11963
+ * leave a SecureString reference unresolved without paying a lookup at all once
11964
+ * the type is known). A reference NOT in the set is only "not known to be
11965
+ * secure" — never "proven public" — so every arm that would leak still asks AWS
11966
+ * for the type first. Only the TYPE is remembered, never the decrypted value:
11967
+ * on the diff path the lookup is made with `WithDecryption: false`, so the
11968
+ * plaintext is never fetched at all there.
11969
+ *
11970
+ * A `secretsmanager` reference is recorded TOO, and that is issue
11971
+ * [#1916](https://github.com/go-to-k/cdkd/issues/1916). It needed no memory
11972
+ * while the only question asked of the set was "is this expression secret?",
11973
+ * which its spelling settles — but the set is ALSO the candidate list the
11974
+ * redaction path matches an INTRINSIC source leaf against
11975
+ * ({@link secret-redaction.redactSecretsForState}), and a list holding only the
11976
+ * ssm half cannot name the losing member of a collapsed
11977
+ * secretsmanager/secretsmanager pair. Recording every kind is what makes the
11978
+ * set mean what its name says. It changes no verdict here: every arm that reads
11979
+ * it for secret-ness already answers `true` on a `secretsmanager` spelling
11980
+ * before consulting it.
11981
+ *
11982
+ * The store lives in `secret-redaction.ts` rather than here (issue
11983
+ * [#1910](https://github.com/go-to-k/cdkd/issues/1910)) because the redaction
11984
+ * path is the other consumer: one set in the LEAF module means the redactor can
11985
+ * answer with no caller threading it, and the resolver reaches it along an
11986
+ * import edge it already has — the reverse would close a cycle.
11987
+ */
11988
+ const recordedSecretExpressions = {
11989
+ has: (expression) => isRecordedSecretExpression(expression),
11990
+ add: (expression) => recordSecretExpression(expression),
11991
+ delete: (expression) => forgetSecretExpression(expression),
11992
+ clear: () => clearRecordedSecretExpressions()
11993
+ };
11519
11994
  /**
11520
11995
  * Cache for EC2 instance attributes that require a live DescribeInstances
11521
11996
  * lookup (PrivateIp / PublicIp / PrivateDnsName / PublicDnsName /
@@ -13327,7 +13802,7 @@ var IntrinsicFunctionResolver = class {
13327
13802
  });
13328
13803
  for (const { fullMatch, inner } of matches) {
13329
13804
  const service = inner.split(":")[0];
13330
- const isKnownSecret = service === "secretsmanager" || secureStringSsmReferences.has(fullMatch);
13805
+ const isKnownSecret = service === "secretsmanager" || recordedSecretExpressions.has(fullMatch);
13331
13806
  if (isKnownSecret && context?.skipDynamicReferences) continue;
13332
13807
  if (fullMatch in cachedDynamicReferences) {
13333
13808
  const cached = cachedDynamicReferences[fullMatch];
@@ -13342,8 +13817,8 @@ var IntrinsicFunctionResolver = class {
13342
13817
  else if (service === "ssm") {
13343
13818
  const decrypt = context?.skipDynamicReferences !== true;
13344
13819
  const param = await this.resolveSSMReference(parts, decrypt);
13345
- if (param.type === "SecureString") secureStringSsmReferences.add(fullMatch);
13346
- else if (!param.secure) secureStringSsmReferences.delete(fullMatch);
13820
+ if (param.type === "SecureString") recordedSecretExpressions.add(fullMatch);
13821
+ else if (!param.secure) recordedSecretExpressions.delete(fullMatch);
13347
13822
  isSecret = param.secure;
13348
13823
  if (param.secure) {
13349
13824
  if (!decrypt) continue;
@@ -13354,7 +13829,10 @@ var IntrinsicFunctionResolver = class {
13354
13829
  continue;
13355
13830
  }
13356
13831
  cachedDynamicReferences[fullMatch] = resolved;
13357
- if (isSecret && resolved) context?.recordedSecretValues?.set(resolved, fullMatch);
13832
+ if (isSecret && resolved) {
13833
+ context?.recordedSecretValues?.set(resolved, fullMatch);
13834
+ if (service === "secretsmanager") recordedSecretExpressions.add(fullMatch);
13835
+ }
13358
13836
  result = result.replace(fullMatch, () => resolved);
13359
13837
  }
13360
13838
  return result;
@@ -14409,7 +14887,7 @@ var CloudControlProvider = class {
14409
14887
  if (context?.finalSnapshotIdentifier !== void 0) throw new ProvisioningError(`${logicalId} (${resourceType}) requires a final snapshot (DeletionPolicy: Snapshot), but the Cloud Control API delete route has no final-snapshot parameter. Re-run with --skip-final-snapshot after snapshotting manually, or retain the resource.`, resourceType, logicalId, physicalId);
14410
14888
  if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
14411
14889
  this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
14412
- const { ASGProvider } = await import("./asg-provider-BKIOy3_q.js").then((n) => n.n);
14890
+ const { ASGProvider } = await import("./asg-provider-BqKgMaXN.js").then((n) => n.n);
14413
14891
  return await new ASGProvider().delete(logicalId, physicalId, resourceType, _properties, context);
14414
14892
  }
14415
14893
  const isProtectedEc2Instance = context?.removeProtection === true && resourceType === "AWS::EC2::Instance";
@@ -21030,10 +21508,35 @@ async function resolveReplayProps(props, resolver, secrets) {
21030
21508
  * (GHSA fix). The record's `properties` may be the provider's
21031
21509
  * `effectiveProperties`, which can echo the value we just resolved for the
21032
21510
  * provider call — so scrub it with the same per-op secrets map before it is
21033
- * persisted. No-op when the op resolved no secret.
21034
- */
21035
- function redactRollbackRecord(record, secrets) {
21036
- return secrets.size > 0 ? scrubResourceRecord(record, secrets) : record;
21511
+ * persisted.
21512
+ *
21513
+ * `journaledProps` is the POSITION source (issue #1910): the JOURNALED previous
21514
+ * properties, whose leaves still carry the unresolved `{{resolve:...}}`
21515
+ * expressions this replay resolved FROM. Without it two expressions sharing one
21516
+ * resolved value collapse onto whichever the replay recorded last, so the state
21517
+ * this rollback writes disagrees with the template at one leaf and the next
21518
+ * deploy reports a change that never converges — the same defect the four
21519
+ * deploy-side writers had, arriving here through the replay instead.
21520
+ *
21521
+ * It takes `STATE_DERIVED_RULES`, which is BOTH relaxations. The source is a
21522
+ * persisted record, so it holds no PUBLIC expressions (a `String` ssm reference
21523
+ * is stored resolved) and any `{{resolve:...}}` in it is by construction a
21524
+ * secret — that is `trustAnyExpression`. And the bag WAS produced by resolving
21525
+ * that source (`resolveReplayProps` -> the provider's `effectiveProperties`),
21526
+ * so the two have identical structure and positional array descent is sound —
21527
+ * that is `descendArrays`. Using `STATE_SOURCED_READBACK_RULES` here reads
21528
+ * plausible and is wrong in the quiet direction: it turns array descent off, so
21529
+ * a secret nested in a `Tags[]` / ECS `secrets[]` element would still collapse
21530
+ * on the replay path.
21531
+ *
21532
+ * No-op when the op resolved no secret.
21533
+ */
21534
+ function redactRollbackRecord(record, secrets, journaledProps) {
21535
+ if (secrets.size === 0) return record;
21536
+ return scrubResourceRecord(journaledProps === void 0 ? record : {
21537
+ ...record,
21538
+ properties: redactSecretsForState(record.properties, secrets, journaledProps, STATE_DERIVED_RULES)
21539
+ }, secrets);
21037
21540
  }
21038
21541
  async function updateWithRollbackRetry(provider, args, logicalId, logger, isInterrupted) {
21039
21542
  if (provider.disableOuterRetry) return await provider.update(...args);
@@ -21279,7 +21782,7 @@ async function replaySingle(op, stateResources, stackName, ctx, orphanLogicalIds
21279
21782
  physicalId: createResult.physicalId,
21280
21783
  attributes: createResult.attributes ?? {},
21281
21784
  properties: recordedPropertiesAfterReplayCreate(prevRecord, createResult)
21282
- }, secrets);
21785
+ }, secrets, prevRecord.properties);
21283
21786
  await afterOp?.(op.logicalId);
21284
21787
  if (!deletedNewFirst && !adoptedLiveNewResource) try {
21285
21788
  const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
@@ -21330,7 +21833,7 @@ async function replaySingle(op, stateResources, stackName, ctx, orphanLogicalIds
21330
21833
  desiredProps ?? {},
21331
21834
  currentProps ?? {}
21332
21835
  ], op.logicalId, logger, isInterrupted);
21333
- stateResources[op.logicalId] = redactRollbackRecord(recordAfterRollbackUpdate(previousState, revertResult), secrets);
21836
+ stateResources[op.logicalId] = redactRollbackRecord(recordAfterRollbackUpdate(previousState, revertResult), secrets, previousState.properties);
21334
21837
  logger.info(` Rollback: ${op.logicalId} restored successfully`);
21335
21838
  await afterOp?.(op.logicalId);
21336
21839
  ctx.recordEvent?.({
@@ -21470,7 +21973,7 @@ async function replayFailedOperations(failedOps, stateResources, stackName, ctx,
21470
21973
  desiredProps ?? {},
21471
21974
  attemptedProps ?? {}
21472
21975
  ], op.logicalId, logger, options.isInterrupted);
21473
- stateResources[op.logicalId] = redactRollbackRecord(recordAfterRollbackUpdate(prev, revertFailedResult), secrets);
21976
+ stateResources[op.logicalId] = redactRollbackRecord(recordAfterRollbackUpdate(prev, revertFailedResult), secrets, prev.properties);
21474
21977
  logger.info(` Rollback: ${op.logicalId} reverted successfully`);
21475
21978
  await options.afterOp?.(op.logicalId);
21476
21979
  ctx.recordEvent?.({
@@ -21596,7 +22099,7 @@ const FLUSH_INTERVAL_MS = 2e3;
21596
22099
  const FLUSH_EVENT_THRESHOLD = 50;
21597
22100
  /** Build-time cdkd version, with a dev fallback for non-built contexts. */
21598
22101
  function getCdkdVersion() {
21599
- return "0.283.23";
22102
+ return "0.283.25";
21600
22103
  }
21601
22104
  /**
21602
22105
  * Generate a time-sortable unique run id, e.g.
@@ -22210,6 +22713,17 @@ var DeployEngine = class {
22210
22713
  */
22211
22714
  perResourceSecrets = /* @__PURE__ */ new Map();
22212
22715
  /**
22716
+ * PER-RESOURCE unresolved TEMPLATE properties, keyed by logicalId (issues
22717
+ * #1904 / #1900). The redaction choke point uses this as the POSITION source:
22718
+ * wherever the template leaf is a `{{resolve:...}}` string, state persists
22719
+ * that string verbatim instead of asking the value-keyed map which expression
22720
+ * a plaintext came from — a question that map cannot answer when two
22721
+ * expressions resolve to the same value. Captured at the same two sites that
22722
+ * populate `perResourceSecrets`, where the unresolved bag is already in hand.
22723
+ * Reset per `deploy()`.
22724
+ */
22725
+ perResourceTemplateProps = /* @__PURE__ */ new Map();
22726
+ /**
22213
22727
  * Resolved secrets recorded while resolving the stack OUTPUTS (a `CfnOutput`
22214
22728
  * whose Value resolves a `{{resolve:...}}` reference). Separate from the
22215
22729
  * per-resource maps for the same anti-cross-contamination reason. Reset per
@@ -22217,6 +22731,15 @@ var DeployEngine = class {
22217
22731
  */
22218
22732
  outputSecrets = /* @__PURE__ */ new Map();
22219
22733
  /**
22734
+ * UNRESOLVED template `Outputs` values, keyed by output name (issue #1910) —
22735
+ * the outputs' POSITION source, the sibling of `perResourceTemplateProps` for
22736
+ * the bag `resolveOutputs` produces. Without it two outputs resolving one
22737
+ * secret collapse onto whichever expression was recorded last, exactly as two
22738
+ * resource properties did before #1904. Captured in `resolveOutputs` at the
22739
+ * same point `outputSecrets` is accumulated. Reset per `deploy()`.
22740
+ */
22741
+ outputsTemplateSource = {};
22742
+ /**
22220
22743
  * Per-logical-id snapshot of the intrinsic-RESOLVED desired properties
22221
22744
  * each CREATE / UPDATE attempted (issue #1198). Written just before the
22222
22745
  * provider call; read only when the op FAILS, to journal the failed op's
@@ -22258,7 +22781,9 @@ var DeployEngine = class {
22258
22781
  this.recordedImports = [];
22259
22782
  this.recordedOutputReads = [];
22260
22783
  this.perResourceSecrets = /* @__PURE__ */ new Map();
22784
+ this.perResourceTemplateProps = /* @__PURE__ */ new Map();
22261
22785
  this.outputSecrets = /* @__PURE__ */ new Map();
22786
+ this.outputsTemplateSource = {};
22262
22787
  this.resolver.resetPhysicalIdFallbackCount();
22263
22788
  return withStackName(stackName, () => this.doDeploy(stackName, template));
22264
22789
  }
@@ -22290,13 +22815,19 @@ var DeployEngine = class {
22290
22815
  * un-redacted resolved bag; only the persisted copy is rewritten.
22291
22816
  */
22292
22817
  /**
22293
- * Bag-level redaction of a single properties bag against a specific secrets
22294
- * map (GHSA fix). Used by the UPDATE no-op re-check so a resolved-plaintext
22295
- * bag is compared against the expression-bearing stored bag on equal footing.
22818
+ * Redact the resolved stack OUTPUTS bag, positioned by the unresolved
22819
+ * template `Outputs` values (issue #1910).
22820
+ *
22821
+ * A single entry point because THREE call sites redact this same bag — the
22822
+ * state-persist choke point, the no-change re-check, and the post-deploy
22823
+ * publish that feeds the exports index / deploy summary — and before this
22824
+ * they each spelled the value-only redaction separately. Two outputs
22825
+ * resolving one secret collapsed onto whichever expression was recorded last
22826
+ * at all three.
22296
22827
  */
22297
- redactPropsWith(props, secrets) {
22298
- if (secrets.size === 0) return props;
22299
- return redactSecretsForState(props, secrets);
22828
+ redactOutputs(outputs) {
22829
+ if (this.outputSecrets.size === 0) return outputs;
22830
+ return redactSecretsForState(outputs, this.outputSecrets, this.outputsTemplateSource);
22300
22831
  }
22301
22832
  /**
22302
22833
  * Redact resolved secret plaintext out of rollback-journal operations (GHSA
@@ -22307,21 +22838,34 @@ var DeployEngine = class {
22307
22838
  * whole-secret value from one resource cannot rewrite another's literal.
22308
22839
  * Preserves ops that carry none of those fields, or whose resource recorded
22309
22840
  * no secret.
22841
+ *
22842
+ * The POSITION source matters MORE here than anywhere else (issue #1910).
22843
+ * This journal is not just persisted, it is REPLAYED to AWS by the rollback
22844
+ * executor's `resolveReplayProps`, so a leaf redacted onto a SIBLING's
22845
+ * expression — which is what the value-keyed map does when two expressions
22846
+ * resolve to one value — re-resolves at replay time to the wrong reference.
22847
+ * For two `:AWSCURRENT` / `:AWSPREVIOUS` stages of one secret that is the
22848
+ * wrong VERSION shipped to the live resource the moment the two diverge.
22849
+ *
22850
+ * The two SIDES take different sources, and conflating them would be a fresh
22851
+ * defect rather than a simplification: `properties` / `attemptedProperties`
22852
+ * are this deploy's DESIRED bags, produced by resolving the CURRENT template,
22853
+ * so the template bag positions them. `previousState` is a record read back
22854
+ * from STATE, whose leaves already hold expressions from whenever it was
22855
+ * written — the current template is not its source and may not even have the
22856
+ * same shape — so it is redacted against ITSELF via `scrubResourceRecord`,
22857
+ * the same #1900 fallback an UNCHANGED resource takes.
22310
22858
  */
22311
22859
  redactOperationsForJournal(operations) {
22312
22860
  return operations.map((op) => {
22313
22861
  const secrets = this.perResourceSecrets.get(op.logicalId);
22314
- if (!secrets || secrets.size === 0) return op;
22862
+ const templateProps = this.perResourceTemplateProps.get(op.logicalId);
22863
+ if ((!secrets || secrets.size === 0) && !op.previousState) return op;
22864
+ const ownSecrets = secrets ?? /* @__PURE__ */ new Map();
22315
22865
  const next = { ...op };
22316
- if (next.properties) next.properties = redactSecretsForState(next.properties, secrets);
22317
- if (next.attemptedProperties) next.attemptedProperties = redactSecretsForState(next.attemptedProperties, secrets);
22318
- if (next.previousState) {
22319
- const prev = { ...next.previousState };
22320
- prev.properties = redactSecretsForState(prev.properties, secrets);
22321
- if (prev.attributes) prev.attributes = redactSecretsForState(prev.attributes, secrets);
22322
- if (prev.observedProperties) prev.observedProperties = redactSecretsForState(prev.observedProperties, secrets);
22323
- next.previousState = prev;
22324
- }
22866
+ if (next.properties) next.properties = redactSecretsForState(next.properties, ownSecrets, templateProps);
22867
+ if (next.attemptedProperties) next.attemptedProperties = redactSecretsForState(next.attemptedProperties, ownSecrets, templateProps);
22868
+ if (next.previousState) next.previousState = scrubResourceRecord(next.previousState, ownSecrets);
22325
22869
  return next;
22326
22870
  });
22327
22871
  }
@@ -22329,12 +22873,13 @@ var DeployEngine = class {
22329
22873
  const resources = {};
22330
22874
  for (const [logicalId, record] of Object.entries(state.resources)) {
22331
22875
  const secrets = this.perResourceSecrets.get(logicalId);
22332
- resources[logicalId] = secrets ? scrubResourceRecord(record, secrets) : record;
22876
+ const templateProps = this.perResourceTemplateProps.get(logicalId);
22877
+ resources[logicalId] = scrubResourceRecord(record, secrets ?? /* @__PURE__ */ new Map(), templateProps);
22333
22878
  }
22334
22879
  return {
22335
22880
  ...state,
22336
22881
  resources,
22337
- outputs: this.outputSecrets.size > 0 ? redactSecretsForState(state.outputs, this.outputSecrets) : state.outputs
22882
+ outputs: this.redactOutputs(state.outputs)
22338
22883
  };
22339
22884
  }
22340
22885
  /**
@@ -22619,7 +23164,7 @@ var DeployEngine = class {
22619
23164
  this.logger.info("No changes detected. Stack is up to date.");
22620
23165
  let persistedOutputs = currentState.outputs ?? {};
22621
23166
  if (!this.options.dryRun) {
22622
- const resolvedOutputs = this.redactPropsWith(await this.resolveOutputs(effectiveTemplate, currentState.resources, stackName, parameterValues, conditions), this.outputSecrets);
23167
+ const resolvedOutputs = this.redactOutputs(await this.resolveOutputs(effectiveTemplate, currentState.resources, stackName, parameterValues, conditions));
22623
23168
  const resolutionFailed = Object.values(resolvedOutputs).some((v) => v === void 0);
22624
23169
  const outputsChanged = !resolutionFailed && !outputMapsEqual(persistedOutputs, resolvedOutputs);
22625
23170
  if (resolutionFailed && !outputMapsEqual(persistedOutputs, resolvedOutputs)) this.logger.warn("Outputs changed but one or more could not be resolved; keeping the previously persisted outputs. A downstream Fn::ImportValue may fail until the next deploy.");
@@ -22947,7 +23492,7 @@ var DeployEngine = class {
22947
23492
  let outputs;
22948
23493
  try {
22949
23494
  outputs = await this.resolveOutputs(template, newResources, stackName, parameterValues, conditions);
22950
- outputs = this.redactPropsWith(outputs, this.outputSecrets);
23495
+ outputs = this.redactOutputs(outputs);
22951
23496
  } catch (outputError) {
22952
23497
  await this.persistStateAfterOutputFailure(stackName, currentState, newResources, currentEtag, pendingMigration);
22953
23498
  await this.writeRollbackJournalSegment(stackName, completedOperations, failedOperations, "no-rollback-failure", currentEtag === void 0);
@@ -23343,6 +23888,7 @@ var DeployEngine = class {
23343
23888
  ...conditions && { conditions }
23344
23889
  }, stackName);
23345
23890
  const resolvedProps = await this.resolver.resolve(desiredProps, context);
23891
+ this.perResourceTemplateProps.set(logicalId, desiredProps);
23346
23892
  if (context.recordedSecretValues) this.perResourceSecrets.set(logicalId, context.recordedSecretValues);
23347
23893
  this.auditResolvedAssetReferences(logicalId, resourceType, resolvedProps);
23348
23894
  this.attemptedResolvedProps.set(logicalId, resolvedProps);
@@ -23387,9 +23933,10 @@ var DeployEngine = class {
23387
23933
  const resolvedProps = await this.resolver.resolve(desiredProps, context);
23388
23934
  const updateSecrets = context.recordedSecretValues ?? /* @__PURE__ */ new Map();
23389
23935
  this.perResourceSecrets.set(logicalId, updateSecrets);
23936
+ this.perResourceTemplateProps.set(logicalId, desiredProps);
23390
23937
  this.auditResolvedAssetReferences(logicalId, resourceType, resolvedProps);
23391
23938
  this.attemptedResolvedProps.set(logicalId, resolvedProps);
23392
- if (JSON.stringify(this.redactPropsWith(resolvedProps, updateSecrets)) === JSON.stringify(currentProps)) {
23939
+ if (JSON.stringify(redactSecretsForState(resolvedProps, updateSecrets, desiredProps)) === JSON.stringify(currentProps)) {
23393
23940
  if (change.attributeChanges && change.attributeChanges.length > 0) {
23394
23941
  const attrSummary = change.attributeChanges.map((a) => `${a.attribute}: ${a.oldValue ?? "(unset)"} → ${a.newValue ?? "(unset)"}`).join(", ");
23395
23942
  this.logger.info(` ↻ ${logicalId} (${resourceType}) attribute update: ${attrSummary}`);
@@ -23907,7 +24454,10 @@ var DeployEngine = class {
23907
24454
  outputs[outputKey] = value;
23908
24455
  if (output.Export?.Name) {
23909
24456
  const exportName = typeof output.Export.Name === "string" ? output.Export.Name : await this.resolver.resolve(output.Export.Name, context);
23910
- if (typeof exportName === "string") outputs[exportName] = value;
24457
+ if (typeof exportName === "string") {
24458
+ outputs[exportName] = value;
24459
+ this.outputsTemplateSource[exportName] = output.Value;
24460
+ }
23911
24461
  }
23912
24462
  } catch (error) {
23913
24463
  if (this.options.strictGetAtt) throw new Error(`Failed to resolve output ${outputKey}: ${error instanceof Error ? error.message : String(error)} (--strict-getatt promotes output resolution failures to deploy errors; drop the flag to warn and skip the output instead)`, { cause: error });
@@ -23916,6 +24466,7 @@ var DeployEngine = class {
23916
24466
  }
23917
24467
  }
23918
24468
  if (context.recordedSecretValues) for (const [value, expr] of context.recordedSecretValues) this.outputSecrets.set(value, expr);
24469
+ for (const [outputKey, output] of Object.entries(template.Outputs)) this.outputsTemplateSource[outputKey] = output.Value;
23919
24470
  return outputs;
23920
24471
  }
23921
24472
  buildDisplayOutputs(template, resolvedOutputs) {
@@ -23930,5 +24481,5 @@ var DeployEngine = class {
23930
24481
  };
23931
24482
 
23932
24483
  //#endregion
23933
- export { coerceCfnBoolean as $, resolveCaptureObservedState as $t, cyan as A, LocalStartServiceError as An, rewriteTemplateAssetReferences as At, findSilentDropProperties as B, StateError as Bn, buildDockerImage as Bt, makeCanonicalizePropertiesFn as C, AssetError as Cn, shouldRetainResource as Ct, renderStatefulReason as D, DeployCancelledError as Dn, buildAssetRedirectMap as Dt, isStatefulRecreateTargetSync as E, DependencyError as En, WorkGraph as Et, IAMRoleProvider as F, ProvisioningError as Fn, getBootstrapMarkerKey as Ft, IntrinsicFunctionResolver as G, withErrorHandling as Gn, AssetManifestLoader as Gt, slowCcOperationTimeoutMs as H, formatError as Hn, getDockerCmd as Ht, collectInlinePolicyNamesManagedBySiblings as I, ResourceTimeoutError as In, parseBootstrapMarker as It, refStateLookupFromResource as J, isThrottlingError as Jn, synthesisStatusMessage as Jt, cfnRefValueFromPhysicalId as K, isMarkedNonRetryable as Kn, getDockerImageBySourceHash as Kt, clearOnUpdateRemoval as L, ResourceUpdateNotSupportedError as Ln, validateAssetBucketName as Lt, green as M, MissingCdkCliError as Mn, AssetModeResolver as Mt, red as N, NestedStackChildDirectDestroyError as Nn, BOOTSTRAP_MARKER_PREFIX as Nt, formatResourceLine as O, LocalInvokeBuildError as On, createAssetRedirectResolver as Ot, yellow as P, PartialFailureError as Pn, ensureAssetStorage as Pt, assertRegionMatch as Q, resolveAutoAssetStorage as Qt, ProviderRegistry as R, StackHasActiveImportsError as Rn, validateContainerRepoName as Rt, unsupportedFinalSnapshotError as S, setAwsClients as Sn, rebuildClientForBucketRegion as St, MULTI_REGION_RECREATE_BLOCKED_TYPES as T, ConfigError as Tn, stringifyValue as Tt, disableInstanceApiTermination as U, isCdkdError as Un, runDockerForeground as Ut, CloudControlProvider as V, SynthesisError as Vn, formatDockerLoginError as Vt, isTerminationProtectionPropagationError as W, normalizeAwsError as Wn, runDockerStreaming as Wt, normalizeAwsTagsToCfn as X, __exportAll as Xn, getLegacyStateBucketName as Xt, WAFv2WebACLProvider as Y, markNonRetryable as Yn, getDefaultStateBucketName as Yt, resolveExplicitPhysicalId as Z, resolveApp as Zt, buildFinalSnapshotIdentifier as _, clearBucketRegionCache as _n, withRetry as _t, DeploymentEventsStore as a, warnDeprecatedNoPrefixCliFlag as an, requireConfigObject as at, isFinalSnapshotError as b, getAwsClients as bn, LockManager as bt, replayFailedOperations as c, MIGRATE_TMP_PREFIX as cn, scrubResourceRecord as ct, deleteSkipReason as d, expectedOwnerParam as dn, s3BucketDualStackDomainName as dt, resolveSkipPrefix as en, configBooleanRefusal as et, withResourceDeadline as f, PARTITION_TABLE as fn, s3BucketRegionalDomainName as ft, PRE_DELETE_SNAPSHOT_TYPES as g, processStackMessages as gn, describeTypeWithThrottleRetry as gt, ATOMIC_FINAL_SNAPSHOT_TYPES as h, AssemblyReader as hn, DiffCalculator as ht, DeploymentEventsReader as i, stateBucketExistenceConfirmed as in, requireConfigArray as it, gray as j, LockError as jn, escapeRegExp$1 as jt, bold as k, LocalMigrateError as kn, loadPublishableAssetManifest as kt, replayRollback as l, findLargeInlineResources as ln, s3BucketArn as lt, computeImplicitDeleteEdges as m, derivePartitionAndUrlSuffix as mn, applyRoleArnIfSet as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, resolveStateBucketWithDefaultAndSource as nn, readConfigString as nt, planFailedOps as o, CFN_TEMPLATE_BODY_LIMIT as on, requireConfigString as ot, IMPLICIT_DELETE_DEPENDENCIES as p, canonicalizeRegion as pn, s3BucketWebsiteUrl as pt, getAccountInfo as q, isRetryableTransientError as qn, Synthesizer as qt, DeployEngine as r, resolveUseCdkBootstrapAssets as rn, replayWarn as rt, planRollback as s, CFN_TEMPLATE_URL_LIMIT as sn, redactSecretsForState as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, resolveStateBucketWithDefault as tn, configStringRefusal as tt, UNSPECIFIED_SKIP_REASON as u, uploadCfnTemplate as un, s3BucketDomainName as ut, ccRoutedFinalSnapshotError as v, resolveBucketRegion as vn, DagBuilder as vt, extractDeploymentEventError as w, CdkdError as wn, AssetPublisher as wt, refusesFinalSnapshot as x, resetAwsClients as xn, S3StateBackend as xt, createPreDeleteFinalSnapshot as y, AwsClients as yn, TemplateParser as yt, findActionableSilentDrops as z, StackTerminationProtectionError as zn, buildDenyExternalAccessPolicy as zt };
23934
- //# sourceMappingURL=deploy-engine-CQXpKq7o.js.map
24484
+ export { coerceCfnBoolean as $, resolveAutoAssetStorage as $t, cyan as A, LocalMigrateError as An, loadPublishableAssetManifest as At, findSilentDropProperties as B, StackTerminationProtectionError as Bn, buildDenyExternalAccessPolicy as Bt, makeCanonicalizePropertiesFn as C, setAwsClients as Cn, rebuildClientForBucketRegion as Ct, renderStatefulReason as D, DependencyError as Dn, WorkGraph as Dt, isStatefulRecreateTargetSync as E, ConfigError as En, stringifyValue as Et, IAMRoleProvider as F, PartialFailureError as Fn, ensureAssetStorage as Ft, IntrinsicFunctionResolver as G, normalizeAwsError as Gn, runDockerStreaming as Gt, slowCcOperationTimeoutMs as H, SynthesisError as Hn, formatDockerLoginError as Ht, collectInlinePolicyNamesManagedBySiblings as I, ProvisioningError as In, getBootstrapMarkerKey as It, refStateLookupFromResource as J, isRetryableTransientError as Jn, Synthesizer as Jt, cfnRefValueFromPhysicalId as K, withErrorHandling as Kn, AssetManifestLoader as Kt, clearOnUpdateRemoval as L, ResourceTimeoutError as Ln, parseBootstrapMarker as Lt, green as M, LockError as Mn, escapeRegExp$1 as Mt, red as N, MissingCdkCliError as Nn, AssetModeResolver as Nt, formatResourceLine as O, DeployCancelledError as On, buildAssetRedirectMap as Ot, yellow as P, NestedStackChildDirectDestroyError as Pn, BOOTSTRAP_MARKER_PREFIX as Pt, assertRegionMatch as Q, resolveApp as Qt, ProviderRegistry as R, ResourceUpdateNotSupportedError as Rn, validateAssetBucketName as Rt, unsupportedFinalSnapshotError as S, resetAwsClients as Sn, S3StateBackend as St, MULTI_REGION_RECREATE_BLOCKED_TYPES as T, CdkdError as Tn, AssetPublisher as Tt, disableInstanceApiTermination as U, formatError as Un, getDockerCmd as Ut, CloudControlProvider as V, StateError as Vn, buildDockerImage as Vt, isTerminationProtectionPropagationError as W, isCdkdError as Wn, runDockerForeground as Wt, normalizeAwsTagsToCfn as X, markNonRetryable as Xn, getDefaultStateBucketName as Xt, WAFv2WebACLProvider as Y, isThrottlingError as Yn, synthesisStatusMessage as Yt, resolveExplicitPhysicalId as Z, __exportAll as Zn, getLegacyStateBucketName as Zt, buildFinalSnapshotIdentifier as _, processStackMessages as _n, describeTypeWithThrottleRetry as _t, DeploymentEventsStore as a, stateBucketExistenceConfirmed as an, requireConfigObject as at, isFinalSnapshotError as b, AwsClients as bn, TemplateParser as bt, replayFailedOperations as c, CFN_TEMPLATE_URL_LIMIT as cn, redactSecretsForState as ct, deleteSkipReason as d, uploadCfnTemplate as dn, s3BucketDomainName as dt, resolveCaptureObservedState as en, configBooleanRefusal as et, withResourceDeadline as f, expectedOwnerParam as fn, s3BucketDualStackDomainName as ft, PRE_DELETE_SNAPSHOT_TYPES as g, AssemblyReader as gn, DiffCalculator as gt, ATOMIC_FINAL_SNAPSHOT_TYPES as h, derivePartitionAndUrlSuffix as hn, applyRoleArnIfSet as ht, DeploymentEventsReader as i, resolveUseCdkBootstrapAssets as in, requireConfigArray as it, gray as j, LocalStartServiceError as jn, rewriteTemplateAssetReferences as jt, bold as k, LocalInvokeBuildError as kn, createAssetRedirectResolver as kt, replayRollback as l, MIGRATE_TMP_PREFIX as ln, scrubResourceRecord as lt, computeImplicitDeleteEdges as m, canonicalizeRegion as mn, s3BucketWebsiteUrl as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, resolveStateBucketWithDefault as nn, readConfigString as nt, planFailedOps as o, warnDeprecatedNoPrefixCliFlag as on, requireConfigString as ot, IMPLICIT_DELETE_DEPENDENCIES as p, PARTITION_TABLE as pn, s3BucketRegionalDomainName as pt, getAccountInfo as q, isMarkedNonRetryable as qn, getDockerImageBySourceHash as qt, DeployEngine as r, resolveStateBucketWithDefaultAndSource as rn, replayWarn as rt, planRollback as s, CFN_TEMPLATE_BODY_LIMIT as sn, TEMPLATE_SOURCED_READBACK_RULES as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, resolveSkipPrefix as tn, configStringRefusal as tt, UNSPECIFIED_SKIP_REASON as u, findLargeInlineResources as un, s3BucketArn as ut, ccRoutedFinalSnapshotError as v, clearBucketRegionCache as vn, withRetry as vt, extractDeploymentEventError as w, AssetError as wn, shouldRetainResource as wt, refusesFinalSnapshot as x, getAwsClients as xn, LockManager as xt, createPreDeleteFinalSnapshot as y, resolveBucketRegion as yn, DagBuilder as yt, findActionableSilentDrops as z, StackHasActiveImportsError as zn, validateContainerRepoName as zt };
24485
+ //# sourceMappingURL=deploy-engine-MHqWGHl9.js.map