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.
- cloudg/__init__.py +21 -0
- cloudg/api.py +613 -0
- cloudg/cli.py +934 -0
- cloudg/collectors/__init__.py +5 -0
- cloudg/collectors/aws.py +830 -0
- cloudg/collectors/azure.py +362 -0
- cloudg/collectors/base.py +44 -0
- cloudg/collectors/gcp.py +170 -0
- cloudg/collectors/multi.py +329 -0
- cloudg/config.py +261 -0
- cloudg/coverage.py +93 -0
- cloudg/credentials.py +451 -0
- cloudg/graph/__init__.py +6 -0
- cloudg/graph/builder.py +336 -0
- cloudg/graph/ontology.py +914 -0
- cloudg/graph/rag_export.py +533 -0
- cloudg/graph/reachability.py +237 -0
- cloudg/normaliser.py +390 -0
- cloudg/policies/custodian-aws-security.yml +139 -0
- cloudg/policies/custodian-azure.yml +95 -0
- cloudg/policies/custodian-gcp.yml +87 -0
- cloudg/policies/custodian.yml +131 -0
- cloudg/region_discovery.py +330 -0
- cloudg/registry.py +142 -0
- cloudg/renderers/__init__.py +1 -0
- cloudg/renderers/html_report.py +430 -0
- cloudg/renderers/json_export.py +65 -0
- cloudg/renderers/svg.py +689 -0
- cloudg/renderers/terraform_export.py +666 -0
- cloudg/retry.py +103 -0
- cloudg/rules/cis_aws_v3.yaml +148 -0
- cloudg/rules/cis_azure_v2.yaml +142 -0
- cloudg/rules/cis_gcp_v3.yaml +133 -0
- cloudg/rules/frameworks/aws_foundational_security_best_practices_aws.yaml +1286 -0
- cloudg/rules/frameworks/cis_5.0_aws.yaml +280 -0
- cloudg/rules/frameworks/cis_5.0_azure.yaml +475 -0
- cloudg/rules/frameworks/cis_5.0_gcp.yaml +327 -0
- cloudg/rules/frameworks/gdpr_aws.yaml +95 -0
- cloudg/rules/frameworks/hipaa_aws.yaml +461 -0
- cloudg/rules/frameworks/hipaa_azure.yaml +504 -0
- cloudg/rules/frameworks/hipaa_gcp.yaml +211 -0
- cloudg/rules/frameworks/iso27001_2022_aws.yaml +789 -0
- cloudg/rules/frameworks/iso27001_2022_azure.yaml +477 -0
- cloudg/rules/frameworks/iso27001_2022_gcp.yaml +240 -0
- cloudg/rules/frameworks/mitre_attack_aws.yaml +596 -0
- cloudg/rules/frameworks/mitre_attack_azure.yaml +418 -0
- cloudg/rules/frameworks/mitre_attack_gcp.yaml +300 -0
- cloudg/rules/frameworks/nist_800_53_revision_5_aws.yaml +3234 -0
- cloudg/rules/frameworks/nist_csf_2.0_aws.yaml +828 -0
- cloudg/rules/frameworks/pci_4.0_aws.yaml +7108 -0
- cloudg/rules/frameworks/pci_4.0_azure.yaml +4696 -0
- cloudg/rules/frameworks/pci_4.0_gcp.yaml +5405 -0
- cloudg/rules/frameworks/soc2_aws.yaml +421 -0
- cloudg/rules/frameworks/soc2_azure.yaml +477 -0
- cloudg/rules/frameworks/soc2_gcp.yaml +342 -0
- cloudg/rules/gdpr.yaml +56 -0
- cloudg/rules/hipaa_security_rule.yaml +83 -0
- cloudg/rules/iso_27001_2022.yaml +88 -0
- cloudg/rules/nist_800_53.yaml +134 -0
- cloudg/rules/pci_dss_v4.yaml +108 -0
- cloudg/rules/soc2_tsc.yaml +93 -0
- cloudg/scanners/__init__.py +1 -0
- cloudg/scanners/checkov.py +174 -0
- cloudg/scanners/iam_linter.py +200 -0
- cloudg/scanners/prowler.py +237 -0
- cloudg/scanners/scoutsuite.py +163 -0
- cloudg/scanners/trivy.py +339 -0
- cloudg/schema/__init__.py +25 -0
- cloudg/schema/models.py +260 -0
- cloudg/templates/report.html.j2 +828 -0
- cloudg-0.3.0.dist-info/METADATA +330 -0
- cloudg-0.3.0.dist-info/RECORD +75 -0
- cloudg-0.3.0.dist-info/WHEEL +4 -0
- cloudg-0.3.0.dist-info/entry_points.txt +14 -0
- cloudg-0.3.0.dist-info/licenses/LICENSE +21 -0
cloudg/collectors/aws.py
ADDED
|
@@ -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
|