@go-to-k/cdkd 0.285.3 → 0.285.4

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.
@@ -1,6 +1,6 @@
1
1
  import { d as generateResourceName, f as generateResourceNameWithFallback, g as withStackName, m as looksLikeCdkdGeneratedName, n as getLogger, o as getLiveRenderer, p as getCurrentStackName, u as applyDefaultNameForFallback } from "./logger-Dz3Le2Pw.js";
2
2
  import { t as awsClientDefaults } from "./aws-client-defaults-D-iYiIJ6.js";
3
- import { t as getCdkdVersion } from "./version-B4YqJKXh.js";
3
+ import { t as getCdkdVersion } from "./version-Dlq6xZAQ.js";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { CreateBucketCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketLocationCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchKey, PutBucketEncryptionCommand, PutBucketPolicyCommand, PutObjectCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
@@ -11096,22 +11096,35 @@ function dynamicReferenceSpans(value) {
11096
11096
  * a MIXED leaf than to a whole token on the identical source, and that
11097
11097
  * inconsistency is what persisted a decrypted secret.
11098
11098
  *
11099
- * ACCEPTED CONSEQUENCE, recorded rather than papered over: on the empty-map
11100
- * paths a genuinely PUBLIC ssm mixed leaf is now OVER-redacted, so the baseline
11101
- * no longer matches AWS and `cdkd drift` reports a phantom on it. That is
11102
- * reachable only through the one documented hole in the premise above — `cdkd
11103
- * import`'s warn path, which can leave a public expression in state. The trade
11104
- * is deliberate and asymmetric: under-redaction persists a decrypted secret,
11105
- * which is a disclosure and is what this lane exists to prevent, while
11106
- * over-redaction is visible, recoverable and discloses nothing. Closing it
11107
- * properly needs a real TYPE classification on these paths, which is issue
11108
- * [#2012](https://github.com/go-to-k/cdkd/issues/2012)'s mechanism; the
11109
- * over-redaction itself is tracked as issue
11110
- * [#2036](https://github.com/go-to-k/cdkd/issues/2036).
11111
- *
11112
- * `tests/integration/secrets-dynamic-ref` is the end-to-end proof on BOTH
11113
- * paths, and it is the only place the empty-map defect surfaced: Phase 1g
11114
- * covers the populated-map deploy and Phase 1f the empty-map command.
11099
+ * NOT CLOSED here. Issue
11100
+ * [#2036](https://github.com/go-to-k/cdkd/issues/2036) tracks the price this
11101
+ * refusal pays: a genuinely PUBLIC ssm mixed leaf is OVER-redacted on the
11102
+ * empty-map paths, so the baseline no longer matches AWS. Giving the empty-map
11103
+ * path POSITIVE evidence (a store of PROVEN-public verdicts, which the
11104
+ * resolver's own `pinSecretVerdict` retraction already computes) was drafted in
11105
+ * PR #2415 and WITHDRAWN there: such a store is keyed on the bare expression
11106
+ * and lives for the whole process, so on a `cdkd deploy --all` spanning regions
11107
+ * a verdict recorded where the parameter is a plain `String` un-redacts a
11108
+ * SecureString of the same name in another region — measured, and the
11109
+ * un-redacting direction, which is worse than the over-redaction it fixes. Any
11110
+ * revival must key the verdict by SCOPE (region + account) at the READ side.
11111
+ *
11112
+ * The residual is therefore the whole population an empty map describes, which
11113
+ * is the state issue #2036 records. Refusing is still the right way to be wrong
11114
+ * here: under-redaction persists a decrypted secret, a disclosure and the thing
11115
+ * this lane exists to prevent, while over-redaction is visible, recoverable and
11116
+ * discloses nothing.
11117
+ *
11118
+ * `tests/integration/secrets-dynamic-ref` is the end-to-end proof, and it is
11119
+ * the only place the empty-map defect surfaced — every unit assertion passed.
11120
+ * Three of its phases pin a DIFFERENT map state for the same two leaves, which
11121
+ * is what makes the split observable rather than asserted: Phase 1 is the
11122
+ * populated-map deploy (the resource is being created), Phase 1g the EMPTY-map
11123
+ * deploy (the resource is UNCHANGED, so it has no per-resource map but the
11124
+ * resolver has still classified the parameter this run), and Phase 1f the
11125
+ * empty-map command, which classifies nothing and therefore still refuses. An
11126
+ * earlier revision of this sentence called Phase 1g the populated-map case,
11127
+ * which is the opposite of what that phase is built to reach.
11115
11128
  */
11116
11129
  function mixedLeafMayCarryPublicReference(source, secrets) {
11117
11130
  if (secrets.size === 0) return false;
@@ -11267,7 +11280,9 @@ function anchorSignature(source) {
11267
11280
  *
11268
11281
  * Two conditions, both required:
11269
11282
  *
11270
- * 1. the key sets (objects) or index counts (arrays) match, and
11283
+ * 1. every SOURCE key is present in the bag (objects) or the index counts match
11284
+ * (arrays) — see the object arm for why containment rather than equality,
11285
+ * and which of the two directions is the fabrication guard, and
11271
11286
  * 2. every position whose SOURCE carries no dynamic reference is deep-equal on
11272
11287
  * both sides -- the *anchors*.
11273
11288
  *
@@ -11307,9 +11322,7 @@ function anchorsCorroboratePairing(bag, source, anchors) {
11307
11322
  if (isDynamicReferenceString(source)) return typeof bag === "string";
11308
11323
  if (isPlainObject$2(source)) {
11309
11324
  if (!isPlainObject$2(bag)) return false;
11310
- const sourceKeys = Object.keys(source);
11311
- if (sourceKeys.length !== Object.keys(bag).length) return false;
11312
- return sourceKeys.every((k) => Object.hasOwn(bag, k) && anchorsCorroboratePairing(bag[k], source[k], anchors));
11325
+ return Object.keys(source).every((k) => Object.hasOwn(bag, k) && anchorsCorroboratePairing(bag[k], source[k], anchors));
11313
11326
  }
11314
11327
  if (Array.isArray(source)) {
11315
11328
  if (!Array.isArray(bag) || bag.length !== source.length) return false;
@@ -11398,6 +11411,394 @@ function unkeyedArrayPairsByAnchors(bag, source) {
11398
11411
  return true;
11399
11412
  }
11400
11413
  /**
11414
+ * Marks a position one of the POSITION passes DECIDED, in the parallel tree
11415
+ * {@link refuseUncertifiedReadbackPositions} builds when asked to `mark`.
11416
+ *
11417
+ * A SENTINEL rather than a value comparison, and that distinction was a
11418
+ * security blocker on PR #2415. {@link preferPositionDecisions} first inferred
11419
+ * "not decided" from `refused === bag`, which cannot tell an UNDECIDED position
11420
+ * from one the pass decided IN FAVOUR of the value already there. Two shapes
11421
+ * hit it, both fabricating a baseline `cdkd drift --revert` then pushes:
11422
+ *
11423
+ * - the resolver's unsupported-service arm leaves an `{{resolve:ssm-secure:`
11424
+ * token LITERAL, so AWS echoes it back and the source leaf EQUALS the bag
11425
+ * leaf. The string arm returns `source` — a decision — and the equality made
11426
+ * it look like no decision at all. (A BARE such token takes the whole-token
11427
+ * arm and one embedded in text takes the mixed-leaf arm; both decide, and
11428
+ * both were misread.)
11429
+ * - the empty-map arm that deliberately KEEPS a leaf returns `bag` by design.
11430
+ *
11431
+ * A symbol cannot be produced by any walk of JSON, so no readback value can
11432
+ * impersonate it.
11433
+ */
11434
+ const POSITION_DECIDED = Symbol("position decided by a position pass");
11435
+ /**
11436
+ * Record one (plaintext -> expression) pair, or strike the plaintext out.
11437
+ *
11438
+ * Below {@link MIN_NEEDLE_LENGTH} nothing is recorded, and this floor DECIDES
11439
+ * rather than mirrors. {@link buildNeedleRegex} applies the same threshold, so
11440
+ * a short needle is dropped from the SUBSTRING arm either way — but the value
11441
+ * scan's other arm is a WHOLE-VALUE lookup (`secrets.get(leaf)`) that matches
11442
+ * at ANY length, so without this line a two-character derived plaintext would
11443
+ * still rewrite every leaf equal to it. That is the false redaction with a
11444
+ * blast radius {@link expressionMaySeedANeedle} exists to bound, arriving by
11445
+ * length instead of by provenance: a public config value of `us` or `dev` is
11446
+ * exactly the kind of short plaintext a readback carries in a dozen unrelated
11447
+ * fields.
11448
+ */
11449
+ function learnNeedle(collector, plaintext, expression) {
11450
+ if (plaintext.length < 4) return;
11451
+ if (collector.poisoned.has(plaintext)) return;
11452
+ if (expressionSecretIsInferred(expression)) collector.inferred.add(plaintext);
11453
+ const already = collector.needles.get(plaintext);
11454
+ if (already === void 0) {
11455
+ collector.needles.set(plaintext, expression);
11456
+ return;
11457
+ }
11458
+ if (already === expression) return;
11459
+ collector.needles.delete(plaintext);
11460
+ collector.poisoned.add(plaintext);
11461
+ }
11462
+ /**
11463
+ * The `{{resolve:<service>:` prefixes whose resolved value IS a secret,
11464
+ * whatever the parameter or secret is called.
11465
+ *
11466
+ * `ssm` is in the list and `ssm-secure` is spelled separately, because
11467
+ * `startsWith('{{resolve:ssm:')` is FALSE for `{{resolve:ssm-secure:` — the
11468
+ * next character is `-`. The two are disjoint tests, not one with a prefix
11469
+ * relationship, which is the trap `mixedLeafMayCarryPublicReference`'s own
11470
+ * comment already records from the other direction.
11471
+ */
11472
+ const SECRET_BEARING_REFERENCE_PREFIXES = [
11473
+ "{{resolve:secretsmanager:",
11474
+ "{{resolve:ssm-secure:",
11475
+ "{{resolve:ssm:"
11476
+ ];
11477
+ /**
11478
+ * The prefixes whose SPELLING settles secret-ness, with no lookup and no
11479
+ * inference — the subset of {@link SECRET_BEARING_REFERENCE_PREFIXES} that
11480
+ * {@link expressionSecretIsInferred} treats as certain.
11481
+ *
11482
+ * An ALLOWLIST rather than "the admission list minus `{{resolve:ssm:`", and the
11483
+ * difference is what happens to the NEXT entry someone adds. Subtracting makes a
11484
+ * new prefix default to CERTAIN, i.e. to the WIDER blast radius, which is the
11485
+ * wrong direction to fail in; listing makes it default to inferred until someone
11486
+ * deliberately promotes it.
11487
+ */
11488
+ const SPELLED_SECRET_REFERENCE_PREFIXES = ["{{resolve:secretsmanager:", "{{resolve:ssm-secure:"];
11489
+ /**
11490
+ * Is this expression's secret-ness INFERRED rather than spelled?
11491
+ *
11492
+ * SPELLING, and ONLY spelling. `secretsmanager:` and `ssm-secure:` say what they
11493
+ * are, in a way that is true in every region and every account. A bare
11494
+ * `{{resolve:ssm:` token is not: it is accepted as secret-bearing on the #1901
11495
+ * premise (a public `String` is persisted RESOLVED, so a token SURVIVING in a
11496
+ * state bag is a `SecureString`), which is sound for the leaf itself and NOT
11497
+ * sound as a licence to rewrite every other leaf that merely CONTAINS the value.
11498
+ *
11499
+ * A RECORDED verdict deliberately does NOT promote one, even though it is a real
11500
+ * `GetParameter` answer. {@link recordedSecretExpressions} is keyed on the bare
11501
+ * expression and lives for the whole process, so on a `cdkd deploy --all` a
11502
+ * verdict pinned where the parameter is a `SecureString` is inherited where it
11503
+ * is a plain `String` — and the `skipDynamicReferences` diff path skips the
11504
+ * lookup on a `true` verdict, so the second region never retracts it. That is
11505
+ * the SAME region blindness this PR withdrew issue #2036's public store for; a
11506
+ * secret-direction verdict is safe to inherit for ADMISSION (it can only
11507
+ * over-redact a leaf) and is not safe for BLAST RADIUS. The cost of ignoring it
11508
+ * here is the substring arm for a verdict-backed, same-region ssm
11509
+ * `SecureString` on the empty-map path — a strict subset of the population the
11510
+ * no-verdict case already concedes, and in the same direction.
11511
+ *
11512
+ * The difference is a `--revert` WRITE. Measured on this module: a bare `ssm`
11513
+ * token whose value is `production` turned `my-production-logs` into
11514
+ * `my-{{resolve:ssm:/app/env}}-logs`, exactly the failure
11515
+ * {@link expressionMaySeedANeedle}'s own doc names — and if that parameter is in
11516
+ * fact public, the baseline now holds a value AWS never reported, which `cdkd
11517
+ * drift --revert` re-resolves and pushes, renaming the live bucket the day the
11518
+ * parameter changes.
11519
+ *
11520
+ * So evidence strength decides BLAST RADIUS, not admission: an inferred needle
11521
+ * still closes issue #2012's two rows, because both are WHOLE-VALUE positions
11522
+ * (an unpaired element and an observed key both hold the plaintext and nothing
11523
+ * else). Only the substring arm is withheld. Issue #2036's withdrawn verdict
11524
+ * store is what would promote these to certain; until it returns, scoped by
11525
+ * region and account, this is the honest bound.
11526
+ */
11527
+ function expressionSecretIsInferred(expression) {
11528
+ return !SPELLED_SECRET_REFERENCE_PREFIXES.some((prefix) => expression.startsWith(prefix));
11529
+ }
11530
+ /**
11531
+ * May this expression's resolved value be used as a REDACTION NEEDLE?
11532
+ *
11533
+ * A stricter question than "may this expression be persisted at this position",
11534
+ * which is what `trustAnyExpression` answers, and the difference is the whole
11535
+ * reason this predicate exists. Persisting a source leaf VERBATIM is bounded to
11536
+ * that one position; promoting the value it replaced to a needle rewrites EVERY
11537
+ * leaf in the record that equals it, so a wrong answer here is a false
11538
+ * redaction with a blast radius rather than a mislabelled leaf.
11539
+ *
11540
+ * Two classes, and each was measured rather than reasoned about:
11541
+ *
11542
+ * - a NON-SECRET SERVICE. `isSingleDynamicReferenceToken` accepts any
11543
+ * `{{resolve:<anything>}}` spelling, and the resolver's unsupported-service
11544
+ * arm WARNS and returns the literal — so AWS holds the token text itself and
11545
+ * the leaf beside it is ordinary data. `cdkd drift`'s own
11546
+ * `--revert does not register a live value for a look-alike spelling` case
11547
+ * pins exactly this for its sibling registration path
11548
+ * (`{{resolve:notaservice:/x}}`), and this predicate is what keeps the two
11549
+ * commands answering it the same way.
11550
+ * - a plain `ssm` reference is ACCEPTED, on the same
11551
+ * #1901 premise the whole-token arm one level up already acts on: a public
11552
+ * `String` / `StringList` parameter is persisted RESOLVED, so a
11553
+ * `{{resolve:ssm:` token SURVIVING in a persisted state bag is a
11554
+ * `SecureString` by construction. Requiring a recorded verdict instead would
11555
+ * make the needle unavailable on `cdkd state refresh-observed`, whose process
11556
+ * resolves nothing and therefore records nothing — i.e. it would fail exactly
11557
+ * where issue #2012 is reported. A PROVEN-public verdict would refine this,
11558
+ * and issue #2036's store was to supply one; PR #2415 withdrew it as a
11559
+ * cross-region disclosure, so a genuinely public parameter's resolved value
11560
+ * CAN still seed a needle here. Bounded by the per-record scope and by
11561
+ * {@link MIN_NEEDLE_LENGTH}, and visible as over-redaction rather than as a
11562
+ * leak.
11563
+ */
11564
+ function expressionMaySeedANeedle(expression) {
11565
+ return isRecordedSecretExpression(expression) || SECRET_BEARING_REFERENCE_PREFIXES.some((prefix) => expression.startsWith(prefix));
11566
+ }
11567
+ /**
11568
+ * Learn from a position whose SOURCE is a WHOLE `{{resolve:...}}` token.
11569
+ *
11570
+ * This is the strongest needle available on a readback path, and it asserts
11571
+ * nothing the walk was not already asserting: the caller is about to persist
11572
+ * `expression` OVER `bag` at this very position, which is the claim that `bag`
11573
+ * is that expression's resolved value. Reading the same claim back out as a
11574
+ * needle is free.
11575
+ *
11576
+ * TWO refusals, both narrow and both necessary:
11577
+ *
11578
+ * - a bag leaf that is ITSELF a complete token is not a plaintext at all. It is
11579
+ * a record that was already redacted (a re-scrub, a second
11580
+ * `refresh-observed`), and pairing it with itself would put a `{{resolve:...}}`
11581
+ * string in the needle set, where the value scan's own token guard would then
11582
+ * have to keep stepping over it.
11583
+ * - a token that does not name a SECRET-BEARING reference at all — see
11584
+ * {@link expressionMaySeedANeedle}.
11585
+ */
11586
+ function learnWholeTokenNeedle(collector, bag, source) {
11587
+ if (isSingleDynamicReferenceToken(bag)) return;
11588
+ if (!expressionMaySeedANeedle(source)) return;
11589
+ learnNeedle(collector, bag, source);
11590
+ }
11591
+ /**
11592
+ * Learn from a MIXED leaf — a reference embedded in surrounding text, which
11593
+ * this module calls the DOMINANT CDK shape (an `Fn::Join` around
11594
+ * `secret.secretValueFromJson(...)`).
11595
+ *
11596
+ * The caller is about to persist `source` over `bag`, i.e. it has already
11597
+ * decided the two are the same leaf one resolution apart. Extracting the
11598
+ * plaintext is then arithmetic rather than inference, PROVIDED the extraction
11599
+ * is unambiguous, which is what the guards below establish:
11600
+ *
11601
+ * - EXACTLY ONE span. With two references the text between them cannot be
11602
+ * split between the two resolved values without guessing. This one is
11603
+ * CONSERVATIVE rather than a correctness guard, and saying so is what stops
11604
+ * the next reader treating it as load-bearing: with two RESOLVED references
11605
+ * the frame check below refuses independently, because the computed SUFFIX
11606
+ * would then contain a whole `{{resolve:...}}` token and a resolved readback
11607
+ * cannot end with one. The shape it genuinely decides is a second reference
11608
+ * that survives LITERALLY in the readback — the resolver's
11609
+ * unsupported-service arm (`ssm-secure:`) produces exactly that — where the
11610
+ * extraction would in fact be right and is declined anyway. Measured: a
11611
+ * both-resolved fixture leaves this line unfenced.
11612
+ * - the source's literal PREFIX and SUFFIX must both be present at the ends of
11613
+ * the bag. That is what proves the leaf really is this source resolved; AWS
11614
+ * normalising any of the surrounding text refuses instead of yielding a
11615
+ * needle sliced at the wrong offsets.
11616
+ * - the two must not overlap, and something must remain between them.
11617
+ *
11618
+ * Anchoring at the ENDS rather than searching is deliberate: a secret whose own
11619
+ * text repeats the suffix (`abc@h` inside `postgres://u:abc@h@h`) still slices
11620
+ * correctly, while an `indexOf` scan would cut it short.
11621
+ */
11622
+ function learnMixedLeafNeedle(collector, bag, source) {
11623
+ const spans = dynamicReferenceSpans(source);
11624
+ if (spans.length !== 1) return;
11625
+ const [span] = spans;
11626
+ const token = source.slice(span.start, span.end);
11627
+ if (!expressionMaySeedANeedle(token)) return;
11628
+ const prefix = source.slice(0, span.start);
11629
+ const suffix = source.slice(span.end);
11630
+ if (bag.length <= prefix.length + suffix.length) return;
11631
+ if (!bag.startsWith(prefix) || !bag.endsWith(suffix)) return;
11632
+ const plaintext = bag.slice(prefix.length, bag.length - suffix.length);
11633
+ if (isSingleDynamicReferenceToken(plaintext)) return;
11634
+ learnNeedle(collector, plaintext, token);
11635
+ }
11636
+ /**
11637
+ * Read the mark tree one level down. It has the SAME shape as `refused` by
11638
+ * construction (one function, one set of inputs), but this stays defensive: a
11639
+ * missing level yields `undefined`, which reads as "not decided" and therefore
11640
+ * lets the scan act. That is the same answer the pre-mark code gave, so a shape
11641
+ * surprise cannot silently start SUPPRESSING redaction — but it fails toward
11642
+ * SCANNING, which is the fabrication direction the mark tree exists to stop.
11643
+ * Both are stated because neither default is free; the shapes are identical by
11644
+ * construction (one function, one set of inputs), so this arm is a backstop
11645
+ * rather than a policy.
11646
+ */
11647
+ function asChild(marks, key) {
11648
+ return isPlainObject$2(marks) && hasPlainPrototype(marks) ? marks[key] : void 0;
11649
+ }
11650
+ /** The array-arm twin of {@link asChild}; same fail-open, same reason. */
11651
+ function asIndex(marks, index) {
11652
+ return Array.isArray(marks) ? marks[index] : void 0;
11653
+ }
11654
+ /**
11655
+ * Merge the DERIVED-needle value scan back over the two POSITION passes, so the
11656
+ * scan can only ever ADD a rewrite and never EDIT one.
11657
+ *
11658
+ * WHY THIS EXISTS AT ALL. Issue #2012's fix has been through both orderings and
11659
+ * each has its own way of turning a redaction fix into a fabricated baseline —
11660
+ * the two are mirror images, which is why the answer is a merge rather than a
11661
+ * third choice of order:
11662
+ *
11663
+ * - SCAN FIRST (the first revision): a needle rewrites a frame LITERAL that
11664
+ * happens to embed a learned plaintext, {@link unkeyedArrayPairsByAnchors} is
11665
+ * then re-run against the SCANNED bag, the anchor no longer deep-equals its
11666
+ * source, the whole array refuses, and a sibling MIXED leaf that position
11667
+ * ALREADY redacted persists in plaintext. Under-redaction.
11668
+ * - SCAN LAST, unrestricted (the second): the scan now runs over leaves whose
11669
+ * content came from the SOURCE. A needle occurring in the literal FRAME of a
11670
+ * source-taken mixed leaf is replaced, so
11671
+ * `postgres://appuser:{{resolve:secretsmanager:...}}@h/db` becomes
11672
+ * `postgres://{{resolve:ssm:/app/db-user}}:{{resolve:...}}@h/db` when
11673
+ * `appuser` is also some whole-token position's resolved value — a reference
11674
+ * the template never had at that offset. `cdkd drift --revert` re-resolves the
11675
+ * baseline before pushing it, so once that parameter's value changes the
11676
+ * revert writes a DIFFERENT user to the live resource. Fabricated baseline,
11677
+ * which is the bar {@link refuseUncertifiedReadbackPositions} refuses to break
11678
+ * at its own bottom.
11679
+ *
11680
+ * THE RULE. A position the passes DECIDED is theirs; a position they left alone
11681
+ * belongs to the scan. Expressed as a walk over their OUTPUT rather than as a
11682
+ * second copy of their pairing logic, because a mirror of `identityKeyFor` /
11683
+ * `unkeyedArrayPairsByAnchors` would drift from the original and a needle
11684
+ * applied at a MIS-paired position is a false redaction everywhere it matches.
11685
+ * The shapes line up position-for-position for free: neither pass adds a key,
11686
+ * an element, or a scalar-over-container, so `refused` is `bag`'s own shape with
11687
+ * some leaves replaced.
11688
+ *
11689
+ * `typeof bag === 'string'` at the leaf: a derived needle can only ever rewrite
11690
+ * a STRING, so for every other leaf the two answers agree and taking `refused`
11691
+ * is free.
11692
+ *
11693
+ * KEEPING A NON-PLAIN LEAF INTACT takes the prototype guard on the object arm,
11694
+ * NOT that leaf rule, and an earlier revision of this comment claimed the
11695
+ * opposite — measured wrong. The scan's own walk rebuilds objects and turns a
11696
+ * `Date` the provider readback carries (`LastModified`) into `{}`; that
11697
+ * flattening predates this module's derived needles on the POPULATED-map path
11698
+ * (issue #2427). The object arm runs FIRST here and `isPlainObject` admits a
11699
+ * `Date`, so without `hasPlainPrototype` this walk did the flattening ITSELF —
11700
+ * newly extending #2427 to the EMPTY-map path, where the unchanged-resource
11701
+ * `drainObservedCaptures` baseline lives and where `cdkd drift --revert` pushes
11702
+ * the result to the live resource. With the guard a non-plain leaf falls
11703
+ * through to `refused`. That is the position passes' own answer — usually the
11704
+ * bag by identity, though NOT universally: their object arm has no prototype
11705
+ * guard of its own, so a non-plain leaf whose source subtree carries a
11706
+ * reference is already flattened one function earlier. Same defect as issue
11707
+ * #2427, one layer up, and out of this lane's scope.
11708
+ *
11709
+ * The net effect is byte-identical to the FIRST ordering on every input where
11710
+ * the un-certification did not fire — which is the whole point: it keeps that
11711
+ * ordering's intent and drops only its defect.
11712
+ */
11713
+ function preferPositionDecisions(scanned, refused, bag, marks, inferred) {
11714
+ if (isPlainObject$2(bag) && hasPlainPrototype(bag) && isPlainObject$2(refused) && isPlainObject$2(scanned)) {
11715
+ const out = Object.create(null);
11716
+ for (const [k, v] of Object.entries(refused)) out[k] = preferPositionDecisions(scanned[k], v, bag[k], asChild(marks, k), inferred);
11717
+ return out;
11718
+ }
11719
+ if (Array.isArray(bag) && Array.isArray(refused) && Array.isArray(scanned) && refused.length === bag.length && scanned.length === bag.length) return refused.map((item, i) => preferPositionDecisions(scanned[i], item, bag[i], asIndex(marks, i), inferred));
11720
+ if (typeof bag !== "string" || marks === POSITION_DECIDED) return refused;
11721
+ return scanned === bag ? inferred.get(bag) ?? scanned : scanned;
11722
+ }
11723
+ /**
11724
+ * DERIVED NEEDLES (issue [#2012](https://github.com/go-to-k/cdkd/issues/2012)):
11725
+ * the secrets map an empty-map readback path can build from its OWN two bags,
11726
+ * with no resolution, no AWS call and no new permission.
11727
+ *
11728
+ * THE PROBLEM THIS ANSWERS. On the readback paths the secrets map is empty by
11729
+ * construction (nothing was resolved), so the value scan has no needles and
11730
+ * POSITION is the only mechanism. Two shapes have no position to argue from and
11731
+ * kept their plaintext: an UNPAIRED array element beside a paired one, and an
11732
+ * observed KEY the source does not carry. Both are places {@link redactByPath}
11733
+ * ALREADY delegates to the value scan — it is the scan that had nothing to say.
11734
+ *
11735
+ * WHAT MAKES A NEEDLE AVAILABLE WITHOUT FETCHING. The same record almost always
11736
+ * carries the same secret at a position the pass DOES certify: the paired
11737
+ * sibling, the key the source does carry. Certifying such a position IS the
11738
+ * assertion that AWS's value there is that expression's resolved form — the
11739
+ * pass acts on it by persisting the expression over it. Reading that assertion
11740
+ * back out gives a plaintext, and a plaintext is exactly what the value scan
11741
+ * was missing. Issue #2012's own direction was to RESOLVE the record's
11742
+ * expressions to get one, which would have made `cdkd state refresh-observed`
11743
+ * and every deploy's observed capture FETCH secrets: a new IAM requirement, a
11744
+ * new failure mode and a new place plaintext lives. None of that is needed —
11745
+ * AWS already handed us the plaintext, in the very bag being redacted.
11746
+ *
11747
+ * `redactByPath`'s own comment argued the opposite direction and was right
11748
+ * about it: seeding the scan from the SOURCE's expressions cannot work, because
11749
+ * a scan needs PLAINTEXT needles. This seeds it from the BAG's values, which is
11750
+ * the half that exists.
11751
+ *
11752
+ * SCOPE, and why each bound is where it is:
11753
+ *
11754
+ * - ONLY the readback-projected rules. Every other caller either has a real map
11755
+ * or has a source of a different generation, where a value learned from one
11756
+ * generation must not rewrite the other.
11757
+ * - ONLY when the map is EMPTY, and this bound is load-bearing for a reason
11758
+ * that is not obvious: {@link crossStackAssociations} and
11759
+ * {@link nestedStackParameterExpressions} are `WeakMap`s keyed by the
11760
+ * RecordedSecretValues INSTANCE, so handing the pipeline a different Map
11761
+ * object would silently lose every association that pass recorded. With an
11762
+ * empty map there are none to lose (an association is only ever recorded for
11763
+ * a plaintext that map holds).
11764
+ * - the pairs are scoped to ONE record, exactly as `perResourceSecrets` is on
11765
+ * the deploy path, so one resource's secret can never rewrite another's
11766
+ * coinciding literal.
11767
+ *
11768
+ * WHERE THE RESULT IS APPLIED, and this is a SEQUENCING claim rather than a
11769
+ * scoping one: the returned map is handed to a plain VALUE pass over the RAW
11770
+ * bag, whose result is then MERGED over the output of both position passes by
11771
+ * {@link preferPositionDecisions}. It reaches neither position pass. Both naive
11772
+ * orderings are wrong and that function's doc has the measurements: scanning
11773
+ * FIRST lets a needle rewrite a frame LITERAL and un-certify an anchor pairing
11774
+ * (under-redaction), scanning LAST over their output lets one rewrite a literal
11775
+ * inside a leaf they took from SOURCE (a fabricated baseline). The merge is what
11776
+ * makes "a derived needle can only ADD rewrites" literally true.
11777
+ *
11778
+ * Returns `undefined` when nothing is learned, so the no-secret path skips the
11779
+ * scan and the merge entirely and stays byte-identical to the position passes'
11780
+ * own output.
11781
+ */
11782
+ function deriveReadbackNeedles(bag, source, secrets, rules) {
11783
+ if (!isReadbackProjectedFromState(rules)) return void 0;
11784
+ if (secrets.size > 0) return void 0;
11785
+ if (!subtreeHasDynamicReference(source)) return void 0;
11786
+ const collector = {
11787
+ needles: /* @__PURE__ */ new Map(),
11788
+ poisoned: /* @__PURE__ */ new Set(),
11789
+ inferred: /* @__PURE__ */ new Set()
11790
+ };
11791
+ refuseUncertifiedReadbackPositions(bag, source, secrets, collector);
11792
+ if (collector.needles.size === 0) return void 0;
11793
+ const certain = /* @__PURE__ */ new Map();
11794
+ const inferred = /* @__PURE__ */ new Map();
11795
+ for (const [plaintext, expression] of collector.needles) (collector.inferred.has(plaintext) ? inferred : certain).set(plaintext, expression);
11796
+ return {
11797
+ certain,
11798
+ inferred
11799
+ };
11800
+ }
11801
+ /**
11401
11802
  * Refuse to persist a readback leaf the path pass could not CERTIFY, at any
11402
11803
  * position the STATE source proves is secret-bearing (issue #1926 review).
11403
11804
  *
@@ -11482,39 +11883,59 @@ function unkeyedArrayPairsByAnchors(bag, source) {
11482
11883
  * --revert` pushes the BASELINE to AWS, so a masked baseline would write the
11483
11884
  * literal `***` onto the live resource (the issue #1498 / #1501 class).
11484
11885
  *
11485
- * KNOWN RESIDUAL, the last row: an observed KEY the source object does not
11486
- * carry has no source leaf to take and no needle to match. It is NOT refused
11487
- * the way an unpaired array ELEMENT is, and the asymmetry is deliberate rather
11488
- * than an oversight an extra array element is a PEER of the secret-bearing
11489
- * ones (another `Environment` entry), so suspicion is warranted and extras are
11490
- * rare, while an extra object KEY is a different FIELD entirely (`Runtime`,
11491
- * `FunctionArn`, `LastModified`) and is the NORM in an AWS readback. Refusing
11492
- * those would empty the drift baseline of every secret-bearing resource.
11493
- * Tracked as issue [#2012](https://github.com/go-to-k/cdkd/issues/2012).
11494
- */
11495
- function refuseUncertifiedReadbackPositions(bag, source, secrets) {
11886
+ * The last two rows were the RESIDUAL and are closed by DERIVED NEEDLES (issue
11887
+ * #2012) see {@link deriveReadbackNeedles}. Neither has a position to argue
11888
+ * from: an unpaired array element and an observed KEY the source does not carry
11889
+ * are both positions with no source leaf to take. What they never lacked was a
11890
+ * VALUE the same plaintext usually sits at a position this pass DOES certify,
11891
+ * and certifying it is already an assertion that the value is that expression's
11892
+ * resolved form. Reading that assertion back out as a needle turns the value
11893
+ * scan on for the rest of the record without resolving anything.
11894
+ *
11895
+ * The extra-KEY asymmetry stays as it was and is worth restating, because the
11896
+ * needle does not replace it: an extra array element is a PEER of the
11897
+ * secret-bearing ones (another `Environment` entry), while an extra object KEY
11898
+ * is a different FIELD entirely (`Runtime`, `FunctionArn`, `LastModified`) and
11899
+ * is the NORM in an AWS readback. Refusing those wholesale would empty the
11900
+ * drift baseline of every secret-bearing resource, which is why they are
11901
+ * value-scanned rather than refused.
11902
+ *
11903
+ * `learn` is the LEARN PASS's collector and is `undefined` on the substituting
11904
+ * pass. It changes NO verdict — every branch below decides exactly what it
11905
+ * decided before — it only records the (plaintext, expression) pairs the
11906
+ * certified positions establish. Deriving them through this function rather
11907
+ * than a second walk is deliberate: the pairing rules (identity keys, anchor
11908
+ * corroboration, the refusals) are subtle enough that a mirror of them would
11909
+ * drift, and a needle learned from a MIS-paired position is a false redaction
11910
+ * everywhere it then matches.
11911
+ */
11912
+ function refuseUncertifiedReadbackPositions(bag, source, secrets, learn, mark) {
11496
11913
  if (isDynamicReferenceString(source) && typeof bag === "string") {
11497
- if (isSingleDynamicReferenceToken(source)) return source;
11498
- if (mixedLeafMayCarryPublicReference(source, secrets)) return bag;
11499
- return source;
11914
+ if (isSingleDynamicReferenceToken(source)) {
11915
+ if (learn) learnWholeTokenNeedle(learn, bag, source);
11916
+ return mark ? POSITION_DECIDED : source;
11917
+ }
11918
+ if (mixedLeafMayCarryPublicReference(source, secrets)) return mark ? POSITION_DECIDED : bag;
11919
+ if (learn) learnMixedLeafNeedle(learn, bag, source);
11920
+ return mark ? POSITION_DECIDED : source;
11500
11921
  }
11501
11922
  if (!subtreeHasDynamicReference(source)) return bag;
11502
11923
  if (isPlainObject$2(bag) && isPlainObject$2(source)) {
11503
11924
  const out = Object.create(null);
11504
- for (const [k, v] of Object.entries(bag)) out[k] = Object.hasOwn(source, k) ? refuseUncertifiedReadbackPositions(v, source[k], secrets) : v;
11925
+ for (const [k, v] of Object.entries(bag)) out[k] = Object.hasOwn(source, k) ? refuseUncertifiedReadbackPositions(v, source[k], secrets, learn, mark) : v;
11505
11926
  return out;
11506
11927
  }
11507
11928
  if (Array.isArray(bag) && Array.isArray(source)) {
11508
11929
  const key = identityKeyFor(bag, source);
11509
11930
  if (key === void 0) {
11510
11931
  if (!unkeyedArrayPairsByAnchors(bag, source)) return bag;
11511
- return bag.map((item, i) => refuseUncertifiedReadbackPositions(item, source[i], secrets));
11932
+ return bag.map((item, i) => refuseUncertifiedReadbackPositions(item, source[i], secrets, learn, mark));
11512
11933
  }
11513
11934
  const sourceByIdentity = /* @__PURE__ */ new Map();
11514
11935
  for (const item of source) sourceByIdentity.set(item[key], item);
11515
11936
  return bag.map((item) => {
11516
11937
  const partner = sourceByIdentity.get(item[key]);
11517
- return partner === void 0 ? item : refuseUncertifiedReadbackPositions(item, partner, secrets);
11938
+ return partner === void 0 ? item : refuseUncertifiedReadbackPositions(item, partner, secrets, learn, mark);
11518
11939
  });
11519
11940
  }
11520
11941
  return bag;
@@ -11532,7 +11953,12 @@ function redactSecretsForState(bag, secrets, source, rules = TEMPLATE_DERIVED_RU
11532
11953
  if (secrets.size === 0 && source === void 0) return bag;
11533
11954
  if (source !== void 0) {
11534
11955
  const positioned = redactByPath(bag, source, secrets, rules, new Set(secrets.values()));
11535
- return isReadbackProjectedFromState(rules) ? refuseUncertifiedReadbackPositions(positioned, source, secrets) : positioned;
11956
+ if (!isReadbackProjectedFromState(rules)) return positioned;
11957
+ const refused = refuseUncertifiedReadbackPositions(positioned, source, secrets);
11958
+ const derived = deriveReadbackNeedles(bag, source, secrets, rules);
11959
+ if (derived === void 0) return refused;
11960
+ const marks = refuseUncertifiedReadbackPositions(positioned, source, secrets, void 0, true);
11961
+ return preferPositionDecisions(redactSecretsForState(bag, derived.certain), refused, bag, marks, derived.inferred);
11536
11962
  }
11537
11963
  const regex = buildNeedleRegex(secrets.keys());
11538
11964
  const wholeValueExpr = (s) => s === "" ? void 0 : secrets.get(s);
@@ -20729,7 +21155,7 @@ var CloudControlProvider = class {
20729
21155
  await this.confirmDeleteTargetIdentity(logicalId, resourceType, physicalId, context);
20730
21156
  if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
20731
21157
  this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
20732
- const { ASGProvider } = await import("./asg-provider-D3bSjRiw.js").then((n) => n.n);
21158
+ const { ASGProvider } = await import("./asg-provider-sRJzRAe_.js").then((n) => n.n);
20733
21159
  return await new ASGProvider().delete(logicalId, physicalId, resourceType, _properties, context);
20734
21160
  }
20735
21161
  const isProtectedEc2Instance = context?.removeProtection === true && resourceType === "AWS::EC2::Instance";
@@ -32718,4 +33144,4 @@ var DeployEngine = class {
32718
33144
 
32719
33145
  //#endregion
32720
33146
  export { maskerOrIdentity as $, CFN_TEMPLATE_BODY_LIMIT as $n, maskSecretsInText as $t, renderStatefulReason as A, describeAwsFailure as An, PartialFailureError as Ar, requireConfigString as At, exportAliasCollisionScrubWarning as B, Synthesizer as Bn, normalizeAwsError as Br, INTRINSIC_KEYS as Bt, isFinalSnapshotError as C, getBootstrapMarkerKey as Cn, DynamicReferenceRegionAmbiguousError as Cr, coerceCfnBoolean as Ct, extractDeploymentEventError as D, validateAssetBucketName as Dn, LockError as Dr, replayWarn as Dt, makeCanonicalizePropertiesFn as E, readBootstrapMarkerBody as En, LocalStartServiceError as Er, readConfigString as Et, green as F, partitionSensitiveEnv as Fn, StackTerminationProtectionError as Fr, s3BucketDualStackDomainName as Ft, IAMRoleProvider as G, resolveAutoAssetStorage as Gn, isTransientServerError as Gr, STATE_SOURCED_CROSS_GENERATION_RULES as Gt, secretBearingStateKeyWarning as H, getDefaultStateBucketName as Hn, isMarkedNonRetryable as Hr, withRetry as Ht, red as I, runDockerForeground as In, StateError as Ir, s3BucketRegionalDomainName as It, ProviderRegistry as J, resolveStateBucketWithDefault as Jn, retryClassificationText as Jr, createSecretMasker as Jt, collectInlinePolicyNamesManagedBySiblings as K, resolveCaptureObservedState as Kn, markNonRetryable as Kr, STATE_SOURCED_READBACK_RULES as Kt, yellow as L, runDockerStreaming as Ln, SynthesisError as Lr, s3BucketWebsiteUrl as Lt, bold as M, dockerSpawnEnvWithSensitive as Mn, ResourceTimeoutError as Mr, producerRegionsFromState as Mt, cyan as N, formatDockerLoginError as Nn, ResourceUpdateNotSupportedError as Nr, s3BucketArn as Nt, MULTI_REGION_RECREATE_BLOCKED_TYPES as O, validateContainerRepoName as On, MissingCdkCliError as Or, requireConfigArray as Ot, gray as P, getDockerCmd as Pn, StackHasActiveImportsError as Pr, s3BucketDomainName as Pt, maskDeep as Q, warnDeprecatedNoPrefixCliFlag as Qn, maskSecretsInError as Qt, collectDeclaredOutputNames as R, AssetManifestLoader as Rn, formatError as Rr, applyRoleArnIfSet as Rt, createPreDeleteFinalSnapshot as S, ensureAssetStorage as Sn, DeployCancelledError as Sr, assertRegionMatch as St, unsupportedFinalSnapshotError as T, parseBootstrapMarker as Tn, LocalMigrateError as Tr, configStringRefusal as Tt, stateKeySecretExposure as U, getLegacyStateBucketName as Un, isRetryableTransientError as Ur, DagBuilder as Ut, isExportAliasCollision as V, synthesisStatusMessage as Vn, withErrorHandling as Vr, describeTypeWithThrottleRetry as Vt, getCurrentResourceSecrets as W, resolveApp as Wn, isThrottlingError as Wr, TemplateParser as Wt, findSilentDropProperties as X, resolveUseCdkBootstrapAssets as Xn, errorCauseChain as Xt, findActionableSilentDrops as Y, resolveStateBucketWithDefaultAndSource as Yn, __exportAll as Yr, dynamicReferenceTokens as Yt, createMaskedRetryLogger as Z, stateBucketExistenceConfirmed as Zn, isSingleDynamicReferenceToken as Zt, computeImplicitDeleteEdges as _, escapeRegExp$1 as _n, AssetError as _r, parameterTypeMayLoseSecretIdentity as _t, DeploymentEventsStore as a, rebuildClientForBucketRegion as an, expectedOwnerParam as ar, isInterruptedWaitError as at, buildFinalSnapshotIdentifier as b, BOOTSTRAP_MARKER_PREFIX as bn, CrossAccountSecretRefusalError as br, normalizeAwsTagsToCfn as bt, replayFailedOperations as c, importableOutputs as cn, derivePartitionAndUrlSuffix as cr, slowCcOperationTimeoutMs as ct, updatePartialReason as d, stringifyValue as dn, clearBucketRegionCache as dr, IntrinsicFunctionResolver as dt, redactSecretsForState as en, CFN_TEMPLATE_URL_LIMIT as er, CUSTOM_RESOURCE_RESPONSE_PREFIX as et, UNSPECIFIED_SKIP_REASON as f, WorkGraph as fn, resolveBucketRegion as fr, carriesDynamicReference as ft, IMPLICIT_DELETE_DEPENDENCIES as g, rewriteTemplateAssetReferences as gn, setAwsClients as gr, isUnboundTemplateParameter as gt, maskingRetryLogger as h, loadPublishableAssetManifest as hn, resetAwsClients as hr, getAccountInfo as ht, DeploymentEventsReader as i, S3StateBackend as in, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as ir, interruptWatchListenerCount as it, formatResourceLine as j, buildDockerImage as jn, ProvisioningError as jr, classifyReplaySecretRegion as jt, isStatefulRecreateTargetSync as k, buildDenyExternalAccessPolicy as kn, NestedStackChildDirectDestroyError as kr, requireConfigObject as kt, replayRollback as l, shouldRetainResource as ln, AssemblyReader as lr, disableInstanceApiTermination as lt, withResourceDeadline as m, createAssetRedirectResolver as mn, getAwsClients as mr, coerceParameterTypedValue as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, LockManager as nn, findLargeInlineResources as nr, beginCommandInterruptScope as nt, planFailedOps as o, exportNamesCarriedFrom as on, PARTITION_TABLE as or, startInterruptWatch as ot, deleteSkipReason as p, buildAssetRedirectMap as pn, AwsClients as pr, cfnRefValueFromPhysicalId as pt, clearOnUpdateRemoval as q, resolveSkipPrefix as qn, markRedactedCause as qr, TEMPLATE_SOURCED_RULES as qt, DeployEngine as r, displaySafe as rn, uploadCfnTemplate as rr, endCommandInterruptScope as rt, planRollback as s, importableOutputKeys as sn, canonicalizeRegion as sr, CloudControlProvider as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, scrubResourceRecord as tn, MIGRATE_TMP_PREFIX as tr, DEFAULT_STATE_PREFIX as tt, updatePartialMessage as u, AssetPublisher as un, processStackMessages as ur, isTerminationProtectionPropagationError as ut, ATOMIC_FINAL_SNAPSHOT_TYPES as v, stripControlChars as vn, CdkdError as vr, refStateLookupFromResource as vt, refusesFinalSnapshot as w, isCrossRegionRedirect as wn, LocalInvokeBuildError as wr, configBooleanRefusal as wt, ccRoutedFinalSnapshotError as x, assertAssetBucketRegion as xn, DependencyError as xr, resolveExplicitPhysicalId as xt, PRE_DELETE_SNAPSHOT_TYPES as y, AssetModeResolver as yn, ConfigError as yr, WAFv2WebACLProvider as yt, collectPublishedOutputNames as z, getDockerImageBySourceHash as zn, isCdkdError as zr, DiffCalculator as zt };
32721
- //# sourceMappingURL=deploy-engine-3lJaFGjA.js.map
33147
+ //# sourceMappingURL=deploy-engine-3EmPzxSN.js.map