-
-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathgather.ts
More file actions
2408 lines (2336 loc) · 126 KB
/
Copy pathgather.ts
File metadata and controls
2408 lines (2336 loc) · 126 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Shared read+classify pipeline used by both `check` and `record`.
import { CloudControlClient, GetResourceCommand } from '@aws-sdk/client-cloudcontrol';
import {
CloudFormationClient,
DescribeStackResourcesCommand,
ListStackResourcesCommand,
} from '@aws-sdk/client-cloudformation';
import {
DescribeSecurityGroupsCommand,
DescribeSubnetsCommand,
DescribeRegionsCommand,
EC2Client,
GetDefaultCreditSpecificationCommand,
GetEbsEncryptionByDefaultCommand,
GetInstanceMetadataDefaultsCommand,
type UnlimitedSupportedInstanceFamily,
} from '@aws-sdk/client-ec2';
import { ECSClient, ListAccountSettingsCommand } from '@aws-sdk/client-ecs';
import {
DescribeCertificatesCommand,
DescribeOptionGroupOptionsCommand,
RDSClient,
} from '@aws-sdk/client-rds';
import {
DatabaseMigrationServiceClient,
DescribeOrderableReplicationInstancesCommand,
} from '@aws-sdk/client-database-migration-service';
import { GetServiceSettingCommand, SSMClient } from '@aws-sdk/client-ssm';
import { buildCorpusCase, CORPUS_DIR_ENV, recordCorpusCase } from '../corpus/record.js';
import { type Desired, loadDesired } from '../desired/template-adapter.js';
import {
AAS_SCALABLE_DIMENSIONS,
type AccountDefaults,
CLUSTER_ECHO_CHILD,
classifyResource,
normalizeLiveModel,
} from '../diff/classify.js';
import { resolveProperties } from '../normalize/intrinsic-resolver.js';
import { READ_RETRY } from '../read/client-config.js';
import {
fetchManagedAliasTargets,
kmsWarnDecision,
typeNeedsManagedKeyResolution,
usesManagedKmsAlias,
} from '../read/kms-aliases.js';
import { type AddedChild, CHILD_ENUMERATORS } from '../read/child-enumerators.js';
import { SDK_OVERRIDES } from '../read/overrides.js';
import { CC_IDENTIFIER_ADAPTERS, readLive, type ReadResult } from '../read/router.js';
import { getSchemaInfoResult } from '../schema/schema-strip.js';
import type { DesiredResource, Finding, SchemaInfo } from '../types.js';
export interface GatherResult {
desired: Desired;
findings: Finding[];
schemas: Map<string, SchemaInfo>; // resourceType -> schema (so revert can honor createOnly)
// logicalId -> the UN-stripped live model (CC GetResource / SDK override read), kept so
// the revert write path can see live-only data the compare-side strips — notably the
// `aws:*` managed tags `stripAwsTagsDeep` removes, which a Tags revert must preserve on
// the WRITE side (tagPreservingOps). Resources with no readable live model are absent.
liveByLogical: Map<string, Record<string, unknown>>;
}
// Project the per-resource live reads into the logicalId -> live-model map carried on
// GatherResult (only the resources that actually read back a model).
function liveModelMap(reads: Map<string, ReadResult>): Map<string, Record<string, unknown>> {
const out = new Map<string, Record<string, unknown>>();
for (const [logicalId, read] of reads) if (read.live) out.set(logicalId, read.live);
return out;
}
// #889: resource types whose UNDECLARED default security-group list (ALB SecurityGroups / ENI
// GroupSet) is gated in classify against the VPC-default SG ids — mirror of
// typeNeedsManagedKeyResolution. When a stack declares one, prefetch the account/region default
// SGs so the classifier can DERIVE-gate the fold (single default SG folds; an append/swap surfaces).
export const DEFAULT_SG_LIST_TYPES: ReadonlySet<string> = new Set([
'AWS::ElasticLoadBalancingV2::LoadBalancer',
'AWS::EC2::NetworkInterface',
// #976: Neptune DBCluster's undeclared VpcSecurityGroupIds default is the VPC default SG —
// gated in classify against the prefetched default-SG ids so an OOB swap/append surfaces.
'AWS::Neptune::DBCluster',
// #1266: AmazonMQ Broker's undeclared SecurityGroups default is the VPC default SG — same gate,
// so the prefetch must fire when a broker is present too.
'AWS::AmazonMQ::Broker',
// #1269: RedshiftServerless Workgroup's undeclared SecurityGroupIds default is the default-VPC SG
// — same gate, so the prefetch must fire when a workgroup is present too.
'AWS::RedshiftServerless::Workgroup',
// #1492: a Redshift Cluster's undeclared VpcSecurityGroupIds default is the VPC default SG (the
// #976 Neptune shape) — registered in classify DEFAULT_SG_LIST_PATHS, so the prefetch must fire
// when a cluster is present or the OOB-swap gate loses its default-SG ids (detection silently lost).
'AWS::Redshift::Cluster',
// #1499: the RDS/DocDB twins of the #976 Neptune shape — a barest DBCluster/DBInstance/DocDB
// cluster that declares no security groups reads back the VPC default SG. These are registered in
// classify DEFAULT_SG_LIST_PATHS (VpcSecurityGroupIds / VPCSecurityGroups) but were MISSING here,
// so for a stack containing only such a type the prefetch never fired → defaultSgIds empty →
// shouldFoldDefaultSgList failed OPEN and an OOB `rds/docdb modify-db-cluster|instance
// --vpc-security-group-ids` swap/append was silently NOT detected. Register them so the prefetch
// fires and the derived VPC-default-SG gate keeps its swap/append detection.
'AWS::RDS::DBCluster',
'AWS::RDS::DBInstance',
'AWS::DocDB::DBCluster',
// #640: an EC2::Instance that declares no security group reads back its VPC default SG in
// SecurityGroupIds (registered in classify DEFAULT_SG_LIST_PATHS by PR #1449). It too was MISSING
// here, so a stack whose only default-SG-gated resource is a bare instance (no ENI/ALB present)
// never fired the prefetch → defaultSgIds empty → an OOB `ec2 modify-instance-attribute --groups`
// swap/append was silently NOT detected. Register it so the derived gate keeps its detection.
'AWS::EC2::Instance',
// #1532: a DAX cluster's undeclared SecurityGroupIds default is the subnet-group VPC's
// default SG — registered in classify DEFAULT_SG_LIST_PATHS, so the prefetch must fire when
// a DAX cluster is present or the OOB-swap gate loses its default-SG ids.
'AWS::DAX::Cluster',
// #1557: a DMS ReplicationInstance that declares no VpcSecurityGroupIds reads back its
// subnet-group VPC's default SG — registered in classify DEFAULT_SG_LIST_PATHS, so the prefetch
// must fire when a replication instance is present or the OOB-swap gate loses its default-SG ids.
'AWS::DMS::ReplicationInstance',
// A ClientVpnEndpoint that declares no SecurityGroupIds gains its associated VPC's default SG
// when the first target-network association lands (live-confirmed us-east-1 2026-07-13) —
// registered in classify DEFAULT_SG_LIST_PATHS, so the prefetch must fire when an endpoint is
// present or the OOB-swap gate loses its default-SG ids.
'AWS::EC2::ClientVpnEndpoint',
// A VPC Lattice resource gateway that declares no SecurityGroupIds reads back its VPC's
// default SG (live, lattice2-hunt 2026-07-15) — registered in classify
// DEFAULT_SG_LIST_PATHS, so the prefetch must fire when a resource gateway is present or
// the OOB-swap gate loses its default-SG ids.
'AWS::VpcLattice::ResourceGateway',
// #1699: a barest Interface VPC endpoint that declares no SecurityGroupIds is placed into
// the VPC's default SG (live, elbpack-hunt 2026-08-01) — registered in classify
// DEFAULT_SG_LIST_PATHS, so the prefetch must fire when a VPC endpoint is present or the
// OOB-swap gate loses its default-SG ids.
'AWS::EC2::VPCEndpoint',
]);
// #1269: types whose undeclared SubnetIds default to ALL of the account's DEFAULT-VPC subnets —
// gated in classify against the prefetched default-VPC subnet ids so an OOB re-placement into a
// non-default subnet surfaces. Distinct from DEFAULT_SG_LIST_TYPES (that is a single-SG gate).
const DEFAULT_SUBNET_LIST_TYPES: ReadonlySet<string> = new Set([
'AWS::RedshiftServerless::Workgroup',
]);
// #889: fetch the account/region VPC-default security-group ids — one `DescribeSecurityGroups`
// filtered by group-name=default returns exactly one default SG per VPC. Mirrors the
// fetchManagedAliasTargets prefetch pattern: cached per region, FAIL OPEN (return an empty set on
// ANY error — missing ec2:DescribeSecurityGroups, throttle, network) so classify keeps folding the
// undeclared SG list and a clean deploy never gains a first-run false positive. The derived
// swap/append detection is therefore best-effort and requires ec2:DescribeSecurityGroups.
const defaultSgIdsCache = new Map<string, Set<string>>();
async function fetchDefaultSgIds(region: string): Promise<Set<string>> {
const cached = defaultSgIdsCache.get(region);
if (cached) return cached;
const ids = new Set<string>();
try {
const c = new EC2Client({ region, ...READ_RETRY });
let token: string | undefined;
do {
const r = await c.send(
new DescribeSecurityGroupsCommand({
Filters: [{ Name: 'group-name', Values: ['default'] }],
NextToken: token,
MaxResults: 1000,
})
);
for (const g of r.SecurityGroups ?? []) if (g.GroupId) ids.add(g.GroupId);
token = r.NextToken;
} while (token);
} catch {
// Fail open: leave the set empty so classify keeps folding (no new first-run false positive).
// Not cached on error, so the next stack in the region retries (mirrors the transient path).
return ids;
}
defaultSgIdsCache.set(region, ids);
return ids;
}
// #1269: fetch the account/region DEFAULT-VPC subnet ids — one `DescribeSubnets` filtered by
// `default-for-az=true` returns exactly the default VPC's subnets (one per AZ; a default VPC's
// subnets ARE its default-for-az subnets). Mirrors fetchDefaultSgIds: cached per region, FAIL OPEN
// (empty set on ANY error — missing ec2:DescribeSubnets, throttle, network) so classify keeps
// folding the undeclared subnet list and a clean deploy never gains a first-run false positive.
const defaultSubnetIdsCache = new Map<string, Set<string>>();
async function fetchDefaultVpcSubnetIds(region: string): Promise<Set<string>> {
const cached = defaultSubnetIdsCache.get(region);
if (cached) return cached;
const ids = new Set<string>();
try {
const c = new EC2Client({ region, ...READ_RETRY });
let token: string | undefined;
do {
const r = await c.send(
new DescribeSubnetsCommand({
Filters: [{ Name: 'default-for-az', Values: ['true'] }],
NextToken: token,
MaxResults: 1000,
})
);
for (const s of r.Subnets ?? []) if (s.SubnetId) ids.add(s.SubnetId);
token = r.NextToken;
} while (token);
} catch {
// Fail open: leave the set empty so classify keeps folding (no new first-run false positive).
// Not cached on error, so the next stack in the region retries (mirrors the transient path).
return ids;
}
defaultSubnetIdsCache.set(region, ids);
return ids;
}
// #1070: the effective account/region default settings a few undeclared defaults derive from — each
// is an account-level control the owner can change (a documented hardening best practice), so a
// fixed KNOWN_DEFAULTS pin FPs on every fresh deploy in an account that adopted it. Each lookup is
// ONE read-only call, cached per region, and FAILS OPEN (returns undefined on any error — denied
// permission, throttle, network — WITHOUT caching, so the next stack retries) so classify falls back
// to the factory-default constant and a clean deploy never gains a first-run false positive. The
// derived out-of-band change detection is therefore best-effort and requires the read permission.
// ecs:ListAccountSettings effective `containerInsights` — AWS::ECS::Cluster.ClusterSettings default.
const ecsContainerInsightsCache = new Map<string, string | undefined>();
async function fetchEcsContainerInsightsDefault(region: string): Promise<string | undefined> {
if (ecsContainerInsightsCache.has(region)) return ecsContainerInsightsCache.get(region);
try {
const c = new ECSClient({ region, ...READ_RETRY });
const r = await c.send(
new ListAccountSettingsCommand({ name: 'containerInsights', effectiveSettings: true })
);
const value = r.settings?.find((s) => s.name === 'containerInsights')?.value;
ecsContainerInsightsCache.set(region, value);
return value;
} catch {
return undefined;
}
}
// ssm:GetServiceSetting `/ssm/parameter-store/default-parameter-tier` — AWS::SSM::Parameter.Tier default.
const ssmParameterTierCache = new Map<string, string | undefined>();
async function fetchSsmDefaultParameterTier(region: string): Promise<string | undefined> {
if (ssmParameterTierCache.has(region)) return ssmParameterTierCache.get(region);
try {
const c = new SSMClient({ region, ...READ_RETRY });
const r = await c.send(
new GetServiceSettingCommand({ SettingId: '/ssm/parameter-store/default-parameter-tier' })
);
const value = r.ServiceSetting?.SettingValue;
ssmParameterTierCache.set(region, value);
return value;
} catch {
return undefined;
}
}
// ec2:GetEbsEncryptionByDefault — AWS::EC2::Volume.Encrypted reads back `true` undeclared when on.
const ebsEncryptionByDefaultCache = new Map<string, boolean | undefined>();
async function fetchEbsEncryptionByDefault(region: string): Promise<boolean | undefined> {
if (ebsEncryptionByDefaultCache.has(region)) return ebsEncryptionByDefaultCache.get(region);
try {
const c = new EC2Client({ region, ...READ_RETRY });
const r = await c.send(new GetEbsEncryptionByDefaultCommand({}));
const value = r.EbsEncryptionByDefault;
ebsEncryptionByDefaultCache.set(region, value);
return value;
} catch {
return undefined;
}
}
// #1070 item 4: ec2:GetDefaultCreditSpecification per burstable family — the account-effective
// default CpuCredits ('standard'|'unlimited') an EC2::Instance of that family reads back when it
// declares no CreditSpecification. Cached per (region, family), fail-open (undefined, not cached).
const ec2CreditDefaultCache = new Map<string, string | undefined>();
async function fetchEc2FamilyCreditDefault(
region: string,
family: string
): Promise<string | undefined> {
const cacheKey = `${region}|${family}`;
if (ec2CreditDefaultCache.has(cacheKey)) return ec2CreditDefaultCache.get(cacheKey);
try {
const c = new EC2Client({ region, ...READ_RETRY });
// `family` is regex-gated to a `t<digit>` burstable prefix; an unsupported value simply throws
// at the API and is caught below (fail-open). The SDK types InstanceFamily as a closed union.
const r = await c.send(
new GetDefaultCreditSpecificationCommand({
InstanceFamily: family as UnlimitedSupportedInstanceFamily,
})
);
const value = r.InstanceFamilyCreditSpecification?.CpuCredits;
ec2CreditDefaultCache.set(cacheKey, value);
return value;
} catch {
return undefined;
}
}
// #1070 item 5: rds:DescribeCertificates — the account's CUSTOMER-OVERRIDE default CA identifier
// (the cert with `CustomerOverride=true`), which every new RDS/DocDB DBInstance that declares no
// CACertificateIdentifier reads back. Undefined when no override is set (the account uses AWS's
// system default) → classify falls back to the KNOWN_DEFAULTS constant. Cached per region, fail-open.
const rdsDefaultCaCache = new Map<string, string | undefined>();
async function fetchRdsDefaultCaIdentifier(region: string): Promise<string | undefined> {
if (rdsDefaultCaCache.has(region)) return rdsDefaultCaCache.get(region);
try {
const c = new RDSClient({ region, ...READ_RETRY });
const r = await c.send(new DescribeCertificatesCommand({}));
const override = (r.Certificates ?? []).find((cert) => cert.CustomerOverride === true);
const value = override?.CertificateIdentifier;
rdsDefaultCaCache.set(region, value);
return value;
} catch {
return undefined;
}
}
// #1557: dms:DescribeOrderableReplicationInstances — the per-class DefaultAllocatedStorage a DMS
// ReplicationInstance materializes when it declares no AllocatedStorage (dms.c6i.large → 100, NOT
// the generic "50 GB" the docs cite). Paginates the orderable catalog once and maps each
// ReplicationInstanceClass to its DefaultAllocatedStorage (the value is stable across engine
// versions for a class, so last-seen wins). classify equality-gates the live storage against the
// declared class's default. Cached per region, FAIL OPEN (empty map on error → the fold falls
// through to plain `undeclared`, today's behavior).
const dmsAllocatedStorageDefaultsCache = new Map<string, Record<string, number>>();
export async function fetchDmsAllocatedStorageDefaults(
region: string
): Promise<Record<string, number>> {
const cached = dmsAllocatedStorageDefaultsCache.get(region);
if (cached) return cached;
const map: Record<string, number> = {};
try {
const c = new DatabaseMigrationServiceClient({ region, ...READ_RETRY });
let marker: string | undefined;
do {
const r = await c.send(
new DescribeOrderableReplicationInstancesCommand({ Marker: marker, MaxRecords: 100 })
);
for (const o of r.OrderableReplicationInstances ?? []) {
if (
typeof o.ReplicationInstanceClass === 'string' &&
typeof o.DefaultAllocatedStorage === 'number'
) {
map[o.ReplicationInstanceClass] = o.DefaultAllocatedStorage;
}
}
marker = r.Marker;
} while (marker);
} catch {
// fail open — leave whatever was collected (possibly empty)
}
dmsAllocatedStorageDefaultsCache.set(region, map);
return map;
}
// #1070 item 3: ec2:GetInstanceMetadataDefaults — the account-level IMDS defaults the owner set
// (`ec2:modify-instance-metadata-defaults`). Returns only the SET fields among HttpTokens /
// HttpPutResponseHopLimit / HttpEndpoint / InstanceMetadataTags (unset ones are absent / null);
// `ManagedBy` is metadata, not a MetadataOptions field, so it is dropped. classify overlays these
// onto the AL2023 MetadataOptions constant for an EC2::Instance that declares no MetadataOptions.
// Undefined when nothing is set at account level → the constant stands. Cached per region, fail-open.
const instanceMetadataDefaultsCache = new Map<
string,
Record<string, string | number> | undefined
>();
async function fetchInstanceMetadataDefaults(
region: string
): Promise<Record<string, string | number> | undefined> {
if (instanceMetadataDefaultsCache.has(region)) return instanceMetadataDefaultsCache.get(region);
try {
const c = new EC2Client({ region, ...READ_RETRY });
const r = await c.send(new GetInstanceMetadataDefaultsCommand({}));
const al = r.AccountLevel;
const out: Record<string, string | number> = {};
if (al?.HttpTokens != null) out.HttpTokens = al.HttpTokens;
if (al?.HttpPutResponseHopLimit != null)
out.HttpPutResponseHopLimit = al.HttpPutResponseHopLimit;
if (al?.HttpEndpoint != null) out.HttpEndpoint = al.HttpEndpoint;
if (al?.InstanceMetadataTags != null) out.InstanceMetadataTags = al.InstanceMetadataTags;
const value = Object.keys(out).length > 0 ? out : undefined;
instanceMetadataDefaultsCache.set(region, value);
return value;
} catch {
return undefined;
}
}
// Regions already warned about a denied kms:ListAliases — the warning is one-per-region
// (a multi-stack run in the same region should not repeat it). Process-lifetime (matches
// the per-region alias cache in kms-aliases.ts).
const kmsDeniedWarned = new Set<string>();
// Regions already warned about a TRANSIENT kms:ListAliases failure (#963). A SEPARATE set
// from kmsDeniedWarned so a transient blip's dedupe never masks a later stack's GENUINE
// denial in the same region — the transient failure is not cached (kms-aliases.ts), so the
// next stack re-queries and a real denial then still surfaces.
const kmsTransientWarned = new Set<string>();
// Bounded-concurrency live-read pool (pull-next-when-free): serial reads cost
// ~300ms each, so 200+ resources took >1min; the SDK's adaptive retry handles
// any throttling. Stores each read in `reads` and feeds ctx.liveAttrs so
// Fn::GetAtt can resolve against real attributes.
const POOL_SIZE = 6;
async function readAll(
cc: CloudControlClient,
targets: DesiredResource[],
region: string,
desired: Desired,
reads: Map<string, ReadResult>
): Promise<void> {
let cursor = 0;
const worker = async (): Promise<void> => {
while (cursor < targets.length) {
const r = targets[cursor++]!;
const read = await readLive(cc, r, region, desired.accountId);
reads.set(r.logicalId, read);
if (read.live) desired.ctx.liveAttrs[r.logicalId] = read.live;
}
};
await Promise.all(Array.from({ length: Math.min(POOL_SIZE, targets.length) }, () => worker()));
}
// Turn an enumerated out-of-band child into an `added` finding. logicalId is
// synthesized (the child is not in the template, so it has none) from the parent's
// logical id + the CC identifier — stable and unique. physicalId carries the CC
// identifier so revert can DeleteResource it; constructPath gives the report a
// readable label even when the parent has no CDK construct path.
//
// `actual` is the child's FULL, normalized live model (PR4): `added` is now
// record-able (the resource-level analog of recording an undeclared property), so the
// baseline snapshots this value and a later out-of-band CHANGE to the child surfaces
// as drift. The model is normalized identically to classify's live side
// (normalizeLiveModel) so a volatile readOnly field never reads as a false "changed
// since record". Falls back to the enumerator's identity-only snippet when the CC
// GetResource fails (so the resource is still reported, just not change-watchable).
export function addedFinding(
parent: DesiredResource,
c: AddedChild,
read: { model: Record<string, unknown>; ok: boolean }
): Finding {
return {
tier: 'added',
logicalId: `${parent.logicalId}/${c.identifier}`,
physicalId: c.identifier,
constructPath: `${parent.constructPath ?? parent.logicalId} ▸ ${c.label}`,
resourceType: c.resourceType,
// #1737: explicit parent identity, so applyBaseline can match the finding against the
// baseline's `enumeratedParents` marker (appeared-since-record confirmation) without
// re-splitting the synthesized logicalId.
parentLogicalId: parent.logicalId,
parentResourceType: parent.resourceType,
path: '',
actual: read.model,
note: read.ok
? 'created out of band — not in your CloudFormation template'
: 'created out of band — not in your CloudFormation template; live model unreadable this run',
// a degraded read carries only the identity snippet — not change-watchable this run
...(read.ok ? {} : { modelReadFailed: true }),
};
}
// Added types whose Cloud Control GetResource is DOOMED — NON_PROVISIONABLE in the CC
// registry, so `GetResource` throws `UnsupportedActionException` on every run (#1431). For
// these the model read below would always fail, flag the finding `modelReadFailed`, and
// `record`/`ignore` could never endorse the resource — it re-surfaced as `added` on every
// `check` with no way to accept it. But the child ENUMERATOR already carries the full,
// recordable model in its `live` snippet (a Route53 RecordSet's Name/Type/TTL/ResourceRecords/
// AliasTarget), so use THAT as the recordable model instead of the doomed CC read. (Its real
// delete goes through a type-specific SDK deleter, not CC — see revert/writers.ts SDK_DELETERS.)
// AWS::SQS::QueuePolicy (#835): the CC primaryIdentifier is a service-generated `Id`, which
// an out-of-band `set-queue-attributes Policy=…` never produces — so a CC GetResource keyed on
// the queue URL the enumerator carries would always fail. The enumerator's `live` snippet
// ({ Queues, PolicyDocument }) IS the recordable model; use it. (Its real delete goes through
// the `deleteSqsQueuePolicy` SDK deleter — SetQueueAttributes with an empty Policy — not CC.)
// AWS::SecretsManager::ResourcePolicy (#835): same generated-`Id` primaryIdentifier situation as
// AWS::SQS::QueuePolicy — an out-of-band `put-resource-policy` produces no CFn `Id`, so a CC
// GetResource keyed on the secret ARN the enumerator carries would fail. The enumerator's `live`
// snippet ({ SecretId, ResourcePolicy }) IS the recordable model; use it. (Its real delete goes
// through the `deleteSecretsManagerResourcePolicy` SDK deleter — DeleteResourcePolicy — not CC.)
// AWS::SNS::TopicPolicy (#835): same generated-`Id` primaryIdentifier situation — an out-of-band
// `set-topic-attributes Policy=…` produces no CFn `Id`, so a CC GetResource keyed on the topic ARN
// the enumerator carries would fail. The enumerator's `live` snippet ({ Topics, PolicyDocument }) IS
// the recordable model; use it. (Its real delete goes through the `deleteSnsTopicPolicy` SDK deleter
// — SetTopicAttributes back to the AWS-default policy — not CC.)
// AWS::KMS::Grant (#835): a SYNTHETIC type — a KMS grant is not a CloudFormation / Cloud Control
// resource at all, so CC GetResource / DescribeType cannot know it. The child enumerator's `live`
// snippet ({ GrantId, GranteePrincipal, Operations, … }) IS the recordable model; use it. (Its real
// delete goes through the `deleteKmsGrant` SDK deleter — RevokeGrant keyed on the parent key +
// GrantId — not CC.)
const CC_GET_UNSUPPORTED_ADDED_TYPES = new Set<string>([
'AWS::Route53::RecordSet',
'AWS::SQS::QueuePolicy',
'AWS::SecretsManager::ResourcePolicy',
'AWS::SNS::TopicPolicy',
'AWS::KMS::Grant',
]);
// Read the added child's FULL live model via Cloud Control GetResource (its
// `identifier` is the CC composite, the same one revert's DeleteResource consumes) and
// normalize it for record/compare. On any read/parse error return the enumerator's
// identity-only `live` snippet with `ok: false` — the resource is still REPORTED as
// added, but the finding is flagged `modelReadFailed` so record skips snapshotting the
// partial model and applyBaseline never false-flags it as "changed" (a degraded snippet
// vs a recorded full model would otherwise differ). `cfn` fetches the child type's schema
// (readOnly/writeOnly strip); `schemas` is the shared cache.
export async function readAddedModel(
cc: CloudControlClient,
cfn: CloudFormationClient,
c: AddedChild,
schemas: Map<string, SchemaInfo>,
oaiCanonicalIds: Record<string, string>,
// #1551: needed to drive the type's SDK_OVERRIDES reader on the CC-failure path;
// optional so identity-snippet-only callers/tests stay source-compatible (without
// them the override attempt is skipped and the snippet degrade stands).
region?: string,
accountId?: string
): Promise<{ model: Record<string, unknown>; ok: boolean }> {
// Normalize a raw live model with the (cached) child-type schema. Only re-cache a SUCCESSFUL
// fetch: a DescribeType failure returns an EMPTY schema (#751 — schema-strip itself does not
// cache it), and caching that EMPTY in the per-run map would poison every later resource of
// this type (writeOnly reinclude drops declared write-only props, createOnly bars lost) even
// after the throttle clears — so leave the map unset on failure to let the next occurrence
// re-fetch (#1067). The EMPTY still drives THIS resource's normalize (degraded, no strip).
const normalizeWith = async (
raw: Record<string, unknown>
): Promise<{ model: Record<string, unknown>; ok: true }> => {
let schema = schemas.get(c.resourceType);
if (!schema) {
const res = await getSchemaInfoResult(cfn, c.resourceType);
schema = res.info;
if (!res.failed) schemas.set(c.resourceType, schema);
}
return {
model: normalizeLiveModel(raw, schema, { oaiCanonicalIds, resourceType: c.resourceType }),
ok: true,
};
};
// #1431: a NON_PROVISIONABLE type's CC GetResource always fails — skip it and use the
// enumerator's full `live` snippet as the recordable model, so record/ignore can endorse it.
if (CC_GET_UNSUPPORTED_ADDED_TYPES.has(c.resourceType)) {
return normalizeWith(c.live);
}
try {
const g = await cc.send(
new GetResourceCommand({ TypeName: c.resourceType, Identifier: c.identifier })
);
const raw = JSON.parse(g.ResourceDescription?.Properties ?? '{}') as Record<string, unknown>;
return normalizeWith(raw);
} catch {
// #1551: a CC-gap added child (a type whose declared reads go through SDK_OVERRIDES —
// observed on an out-of-band AWS::Glue::Table) always lands here, degrading to the
// identity snippet with `modelReadFailed` — so `record` could never snapshot its real
// model and a LATER change to the added child stayed invisible. Try the type's
// override reader before degrading: `physicalId` is the enumerator's CC-composite
// identifier (the same form the reader consumes for declared resources), and the
// snippet stands in for `declared` (readers only key on identity fields from it).
const override = SDK_OVERRIDES[c.resourceType];
if (override && region !== undefined && accountId !== undefined) {
try {
const raw = await override({
physicalId: c.identifier,
declared: c.live,
region,
accountId,
});
if (raw !== undefined) return normalizeWith(raw);
} catch {
// fall through to the snippet degrade below
}
}
return { model: c.live, ok: false };
}
}
// A child enumerated off a declared parent but ABSENT from that parent's own template
// is only "out of band" if NO CloudFormation stack manages it. The common false
// positive is a cross-stack reference: a child resource CDK places in a SIBLING stack
// of the same app (e.g. `topic.addSubscription(new SqsSubscription(queue))` puts the
// `AWS::SNS::Subscription` in the QUEUE's stack to avoid a dependency cycle, so checking
// the TOPIC's stack finds a live subscription not in that template) — it is fully
// CFn-managed, just by a sibling (#666). DescribeStackResources resolves the owning stack
// account-wide from the physical id alone, so this fixes both single-stack and multi-stack
// (`--all`) runs. Only the child types whose CC primaryIdentifier IS the CFn physical id
// (a bare ARN / UUID — the cross-stack class: SNS Subscription, ELBv2 Listener/Rule,
// EventBus Rule, Lambda ESM/Alias/Version, AppSync …) are resolvable; the composite-id
// children (`RestApiId|…`, `UserPoolId|…`) are within-stack API Gateway / Cognito
// sub-resources that this class never covers, so they are left alone (skipped by the `|`
// guard). Fails OPEN: any error (denied, throttled, or a physical id CFn does not accept)
// keeps the current behavior — the child is still reported as `added` rather than silently
// dropped. Results are memoized per physical id (added candidates are rare, but a shared
// parent can enumerate the same live child under multiple declared parents).
// 'managed' = a CFn stack owns it (sibling-managed, not out of band); 'notManaged' = no stack
// owns it (genuinely out of band -> report as `added`); 'unverified' = the membership check
// itself FAILED (throttle / AccessDenied / network) OR could not reach the owning scope
// (a cross-account / cross-region CFn-managed child), so we CANNOT say either way (#754, #959).
export type SiblingCheck = 'managed' | 'notManaged' | 'unverified';
// The sibling-membership probe (DescribeStackResources) runs on the check's OWN CloudFormation
// client, scoped to the run's account+region. Its `ValidationError` ("Stack for <id> does not
// exist") therefore only proves the child is not in a stack of THIS account+region — NOT that it
// is unmanaged everywhere. A child fully CFn-managed by a stack in a DIFFERENT account or region
// (the canonical case: an SNS cross-account / cross-region `AWS::SNS::Subscription` fan-out — the
// subscription lives on the topic's account+region but is declared in the SUBSCRIBER's foreign
// stack) is invisible to that call and would be false-flagged `added` with a DESTRUCTIVE
// DeleteResource revert offer (#959). The child's physical id is an ARN carrying its own
// account+region, so parse it: only when the ARN's account AND region MATCH the check's scope is
// a `ValidationError` a DEFINITIVE not-managed (safe to report `added`); an ARN in a foreign
// account or region is UNVERIFIABLE — the owning stack is simply unreachable from here. Returns
// `true` (definitive) only for a same-account+region ARN (or an id we cannot parse as an ARN,
// which is inherently local — a bare name/UUID minted in this account+region). Returns `false`
// (unverifiable) for a foreign-scope ARN.
export function isDefinitiveNotManaged(
physicalId: string,
accountId: string,
region: string
): boolean {
// ARN form: arn:partition:service:region:account-id:resource — region at [3], account at [4].
if (!physicalId.startsWith('arn:')) return true;
const seg = physicalId.split(':');
const arnRegion = seg[3] ?? '';
const arnAccount = seg[4] ?? '';
// Some ARNs omit region and/or account (empty segment) — those carry no foreign signal and are
// treated as local (definitive). Only a NON-EMPTY segment that DIFFERS marks a foreign scope.
if (arnRegion !== '' && arnRegion !== region) return false;
if (arnAccount !== '' && arnAccount !== accountId) return false;
return true;
}
// The child may carry EXPLICIT foreign-scope metadata that the physical-id ARN parse cannot see
// (#1322). An AWS::SNS::Subscription's physical id is `<topicArn>:<uuid>` — always minted under
// the TOPIC's (= this check's) account+region, so `isDefinitiveNotManaged` always reads it LOCAL
// even when a FOREIGN subscriber stack owns it. `ListSubscriptionsByTopic` DOES return the true
// scope: `Owner` (the subscription's owning account) and, for a cross-account / cross-region
// fan-out, an ARN `Endpoint` carrying the subscriber's account+region. When either signals a
// foreign scope, a ValidationError from the local DescribeStackResources is UNVERIFIABLE (the
// owning stack is simply unreachable from here), so it must NOT be a definitive not-managed.
export function hasForeignScopeSignal(c: AddedChild, accountId: string, region: string): boolean {
const owner = c.ownerAccountId;
if (typeof owner === 'string' && owner !== '' && owner !== accountId) {
return true;
}
return (c.scopeArns ?? []).some(
(arn) => arn.startsWith('arn:') && !isDefinitiveNotManaged(arn, accountId, region)
);
}
// Probe ONE candidate CloudFormation physical id against the run's account+region: does a
// stack own a resource of THIS child's type (`c.resourceType`) with this exact physical id?
// Returns the tri-state and memoizes any DEFINITIVE answer per physical id (a transient
// throttle / unverifiable foreign-scope id is left un-cached so a retry can resolve it).
// The memo MUST be keyed by `${resourceType}|${physicalId}`, not the physical id alone: the
// `owns` predicate gates on `ResourceType === c.resourceType`, so the SAME physical id can be
// 'managed' for one child type yet 'notManaged' for another (#1310). Two child types can share
// a segment name — e.g. a shared log group's MetricFilter and SubscriptionFilter fan-out both
// probe a `errors` segment — and a type-blind cache would replay a MetricFilter's 'managed'
// answer for a SubscriptionFilter of the same name, folding a rogue OOB subscription filter to
// managed. Keying by type keeps each type's answer independent.
async function probeSiblingPhysicalId(
cfn: CloudFormationClient,
c: AddedChild,
physicalId: string,
cache: Map<string, SiblingCheck>,
accountId: string,
region: string
): Promise<SiblingCheck> {
const cacheKey = `${c.resourceType}|${physicalId}`;
const cached = cache.get(cacheKey);
if (cached !== undefined) return cached;
let managed = false;
try {
const res = await cfn.send(
new DescribeStackResourcesCommand({ PhysicalResourceId: physicalId })
);
const owns = (
rs: { PhysicalResourceId?: string | undefined; ResourceType?: string | undefined }[]
): boolean =>
rs.some((r) => r.PhysicalResourceId === physicalId && r.ResourceType === c.resourceType);
const first = res.StackResources ?? [];
if (owns(first)) {
managed = true;
} else {
// DescribeStackResources(PhysicalResourceId) returns ONLY the first 100 resources of the
// owning stack and CANNOT paginate (#726). When the child is beyond that window in a big
// sibling stack the match above misses and a fully CFn-managed child would be false-flagged
// `added` (with a DeleteResource revert offer). Fall back to the PAGINATED
// ListStackResources on the owning stack — its name comes from any resource Describe DID
// return (they all belong to the one owning stack).
const stackName = first[0]?.StackName;
if (stackName) {
let next: string | undefined;
do {
const page = await cfn.send(
new ListStackResourcesCommand({ StackName: stackName, NextToken: next })
);
if (owns(page.StackResourceSummaries ?? [])) {
managed = true;
break;
}
next = page.NextToken;
} while (next);
}
}
} catch (e) {
// Distinguish a GENUINE not-found from a FAILED / UNREACHABLE check (#754, #959).
// CloudFormation answers DescribeStackResources for a physical id that belongs to no stack of
// THIS account+region with a ValidationError ("Stack for <id> does not exist"). That is only a
// definite "not managed" when the child's own scope IS this account+region — a child managed
// by a stack in a DIFFERENT account/region is equally invisible to this scoped client and
// yields the SAME ValidationError, so treating every ValidationError as `notManaged` would
// false-flag a foreign-managed child `added` and offer a destructive DeleteResource on a
// resource another stack legitimately owns (#959, the SNS cross-account/region fan-out). Parse
// the physical-id ARN's account+region: only when it MATCHES the check's scope is this a
// definitive, cacheable `notManaged` (a real out-of-band addition — still reported `added`);
// a foreign-scope ARN is UNVERIFIABLE, so fall through to `unverified` (fail safe: reported as
// coverage-incomplete, NEVER a destructive delete). Any OTHER error (Throttling under an --all
// sweep, AccessDenied without cloudformation:DescribeStackResources, a network blip) is also
// 'unverified' and NOT memoized (a transient throttle must not poison the run). The foreign-
// scope determination IS deterministic per physical id, so caching it as 'notManaged' is not
// done — we leave it un-cached like the other unverifiable cases for uniform handling. The
// ARN parse is a GENERIC backstop, but it MISREADS a child whose physical id is always local
// to its parent yet may be foreign-owned (an SNS Subscription arn is `<topicArn>:<uuid>`),
// so an EXPLICIT foreign-scope metadata signal (`Owner` / ARN `Endpoint`) overrides it and
// also downgrades this to `unverified` (#1322).
if (
(e as { name?: string }).name === 'ValidationError' &&
isDefinitiveNotManaged(physicalId, accountId, region) &&
!hasForeignScopeSignal(c, accountId, region)
) {
cache.set(cacheKey, 'notManaged');
return 'notManaged';
}
return 'unverified';
}
const result: SiblingCheck = managed ? 'managed' : 'notManaged';
cache.set(cacheKey, result);
return result;
}
export async function isManagedBySiblingStack(
cfn: CloudFormationClient,
c: AddedChild,
cache: Map<string, SiblingCheck>,
accountId: string,
region: string
): Promise<SiblingCheck> {
// The sibling-stack lookup uses the CloudFormation PHYSICAL-ID form, which for most child
// types IS the CC primaryIdentifier (`identifier`); it diverges only where CC's identifier
// is not the CFn physical id — e.g. AWS::Events::Rule, whose CC identifier is the rule Arn
// but whose CFn physical id is `<busName>|<ruleName>` for a custom-bus rule (#895). The
// enumerator carries that form on `siblingLookupId` (defaulting to `identifier`).
const physicalId = c.siblingLookupId ?? c.identifier;
// A `siblingLookupId` explicitly set by the enumerator (Events::Rule custom-bus
// `<busName>|<ruleName>`) IS a valid CFn physical id verbatim (even with a `|`), so probe it
// as-is. Only the CC `identifier` composites — which NEVER set `siblingLookupId` — need the
// per-segment fan-out below.
if (c.siblingLookupId !== undefined || !physicalId.includes('|')) {
return probeSiblingPhysicalId(cfn, c, physicalId, cache, accountId, region);
}
// A pipe-composite CC identifier (`ServiceArn|Cluster`, `UserPoolId|ClientId`,
// `LogGroupName|FilterName`, …) is the join of a PARENT id + the child's own id — and the
// child's CFn PHYSICAL id is the BARE half (ECS = ServiceArn, Cognito = ClientId, Logs =
// FilterName). A shared-parent split (a cluster stack + per-service stacks, an auth stack +
// app stacks) makes such a child fully CloudFormation-managed by a SIBLING stack, yet the
// old wholesale `physicalId.includes('|') → 'notManaged'` short-circuit false-flagged every
// one `added` with a destructive DeleteResource revert offer (#800). Which segment is the
// child's physical id varies per type (first, second, and the two Logs filter types even
// ORDER it oppositely), so probe EACH segment; the `owns` predicate already gates on
// `ResourceType === c.resourceType`, so a segment that is the (differently-typed) parent id
// — or a non-physical-id half of a genuine within-stack API Gateway / Cognito sub-resource —
// never false-matches, and those simply fall through to `notManaged`/`unverified` as before.
// Combine the segment results fail-SAFE: ANY segment proving sibling ownership wins
// ('managed'); otherwise if ANY segment was UNVERIFIABLE (throttle / foreign-scope) return
// 'unverified' (report as coverage-incomplete, never a destructive delete — #754); only when
// EVERY segment is a definitive not-managed is the composite a genuine out-of-band `added`.
let sawUnverified = false;
for (const segment of physicalId.split('|')) {
if (!segment) continue; // skip an empty segment (defensive: leading/trailing/double `|`)
const seg = await probeSiblingPhysicalId(cfn, c, segment, cache, accountId, region);
if (seg === 'managed') return 'managed';
if (seg === 'unverified') sawUnverified = true;
}
return sawUnverified ? 'unverified' : 'notManaged';
}
// Combine a global-service child's per-region sibling probes into one verdict, fail-SAFE. Given a
// child whose run-region probe was already a DEFINITIVE not-managed, sweep the OTHER enabled
// regions: ANY region proving sibling ownership wins ('managed' — the child is CFn-managed, just by
// a stack in another region, so NOT out of band); otherwise if ANY region was UNVERIFIABLE
// (throttle / AccessDenied / network) return 'unverified' (report as coverage-incomplete, NEVER a
// destructive DeleteResource on a record a foreign-region stack legitimately owns — #754/#959);
// only when EVERY region is a definitive not-managed is the child a genuine out-of-band `added`
// (#1651). `probeInRegion` is injected so this combine logic is unit-testable without AWS.
export async function resolveCrossRegionSibling(
regions: string[],
probeInRegion: (region: string) => Promise<SiblingCheck>
): Promise<SiblingCheck> {
let sawUnverified = false;
for (const region of regions) {
const r = await probeInRegion(region);
if (r === 'managed') return 'managed';
if (r === 'unverified') sawUnverified = true;
}
return sawUnverified ? 'unverified' : 'notManaged';
}
// A run-scoped cross-region sibling probe for GLOBAL-service children (#1651). The
// AWS::Route53::RecordSet case: the zone is managed by a stack in the check's region, but its
// records may be managed by other apps' stacks in DIFFERENT regions — invisible to the run-region
// DescribeStackResources, so they false-flag `added`. When the run-region probe is a definitive
// not-managed, `escalate` sweeps the account's OTHER enabled regions before concluding out-of-band.
// Fail-SAFE throughout: if the enabled-region list cannot be enumerated (denied / throttled), or
// any region's probe is unverifiable, it returns 'unverified' — coverage-incomplete, never a
// destructive delete offer. The enabled-region list, per-region CloudFormation clients, and each
// region's probe cache are memoized for the whole run (a zone can enumerate many candidate records).
export interface CrossRegionSiblingProbe {
escalate(c: AddedChild): Promise<SiblingCheck>;
}
export function makeCrossRegionSiblingProbe(
accountId: string,
runRegion: string,
// Injectable for tests; defaults to a real EC2 DescribeRegions of the account's enabled regions
// (excluding the run region, already probed) and a real per-region CloudFormation probe.
deps?: {
enabledRegions?: () => Promise<string[] | undefined>;
probeInRegion?: (c: AddedChild, region: string) => Promise<SiblingCheck>;
}
): CrossRegionSiblingProbe {
let regionsPromise: Promise<string[] | undefined> | undefined;
const cfnClients = new Map<string, CloudFormationClient>();
const caches = new Map<string, Map<string, SiblingCheck>>();
const fetchEnabledRegions =
deps?.enabledRegions ??
(async (): Promise<string[] | undefined> => {
try {
const ec2 = new EC2Client({ region: runRegion, ...READ_RETRY });
const res = await ec2.send(new DescribeRegionsCommand({ AllRegions: false }));
return (res.Regions ?? [])
.map((r) => r.RegionName)
.filter((n): n is string => typeof n === 'string' && n !== '' && n !== runRegion);
} catch {
// Cannot enumerate regions -> cannot prove the child is unmanaged everywhere. The caller
// fail-safes this to 'unverified' (never a destructive delete on a possibly foreign-managed
// record).
return undefined;
}
});
// Enumerate the enabled regions ONCE per run (a zone can enumerate many candidate records).
const enabledRegions = (): Promise<string[] | undefined> =>
(regionsPromise ??= fetchEnabledRegions());
const probeInRegion =
deps?.probeInRegion ??
((c: AddedChild, region: string): Promise<SiblingCheck> => {
let client = cfnClients.get(region);
if (!client) {
client = new CloudFormationClient({ region, ...READ_RETRY });
cfnClients.set(region, client);
}
let cache = caches.get(region);
if (!cache) {
cache = new Map();
caches.set(region, cache);
}
// A global-service child's siblingLookupId is its bare CFn physical id (no `|` composite),
// so probe it directly against this region's account scope.
const physicalId = c.siblingLookupId ?? c.identifier;
return probeSiblingPhysicalId(client, c, physicalId, cache, accountId, region);
});
return {
async escalate(c: AddedChild): Promise<SiblingCheck> {
const regions = await enabledRegions();
if (regions === undefined) return 'unverified';
return resolveCrossRegionSibling(regions, (region) => probeInRegion(c, region));
},
};
}
interface ClassifyOpts {
accountId: string;
region: string;
kmsAliasTargets: Record<string, string>;
stackTags: Record<string, string>; // CFn stack-level tags (cdk deploy --tags), subtracted from live Tags (#683)
oaiCanonicalIds: Record<string, string>;
siblingSgRules: Record<string, { ingress: unknown[]; egress: unknown[] }>;
siblingEventBusPolicies: Record<string, unknown[]>;
siblingManagedPolicyAttachments: Record<string, string[]>;
siblingUserGroups: Record<string, string[]>;
siblingEipAssociations: Set<string>;
siblingSubnetCidrBlocks: Set<string>;
siblingTargetGroupRegistrars: Set<string>;
bucketNotificationManaged: Set<string>;
// #1283: per managed-bucket physical id, the CR's DECLARED NotificationConfiguration (S3 API
// shape) — classify translates it into the live CFn shape and equality-gates. Carried
// alongside bucketNotificationManaged (the id set); a bucket in the set always has an entry.
bucketNotificationConfigs: Record<string, Record<string, unknown>>;
clusterEchoModel: Record<string, Record<string, unknown>>;
rdsOptionSettingDefaults: Record<string, Record<string, Record<string, string | null>>>;
}
// Rules declared by standalone AWS::EC2::SecurityGroupIngress / ::SecurityGroupEgress
// resources, keyed by the target SG's resolved GroupId (== the SG's physical id). CDK emits
// such a resource whenever a rule references a token it cannot inline (self/peer SG ref,
// prefix list, imported SG). The live SecurityGroup REFLECTS these rules in its own ingress/
// egress arrays, so classify subtracts them to avoid double-counting (see SG_RULE_REFLECTION
// in diff/classify.ts). Fail-open: a rule whose GroupId did not resolve to a concrete sg-id
// is skipped (the SG keeps the reflected rule -> a one-time visible FP, never a hidden change).
const SG_RULE_RESOURCE_SIDE: Record<string, 'ingress' | 'egress'> = {
'AWS::EC2::SecurityGroupIngress': 'ingress',
'AWS::EC2::SecurityGroupEgress': 'egress',
};
export function buildSiblingSgRules(
desired: Desired
): Record<string, { ingress: unknown[]; egress: unknown[] }> {
const map: Record<string, { ingress: unknown[]; egress: unknown[] }> = {};
for (const r of desired.resources) {
const side = SG_RULE_RESOURCE_SIDE[r.resourceType];
if (!side) continue;
const decl = r.declared;
if (!decl || typeof decl !== 'object') continue;
const groupId = (decl as Record<string, unknown>).GroupId;
if (typeof groupId !== 'string' || !groupId) continue; // unresolved intrinsic -> skip
const rule = { ...(decl as Record<string, unknown>) };
delete rule.GroupId;
// A same-account SG-to-SG reference reads back a SourceSecurityGroupOwnerId AWS injects
// (the account that owns the referenced SG) that the template never declares. Fill it in
// with the stack's own account so (a) the classify subset-match still matches the live
// reflected rule and (b) a revert's whole-array CC replacement re-sends the rule in its
// EXACT live form — otherwise CC treats the owner-less rule as different and replaces it,
// orphaning the sibling resource (observed live). A cross-account peer DECLARES the owner
// id (CDK requires it), so it is already present and not overwritten.
if (
typeof rule.SourceSecurityGroupId === 'string' &&
rule.SourceSecurityGroupOwnerId === undefined &&
desired.accountId
) {
rule.SourceSecurityGroupOwnerId = desired.accountId;
}
(map[groupId] ??= { ingress: [], egress: [] })[side].push(rule);
}
return map;
}
// Statements declared by sibling AWS::Events::EventBusPolicy resources, keyed by their
// TARGET bus identifier (the resolved `EventBusName` == the bus's physical id; an absent /
// "default" name targets the default bus, keyed "default"). The live AWS::Events::EventBus
// REFLECTS these statements in an aggregated undeclared `Policy` = `{Version, Statement[]}`,
// so classify subtracts them (see subtractSiblingEventBusStatements in diff/classify.ts) to
// avoid a first-run FP + double-reporting — the sibling EventBusPolicy is tracked + compared
// as its own resource. Each sibling contributes ONE statement (its resolved `Statement`, a
// single statement object; CFn EventBusPolicy declares exactly one), stamped with its
// `StatementId` as the reflected `Sid` when the statement omits one. Fail-open: a policy whose
// target bus did not resolve to a concrete string is skipped (the bus keeps the reflected
// statement -> a one-time visible FP, never a hidden change). Any live statement that matches
// NO sibling (a purely out-of-band injection) is left to surface.
const EVENT_BUS_POLICY_TYPE = 'AWS::Events::EventBusPolicy';
const DEFAULT_EVENT_BUS = 'default';
export function buildSiblingEventBusPolicies(desired: Desired): Record<string, unknown[]> {
const map: Record<string, unknown[]> = {};
for (const r of desired.resources) {
if (r.resourceType !== EVENT_BUS_POLICY_TYPE) continue;
const decl = r.declared;
if (!decl || typeof decl !== 'object') continue;
const d = decl as Record<string, unknown>;
// An absent EventBusName (or the literal "default") targets the default event bus. A
// custom bus is targeted by its resolved Name (== the bus's physical id). Skip an
// unresolved intrinsic (fail-open — the bus keeps the reflected statement).
const busName = d.EventBusName;
let busKey: string;
if (busName === undefined) busKey = DEFAULT_EVENT_BUS;
else if (typeof busName === 'string' && busName) busKey = busName;
else continue;
// The single statement this policy declares (`Statement`), stamped with its `StatementId`
// as the `Sid` AWS reflects when the statement itself omits one.
const stmt = d.Statement;
if (!stmt || typeof stmt !== 'object') continue;
const statement = { ...(stmt as Record<string, unknown>) };
if (statement.Sid === undefined && typeof d.StatementId === 'string' && d.StatementId) {
statement.Sid = d.StatementId;
}
(map[busKey] ??= []).push(statement);
}
return map;
}
// Bucket physical ids (== bucket names) whose S3 notifications are managed by a
// Custom::S3BucketNotifications custom resource, MAPPED to the CR's DECLARED
// `NotificationConfiguration` (the intended config, in the S3 API property shape). CDK renders
// `bucket.addEventNotification()` / `enableEventBridgeNotification()` as this CR (which cdkrd
// cannot read/verify, so it is `skipped`), NOT as the bucket's own NotificationConfiguration
// property — so the live bucket REFLECTS the CR-applied config while its template resource
// declares nothing, surfacing the whole NotificationConfiguration as false undeclared drift on
// every such bucket. The config is IaC-managed (by the CR), not out of band; classify translates
// this declared config into the live CFn resource shape and EQUALITY-GATES it against the live
// value — folding a matching (clean-deploy) config while SURFACING an out-of-band `put-bucket-
// notification-configuration` that adds / swaps / removes a target (#1283). Fail-open: a CR whose
// BucketName did not resolve to a concrete name is skipped (the bucket keeps the reflected config
// -> a one-time visible FP, never a hidden change); a CR with no `NotificationConfiguration`
// object maps to `{}` (an empty config that folds only an empty live config).
const S3_NOTIFICATIONS_CR_TYPE = 'Custom::S3BucketNotifications';
// Resolve each Custom::S3BucketNotifications CR's target bucket physical id (from its declared
// `BucketName`, a concrete name or a `Ref` to a declared bucket) paired with the CR's declared
// `NotificationConfiguration` (S3 API shape, or `{}` when absent). Shared by the two builders
// below so the id set and the config map are always derived identically.
function resolveBucketNotificationCrs(
desired: Desired
): Array<[physicalId: string, config: Record<string, unknown>]> {