@friggframework/devtools 2.0.0--canary.474.86c5119.0 → 2.0.0--canary.474.898a56c.0

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.
Files changed (24) hide show
  1. package/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js +167 -0
  2. package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +762 -0
  3. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +154 -1
  4. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +20 -5
  5. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js +3 -3
  6. package/infrastructure/domains/health/docs/ACME-DEV-DRIFT-ANALYSIS.md +267 -0
  7. package/infrastructure/domains/health/docs/BUILD-VS-DEPLOYED-TEMPLATE-ANALYSIS.md +324 -0
  8. package/infrastructure/domains/health/docs/ORPHAN-DETECTION-ANALYSIS.md +386 -0
  9. package/infrastructure/domains/health/docs/SPEC-CLEANUP-COMMAND.md +1419 -0
  10. package/infrastructure/domains/health/docs/TDD-IMPLEMENTATION-SUMMARY.md +391 -0
  11. package/infrastructure/domains/health/docs/TEMPLATE-COMPARISON-IMPLEMENTATION.md +551 -0
  12. package/infrastructure/domains/health/domain/services/__tests__/health-score-percentage-based.test.js +380 -0
  13. package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +645 -0
  14. package/infrastructure/domains/health/domain/services/__tests__/template-parser.test.js +496 -0
  15. package/infrastructure/domains/health/domain/services/health-score-calculator.js +174 -91
  16. package/infrastructure/domains/health/domain/services/health-score-calculator.test.js +332 -228
  17. package/infrastructure/domains/health/domain/services/logical-id-mapper.js +330 -0
  18. package/infrastructure/domains/health/domain/services/template-parser.js +245 -0
  19. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-cfn-tagged.test.js +312 -0
  20. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-multi-stack.test.js +367 -0
  21. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js +432 -0
  22. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js +108 -14
  23. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js +69 -12
  24. package/package.json +6 -6
@@ -0,0 +1,432 @@
1
+ /**
2
+ * TDD Test for Orphan Detection with Relationship Analysis
3
+ *
4
+ * PROBLEM: When multiple orphaned resources of the same type are detected,
5
+ * we need to help users identify which ones are actually relevant to import.
6
+ *
7
+ * Real-world example from acme-integrations-dev:
8
+ * - 3 orphaned VPCs detected (vpc-0eadd96976d29ede7, vpc-0e2351eac99adcb83, vpc-020a0365610c05f0b)
9
+ * - 10 orphaned subnets detected
10
+ * - 3 orphaned security groups detected
11
+ * - 16 Lambda functions with VPC drift (all reference same VPC/subnets/SGs)
12
+ *
13
+ * ACTUAL AWS REALITY (verified 2025-10-27):
14
+ * - Lambda functions use DEFAULT VPC: vpc-01f21101d4ed6db59 (172.31.0.0/16, no Frigg tags)
15
+ * - Lambda subnets: subnet-020d32e3ca398a041, subnet-0c186318804aba790 (in default VPC)
16
+ * - Lambda security group: sg-0aca40438d17344c4 (in default VPC)
17
+ * - 3 orphaned VPCs are ALL unused (10.0.0.0/16, all have Frigg CFN tags but not in stack)
18
+ * - CloudFormation stack has ZERO VPCs (stack doesn't manage VPC)
19
+ *
20
+ * SOLUTION: Analyze relationships between drifted resources and orphaned resources
21
+ * to identify which orphaned resources are actually being referenced.
22
+ *
23
+ * Key Insight: If 16 Lambda functions are all drifted to use:
24
+ * - VPC: vpc-01f21101d4ed6db59 (default VPC, no tags)
25
+ * - SecurityGroup: sg-0aca40438d17344c4
26
+ * - Subnets: subnet-020d32e3ca398a041, subnet-0c186318804aba790
27
+ *
28
+ * But we detect 3 orphaned VPCs with Frigg tags:
29
+ * - vpc-0eadd96976d29ede7 (10.0.0.0/16)
30
+ * - vpc-0e2351eac99adcb83 (10.0.0.0/16)
31
+ * - vpc-020a0365610c05f0b (10.0.0.0/16)
32
+ *
33
+ * Then NONE of these orphaned VPCs are actually being used! They're old unused
34
+ * resources from previous deployments that should be cleaned up, not imported.
35
+ *
36
+ * RELATIONSHIP ANALYSIS:
37
+ * 1. Extract referenced resource IDs from drift issues
38
+ * - Lambda VpcConfig.SecurityGroupIds → extract SG IDs
39
+ * - Lambda VpcConfig.SubnetIds → extract subnet IDs
40
+ * - Subnets reference VPCs
41
+ * - Security groups reference VPCs
42
+ *
43
+ * 2. Group orphaned resources by type and count
44
+ *
45
+ * 3. For each orphaned resource:
46
+ * - Check if it's referenced by any drifted resource
47
+ * - If yes, mark as "actively used" (high priority to import)
48
+ * - If no, mark as "unused orphan" (likely old/irrelevant)
49
+ *
50
+ * 4. Add relationship metadata to orphaned resources:
51
+ * - referencedBy: [list of resources that reference this orphan]
52
+ * - relatedOrphans: [other orphans in same VPC/group]
53
+ *
54
+ * 5. When multiple resources of same type exist, show warning:
55
+ * "Multiple VPCs detected. Review relationships before importing."
56
+ */
57
+
58
+ const AWSResourceDetector = require('../aws-resource-detector');
59
+ const StackIdentifier = require('../../../domain/value-objects/stack-identifier');
60
+ const Issue = require('../../../domain/entities/issue');
61
+ const PropertyMismatch = require('../../../domain/entities/property-mismatch');
62
+ const PropertyMutability = require('../../../domain/value-objects/property-mutability');
63
+
64
+ // Mock AWS SDK
65
+ jest.mock('@aws-sdk/client-ec2', () => ({
66
+ EC2Client: jest.fn(),
67
+ DescribeVpcsCommand: jest.fn(),
68
+ DescribeSubnetsCommand: jest.fn(),
69
+ DescribeSecurityGroupsCommand: jest.fn(),
70
+ DescribeRouteTablesCommand: jest.fn(),
71
+ }));
72
+
73
+ jest.mock('@aws-sdk/client-rds', () => ({
74
+ RDSClient: jest.fn(),
75
+ DescribeDBClustersCommand: jest.fn(),
76
+ }));
77
+
78
+ jest.mock('@aws-sdk/client-kms', () => ({
79
+ KMSClient: jest.fn(),
80
+ ListKeysCommand: jest.fn(),
81
+ DescribeKeyCommand: jest.fn(),
82
+ ListAliasesCommand: jest.fn(),
83
+ }));
84
+
85
+ describe('Orphan Detection with Relationship Analysis (TDD)', () => {
86
+ let detector;
87
+ let mockEC2Send;
88
+ let mockRDSSend;
89
+ let mockKMSSend;
90
+
91
+ beforeEach(() => {
92
+ jest.clearAllMocks();
93
+
94
+ // Mock EC2 client
95
+ mockEC2Send = jest.fn();
96
+ const { EC2Client } = require('@aws-sdk/client-ec2');
97
+ EC2Client.mockImplementation(() => ({ send: mockEC2Send }));
98
+
99
+ // Mock RDS client
100
+ mockRDSSend = jest.fn().mockResolvedValue({ DBClusters: [] });
101
+ const { RDSClient } = require('@aws-sdk/client-rds');
102
+ RDSClient.mockImplementation(() => ({ send: mockRDSSend }));
103
+
104
+ // Mock KMS client
105
+ mockKMSSend = jest.fn().mockResolvedValue({ Keys: [] });
106
+ const { KMSClient } = require('@aws-sdk/client-kms');
107
+ KMSClient.mockImplementation(() => ({ send: mockKMSSend }));
108
+
109
+ detector = new AWSResourceDetector({ region: 'us-east-1' });
110
+ });
111
+
112
+ describe('Real-world scenario: acme-integrations-dev multiple VPCs', () => {
113
+ test('should analyze relationships between drifted Lambdas and orphaned VPC resources', async () => {
114
+ const stackIdentifier = new StackIdentifier({
115
+ stackName: 'acme-integrations-dev',
116
+ region: 'us-east-1',
117
+ });
118
+
119
+ // Stack has 16 Lambda functions (all with VPC drift)
120
+ const stackResources = [
121
+ {
122
+ logicalId: 'AttioFunction',
123
+ physicalId: 'acme-integrations-dev-attio',
124
+ resourceType: 'AWS::Lambda::Function',
125
+ },
126
+ {
127
+ logicalId: 'AuthFunction',
128
+ physicalId: 'acme-integrations-dev-auth',
129
+ resourceType: 'AWS::Lambda::Function',
130
+ },
131
+ // ... 14 more Lambda functions
132
+ ];
133
+
134
+ // Mock EC2 responses - 3 VPCs, 10 subnets, 3 security groups
135
+ mockEC2Send.mockImplementation((command) => {
136
+ if (command.constructor.name === 'DescribeVpcsCommand') {
137
+ return Promise.resolve({
138
+ Vpcs: [
139
+ {
140
+ // VPC #1: Old VPC with Frigg tags but not in stack
141
+ VpcId: 'vpc-0eadd96976d29ede7',
142
+ CidrBlock: '10.0.0.0/16',
143
+ State: 'available',
144
+ Tags: [
145
+ {
146
+ Key: 'aws:cloudformation:stack-name',
147
+ Value: 'acme-integrations-dev',
148
+ },
149
+ { Key: 'Name', Value: 'Old VPC' },
150
+ ],
151
+ },
152
+ {
153
+ // VPC #2: Another old VPC
154
+ VpcId: 'vpc-0e2351eac99adcb83',
155
+ CidrBlock: '10.1.0.0/16',
156
+ State: 'available',
157
+ Tags: [
158
+ {
159
+ Key: 'aws:cloudformation:stack-name',
160
+ Value: 'acme-integrations-dev',
161
+ },
162
+ ],
163
+ },
164
+ {
165
+ // VPC #3: Current VPC that Lambdas actually use
166
+ VpcId: 'vpc-020a0365610c05f0b',
167
+ CidrBlock: '10.2.0.0/16',
168
+ State: 'available',
169
+ Tags: [
170
+ {
171
+ Key: 'aws:cloudformation:stack-name',
172
+ Value: 'acme-integrations-dev',
173
+ },
174
+ { Key: 'Name', Value: 'Current VPC' },
175
+ ],
176
+ },
177
+ ],
178
+ });
179
+ }
180
+
181
+ if (command.constructor.name === 'DescribeSubnetsCommand') {
182
+ return Promise.resolve({
183
+ Subnets: [
184
+ // Subnets in VPC #3 (current VPC) - these are the ones Lambdas reference
185
+ {
186
+ SubnetId: 'subnet-020d32e3ca398a041',
187
+ VpcId: 'vpc-020a0365610c05f0b',
188
+ CidrBlock: '10.2.1.0/24',
189
+ AvailabilityZone: 'us-east-1a',
190
+ State: 'available',
191
+ Tags: [
192
+ {
193
+ Key: 'aws:cloudformation:stack-name',
194
+ Value: 'acme-integrations-dev',
195
+ },
196
+ ],
197
+ },
198
+ {
199
+ SubnetId: 'subnet-0c186318804aba790',
200
+ VpcId: 'vpc-020a0365610c05f0b',
201
+ CidrBlock: '10.2.2.0/24',
202
+ AvailabilityZone: 'us-east-1b',
203
+ State: 'available',
204
+ Tags: [
205
+ {
206
+ Key: 'aws:cloudformation:stack-name',
207
+ Value: 'acme-integrations-dev',
208
+ },
209
+ ],
210
+ },
211
+ // Subnets in old VPCs (unused)
212
+ {
213
+ SubnetId: 'subnet-0ad31b5ee6814b8fa',
214
+ VpcId: 'vpc-0eadd96976d29ede7',
215
+ CidrBlock: '10.0.1.0/24',
216
+ AvailabilityZone: 'us-east-1a',
217
+ State: 'available',
218
+ Tags: [
219
+ {
220
+ Key: 'aws:cloudformation:stack-name',
221
+ Value: 'acme-integrations-dev',
222
+ },
223
+ ],
224
+ },
225
+ // ... 7 more unused subnets
226
+ ],
227
+ });
228
+ }
229
+
230
+ if (command.constructor.name === 'DescribeSecurityGroupsCommand') {
231
+ return Promise.resolve({
232
+ SecurityGroups: [
233
+ // SG in current VPC (unused orphan - Lambdas use different SG)
234
+ {
235
+ GroupId: 'sg-07c01370e830b6ad6',
236
+ GroupName: 'default',
237
+ VpcId: 'vpc-020a0365610c05f0b',
238
+ Tags: [
239
+ {
240
+ Key: 'aws:cloudformation:stack-name',
241
+ Value: 'acme-integrations-dev',
242
+ },
243
+ ],
244
+ },
245
+ // SGs in old VPCs (unused)
246
+ {
247
+ GroupId: 'sg-03abddb7fb50aeaff',
248
+ GroupName: 'default',
249
+ VpcId: 'vpc-0eadd96976d29ede7',
250
+ Tags: [
251
+ {
252
+ Key: 'aws:cloudformation:stack-name',
253
+ Value: 'acme-integrations-dev',
254
+ },
255
+ ],
256
+ },
257
+ {
258
+ GroupId: 'sg-027f44ad46727df93',
259
+ GroupName: 'default',
260
+ VpcId: 'vpc-0e2351eac99adcb83',
261
+ Tags: [
262
+ {
263
+ Key: 'aws:cloudformation:stack-name',
264
+ Value: 'acme-integrations-dev',
265
+ },
266
+ ],
267
+ },
268
+ ],
269
+ });
270
+ }
271
+
272
+ return Promise.resolve({});
273
+ });
274
+
275
+ // Drift issues: Lambda functions reference subnets in current VPC
276
+ const driftIssues = [
277
+ Issue.propertyMismatch({
278
+ resourceType: 'AWS::Lambda::Function',
279
+ resourceId: 'acme-integrations-dev-attio',
280
+ mismatch: new PropertyMismatch({
281
+ propertyPath: 'VpcConfig.SubnetIds',
282
+ expectedValue: 'subnet-00ab9e0502e66aac3,subnet-00d085a52937aaf91',
283
+ actualValue: 'subnet-020d32e3ca398a041,subnet-0c186318804aba790', // ← References current VPC subnets
284
+ mutability: PropertyMutability.MUTABLE,
285
+ }),
286
+ }),
287
+ Issue.propertyMismatch({
288
+ resourceType: 'AWS::Lambda::Function',
289
+ resourceId: 'acme-integrations-dev-auth',
290
+ mismatch: new PropertyMismatch({
291
+ propertyPath: 'VpcConfig.SubnetIds',
292
+ expectedValue: 'subnet-00ab9e0502e66aac3,subnet-00d085a52937aaf91',
293
+ actualValue: 'subnet-020d32e3ca398a041,subnet-0c186318804aba790', // ← Same subnets
294
+ mutability: PropertyMutability.MUTABLE,
295
+ }),
296
+ }),
297
+ // ... 14 more Lambda functions with same subnet drift
298
+ ];
299
+
300
+ // Act
301
+ const orphans = await detector.findOrphanedResourcesWithRelationships({
302
+ stackIdentifier,
303
+ stackResources,
304
+ driftIssues,
305
+ });
306
+
307
+ // Assert: Should identify relationship metadata
308
+ expect(orphans.length).toBeGreaterThan(0);
309
+
310
+ // Find the orphaned subnets that are actually referenced
311
+ const referencedSubnets = orphans.filter(
312
+ (o) =>
313
+ o.resourceType === 'AWS::EC2::Subnet' &&
314
+ (o.physicalId === 'subnet-020d32e3ca398a041' ||
315
+ o.physicalId === 'subnet-0c186318804aba790')
316
+ );
317
+
318
+ // These subnets should be marked as "actively used"
319
+ for (const subnet of referencedSubnets) {
320
+ expect(subnet.metadata).toHaveProperty('referencedBy');
321
+ expect(subnet.metadata.referencedBy.length).toBeGreaterThan(0);
322
+ expect(subnet.metadata.isActivelyUsed).toBe(true);
323
+ }
324
+
325
+ // Find the VPC that contains these subnets
326
+ const currentVpc = orphans.find((o) => o.physicalId === 'vpc-020a0365610c05f0b');
327
+
328
+ if (currentVpc) {
329
+ expect(currentVpc.metadata).toHaveProperty('containsReferencedResources');
330
+ expect(currentVpc.metadata.containsReferencedResources).toBe(true);
331
+ }
332
+
333
+ // Old VPCs should NOT be marked as actively used
334
+ const oldVpc1 = orphans.find((o) => o.physicalId === 'vpc-0eadd96976d29ede7');
335
+ if (oldVpc1) {
336
+ expect(oldVpc1.metadata?.isActivelyUsed).toBeFalsy();
337
+ }
338
+ });
339
+
340
+ test('should flag multiple resources of same type for manual review', async () => {
341
+ const stackIdentifier = new StackIdentifier({
342
+ stackName: 'acme-integrations-dev',
343
+ region: 'us-east-1',
344
+ });
345
+
346
+ const stackResources = [
347
+ {
348
+ logicalId: 'MyLambda',
349
+ physicalId: 'my-lambda',
350
+ resourceType: 'AWS::Lambda::Function',
351
+ },
352
+ ];
353
+
354
+ // Mock 3 orphaned VPCs
355
+ mockEC2Send.mockResolvedValue({
356
+ Vpcs: [
357
+ {
358
+ VpcId: 'vpc-1',
359
+ CidrBlock: '10.0.0.0/16',
360
+ State: 'available',
361
+ Tags: [{ Key: 'aws:cloudformation:stack-name', Value: 'acme-integrations-dev' }],
362
+ },
363
+ {
364
+ VpcId: 'vpc-2',
365
+ CidrBlock: '10.1.0.0/16',
366
+ State: 'available',
367
+ Tags: [{ Key: 'aws:cloudformation:stack-name', Value: 'acme-integrations-dev' }],
368
+ },
369
+ {
370
+ VpcId: 'vpc-3',
371
+ CidrBlock: '10.2.0.0/16',
372
+ State: 'available',
373
+ Tags: [{ Key: 'aws:cloudformation:stack-name', Value: 'acme-integrations-dev' }],
374
+ },
375
+ ],
376
+ });
377
+
378
+ const result = await detector.findOrphanedResourcesWithRelationships({
379
+ stackIdentifier,
380
+ stackResources,
381
+ driftIssues: [],
382
+ });
383
+
384
+ // Should include warning metadata
385
+ const summary = detector.analyzeOrphanSummary(result);
386
+
387
+ expect(summary.warnings).toContain(
388
+ 'Multiple VPCs detected (3). Review relationships before importing.'
389
+ );
390
+ expect(summary.multipleResourceTypes).toContain('AWS::EC2::VPC');
391
+ });
392
+ });
393
+
394
+ describe('Helper method: extractReferencedResourceIds', () => {
395
+ test('should extract subnet IDs from VpcConfig.SubnetIds drift', () => {
396
+ const driftIssue = Issue.propertyMismatch({
397
+ resourceType: 'AWS::Lambda::Function',
398
+ resourceId: 'my-lambda',
399
+ mismatch: new PropertyMismatch({
400
+ propertyPath: 'VpcConfig.SubnetIds',
401
+ expectedValue: 'subnet-old-1,subnet-old-2',
402
+ actualValue: 'subnet-020d32e3ca398a041,subnet-0c186318804aba790',
403
+ mutability: PropertyMutability.MUTABLE,
404
+ }),
405
+ });
406
+
407
+ const detector = new AWSResourceDetector({ region: 'us-east-1' });
408
+ const referenced = detector._extractReferencedResourceIds([driftIssue]);
409
+
410
+ expect(referenced.subnetIds).toContain('subnet-020d32e3ca398a041');
411
+ expect(referenced.subnetIds).toContain('subnet-0c186318804aba790');
412
+ });
413
+
414
+ test('should extract security group IDs from VpcConfig.SecurityGroupIds drift', () => {
415
+ const driftIssue = Issue.propertyMismatch({
416
+ resourceType: 'AWS::Lambda::Function',
417
+ resourceId: 'my-lambda',
418
+ mismatch: new PropertyMismatch({
419
+ propertyPath: 'VpcConfig.SecurityGroupIds',
420
+ expectedValue: 'sg-old',
421
+ actualValue: 'sg-0aca40438d17344c4',
422
+ mutability: PropertyMutability.MUTABLE,
423
+ }),
424
+ });
425
+
426
+ const detector = new AWSResourceDetector({ region: 'us-east-1' });
427
+ const referenced = detector._extractReferencedResourceIds([driftIssue]);
428
+
429
+ expect(referenced.securityGroupIds).toContain('sg-0aca40438d17344c4');
430
+ });
431
+ });
432
+ });
@@ -209,34 +209,127 @@ class AWSResourceDetector extends IResourceDetector {
209
209
  }
210
210
 
211
211
  /**
212
- * Find orphaned resources (exist in cloud but not in any stack)
212
+ * Find orphaned resources for a specific stack
213
+ *
214
+ * Orphaned resources are resources that:
215
+ * 1. Have aws:cloudformation:stack-name tag matching target stack
216
+ * OR no CloudFormation tags but exist in region with stack resources
217
+ * 2. Physical ID is NOT in the actual CloudFormation stack resources
218
+ * 3. Are not default AWS resources (default VPC, AWS-managed KMS keys)
219
+ *
220
+ * NOTE: We DON'T trust CloudFormation tags alone. Resources can have
221
+ * CloudFormation tags but not actually be in the stack (manual tagging,
222
+ * failed imports, removed from stack but tags remain, etc.)
223
+ *
224
+ * Instead, we compare against the actual physical IDs from the stack.
225
+ *
226
+ * @param {Object} params
227
+ * @param {StackIdentifier} params.stackIdentifier - Target stack
228
+ * @param {Array} params.stackResources - Resources currently in stack template
229
+ * @returns {Promise<Array>} Orphaned resources
213
230
  */
214
- async findOrphanedResources({ region, resourceTypes = [], excludePhysicalIds = [] }) {
215
- const types = resourceTypes.length > 0 ? resourceTypes : AWSResourceDetector.SUPPORTED_TYPES;
216
-
231
+ async findOrphanedResources({ stackIdentifier, stackResources }) {
217
232
  const orphans = [];
218
233
 
219
- for (const resourceType of types) {
220
- const resources = await this.detectResources({ resourceType, region });
234
+ // Build Set of physical IDs that are actually IN the CloudFormation stack
235
+ // This is the source of truth - not the tags!
236
+ const stackPhysicalIds = new Set(
237
+ stackResources.map((r) => r.physicalId).filter(Boolean)
238
+ );
239
+
240
+ // Check ALL supported resource types, not just types in stack
241
+ // Orphaned resources are by definition NOT in the stack, so we need
242
+ // to check all types that could potentially be orphaned
243
+ const typesToCheck = AWSResourceDetector.SUPPORTED_TYPES;
244
+
245
+ for (const resourceType of typesToCheck) {
246
+ const resources = await this.detectResources({
247
+ resourceType,
248
+ region: stackIdentifier.region,
249
+ });
221
250
 
222
251
  for (const resource of resources) {
223
- // Exclude specified physical IDs
224
- if (excludePhysicalIds.includes(resource.physicalId)) {
252
+ // Rule 1: Check if resource claims to be in this stack
253
+ const cfnStackTag = resource.tags?.['aws:cloudformation:stack-name'];
254
+
255
+ // Skip resources from different stacks
256
+ if (cfnStackTag && cfnStackTag !== stackIdentifier.stackName) {
257
+ continue;
258
+ }
259
+
260
+ // Rule 2: If resource has CloudFormation tag for THIS stack,
261
+ // check if it's actually IN the stack by physical ID
262
+ if (cfnStackTag === stackIdentifier.stackName) {
263
+ // Has CloudFormation tag - check if actually in stack
264
+ if (!stackPhysicalIds.has(resource.physicalId)) {
265
+ // Has tag but NOT in stack = ORPHAN!
266
+ // This is the bug we're fixing
267
+ orphans.push({
268
+ ...resource,
269
+ isOrphaned: true,
270
+ reason: `Resource ${resource.physicalId} has CloudFormation tag for stack ${stackIdentifier.stackName} but is not actually managed by the stack.`,
271
+ });
272
+ }
273
+ // If it IS in stack, skip it (not orphaned)
274
+ continue;
275
+ }
276
+
277
+ // Rule 3: Filter out default AWS resources (no CloudFormation tag)
278
+ if (this._isDefaultAWSResource(resource)) {
225
279
  continue;
226
280
  }
227
281
 
228
- // Mark as orphaned (in real implementation, would check CloudFormation stacks)
229
- orphans.push({
230
- ...resource,
231
- isOrphaned: true,
232
- reason: `Resource ${resource.physicalId} exists in cloud but is not managed by CloudFormation`,
233
- });
282
+ // No CloudFormation tag - check for frigg:stack tag as fallback
283
+ const friggStackTag = resource.tags?.['frigg:stack'];
284
+ if (friggStackTag === stackIdentifier.stackName) {
285
+ // Has frigg tag but no CloudFormation tag and not in stack = orphan
286
+ if (!stackPhysicalIds.has(resource.physicalId)) {
287
+ orphans.push({
288
+ ...resource,
289
+ isOrphaned: true,
290
+ reason: `Resource ${resource.physicalId} has frigg:stack tag but is not managed by CloudFormation stack ${stackIdentifier.stackName}.`,
291
+ });
292
+ }
293
+ }
234
294
  }
235
295
  }
236
296
 
237
297
  return orphans;
238
298
  }
239
299
 
300
+ /**
301
+ * Check if resource is a default AWS resource that should be ignored
302
+ * @private
303
+ */
304
+ _isDefaultAWSResource(resource) {
305
+ // Default VPC (172.31.0.0/16 CIDR block)
306
+ if (
307
+ resource.resourceType === 'AWS::EC2::VPC' &&
308
+ (resource.properties?.IsDefault === true ||
309
+ resource.properties?.CidrBlock === '172.31.0.0/16')
310
+ ) {
311
+ return true;
312
+ }
313
+
314
+ // AWS-managed KMS keys (KeyManager === 'AWS')
315
+ if (
316
+ resource.resourceType === 'AWS::KMS::Key' &&
317
+ resource.properties?.KeyManager === 'AWS'
318
+ ) {
319
+ return true;
320
+ }
321
+
322
+ // Default security groups (GroupName === 'default')
323
+ if (
324
+ resource.resourceType === 'AWS::EC2::SecurityGroup' &&
325
+ resource.properties?.GroupName === 'default'
326
+ ) {
327
+ return true;
328
+ }
329
+
330
+ return false;
331
+ }
332
+
240
333
  // ========================================
241
334
  // Private Resource Detection Methods
242
335
  // ========================================
@@ -262,6 +355,7 @@ class AWSResourceDetector extends IResourceDetector {
262
355
  VpcId: vpc.VpcId,
263
356
  CidrBlock: vpc.CidrBlock,
264
357
  State: vpc.State,
358
+ IsDefault: vpc.IsDefault,
265
359
  EnableDnsHostnames: vpc.EnableDnsHostnames,
266
360
  EnableDnsSupport: vpc.EnableDnsSupport,
267
361
  },