cloudg 0.3.0__py3-none-any.whl

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 (75) hide show
  1. cloudg/__init__.py +21 -0
  2. cloudg/api.py +613 -0
  3. cloudg/cli.py +934 -0
  4. cloudg/collectors/__init__.py +5 -0
  5. cloudg/collectors/aws.py +830 -0
  6. cloudg/collectors/azure.py +362 -0
  7. cloudg/collectors/base.py +44 -0
  8. cloudg/collectors/gcp.py +170 -0
  9. cloudg/collectors/multi.py +329 -0
  10. cloudg/config.py +261 -0
  11. cloudg/coverage.py +93 -0
  12. cloudg/credentials.py +451 -0
  13. cloudg/graph/__init__.py +6 -0
  14. cloudg/graph/builder.py +336 -0
  15. cloudg/graph/ontology.py +914 -0
  16. cloudg/graph/rag_export.py +533 -0
  17. cloudg/graph/reachability.py +237 -0
  18. cloudg/normaliser.py +390 -0
  19. cloudg/policies/custodian-aws-security.yml +139 -0
  20. cloudg/policies/custodian-azure.yml +95 -0
  21. cloudg/policies/custodian-gcp.yml +87 -0
  22. cloudg/policies/custodian.yml +131 -0
  23. cloudg/region_discovery.py +330 -0
  24. cloudg/registry.py +142 -0
  25. cloudg/renderers/__init__.py +1 -0
  26. cloudg/renderers/html_report.py +430 -0
  27. cloudg/renderers/json_export.py +65 -0
  28. cloudg/renderers/svg.py +689 -0
  29. cloudg/renderers/terraform_export.py +666 -0
  30. cloudg/retry.py +103 -0
  31. cloudg/rules/cis_aws_v3.yaml +148 -0
  32. cloudg/rules/cis_azure_v2.yaml +142 -0
  33. cloudg/rules/cis_gcp_v3.yaml +133 -0
  34. cloudg/rules/frameworks/aws_foundational_security_best_practices_aws.yaml +1286 -0
  35. cloudg/rules/frameworks/cis_5.0_aws.yaml +280 -0
  36. cloudg/rules/frameworks/cis_5.0_azure.yaml +475 -0
  37. cloudg/rules/frameworks/cis_5.0_gcp.yaml +327 -0
  38. cloudg/rules/frameworks/gdpr_aws.yaml +95 -0
  39. cloudg/rules/frameworks/hipaa_aws.yaml +461 -0
  40. cloudg/rules/frameworks/hipaa_azure.yaml +504 -0
  41. cloudg/rules/frameworks/hipaa_gcp.yaml +211 -0
  42. cloudg/rules/frameworks/iso27001_2022_aws.yaml +789 -0
  43. cloudg/rules/frameworks/iso27001_2022_azure.yaml +477 -0
  44. cloudg/rules/frameworks/iso27001_2022_gcp.yaml +240 -0
  45. cloudg/rules/frameworks/mitre_attack_aws.yaml +596 -0
  46. cloudg/rules/frameworks/mitre_attack_azure.yaml +418 -0
  47. cloudg/rules/frameworks/mitre_attack_gcp.yaml +300 -0
  48. cloudg/rules/frameworks/nist_800_53_revision_5_aws.yaml +3234 -0
  49. cloudg/rules/frameworks/nist_csf_2.0_aws.yaml +828 -0
  50. cloudg/rules/frameworks/pci_4.0_aws.yaml +7108 -0
  51. cloudg/rules/frameworks/pci_4.0_azure.yaml +4696 -0
  52. cloudg/rules/frameworks/pci_4.0_gcp.yaml +5405 -0
  53. cloudg/rules/frameworks/soc2_aws.yaml +421 -0
  54. cloudg/rules/frameworks/soc2_azure.yaml +477 -0
  55. cloudg/rules/frameworks/soc2_gcp.yaml +342 -0
  56. cloudg/rules/gdpr.yaml +56 -0
  57. cloudg/rules/hipaa_security_rule.yaml +83 -0
  58. cloudg/rules/iso_27001_2022.yaml +88 -0
  59. cloudg/rules/nist_800_53.yaml +134 -0
  60. cloudg/rules/pci_dss_v4.yaml +108 -0
  61. cloudg/rules/soc2_tsc.yaml +93 -0
  62. cloudg/scanners/__init__.py +1 -0
  63. cloudg/scanners/checkov.py +174 -0
  64. cloudg/scanners/iam_linter.py +200 -0
  65. cloudg/scanners/prowler.py +237 -0
  66. cloudg/scanners/scoutsuite.py +163 -0
  67. cloudg/scanners/trivy.py +339 -0
  68. cloudg/schema/__init__.py +25 -0
  69. cloudg/schema/models.py +260 -0
  70. cloudg/templates/report.html.j2 +828 -0
  71. cloudg-0.3.0.dist-info/METADATA +330 -0
  72. cloudg-0.3.0.dist-info/RECORD +75 -0
  73. cloudg-0.3.0.dist-info/WHEEL +4 -0
  74. cloudg-0.3.0.dist-info/entry_points.txt +14 -0
  75. cloudg-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,830 @@
1
+ """Async AWS resource collector using aioboto3 with adaptive retries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from typing import Any
9
+
10
+ from cloudg.collectors.base import BaseCollector
11
+ from cloudg.coverage import CollectionCoverage, ServiceStatus
12
+ from cloudg.schema.models import (
13
+ AssetType,
14
+ CloudAsset,
15
+ CloudProvider,
16
+ EdgeType,
17
+ NetworkEdge,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _get_aio_config() -> Any:
24
+ """Return AioConfig with adaptive retries."""
25
+ try:
26
+ from aiobotocore.config import AioConfig
27
+
28
+ return AioConfig(
29
+ retries={"mode": "adaptive", "max_attempts": 10},
30
+ connect_timeout=10,
31
+ read_timeout=30,
32
+ )
33
+ except ImportError:
34
+ from botocore.config import Config
35
+
36
+ return Config(
37
+ retries={"mode": "adaptive", "max_attempts": 10},
38
+ )
39
+
40
+
41
+ class AsyncAWSCollector(BaseCollector):
42
+ """Collects AWS resources asynchronously using aioboto3.
43
+
44
+ Enumerates: EC2, S3, RDS, VPC, Subnets, Security Groups,
45
+ IAM Users/Roles/Policies, Lambda, ELBv2, ECS, DynamoDB,
46
+ CloudFront, Secrets Manager, KMS.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ session: Any,
52
+ region: str = "us-east-1",
53
+ account_id: str | None = None,
54
+ ) -> None:
55
+ self._boto3_session = session
56
+ self._region = region
57
+ self._account_id = account_id
58
+ self._aioboto3_session: Any = None
59
+ self._aio_config = _get_aio_config()
60
+ self.coverage = CollectionCoverage(provider="aws", region=region, account_id=account_id)
61
+
62
+ def _get_aio_session(self) -> Any:
63
+ """Lazy-init aioboto3 session, propagating credentials from the boto3 session.
64
+
65
+ Credential priority:
66
+ 1. Direct credentials extracted from the boto3 session (access key / secret)
67
+ 2. Profile name from the boto3 session
68
+ 3. Default credential chain (env vars, instance metadata, etc.)
69
+ """
70
+ if self._aioboto3_session is None:
71
+ import aioboto3
72
+
73
+ session_kwargs: dict[str, str | None] = {
74
+ "region_name": self._region,
75
+ }
76
+
77
+ # Extract credentials from the boto3 session
78
+ creds = self._boto3_session.get_credentials()
79
+ if creds is not None:
80
+ resolved = creds.get_frozen_credentials()
81
+ session_kwargs["aws_access_key_id"] = resolved.access_key
82
+ session_kwargs["aws_secret_access_key"] = resolved.secret_key
83
+ if resolved.token:
84
+ session_kwargs["aws_session_token"] = resolved.token
85
+ elif self._boto3_session.profile_name:
86
+ # Fallback to profile if no direct credentials
87
+ session_kwargs["profile_name"] = self._boto3_session.profile_name
88
+
89
+ self._aioboto3_session = aioboto3.Session(**session_kwargs)
90
+ return self._aioboto3_session
91
+
92
+ # ------------------------------------------------------------------
93
+ # Individual service collectors
94
+ # ------------------------------------------------------------------
95
+
96
+ async def _collect_ec2(self) -> list[CloudAsset]:
97
+ """Collect EC2 instances."""
98
+ assets: list[CloudAsset] = []
99
+ session = self._get_aio_session()
100
+ try:
101
+ async with session.client("ec2", region_name=self._region) as ec2:
102
+ paginator = ec2.get_paginator("describe_instances")
103
+ async for page in paginator.paginate():
104
+ for reservation in page.get("Reservations", []):
105
+ for inst in reservation.get("Instances", []):
106
+ tags = {t["Key"]: t["Value"] for t in inst.get("Tags", [])}
107
+ assets.append(
108
+ CloudAsset(
109
+ arn=f"arn:aws:ec2:{self._region}:{self._account_id}:instance/{inst['InstanceId']}",
110
+ name=tags.get("Name", inst["InstanceId"]),
111
+ asset_type=AssetType.EC2,
112
+ provider=CloudProvider.AWS,
113
+ region=self._region,
114
+ account_id=self._account_id,
115
+ tags=tags,
116
+ metadata={
117
+ "instance_type": inst.get("InstanceType"),
118
+ "state": inst.get("State", {}).get("Name"),
119
+ "vpc_id": inst.get("VpcId"),
120
+ "subnet_id": inst.get("SubnetId"),
121
+ "public_ip": inst.get("PublicIpAddress"),
122
+ "private_ip": inst.get("PrivateIpAddress"),
123
+ "image_id": inst.get("ImageId"),
124
+ "security_groups": [
125
+ sg["GroupId"] for sg in inst.get("SecurityGroups", [])
126
+ ],
127
+ },
128
+ raw_data=inst,
129
+ )
130
+ )
131
+ except Exception as exc:
132
+ logger.error("Failed to collect EC2 instances: %s", exc)
133
+ return assets
134
+
135
+ async def _collect_s3(self) -> list[CloudAsset]:
136
+ """Collect S3 buckets."""
137
+ assets: list[CloudAsset] = []
138
+ session = self._get_aio_session()
139
+ try:
140
+ async with session.client("s3", region_name=self._region) as s3:
141
+ response = await s3.list_buckets()
142
+ for bucket in response.get("Buckets", []):
143
+ bucket_name = bucket["Name"]
144
+ # Get bucket details
145
+ metadata: dict[str, Any] = {
146
+ "creation_date": str(bucket.get("CreationDate", ""))
147
+ }
148
+ try:
149
+ acl = await s3.get_bucket_acl(Bucket=bucket_name)
150
+ metadata["acl_grants"] = len(acl.get("Grants", []))
151
+ except Exception:
152
+ pass
153
+ try:
154
+ pub = await s3.get_public_access_block(Bucket=bucket_name)
155
+ metadata["public_access_block"] = pub.get(
156
+ "PublicAccessBlockConfiguration", {}
157
+ )
158
+ except Exception:
159
+ metadata["public_access_block"] = None
160
+ try:
161
+ await s3.get_bucket_encryption(Bucket=bucket_name)
162
+ metadata["encryption"] = True
163
+ except Exception:
164
+ metadata["encryption"] = False
165
+
166
+ assets.append(
167
+ CloudAsset(
168
+ arn=f"arn:aws:s3:::{bucket_name}",
169
+ name=bucket_name,
170
+ asset_type=AssetType.S3_BUCKET,
171
+ provider=CloudProvider.AWS,
172
+ region="global",
173
+ account_id=self._account_id,
174
+ metadata=metadata,
175
+ raw_data=bucket,
176
+ )
177
+ )
178
+ except Exception as exc:
179
+ logger.error("Failed to collect S3 buckets: %s", exc)
180
+ return assets
181
+
182
+ async def _collect_rds(self) -> list[CloudAsset]:
183
+ """Collect RDS instances."""
184
+ assets: list[CloudAsset] = []
185
+ session = self._get_aio_session()
186
+ try:
187
+ async with session.client("rds", region_name=self._region) as rds:
188
+ paginator = rds.get_paginator("describe_db_instances")
189
+ async for page in paginator.paginate():
190
+ for db in page.get("DBInstances", []):
191
+ assets.append(
192
+ CloudAsset(
193
+ arn=db.get("DBInstanceArn", ""),
194
+ name=db.get("DBInstanceIdentifier", ""),
195
+ asset_type=AssetType.RDS_INSTANCE,
196
+ provider=CloudProvider.AWS,
197
+ region=self._region,
198
+ account_id=self._account_id,
199
+ metadata={
200
+ "engine": db.get("Engine"),
201
+ "engine_version": db.get("EngineVersion"),
202
+ "instance_class": db.get("DBInstanceClass"),
203
+ "multi_az": db.get("MultiAZ", False),
204
+ "publicly_accessible": db.get("PubliclyAccessible", False),
205
+ "storage_encrypted": db.get("StorageEncrypted", False),
206
+ "vpc_id": db.get("DBSubnetGroup", {}).get("VpcId"),
207
+ },
208
+ raw_data=db,
209
+ )
210
+ )
211
+ except Exception as exc:
212
+ logger.error("Failed to collect RDS instances: %s", exc)
213
+ return assets
214
+
215
+ async def _collect_vpcs(self) -> list[CloudAsset]:
216
+ """Collect VPCs."""
217
+ assets: list[CloudAsset] = []
218
+ session = self._get_aio_session()
219
+ try:
220
+ async with session.client("ec2", region_name=self._region) as ec2:
221
+ response = await ec2.describe_vpcs()
222
+ for vpc in response.get("Vpcs", []):
223
+ tags = {t["Key"]: t["Value"] for t in vpc.get("Tags", [])}
224
+ assets.append(
225
+ CloudAsset(
226
+ arn=f"arn:aws:ec2:{self._region}:{self._account_id}:vpc/{vpc['VpcId']}",
227
+ name=tags.get("Name", vpc["VpcId"]),
228
+ asset_type=AssetType.VPC,
229
+ provider=CloudProvider.AWS,
230
+ region=self._region,
231
+ account_id=self._account_id,
232
+ tags=tags,
233
+ metadata={
234
+ "vpc_id": vpc["VpcId"],
235
+ "cidr_block": vpc.get("CidrBlock"),
236
+ "is_default": vpc.get("IsDefault", False),
237
+ "state": vpc.get("State"),
238
+ },
239
+ raw_data=vpc,
240
+ )
241
+ )
242
+ except Exception as exc:
243
+ logger.error("Failed to collect VPCs: %s", exc)
244
+ return assets
245
+
246
+ async def _collect_subnets(self) -> list[CloudAsset]:
247
+ """Collect subnets."""
248
+ assets: list[CloudAsset] = []
249
+ session = self._get_aio_session()
250
+ try:
251
+ async with session.client("ec2", region_name=self._region) as ec2:
252
+ response = await ec2.describe_subnets()
253
+ for subnet in response.get("Subnets", []):
254
+ tags = {t["Key"]: t["Value"] for t in subnet.get("Tags", [])}
255
+ assets.append(
256
+ CloudAsset(
257
+ arn=subnet.get("SubnetArn", ""),
258
+ name=tags.get("Name", subnet["SubnetId"]),
259
+ asset_type=AssetType.SUBNET,
260
+ provider=CloudProvider.AWS,
261
+ region=self._region,
262
+ account_id=self._account_id,
263
+ tags=tags,
264
+ metadata={
265
+ "subnet_id": subnet["SubnetId"],
266
+ "vpc_id": subnet.get("VpcId"),
267
+ "cidr_block": subnet.get("CidrBlock"),
268
+ "availability_zone": subnet.get("AvailabilityZone"),
269
+ "map_public_ip": subnet.get("MapPublicIpOnLaunch", False),
270
+ },
271
+ raw_data=subnet,
272
+ )
273
+ )
274
+ except Exception as exc:
275
+ logger.error("Failed to collect subnets: %s", exc)
276
+ return assets
277
+
278
+ async def _collect_security_groups(self) -> list[CloudAsset]:
279
+ """Collect security groups."""
280
+ assets: list[CloudAsset] = []
281
+ session = self._get_aio_session()
282
+ try:
283
+ async with session.client("ec2", region_name=self._region) as ec2:
284
+ response = await ec2.describe_security_groups()
285
+ for sg in response.get("SecurityGroups", []):
286
+ assets.append(
287
+ CloudAsset(
288
+ arn=f"arn:aws:ec2:{self._region}:{self._account_id}:security-group/{sg['GroupId']}",
289
+ name=sg.get("GroupName", sg["GroupId"]),
290
+ asset_type=AssetType.SECURITY_GROUP,
291
+ provider=CloudProvider.AWS,
292
+ region=self._region,
293
+ account_id=self._account_id,
294
+ metadata={
295
+ "group_id": sg["GroupId"],
296
+ "vpc_id": sg.get("VpcId"),
297
+ "description": sg.get("Description"),
298
+ "ingress_rules": sg.get("IpPermissions", []),
299
+ "egress_rules": sg.get("IpPermissionsEgress", []),
300
+ },
301
+ raw_data=sg,
302
+ )
303
+ )
304
+ except Exception as exc:
305
+ logger.error("Failed to collect security groups: %s", exc)
306
+ return assets
307
+
308
+ async def _collect_iam_users(self) -> list[CloudAsset]:
309
+ """Collect IAM users."""
310
+ assets: list[CloudAsset] = []
311
+ session = self._get_aio_session()
312
+ try:
313
+ async with session.client("iam", region_name="us-east-1") as iam:
314
+ paginator = iam.get_paginator("list_users")
315
+ async for page in paginator.paginate():
316
+ for user in page.get("Users", []):
317
+ assets.append(
318
+ CloudAsset(
319
+ arn=user.get("Arn", ""),
320
+ name=user.get("UserName", ""),
321
+ asset_type=AssetType.IAM_USER,
322
+ provider=CloudProvider.AWS,
323
+ region="global",
324
+ account_id=self._account_id,
325
+ metadata={
326
+ "user_id": user.get("UserId"),
327
+ "create_date": str(user.get("CreateDate", "")),
328
+ "password_last_used": str(user.get("PasswordLastUsed", "")),
329
+ },
330
+ raw_data=user,
331
+ )
332
+ )
333
+ except Exception as exc:
334
+ logger.error("Failed to collect IAM users: %s", exc)
335
+ return assets
336
+
337
+ async def _collect_iam_roles(self) -> list[CloudAsset]:
338
+ """Collect IAM roles."""
339
+ assets: list[CloudAsset] = []
340
+ session = self._get_aio_session()
341
+ try:
342
+ async with session.client("iam", region_name="us-east-1") as iam:
343
+ paginator = iam.get_paginator("list_roles")
344
+ async for page in paginator.paginate():
345
+ for role in page.get("Roles", []):
346
+ assets.append(
347
+ CloudAsset(
348
+ arn=role.get("Arn", ""),
349
+ name=role.get("RoleName", ""),
350
+ asset_type=AssetType.IAM_ROLE,
351
+ provider=CloudProvider.AWS,
352
+ region="global",
353
+ account_id=self._account_id,
354
+ metadata={
355
+ "role_id": role.get("RoleId"),
356
+ "assume_role_policy": role.get("AssumeRolePolicyDocument"),
357
+ "max_session_duration": role.get("MaxSessionDuration"),
358
+ },
359
+ raw_data=role,
360
+ )
361
+ )
362
+ except Exception as exc:
363
+ logger.error("Failed to collect IAM roles: %s", exc)
364
+ return assets
365
+
366
+ async def _collect_lambda(self) -> list[CloudAsset]:
367
+ """Collect Lambda functions."""
368
+ assets: list[CloudAsset] = []
369
+ session = self._get_aio_session()
370
+ try:
371
+ async with session.client("lambda", region_name=self._region) as lam:
372
+ paginator = lam.get_paginator("list_functions")
373
+ async for page in paginator.paginate():
374
+ for fn in page.get("Functions", []):
375
+ assets.append(
376
+ CloudAsset(
377
+ arn=fn.get("FunctionArn", ""),
378
+ name=fn.get("FunctionName", ""),
379
+ asset_type=AssetType.LAMBDA_FUNCTION,
380
+ provider=CloudProvider.AWS,
381
+ region=self._region,
382
+ account_id=self._account_id,
383
+ metadata={
384
+ "runtime": fn.get("Runtime"),
385
+ "handler": fn.get("Handler"),
386
+ "memory_size": fn.get("MemorySize"),
387
+ "timeout": fn.get("Timeout"),
388
+ "last_modified": fn.get("LastModified"),
389
+ "vpc_config": fn.get("VpcConfig"),
390
+ },
391
+ raw_data=fn,
392
+ )
393
+ )
394
+ except Exception as exc:
395
+ logger.error("Failed to collect Lambda functions: %s", exc)
396
+ return assets
397
+
398
+ async def _collect_elbv2(self) -> list[CloudAsset]:
399
+ """Collect ELBv2 load balancers."""
400
+ assets: list[CloudAsset] = []
401
+ session = self._get_aio_session()
402
+ try:
403
+ async with session.client("elbv2", region_name=self._region) as elbv2:
404
+ response = await elbv2.describe_load_balancers()
405
+ for lb in response.get("LoadBalancers", []):
406
+ assets.append(
407
+ CloudAsset(
408
+ arn=lb.get("LoadBalancerArn", ""),
409
+ name=lb.get("LoadBalancerName", ""),
410
+ asset_type=AssetType.LOAD_BALANCER,
411
+ provider=CloudProvider.AWS,
412
+ region=self._region,
413
+ account_id=self._account_id,
414
+ metadata={
415
+ "type": lb.get("Type"),
416
+ "scheme": lb.get("Scheme"),
417
+ "vpc_id": lb.get("VpcId"),
418
+ "state": lb.get("State", {}).get("Code"),
419
+ "dns_name": lb.get("DNSName"),
420
+ "security_groups": lb.get("SecurityGroups", []),
421
+ },
422
+ raw_data=lb,
423
+ )
424
+ )
425
+ except Exception as exc:
426
+ logger.error("Failed to collect ELBv2 load balancers: %s", exc)
427
+ self.coverage.record("elbv2", ServiceStatus.FAILED, error=str(exc))
428
+ return assets
429
+
430
+ # ------------------------------------------------------------------
431
+ # Expanded service collectors (ECS, DynamoDB, CloudFront, Secrets Manager, KMS)
432
+ # ------------------------------------------------------------------
433
+
434
+ async def _collect_ecs(self) -> list[CloudAsset]:
435
+ """Collect ECS clusters and services."""
436
+ assets: list[CloudAsset] = []
437
+ session = self._get_aio_session()
438
+ start = time.time()
439
+ try:
440
+ async with session.client(
441
+ "ecs", region_name=self._region, config=self._aio_config
442
+ ) as ecs:
443
+ clusters_resp = await ecs.list_clusters()
444
+ cluster_arns = clusters_resp.get("clusterArns", [])
445
+ if cluster_arns:
446
+ desc = await ecs.describe_clusters(clusters=cluster_arns)
447
+ for cluster in desc.get("clusters", []):
448
+ assets.append(
449
+ CloudAsset(
450
+ arn=cluster.get("clusterArn", ""),
451
+ name=cluster.get("clusterName", ""),
452
+ asset_type=AssetType.ECS_CLUSTER,
453
+ provider=CloudProvider.AWS,
454
+ region=self._region,
455
+ account_id=self._account_id,
456
+ metadata={
457
+ "status": cluster.get("status"),
458
+ "running_tasks": cluster.get("runningTasksCount", 0),
459
+ "active_services": cluster.get("activeServicesCount", 0),
460
+ "capacity_providers": cluster.get("capacityProviders", []),
461
+ },
462
+ raw_data=cluster,
463
+ )
464
+ )
465
+ self.coverage.record(
466
+ "ecs",
467
+ ServiceStatus.SUCCESS,
468
+ asset_count=len(assets),
469
+ duration_ms=int((time.time() - start) * 1000),
470
+ )
471
+ except Exception as exc:
472
+ logger.error("Failed to collect ECS clusters: %s", exc)
473
+ self.coverage.record("ecs", ServiceStatus.FAILED, error=str(exc))
474
+ return assets
475
+
476
+ async def _collect_dynamodb(self) -> list[CloudAsset]:
477
+ """Collect DynamoDB tables."""
478
+ assets: list[CloudAsset] = []
479
+ session = self._get_aio_session()
480
+ start = time.time()
481
+ try:
482
+ async with session.client(
483
+ "dynamodb", region_name=self._region, config=self._aio_config
484
+ ) as ddb:
485
+ paginator = ddb.get_paginator("list_tables")
486
+ async for page in paginator.paginate():
487
+ for table_name in page.get("TableNames", []):
488
+ try:
489
+ desc = await ddb.describe_table(TableName=table_name)
490
+ table = desc.get("Table", {})
491
+ assets.append(
492
+ CloudAsset(
493
+ arn=table.get("TableArn", ""),
494
+ name=table_name,
495
+ asset_type=AssetType.DYNAMODB_TABLE,
496
+ provider=CloudProvider.AWS,
497
+ region=self._region,
498
+ account_id=self._account_id,
499
+ metadata={
500
+ "status": table.get("TableStatus"),
501
+ "item_count": table.get("ItemCount", 0),
502
+ "size_bytes": table.get("TableSizeBytes", 0),
503
+ "billing_mode": table.get("BillingModeSummary", {}).get(
504
+ "BillingMode", "PROVISIONED"
505
+ ),
506
+ "encryption": table.get("SSEDescription", {}).get(
507
+ "Status", "DISABLED"
508
+ ),
509
+ },
510
+ raw_data=table,
511
+ )
512
+ )
513
+ except Exception as exc:
514
+ logger.warning(
515
+ "Failed to describe DynamoDB table %s: %s", table_name, exc
516
+ )
517
+ self.coverage.record(
518
+ "dynamodb",
519
+ ServiceStatus.SUCCESS,
520
+ asset_count=len(assets),
521
+ duration_ms=int((time.time() - start) * 1000),
522
+ )
523
+ except Exception as exc:
524
+ logger.error("Failed to collect DynamoDB tables: %s", exc)
525
+ self.coverage.record("dynamodb", ServiceStatus.FAILED, error=str(exc))
526
+ return assets
527
+
528
+ async def _collect_cloudfront(self) -> list[CloudAsset]:
529
+ """Collect CloudFront distributions."""
530
+ assets: list[CloudAsset] = []
531
+ session = self._get_aio_session()
532
+ start = time.time()
533
+ try:
534
+ async with session.client(
535
+ "cloudfront", region_name="us-east-1", config=self._aio_config
536
+ ) as cf:
537
+ paginator = cf.get_paginator("list_distributions")
538
+ async for page in paginator.paginate():
539
+ dist_list = page.get("DistributionList", {})
540
+ for dist in dist_list.get("Items", []):
541
+ assets.append(
542
+ CloudAsset(
543
+ arn=dist.get("ARN", ""),
544
+ name=dist.get("DomainName", dist.get("Id", "")),
545
+ asset_type=AssetType.CLOUDFRONT,
546
+ provider=CloudProvider.AWS,
547
+ region="global",
548
+ account_id=self._account_id,
549
+ is_internet_exposed=True,
550
+ metadata={
551
+ "status": dist.get("Status"),
552
+ "domain_name": dist.get("DomainName"),
553
+ "origins": [
554
+ o.get("DomainName")
555
+ for o in dist.get("Origins", {}).get("Items", [])
556
+ ],
557
+ "web_acl_id": dist.get("WebACLId", ""),
558
+ "viewer_protocol_policy": dist.get(
559
+ "DefaultCacheBehavior", {}
560
+ ).get("ViewerProtocolPolicy"),
561
+ },
562
+ raw_data=dist,
563
+ )
564
+ )
565
+ self.coverage.record(
566
+ "cloudfront",
567
+ ServiceStatus.SUCCESS,
568
+ asset_count=len(assets),
569
+ duration_ms=int((time.time() - start) * 1000),
570
+ )
571
+ except Exception as exc:
572
+ logger.error("Failed to collect CloudFront distributions: %s", exc)
573
+ self.coverage.record("cloudfront", ServiceStatus.FAILED, error=str(exc))
574
+ return assets
575
+
576
+ async def _collect_secrets_manager(self) -> list[CloudAsset]:
577
+ """Collect Secrets Manager secrets (metadata only, not values)."""
578
+ assets: list[CloudAsset] = []
579
+ session = self._get_aio_session()
580
+ start = time.time()
581
+ try:
582
+ async with session.client(
583
+ "secretsmanager", region_name=self._region, config=self._aio_config
584
+ ) as sm:
585
+ paginator = sm.get_paginator("list_secrets")
586
+ async for page in paginator.paginate():
587
+ for secret in page.get("SecretList", []):
588
+ tags = {t["Key"]: t["Value"] for t in secret.get("Tags", [])}
589
+ assets.append(
590
+ CloudAsset(
591
+ arn=secret.get("ARN", ""),
592
+ name=secret.get("Name", ""),
593
+ asset_type=AssetType.SECRET,
594
+ provider=CloudProvider.AWS,
595
+ region=self._region,
596
+ account_id=self._account_id,
597
+ tags=tags,
598
+ metadata={
599
+ "description": secret.get("Description", ""),
600
+ "rotation_enabled": secret.get("RotationEnabled", False),
601
+ "last_accessed": str(secret.get("LastAccessedDate", "")),
602
+ "last_rotated": str(secret.get("LastRotatedDate", "")),
603
+ "kms_key_id": secret.get("KmsKeyId", ""),
604
+ },
605
+ raw_data=secret,
606
+ )
607
+ )
608
+ self.coverage.record(
609
+ "secretsmanager",
610
+ ServiceStatus.SUCCESS,
611
+ asset_count=len(assets),
612
+ duration_ms=int((time.time() - start) * 1000),
613
+ )
614
+ except Exception as exc:
615
+ logger.error("Failed to collect Secrets Manager secrets: %s", exc)
616
+ self.coverage.record("secretsmanager", ServiceStatus.FAILED, error=str(exc))
617
+ return assets
618
+
619
+ async def _collect_kms(self) -> list[CloudAsset]:
620
+ """Collect KMS keys."""
621
+ assets: list[CloudAsset] = []
622
+ session = self._get_aio_session()
623
+ start = time.time()
624
+ try:
625
+ async with session.client(
626
+ "kms", region_name=self._region, config=self._aio_config
627
+ ) as kms:
628
+ paginator = kms.get_paginator("list_keys")
629
+ async for page in paginator.paginate():
630
+ for key_entry in page.get("Keys", []):
631
+ try:
632
+ key_desc = await kms.describe_key(KeyId=key_entry["KeyId"])
633
+ key_meta = key_desc.get("KeyMetadata", {})
634
+ # Skip AWS-managed keys
635
+ if key_meta.get("KeyManager") == "AWS":
636
+ continue
637
+ assets.append(
638
+ CloudAsset(
639
+ arn=key_meta.get("Arn", ""),
640
+ name=key_meta.get("KeyId", ""),
641
+ asset_type=AssetType.KMS_KEY,
642
+ provider=CloudProvider.AWS,
643
+ region=self._region,
644
+ account_id=self._account_id,
645
+ metadata={
646
+ "key_state": key_meta.get("KeyState"),
647
+ "key_usage": key_meta.get("KeyUsage"),
648
+ "key_manager": key_meta.get("KeyManager"),
649
+ "origin": key_meta.get("Origin"),
650
+ "rotation_enabled": key_meta.get(
651
+ "KeyRotationStatus", False
652
+ ),
653
+ },
654
+ raw_data=key_meta,
655
+ )
656
+ )
657
+ except Exception as exc:
658
+ logger.warning(
659
+ "Failed to describe KMS key %s: %s", key_entry["KeyId"], exc
660
+ )
661
+ self.coverage.record(
662
+ "kms",
663
+ ServiceStatus.SUCCESS,
664
+ asset_count=len(assets),
665
+ duration_ms=int((time.time() - start) * 1000),
666
+ )
667
+ except Exception as exc:
668
+ logger.error("Failed to collect KMS keys: %s", exc)
669
+ self.coverage.record("kms", ServiceStatus.FAILED, error=str(exc))
670
+ return assets
671
+
672
+ # ------------------------------------------------------------------
673
+ # Edge collection
674
+ # ------------------------------------------------------------------
675
+
676
+ async def _collect_sg_edges(self, assets: list[CloudAsset]) -> list[NetworkEdge]:
677
+ """Build edges from security group rules."""
678
+ edges: list[NetworkEdge] = []
679
+ sg_assets = [a for a in assets if a.asset_type == AssetType.SECURITY_GROUP]
680
+
681
+ for sg in sg_assets:
682
+ sg_id = sg.metadata.get("group_id", sg.id)
683
+
684
+ # Ingress rules
685
+ for rule in sg.metadata.get("ingress_rules", []):
686
+ for ip_range in rule.get("IpRanges", []):
687
+ cidr = ip_range.get("CidrIp", "")
688
+ from_port = rule.get("FromPort", 0)
689
+ to_port = rule.get("ToPort", 65535)
690
+ protocol = rule.get("IpProtocol", "-1")
691
+
692
+ port_range = (
693
+ f"{from_port}-{to_port}" if from_port != to_port else str(from_port)
694
+ )
695
+
696
+ # Cap port list to avoid OOM on wide ranges (e.g., 0-65535)
697
+ port_count = min(to_port - from_port + 1, 100)
698
+ edges.append(
699
+ NetworkEdge(
700
+ source_id=cidr,
701
+ target_id=sg_id,
702
+ edge_type=EdgeType.SECURITY_GROUP_RULE,
703
+ ports=list(range(from_port, from_port + port_count)),
704
+ port_range=port_range,
705
+ protocol="ALL" if protocol == "-1" else protocol.upper(),
706
+ cidr=cidr,
707
+ direction="ingress",
708
+ )
709
+ )
710
+
711
+ # Egress rules
712
+ for rule in sg.metadata.get("egress_rules", []):
713
+ for ip_range in rule.get("IpRanges", []):
714
+ cidr = ip_range.get("CidrIp", "")
715
+ from_port = rule.get("FromPort", 0)
716
+ to_port = rule.get("ToPort", 65535)
717
+ protocol = rule.get("IpProtocol", "-1")
718
+
719
+ edges.append(
720
+ NetworkEdge(
721
+ source_id=sg_id,
722
+ target_id=cidr,
723
+ edge_type=EdgeType.SECURITY_GROUP_RULE,
724
+ port_range=f"{from_port}-{to_port}",
725
+ protocol="ALL" if protocol == "-1" else protocol.upper(),
726
+ cidr=cidr,
727
+ direction="egress",
728
+ )
729
+ )
730
+
731
+ return edges
732
+
733
+ async def _collect_containment_edges(self, assets: list[CloudAsset]) -> list[NetworkEdge]:
734
+ """Build VPC → Subnet → Resource containment edges."""
735
+ edges: list[NetworkEdge] = []
736
+ vpc_map = {
737
+ a.metadata.get("vpc_id", a.id): a.id for a in assets if a.asset_type == AssetType.VPC
738
+ }
739
+
740
+ for asset in assets:
741
+ vpc_id = asset.metadata.get("vpc_id")
742
+ if vpc_id and vpc_id in vpc_map and asset.asset_type != AssetType.VPC:
743
+ edges.append(
744
+ NetworkEdge(
745
+ source_id=vpc_map[vpc_id],
746
+ target_id=asset.id,
747
+ edge_type=EdgeType.CONTAINS,
748
+ description=f"VPC {vpc_id} contains {asset.name}",
749
+ )
750
+ )
751
+ return edges
752
+
753
+ # ------------------------------------------------------------------
754
+ # Main interface
755
+ # ------------------------------------------------------------------
756
+
757
+ async def _run_service_collector(self, name: str, coro: Any) -> list[CloudAsset]:
758
+ """Run a service collector with coverage tracking."""
759
+ start = time.time()
760
+ try:
761
+ result = await coro
762
+ duration_ms = int((time.time() - start) * 1000)
763
+ self.coverage.record(
764
+ name, ServiceStatus.SUCCESS, asset_count=len(result), duration_ms=duration_ms
765
+ )
766
+ return result
767
+ except Exception as exc:
768
+ duration_ms = int((time.time() - start) * 1000)
769
+ logger.error("Collector %s failed: %s", name, exc)
770
+ self.coverage.record(
771
+ name, ServiceStatus.FAILED, error=str(exc), duration_ms=duration_ms
772
+ )
773
+ return []
774
+
775
+ async def collect(self) -> list[CloudAsset]:
776
+ """Collect all AWS assets concurrently."""
777
+ logger.info("Starting AWS asset collection in %s", self._region)
778
+
779
+ service_tasks = {
780
+ "ec2": self._collect_ec2(),
781
+ "s3": self._collect_s3(),
782
+ "rds": self._collect_rds(),
783
+ "vpc": self._collect_vpcs(),
784
+ "subnets": self._collect_subnets(),
785
+ "security_groups": self._collect_security_groups(),
786
+ "iam_users": self._collect_iam_users(),
787
+ "iam_roles": self._collect_iam_roles(),
788
+ "lambda": self._collect_lambda(),
789
+ "elbv2": self._collect_elbv2(),
790
+ "ecs": self._collect_ecs(),
791
+ "dynamodb": self._collect_dynamodb(),
792
+ "cloudfront": self._collect_cloudfront(),
793
+ "secretsmanager": self._collect_secrets_manager(),
794
+ "kms": self._collect_kms(),
795
+ }
796
+
797
+ results = await asyncio.gather(
798
+ *(self._run_service_collector(name, coro) for name, coro in service_tasks.items()),
799
+ return_exceptions=True,
800
+ )
801
+
802
+ all_assets: list[CloudAsset] = []
803
+ for result in results:
804
+ if isinstance(result, Exception):
805
+ logger.error("Collector task failed: %s", result)
806
+ elif isinstance(result, list):
807
+ all_assets.extend(result)
808
+
809
+ logger.info(
810
+ "Collected %d AWS assets (%s coverage)",
811
+ len(all_assets),
812
+ f"{self.coverage.coverage_pct}%",
813
+ )
814
+ self._cached_assets = all_assets
815
+ return all_assets
816
+
817
+ async def collect_edges(self) -> list[NetworkEdge]:
818
+ """Collect all network/relationship edges."""
819
+ assets = getattr(self, "_cached_assets", [])
820
+ if not assets:
821
+ assets = await self.collect()
822
+
823
+ sg_edges, containment_edges = await asyncio.gather(
824
+ self._collect_sg_edges(assets),
825
+ self._collect_containment_edges(assets),
826
+ )
827
+
828
+ all_edges = sg_edges + containment_edges
829
+ logger.info("Collected %d AWS edges", len(all_edges))
830
+ return all_edges