cloudwire 0.1.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.
- cloudwire/__init__.py +3 -0
- cloudwire/app/__init__.py +1 -0
- cloudwire/app/graph_store.py +88 -0
- cloudwire/app/main.py +453 -0
- cloudwire/app/models.py +83 -0
- cloudwire/app/scan_jobs.py +368 -0
- cloudwire/app/scanner.py +1149 -0
- cloudwire/cli.py +86 -0
- cloudwire/static/assets/index-CByMF4j6.js +40 -0
- cloudwire/static/assets/index-lik1Sxh5.css +1 -0
- cloudwire/static/index.html +13 -0
- cloudwire-0.1.0.dist-info/METADATA +186 -0
- cloudwire-0.1.0.dist-info/RECORD +16 -0
- cloudwire-0.1.0.dist-info/WHEEL +5 -0
- cloudwire-0.1.0.dist-info/entry_points.txt +2 -0
- cloudwire-0.1.0.dist-info/top_level.txt +1 -0
cloudwire/app/scanner.py
ADDED
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from threading import Lock
|
|
7
|
+
from time import perf_counter
|
|
8
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Set
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
import boto3
|
|
13
|
+
from botocore.config import Config
|
|
14
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
15
|
+
|
|
16
|
+
from .graph_store import GraphStore
|
|
17
|
+
from .models import ScanMode
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalize_service_name(service: str) -> str:
|
|
21
|
+
key = service.lower().strip()
|
|
22
|
+
aliases = {
|
|
23
|
+
"api-gateway": "apigateway",
|
|
24
|
+
"apigw": "apigateway",
|
|
25
|
+
"event-bridge": "eventbridge",
|
|
26
|
+
"events": "eventbridge",
|
|
27
|
+
}
|
|
28
|
+
return aliases.get(key, key)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _safe_list(value: Any) -> List[Any]:
|
|
32
|
+
if isinstance(value, list):
|
|
33
|
+
return value
|
|
34
|
+
if value is None:
|
|
35
|
+
return []
|
|
36
|
+
return [value]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ScanExecutionOptions:
|
|
41
|
+
mode: ScanMode = "quick"
|
|
42
|
+
include_iam_inference: bool = False
|
|
43
|
+
include_resource_describes: bool = False
|
|
44
|
+
max_service_workers: int = 5
|
|
45
|
+
apigw_integration_workers: int = 16
|
|
46
|
+
eventbridge_target_workers: int = 8
|
|
47
|
+
dynamodb_describe_workers: int = 16
|
|
48
|
+
sqs_attribute_workers: int = 16
|
|
49
|
+
iam_workers: int = 8
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ScanCancelledError(Exception):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AWSGraphScanner:
|
|
57
|
+
def __init__(self, store: GraphStore, *, options: ScanExecutionOptions) -> None:
|
|
58
|
+
self.store = store
|
|
59
|
+
self.options = options
|
|
60
|
+
self._region: str = "unknown"
|
|
61
|
+
self.service_scanners: Dict[str, Callable[[boto3.session.Session], None]] = {
|
|
62
|
+
"apigateway": self._scan_apigateway,
|
|
63
|
+
"lambda": self._scan_lambda,
|
|
64
|
+
"sqs": self._scan_sqs,
|
|
65
|
+
"eventbridge": self._scan_eventbridge,
|
|
66
|
+
"dynamodb": self._scan_dynamodb,
|
|
67
|
+
"ec2": self._scan_ec2,
|
|
68
|
+
"ecs": self._scan_ecs,
|
|
69
|
+
"s3": self._scan_s3,
|
|
70
|
+
"rds": self._scan_rds,
|
|
71
|
+
"stepfunctions": self._scan_stepfunctions,
|
|
72
|
+
"sns": self._scan_sns,
|
|
73
|
+
"kinesis": self._scan_kinesis,
|
|
74
|
+
"iam": self._scan_iam,
|
|
75
|
+
"cognito": self._scan_cognito,
|
|
76
|
+
"cloudfront": self._scan_cloudfront,
|
|
77
|
+
"elasticache": self._scan_elasticache,
|
|
78
|
+
"glue": self._scan_glue,
|
|
79
|
+
"appsync": self._scan_appsync,
|
|
80
|
+
}
|
|
81
|
+
self._iam_role_cache: Dict[str, List[Dict[str, Any]]] = {}
|
|
82
|
+
self._iam_cache_lock = Lock()
|
|
83
|
+
self._metrics_lock = Lock()
|
|
84
|
+
self._api_call_counts: Dict[str, int] = {}
|
|
85
|
+
self._service_durations_ms: Dict[str, int] = {}
|
|
86
|
+
self._should_cancel: Optional[Callable[[], bool]] = None
|
|
87
|
+
self._client_config = Config(
|
|
88
|
+
retries={"mode": "adaptive", "max_attempts": 10},
|
|
89
|
+
max_pool_connections=64,
|
|
90
|
+
connect_timeout=3,
|
|
91
|
+
read_timeout=20,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def scan(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
region: str,
|
|
98
|
+
services: List[str],
|
|
99
|
+
account_id: str = "unknown",
|
|
100
|
+
progress_callback: Optional[Callable[[str, str, int, int], None]] = None,
|
|
101
|
+
should_cancel: Optional[Callable[[], bool]] = None,
|
|
102
|
+
) -> Dict[str, Any]:
|
|
103
|
+
normalized_services = list(dict.fromkeys(_normalize_service_name(service) for service in services))
|
|
104
|
+
self._region = region
|
|
105
|
+
self.store.reset(region=region, services=normalized_services)
|
|
106
|
+
self._iam_role_cache = {}
|
|
107
|
+
self._api_call_counts = {}
|
|
108
|
+
self._service_durations_ms = {}
|
|
109
|
+
self._should_cancel = should_cancel
|
|
110
|
+
|
|
111
|
+
if not self.options.include_iam_inference:
|
|
112
|
+
self.store.add_warning("IAM policy dependency inference skipped for faster quick scan mode.")
|
|
113
|
+
if not self.options.include_resource_describes:
|
|
114
|
+
self.store.add_warning("Resource describe enrichment skipped for faster quick scan mode.")
|
|
115
|
+
|
|
116
|
+
session = boto3.session.Session(region_name=region)
|
|
117
|
+
total_services = len(normalized_services)
|
|
118
|
+
completed = 0
|
|
119
|
+
started_at = perf_counter()
|
|
120
|
+
|
|
121
|
+
workers = max(1, min(self.options.max_service_workers, total_services or 1))
|
|
122
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
123
|
+
future_to_service: Dict[Any, str] = {}
|
|
124
|
+
for service in normalized_services:
|
|
125
|
+
if self._is_cancelled():
|
|
126
|
+
break
|
|
127
|
+
if progress_callback:
|
|
128
|
+
progress_callback("start", service, completed, total_services)
|
|
129
|
+
future_to_service[pool.submit(self._scan_service, session, service)] = service
|
|
130
|
+
|
|
131
|
+
def on_service_result(future: Future[Any], service: str) -> None:
|
|
132
|
+
nonlocal completed
|
|
133
|
+
try:
|
|
134
|
+
duration_ms = future.result()
|
|
135
|
+
with self._metrics_lock:
|
|
136
|
+
self._service_durations_ms[service] = duration_ms
|
|
137
|
+
except ScanCancelledError:
|
|
138
|
+
return
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
logger.exception("Unhandled error draining future for service %s", service)
|
|
141
|
+
self.store.add_warning(f"{service} scan failed: {type(exc).__name__} - {exc}")
|
|
142
|
+
completed += 1
|
|
143
|
+
if progress_callback:
|
|
144
|
+
progress_callback("finish", service, completed, total_services)
|
|
145
|
+
|
|
146
|
+
self._drain_futures(future_to_service, on_service_result)
|
|
147
|
+
|
|
148
|
+
duration_ms = int((perf_counter() - started_at) * 1000)
|
|
149
|
+
self.store.update_metadata(
|
|
150
|
+
account_id=account_id,
|
|
151
|
+
scan_mode=self.options.mode,
|
|
152
|
+
include_iam_inference=self.options.include_iam_inference,
|
|
153
|
+
include_resource_describes=self.options.include_resource_describes,
|
|
154
|
+
total_scan_ms=duration_ms,
|
|
155
|
+
service_durations_ms=self._service_durations_ms,
|
|
156
|
+
aws_api_call_counts=self._api_call_counts,
|
|
157
|
+
)
|
|
158
|
+
return self.store.get_graph_payload()
|
|
159
|
+
|
|
160
|
+
def _scan_service(self, session: boto3.session.Session, service: str) -> int:
|
|
161
|
+
start = perf_counter()
|
|
162
|
+
if self._is_cancelled():
|
|
163
|
+
return 0
|
|
164
|
+
scanner = self.service_scanners.get(service)
|
|
165
|
+
try:
|
|
166
|
+
if scanner:
|
|
167
|
+
scanner(session)
|
|
168
|
+
else:
|
|
169
|
+
self._scan_generic_service(session, service)
|
|
170
|
+
except ScanCancelledError:
|
|
171
|
+
return int((perf_counter() - start) * 1000)
|
|
172
|
+
except (ClientError, BotoCoreError) as exc:
|
|
173
|
+
logger.warning("AWS API error scanning %s: %s", service, exc)
|
|
174
|
+
self.store.add_warning(f"{service} scan failed: {type(exc).__name__} - {exc}")
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
logger.exception("Unexpected error scanning service %s", service)
|
|
177
|
+
self.store.add_warning(f"{service} scan failed: {type(exc).__name__} - {exc}")
|
|
178
|
+
return int((perf_counter() - start) * 1000)
|
|
179
|
+
|
|
180
|
+
def _client(self, session: boto3.session.Session, service_name: str) -> Any:
|
|
181
|
+
return session.client(service_name, config=self._client_config)
|
|
182
|
+
|
|
183
|
+
def _increment_api_call(self, service: str, operation: str) -> None:
|
|
184
|
+
self._ensure_not_cancelled()
|
|
185
|
+
key = f"{service}.{operation}"
|
|
186
|
+
with self._metrics_lock:
|
|
187
|
+
self._api_call_counts[key] = self._api_call_counts.get(key, 0) + 1
|
|
188
|
+
|
|
189
|
+
def _is_cancelled(self) -> bool:
|
|
190
|
+
if not self._should_cancel:
|
|
191
|
+
return False
|
|
192
|
+
return bool(self._should_cancel())
|
|
193
|
+
|
|
194
|
+
def _ensure_not_cancelled(self) -> None:
|
|
195
|
+
if self._is_cancelled():
|
|
196
|
+
raise ScanCancelledError()
|
|
197
|
+
|
|
198
|
+
def _node(self, node_id: str, **attrs: Any) -> None:
|
|
199
|
+
self.store.add_node(node_id, region=self._region, **attrs)
|
|
200
|
+
|
|
201
|
+
def _drain_futures(
|
|
202
|
+
self,
|
|
203
|
+
future_map: Dict[Future[Any], Any],
|
|
204
|
+
on_result: Callable[[Future[Any], Any], None],
|
|
205
|
+
) -> None:
|
|
206
|
+
pending = set(future_map)
|
|
207
|
+
while pending:
|
|
208
|
+
if self._is_cancelled():
|
|
209
|
+
for future in list(pending):
|
|
210
|
+
if future.cancel():
|
|
211
|
+
pending.remove(future)
|
|
212
|
+
|
|
213
|
+
done, pending = wait(pending, timeout=0.2, return_when=FIRST_COMPLETED)
|
|
214
|
+
for future in done:
|
|
215
|
+
on_result(future, future_map[future])
|
|
216
|
+
|
|
217
|
+
self._ensure_not_cancelled()
|
|
218
|
+
|
|
219
|
+
def _service_from_arn(self, arn: str) -> str:
|
|
220
|
+
parts = arn.split(":")
|
|
221
|
+
return parts[2] if len(parts) > 2 else "unknown"
|
|
222
|
+
|
|
223
|
+
def _make_node_id(self, service: str, resource: str) -> str:
|
|
224
|
+
return f"{service}:{resource}"
|
|
225
|
+
|
|
226
|
+
def _add_arn_node(self, arn: str, *, label: Optional[str] = None, node_type: str = "resource") -> str:
|
|
227
|
+
self._ensure_not_cancelled()
|
|
228
|
+
service = self._service_from_arn(arn)
|
|
229
|
+
node_id = self._make_node_id(service, arn)
|
|
230
|
+
self._node(
|
|
231
|
+
node_id,
|
|
232
|
+
label=label or arn.split(":")[-1],
|
|
233
|
+
arn=arn,
|
|
234
|
+
service=service,
|
|
235
|
+
type=node_type,
|
|
236
|
+
)
|
|
237
|
+
return node_id
|
|
238
|
+
|
|
239
|
+
def _parse_lambda_arn(self, value: Optional[str]) -> Optional[str]:
|
|
240
|
+
if not value:
|
|
241
|
+
return None
|
|
242
|
+
if ":function:" in value:
|
|
243
|
+
clean = value.split("/invocations")[0]
|
|
244
|
+
idx = clean.find("arn:aws:lambda:")
|
|
245
|
+
if idx >= 0:
|
|
246
|
+
return clean[idx:]
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
def _base_lambda_arn(self, function_arn: str) -> str:
|
|
250
|
+
if ":function:" not in function_arn:
|
|
251
|
+
return function_arn
|
|
252
|
+
prefix, suffix = function_arn.split(":function:", 1)
|
|
253
|
+
function_name = suffix.split(":", 1)[0]
|
|
254
|
+
return f"{prefix}:function:{function_name}"
|
|
255
|
+
|
|
256
|
+
def _scan_apigateway(self, session: boto3.session.Session) -> None:
|
|
257
|
+
self._scan_apigateway_v2(session)
|
|
258
|
+
self._scan_apigateway_rest(session)
|
|
259
|
+
|
|
260
|
+
def _scan_apigateway_v2(self, session: boto3.session.Session) -> None:
|
|
261
|
+
client = self._client(session, "apigatewayv2")
|
|
262
|
+
apis: List[tuple[str, str]] = [] # (api_id, node_id)
|
|
263
|
+
next_token: Optional[str] = None
|
|
264
|
+
|
|
265
|
+
while True:
|
|
266
|
+
self._ensure_not_cancelled()
|
|
267
|
+
kwargs: Dict[str, Any] = {}
|
|
268
|
+
if next_token:
|
|
269
|
+
kwargs["NextToken"] = next_token
|
|
270
|
+
self._increment_api_call("apigateway", "get_apis")
|
|
271
|
+
page = client.get_apis(**kwargs)
|
|
272
|
+
for api in page.get("Items", []):
|
|
273
|
+
self._ensure_not_cancelled()
|
|
274
|
+
api_id = api["ApiId"]
|
|
275
|
+
api_name = api.get("Name") or api_id
|
|
276
|
+
node_id = self._make_node_id("apigateway", api_id)
|
|
277
|
+
self._node(
|
|
278
|
+
node_id,
|
|
279
|
+
label=api_name,
|
|
280
|
+
service="apigateway",
|
|
281
|
+
type="api",
|
|
282
|
+
api_protocol=api.get("ProtocolType"),
|
|
283
|
+
api_endpoint=api.get("ApiEndpoint"),
|
|
284
|
+
)
|
|
285
|
+
apis.append((api_id, node_id))
|
|
286
|
+
next_token = page.get("NextToken")
|
|
287
|
+
if not next_token:
|
|
288
|
+
break
|
|
289
|
+
|
|
290
|
+
if not apis:
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
workers = max(1, min(self.options.apigw_integration_workers, len(apis)))
|
|
294
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
295
|
+
futures = {
|
|
296
|
+
pool.submit(self._fetch_apigwv2_integrations, client, api_id): api_node
|
|
297
|
+
for api_id, api_node in apis
|
|
298
|
+
}
|
|
299
|
+
self._drain_futures(futures, self._apply_apigwv2_integrations)
|
|
300
|
+
|
|
301
|
+
def _fetch_apigwv2_integrations(self, client: Any, api_id: str) -> List[Dict[str, Any]]:
|
|
302
|
+
integrations: List[Dict[str, Any]] = []
|
|
303
|
+
next_token: Optional[str] = None
|
|
304
|
+
while True:
|
|
305
|
+
self._ensure_not_cancelled()
|
|
306
|
+
kwargs: Dict[str, Any] = {"ApiId": api_id}
|
|
307
|
+
if next_token:
|
|
308
|
+
kwargs["NextToken"] = next_token
|
|
309
|
+
self._increment_api_call("apigateway", "get_integrations")
|
|
310
|
+
page = client.get_integrations(**kwargs)
|
|
311
|
+
integrations.extend(page.get("Items", []))
|
|
312
|
+
next_token = page.get("NextToken")
|
|
313
|
+
if not next_token:
|
|
314
|
+
break
|
|
315
|
+
return integrations
|
|
316
|
+
|
|
317
|
+
def _apply_apigwv2_integrations(self, future: Future[Any], api_node: str) -> None:
|
|
318
|
+
integrations = future.result()
|
|
319
|
+
self._ensure_not_cancelled()
|
|
320
|
+
for integration in integrations:
|
|
321
|
+
self._ensure_not_cancelled()
|
|
322
|
+
lambda_arn = self._parse_lambda_arn(integration.get("IntegrationUri"))
|
|
323
|
+
if not lambda_arn:
|
|
324
|
+
continue
|
|
325
|
+
lambda_node = self._add_arn_node(lambda_arn, node_type="lambda")
|
|
326
|
+
self.store.add_edge(
|
|
327
|
+
api_node,
|
|
328
|
+
lambda_node,
|
|
329
|
+
relationship="invokes",
|
|
330
|
+
via="apigatewayv2_integration",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def _scan_apigateway_rest(self, session: boto3.session.Session) -> None:
|
|
334
|
+
client = self._client(session, "apigateway")
|
|
335
|
+
position: Optional[str] = None
|
|
336
|
+
|
|
337
|
+
while True:
|
|
338
|
+
self._ensure_not_cancelled()
|
|
339
|
+
kwargs: Dict[str, Any] = {"limit": 500}
|
|
340
|
+
if position:
|
|
341
|
+
kwargs["position"] = position
|
|
342
|
+
self._increment_api_call("apigateway", "get_rest_apis")
|
|
343
|
+
page = client.get_rest_apis(**kwargs)
|
|
344
|
+
for api in page.get("items", []):
|
|
345
|
+
self._ensure_not_cancelled()
|
|
346
|
+
rest_api_id = api["id"]
|
|
347
|
+
api_node = self._make_node_id("apigateway", rest_api_id)
|
|
348
|
+
self._node(
|
|
349
|
+
api_node,
|
|
350
|
+
label=api.get("name") or rest_api_id,
|
|
351
|
+
service="apigateway",
|
|
352
|
+
type="api",
|
|
353
|
+
endpoint_configuration=api.get("endpointConfiguration", {}),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
tasks: List[tuple[str, str, str, str]] = []
|
|
357
|
+
res_position: Optional[str] = None
|
|
358
|
+
while True:
|
|
359
|
+
self._ensure_not_cancelled()
|
|
360
|
+
res_kwargs: Dict[str, Any] = {"restApiId": rest_api_id, "limit": 500}
|
|
361
|
+
if res_position:
|
|
362
|
+
res_kwargs["position"] = res_position
|
|
363
|
+
self._increment_api_call("apigateway", "get_resources")
|
|
364
|
+
resources_page = client.get_resources(**res_kwargs)
|
|
365
|
+
for resource in resources_page.get("items", []):
|
|
366
|
+
self._ensure_not_cancelled()
|
|
367
|
+
methods = resource.get("resourceMethods", {})
|
|
368
|
+
for http_method in methods.keys():
|
|
369
|
+
tasks.append((rest_api_id, resource["id"], http_method, api_node))
|
|
370
|
+
res_position = resources_page.get("position")
|
|
371
|
+
if not res_position:
|
|
372
|
+
break
|
|
373
|
+
|
|
374
|
+
if tasks:
|
|
375
|
+
workers = max(1, min(self.options.apigw_integration_workers, len(tasks)))
|
|
376
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
377
|
+
futures = {
|
|
378
|
+
pool.submit(
|
|
379
|
+
self._fetch_apigw_rest_integration_lambda,
|
|
380
|
+
client,
|
|
381
|
+
rest_api_id,
|
|
382
|
+
resource_id,
|
|
383
|
+
http_method,
|
|
384
|
+
): api_node
|
|
385
|
+
for rest_api_id, resource_id, http_method, api_node in tasks
|
|
386
|
+
}
|
|
387
|
+
self._drain_futures(futures, self._apply_apigateway_rest_integration)
|
|
388
|
+
|
|
389
|
+
position = page.get("position")
|
|
390
|
+
if not position:
|
|
391
|
+
break
|
|
392
|
+
|
|
393
|
+
def _fetch_apigw_rest_integration_lambda(
|
|
394
|
+
self,
|
|
395
|
+
client: Any,
|
|
396
|
+
rest_api_id: str,
|
|
397
|
+
resource_id: str,
|
|
398
|
+
http_method: str,
|
|
399
|
+
) -> Optional[str]:
|
|
400
|
+
try:
|
|
401
|
+
self._increment_api_call("apigateway", "get_integration")
|
|
402
|
+
integration = client.get_integration(
|
|
403
|
+
restApiId=rest_api_id,
|
|
404
|
+
resourceId=resource_id,
|
|
405
|
+
httpMethod=http_method,
|
|
406
|
+
)
|
|
407
|
+
except ClientError as exc:
|
|
408
|
+
logger.debug("Skipping API Gateway integration %s/%s/%s: %s", rest_api_id, resource_id, http_method, exc)
|
|
409
|
+
return None
|
|
410
|
+
return self._parse_lambda_arn(integration.get("uri"))
|
|
411
|
+
|
|
412
|
+
def _apply_apigateway_rest_integration(self, future: Future[Any], api_node: str) -> None:
|
|
413
|
+
lambda_arn = future.result()
|
|
414
|
+
if not lambda_arn:
|
|
415
|
+
return
|
|
416
|
+
self._ensure_not_cancelled()
|
|
417
|
+
lambda_node = self._add_arn_node(lambda_arn, node_type="lambda")
|
|
418
|
+
self.store.add_edge(
|
|
419
|
+
api_node,
|
|
420
|
+
lambda_node,
|
|
421
|
+
relationship="invokes",
|
|
422
|
+
via="apigateway_rest_integration",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def _scan_lambda(self, session: boto3.session.Session) -> None:
|
|
426
|
+
client = self._client(session, "lambda")
|
|
427
|
+
paginator = client.get_paginator("list_functions")
|
|
428
|
+
functions: List[Dict[str, Any]] = []
|
|
429
|
+
for page in paginator.paginate():
|
|
430
|
+
self._ensure_not_cancelled()
|
|
431
|
+
self._increment_api_call("lambda", "list_functions")
|
|
432
|
+
functions.extend(page.get("Functions", []))
|
|
433
|
+
|
|
434
|
+
function_node_ids: Dict[str, str] = {}
|
|
435
|
+
role_to_function_nodes: Dict[str, List[str]] = {}
|
|
436
|
+
|
|
437
|
+
for fn in functions:
|
|
438
|
+
self._ensure_not_cancelled()
|
|
439
|
+
arn = fn["FunctionArn"]
|
|
440
|
+
node_id = self._add_arn_node(arn, label=fn.get("FunctionName"), node_type="lambda")
|
|
441
|
+
self._node(
|
|
442
|
+
node_id,
|
|
443
|
+
runtime=fn.get("Runtime"),
|
|
444
|
+
handler=fn.get("Handler"),
|
|
445
|
+
role=fn.get("Role"),
|
|
446
|
+
memory_size=fn.get("MemorySize"),
|
|
447
|
+
timeout=fn.get("Timeout"),
|
|
448
|
+
last_modified=fn.get("LastModified"),
|
|
449
|
+
state=fn.get("State"),
|
|
450
|
+
)
|
|
451
|
+
function_node_ids[arn] = node_id
|
|
452
|
+
function_node_ids[self._base_lambda_arn(arn)] = node_id
|
|
453
|
+
|
|
454
|
+
role_arn = fn.get("Role")
|
|
455
|
+
if role_arn:
|
|
456
|
+
role_name = role_arn.split("/")[-1]
|
|
457
|
+
role_to_function_nodes.setdefault(role_name, []).append(node_id)
|
|
458
|
+
|
|
459
|
+
self._scan_lambda_event_sources_global(client, function_node_ids)
|
|
460
|
+
if self.options.include_iam_inference:
|
|
461
|
+
self._scan_lambda_iam_dependencies_parallel(session, role_to_function_nodes)
|
|
462
|
+
|
|
463
|
+
def _scan_lambda_event_sources_global(self, client: Any, function_node_ids: Dict[str, str]) -> None:
|
|
464
|
+
marker: Optional[str] = None
|
|
465
|
+
while True:
|
|
466
|
+
self._ensure_not_cancelled()
|
|
467
|
+
kwargs: Dict[str, Any] = {}
|
|
468
|
+
if marker:
|
|
469
|
+
kwargs["Marker"] = marker
|
|
470
|
+
self._increment_api_call("lambda", "list_event_source_mappings")
|
|
471
|
+
page = client.list_event_source_mappings(**kwargs)
|
|
472
|
+
for mapping in page.get("EventSourceMappings", []):
|
|
473
|
+
self._ensure_not_cancelled()
|
|
474
|
+
event_source_arn = mapping.get("EventSourceArn")
|
|
475
|
+
function_arn = mapping.get("FunctionArn")
|
|
476
|
+
if not event_source_arn or not function_arn:
|
|
477
|
+
continue
|
|
478
|
+
|
|
479
|
+
function_node_id = function_node_ids.get(function_arn) or function_node_ids.get(
|
|
480
|
+
self._base_lambda_arn(function_arn)
|
|
481
|
+
)
|
|
482
|
+
if not function_node_id:
|
|
483
|
+
continue
|
|
484
|
+
|
|
485
|
+
source_node = self._add_arn_node(event_source_arn)
|
|
486
|
+
self.store.add_edge(
|
|
487
|
+
source_node,
|
|
488
|
+
function_node_id,
|
|
489
|
+
relationship="triggers",
|
|
490
|
+
via="lambda_event_source_mapping",
|
|
491
|
+
state=mapping.get("State"),
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
marker = page.get("NextMarker")
|
|
495
|
+
if not marker:
|
|
496
|
+
break
|
|
497
|
+
|
|
498
|
+
def _scan_lambda_iam_dependencies_parallel(
|
|
499
|
+
self,
|
|
500
|
+
session: boto3.session.Session,
|
|
501
|
+
role_to_function_nodes: Dict[str, List[str]],
|
|
502
|
+
) -> None:
|
|
503
|
+
roles = list(role_to_function_nodes.keys())
|
|
504
|
+
if not roles:
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
workers = max(1, min(self.options.iam_workers, len(roles)))
|
|
508
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
509
|
+
future_to_role = {
|
|
510
|
+
pool.submit(self._get_role_policy_documents, session, role_name): role_name
|
|
511
|
+
for role_name in roles
|
|
512
|
+
}
|
|
513
|
+
self._drain_futures(
|
|
514
|
+
future_to_role,
|
|
515
|
+
lambda future, role_name: self._apply_role_policy_dependencies(
|
|
516
|
+
role_name,
|
|
517
|
+
future,
|
|
518
|
+
role_to_function_nodes,
|
|
519
|
+
),
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
def _apply_role_policy_dependencies(
|
|
523
|
+
self,
|
|
524
|
+
role_name: str,
|
|
525
|
+
future: Future[Any],
|
|
526
|
+
role_to_function_nodes: Dict[str, List[str]],
|
|
527
|
+
) -> None:
|
|
528
|
+
try:
|
|
529
|
+
policy_details = future.result()
|
|
530
|
+
except Exception as exc:
|
|
531
|
+
logger.warning("IAM policy lookup failed for role %s: %s", role_name, exc)
|
|
532
|
+
self.store.add_warning(f"iam policy lookup failed for {role_name}: {type(exc).__name__} - {exc}")
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
self._ensure_not_cancelled()
|
|
536
|
+
for function_node_id in role_to_function_nodes.get(role_name, []):
|
|
537
|
+
self._apply_policy_dependencies(function_node_id, policy_details)
|
|
538
|
+
|
|
539
|
+
def _apply_policy_dependencies(self, function_node_id: str, statements: List[Dict[str, Any]]) -> None:
|
|
540
|
+
for statement in statements:
|
|
541
|
+
self._ensure_not_cancelled()
|
|
542
|
+
effect = str(statement.get("Effect", "Allow")).lower()
|
|
543
|
+
if effect != "allow":
|
|
544
|
+
continue
|
|
545
|
+
actions = [str(action).lower() for action in _safe_list(statement.get("Action"))]
|
|
546
|
+
resources = [str(resource) for resource in _safe_list(statement.get("Resource"))]
|
|
547
|
+
|
|
548
|
+
service_hits = self._services_from_actions(actions)
|
|
549
|
+
for service in service_hits:
|
|
550
|
+
for resource in resources or ["*"]:
|
|
551
|
+
if resource == "*":
|
|
552
|
+
continue # wildcard would create meaningless *:* phantom nodes
|
|
553
|
+
target = self._target_from_service_resource(service, resource)
|
|
554
|
+
self.store.add_edge(
|
|
555
|
+
function_node_id,
|
|
556
|
+
target,
|
|
557
|
+
relationship="calls",
|
|
558
|
+
via="lambda_role_policy",
|
|
559
|
+
actions=sorted(service_hits[service]),
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
def _services_from_actions(self, actions: Iterable[str]) -> Dict[str, Set[str]]:
|
|
563
|
+
service_actions: Dict[str, Set[str]] = {}
|
|
564
|
+
for action in actions:
|
|
565
|
+
if ":" not in action:
|
|
566
|
+
continue
|
|
567
|
+
prefix, verb = action.split(":", 1)
|
|
568
|
+
if prefix in {"dynamodb", "sqs", "events", "lambda"}:
|
|
569
|
+
normalized = "eventbridge" if prefix == "events" else prefix
|
|
570
|
+
service_actions.setdefault(normalized, set()).add(verb)
|
|
571
|
+
return service_actions
|
|
572
|
+
|
|
573
|
+
def _target_from_service_resource(self, service: str, resource: str) -> str:
|
|
574
|
+
self._ensure_not_cancelled()
|
|
575
|
+
if resource.startswith("arn:aws:"):
|
|
576
|
+
target = self._add_arn_node(resource, node_type="resource")
|
|
577
|
+
self._node(target, service=service)
|
|
578
|
+
return target
|
|
579
|
+
node_id = self._make_node_id(service, resource)
|
|
580
|
+
self._node(node_id, label=resource, service=service, type="resource", arn=resource)
|
|
581
|
+
return node_id
|
|
582
|
+
|
|
583
|
+
def _get_role_policy_documents(
|
|
584
|
+
self,
|
|
585
|
+
session: boto3.session.Session,
|
|
586
|
+
role_name: str,
|
|
587
|
+
) -> List[Dict[str, Any]]:
|
|
588
|
+
with self._iam_cache_lock:
|
|
589
|
+
cached = self._iam_role_cache.get(role_name)
|
|
590
|
+
if cached is not None:
|
|
591
|
+
return cached
|
|
592
|
+
|
|
593
|
+
iam = self._client(session, "iam")
|
|
594
|
+
policy_docs: List[Dict[str, Any]] = []
|
|
595
|
+
|
|
596
|
+
self._increment_api_call("iam", "list_role_policies")
|
|
597
|
+
inline_policy_names = iam.list_role_policies(RoleName=role_name).get("PolicyNames", [])
|
|
598
|
+
for policy_name in inline_policy_names:
|
|
599
|
+
self._ensure_not_cancelled()
|
|
600
|
+
self._increment_api_call("iam", "get_role_policy")
|
|
601
|
+
raw = iam.get_role_policy(RoleName=role_name, PolicyName=policy_name)
|
|
602
|
+
policy_docs.append(raw.get("PolicyDocument", {}))
|
|
603
|
+
|
|
604
|
+
attached_policies: List[Dict[str, Any]] = []
|
|
605
|
+
marker: Optional[str] = None
|
|
606
|
+
while True:
|
|
607
|
+
self._ensure_not_cancelled()
|
|
608
|
+
kwargs: Dict[str, Any] = {"RoleName": role_name}
|
|
609
|
+
if marker:
|
|
610
|
+
kwargs["Marker"] = marker
|
|
611
|
+
self._increment_api_call("iam", "list_attached_role_policies")
|
|
612
|
+
page = iam.list_attached_role_policies(**kwargs)
|
|
613
|
+
attached_policies.extend(page.get("AttachedPolicies", []))
|
|
614
|
+
marker = page.get("Marker") if page.get("IsTruncated") else None
|
|
615
|
+
if not marker:
|
|
616
|
+
break
|
|
617
|
+
|
|
618
|
+
for attached in attached_policies:
|
|
619
|
+
self._ensure_not_cancelled()
|
|
620
|
+
self._increment_api_call("iam", "get_policy")
|
|
621
|
+
policy = iam.get_policy(PolicyArn=attached["PolicyArn"]).get("Policy", {})
|
|
622
|
+
default_version = policy.get("DefaultVersionId")
|
|
623
|
+
if not default_version:
|
|
624
|
+
continue
|
|
625
|
+
self._increment_api_call("iam", "get_policy_version")
|
|
626
|
+
version = iam.get_policy_version(
|
|
627
|
+
PolicyArn=attached["PolicyArn"],
|
|
628
|
+
VersionId=default_version,
|
|
629
|
+
)
|
|
630
|
+
policy_docs.append(version.get("PolicyVersion", {}).get("Document", {}))
|
|
631
|
+
|
|
632
|
+
statements: List[Dict[str, Any]] = []
|
|
633
|
+
for document in policy_docs:
|
|
634
|
+
statements.extend(_safe_list(document.get("Statement")))
|
|
635
|
+
|
|
636
|
+
with self._iam_cache_lock:
|
|
637
|
+
self._iam_role_cache[role_name] = statements
|
|
638
|
+
return statements
|
|
639
|
+
|
|
640
|
+
def _scan_sqs(self, session: boto3.session.Session) -> None:
|
|
641
|
+
client = self._client(session, "sqs")
|
|
642
|
+
queue_urls: List[str] = []
|
|
643
|
+
next_token: Optional[str] = None
|
|
644
|
+
|
|
645
|
+
while True:
|
|
646
|
+
self._ensure_not_cancelled()
|
|
647
|
+
kwargs: Dict[str, Any] = {}
|
|
648
|
+
if next_token:
|
|
649
|
+
kwargs["NextToken"] = next_token
|
|
650
|
+
self._increment_api_call("sqs", "list_queues")
|
|
651
|
+
page = client.list_queues(**kwargs)
|
|
652
|
+
queue_urls.extend(page.get("QueueUrls", []))
|
|
653
|
+
next_token = page.get("NextToken")
|
|
654
|
+
if not next_token:
|
|
655
|
+
break
|
|
656
|
+
|
|
657
|
+
if not self.options.include_resource_describes:
|
|
658
|
+
for queue_url in queue_urls:
|
|
659
|
+
self._ensure_not_cancelled()
|
|
660
|
+
queue_name = queue_url.rstrip("/").split("/")[-1]
|
|
661
|
+
node_id = self._make_node_id("sqs", queue_url)
|
|
662
|
+
self._node(
|
|
663
|
+
node_id,
|
|
664
|
+
label=queue_name,
|
|
665
|
+
service="sqs",
|
|
666
|
+
type="queue",
|
|
667
|
+
queue_url=queue_url,
|
|
668
|
+
arn=queue_url,
|
|
669
|
+
)
|
|
670
|
+
return
|
|
671
|
+
|
|
672
|
+
workers = max(1, min(self.options.sqs_attribute_workers, len(queue_urls) or 1))
|
|
673
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
674
|
+
futures = {
|
|
675
|
+
pool.submit(self._fetch_sqs_queue_attributes, client, queue_url): queue_url
|
|
676
|
+
for queue_url in queue_urls
|
|
677
|
+
}
|
|
678
|
+
self._drain_futures(futures, self._apply_sqs_queue_attributes)
|
|
679
|
+
|
|
680
|
+
def _fetch_sqs_queue_attributes(self, client: Any, queue_url: str) -> Dict[str, Any]:
|
|
681
|
+
self._increment_api_call("sqs", "get_queue_attributes")
|
|
682
|
+
return client.get_queue_attributes(
|
|
683
|
+
QueueUrl=queue_url,
|
|
684
|
+
AttributeNames=["QueueArn", "VisibilityTimeout", "CreatedTimestamp"],
|
|
685
|
+
).get("Attributes", {})
|
|
686
|
+
|
|
687
|
+
def _apply_sqs_queue_attributes(self, future: Future[Any], queue_url: str) -> None:
|
|
688
|
+
attrs = future.result()
|
|
689
|
+
self._ensure_not_cancelled()
|
|
690
|
+
queue_arn = attrs.get("QueueArn")
|
|
691
|
+
queue_name = queue_url.rstrip("/").split("/")[-1]
|
|
692
|
+
if queue_arn:
|
|
693
|
+
node_id = self._add_arn_node(queue_arn, label=queue_name, node_type="queue")
|
|
694
|
+
else:
|
|
695
|
+
node_id = self._make_node_id("sqs", queue_url)
|
|
696
|
+
self._node(node_id, label=queue_name, service="sqs", type="queue", arn=queue_url)
|
|
697
|
+
self._node(
|
|
698
|
+
node_id,
|
|
699
|
+
queue_url=queue_url,
|
|
700
|
+
visibility_timeout=attrs.get("VisibilityTimeout"),
|
|
701
|
+
created_timestamp=attrs.get("CreatedTimestamp"),
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
def _scan_eventbridge(self, session: boto3.session.Session) -> None:
|
|
705
|
+
client = self._client(session, "events")
|
|
706
|
+
paginator = client.get_paginator("list_rules")
|
|
707
|
+
rules: List[Dict[str, Any]] = []
|
|
708
|
+
for page in paginator.paginate():
|
|
709
|
+
self._ensure_not_cancelled()
|
|
710
|
+
self._increment_api_call("eventbridge", "list_rules")
|
|
711
|
+
rules.extend(page.get("Rules", []))
|
|
712
|
+
|
|
713
|
+
for rule in rules:
|
|
714
|
+
self._ensure_not_cancelled()
|
|
715
|
+
rule_arn = rule.get("Arn") or f"rule:{rule.get('Name')}"
|
|
716
|
+
rule_node = self._add_arn_node(rule_arn, label=rule.get("Name"), node_type="rule")
|
|
717
|
+
self._node(
|
|
718
|
+
rule_node,
|
|
719
|
+
service="eventbridge",
|
|
720
|
+
event_pattern=rule.get("EventPattern"),
|
|
721
|
+
state=rule.get("State"),
|
|
722
|
+
schedule_expression=rule.get("ScheduleExpression"),
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
workers = max(1, min(self.options.eventbridge_target_workers, len(rules) or 1))
|
|
726
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
727
|
+
futures = {pool.submit(self._fetch_eventbridge_targets, client, rule): rule for rule in rules}
|
|
728
|
+
self._drain_futures(futures, self._apply_eventbridge_targets)
|
|
729
|
+
|
|
730
|
+
def _fetch_eventbridge_targets(self, client: Any, rule: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
731
|
+
targets: List[Dict[str, Any]] = []
|
|
732
|
+
next_token: Optional[str] = None
|
|
733
|
+
while True:
|
|
734
|
+
self._ensure_not_cancelled()
|
|
735
|
+
kwargs: Dict[str, Any] = {"Rule": rule["Name"]}
|
|
736
|
+
if rule.get("EventBusName"):
|
|
737
|
+
kwargs["EventBusName"] = rule["EventBusName"]
|
|
738
|
+
if next_token:
|
|
739
|
+
kwargs["NextToken"] = next_token
|
|
740
|
+
self._increment_api_call("eventbridge", "list_targets_by_rule")
|
|
741
|
+
page = client.list_targets_by_rule(**kwargs)
|
|
742
|
+
targets.extend(page.get("Targets", []))
|
|
743
|
+
next_token = page.get("NextToken")
|
|
744
|
+
if not next_token:
|
|
745
|
+
break
|
|
746
|
+
return targets
|
|
747
|
+
|
|
748
|
+
def _apply_eventbridge_targets(self, future: Future[Any], rule: Dict[str, Any]) -> None:
|
|
749
|
+
targets = future.result()
|
|
750
|
+
self._ensure_not_cancelled()
|
|
751
|
+
rule_arn = rule.get("Arn") or f"rule:{rule.get('Name')}"
|
|
752
|
+
rule_node = self._make_node_id(self._service_from_arn(rule_arn), rule_arn)
|
|
753
|
+
for target in targets:
|
|
754
|
+
self._ensure_not_cancelled()
|
|
755
|
+
target_arn = target.get("Arn")
|
|
756
|
+
if not target_arn:
|
|
757
|
+
continue
|
|
758
|
+
target_node = self._add_arn_node(target_arn)
|
|
759
|
+
self.store.add_edge(
|
|
760
|
+
rule_node,
|
|
761
|
+
target_node,
|
|
762
|
+
relationship="triggers",
|
|
763
|
+
via="eventbridge_rule_target",
|
|
764
|
+
target_id=target.get("Id"),
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
def _scan_dynamodb(self, session: boto3.session.Session) -> None:
|
|
768
|
+
client = self._client(session, "dynamodb")
|
|
769
|
+
table_names: List[str] = []
|
|
770
|
+
table_name: Optional[str] = None
|
|
771
|
+
while True:
|
|
772
|
+
self._ensure_not_cancelled()
|
|
773
|
+
kwargs: Dict[str, Any] = {}
|
|
774
|
+
if table_name:
|
|
775
|
+
kwargs["ExclusiveStartTableName"] = table_name
|
|
776
|
+
self._increment_api_call("dynamodb", "list_tables")
|
|
777
|
+
page = client.list_tables(**kwargs)
|
|
778
|
+
table_names.extend(page.get("TableNames", []))
|
|
779
|
+
table_name = page.get("LastEvaluatedTableName")
|
|
780
|
+
if not table_name:
|
|
781
|
+
break
|
|
782
|
+
|
|
783
|
+
if not self.options.include_resource_describes:
|
|
784
|
+
for name in table_names:
|
|
785
|
+
self._ensure_not_cancelled()
|
|
786
|
+
node_id = self._make_node_id("dynamodb", name)
|
|
787
|
+
self._node(node_id, label=name, service="dynamodb", type="table", arn=name)
|
|
788
|
+
return
|
|
789
|
+
|
|
790
|
+
workers = max(1, min(self.options.dynamodb_describe_workers, len(table_names) or 1))
|
|
791
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
792
|
+
futures = {pool.submit(self._describe_table, client, name): name for name in table_names}
|
|
793
|
+
self._drain_futures(futures, self._apply_described_table)
|
|
794
|
+
|
|
795
|
+
def _describe_table(self, client: Any, table_name: str) -> Dict[str, Any]:
|
|
796
|
+
self._increment_api_call("dynamodb", "describe_table")
|
|
797
|
+
return client.describe_table(TableName=table_name).get("Table", {})
|
|
798
|
+
|
|
799
|
+
def _apply_described_table(self, future: Future[Any], table_name: str) -> None:
|
|
800
|
+
try:
|
|
801
|
+
table = future.result()
|
|
802
|
+
except Exception as exc:
|
|
803
|
+
logger.warning("DynamoDB describe_table failed for %s: %s", table_name, exc)
|
|
804
|
+
self.store.add_warning(f"dynamodb describe failed for {table_name}: {type(exc).__name__} - {exc}")
|
|
805
|
+
return
|
|
806
|
+
self._ensure_not_cancelled()
|
|
807
|
+
table_arn = table.get("TableArn", f"dynamodb:{table_name}")
|
|
808
|
+
node_id = self._add_arn_node(table_arn, label=table_name, node_type="table")
|
|
809
|
+
self._node(
|
|
810
|
+
node_id,
|
|
811
|
+
service="dynamodb",
|
|
812
|
+
item_count=table.get("ItemCount"),
|
|
813
|
+
table_size_bytes=table.get("TableSizeBytes"),
|
|
814
|
+
stream_arn=table.get("LatestStreamArn"),
|
|
815
|
+
billing_mode=(table.get("BillingModeSummary") or {}).get("BillingMode"),
|
|
816
|
+
state=table.get("TableStatus"),
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
# ── EC2 ──────────────────────────────────────────────────────────────────
|
|
820
|
+
|
|
821
|
+
def _scan_ec2(self, session: boto3.session.Session) -> None:
|
|
822
|
+
client = self._client(session, "ec2")
|
|
823
|
+
paginator = client.get_paginator("describe_instances")
|
|
824
|
+
for page in paginator.paginate():
|
|
825
|
+
self._ensure_not_cancelled()
|
|
826
|
+
self._increment_api_call("ec2", "describe_instances")
|
|
827
|
+
for reservation in page.get("Reservations", []):
|
|
828
|
+
for instance in reservation.get("Instances", []):
|
|
829
|
+
self._ensure_not_cancelled()
|
|
830
|
+
instance_id = instance.get("InstanceId", "")
|
|
831
|
+
name_tag = next((t["Value"] for t in instance.get("Tags", []) if t.get("Key") == "Name"), None)
|
|
832
|
+
arn = f"arn:aws:ec2:{self._region}:{instance.get('OwnerId', '')}:instance/{instance_id}"
|
|
833
|
+
node_id = self._make_node_id("ec2", instance_id)
|
|
834
|
+
self._node(
|
|
835
|
+
node_id,
|
|
836
|
+
label=name_tag or instance_id,
|
|
837
|
+
service="ec2",
|
|
838
|
+
type="instance",
|
|
839
|
+
arn=arn,
|
|
840
|
+
instance_type=instance.get("InstanceType"),
|
|
841
|
+
state=instance.get("State", {}).get("Name"),
|
|
842
|
+
vpc_id=instance.get("VpcId"),
|
|
843
|
+
subnet_id=instance.get("SubnetId"),
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
# ── ECS ──────────────────────────────────────────────────────────────────
|
|
847
|
+
|
|
848
|
+
def _scan_ecs(self, session: boto3.session.Session) -> None:
|
|
849
|
+
client = self._client(session, "ecs")
|
|
850
|
+
cluster_arns: List[str] = []
|
|
851
|
+
paginator = client.get_paginator("list_clusters")
|
|
852
|
+
for page in paginator.paginate():
|
|
853
|
+
self._ensure_not_cancelled()
|
|
854
|
+
self._increment_api_call("ecs", "list_clusters")
|
|
855
|
+
cluster_arns.extend(page.get("clusterArns", []))
|
|
856
|
+
|
|
857
|
+
for arn in cluster_arns:
|
|
858
|
+
self._ensure_not_cancelled()
|
|
859
|
+
cluster_name = arn.split("/")[-1]
|
|
860
|
+
node_id = self._add_arn_node(arn, label=cluster_name, node_type="cluster")
|
|
861
|
+
self._node(node_id, service="ecs")
|
|
862
|
+
|
|
863
|
+
# List services in this cluster
|
|
864
|
+
svc_paginator = client.get_paginator("list_services")
|
|
865
|
+
for svc_page in svc_paginator.paginate(cluster=arn):
|
|
866
|
+
self._ensure_not_cancelled()
|
|
867
|
+
self._increment_api_call("ecs", "list_services")
|
|
868
|
+
for svc_arn in svc_page.get("serviceArns", []):
|
|
869
|
+
self._ensure_not_cancelled()
|
|
870
|
+
svc_name = svc_arn.split("/")[-1]
|
|
871
|
+
svc_node = self._add_arn_node(svc_arn, label=svc_name, node_type="service")
|
|
872
|
+
self._node(svc_node, service="ecs")
|
|
873
|
+
self.store.add_edge(node_id, svc_node, relationship="hosts")
|
|
874
|
+
|
|
875
|
+
# ── S3 ───────────────────────────────────────────────────────────────────
|
|
876
|
+
|
|
877
|
+
def _scan_s3(self, session: boto3.session.Session) -> None:
|
|
878
|
+
client = self._client(session, "s3")
|
|
879
|
+
self._increment_api_call("s3", "list_buckets")
|
|
880
|
+
response = client.list_buckets()
|
|
881
|
+
for bucket in response.get("Buckets", []):
|
|
882
|
+
self._ensure_not_cancelled()
|
|
883
|
+
name = bucket.get("Name", "")
|
|
884
|
+
arn = f"arn:aws:s3:::{name}"
|
|
885
|
+
node_id = self._make_node_id("s3", name)
|
|
886
|
+
self._node(
|
|
887
|
+
node_id,
|
|
888
|
+
label=name,
|
|
889
|
+
service="s3",
|
|
890
|
+
type="bucket",
|
|
891
|
+
arn=arn,
|
|
892
|
+
creation_date=str(bucket.get("CreationDate", "")),
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
# ── RDS ──────────────────────────────────────────────────────────────────
|
|
896
|
+
|
|
897
|
+
def _scan_rds(self, session: boto3.session.Session) -> None:
|
|
898
|
+
client = self._client(session, "rds")
|
|
899
|
+
# Instances
|
|
900
|
+
paginator = client.get_paginator("describe_db_instances")
|
|
901
|
+
for page in paginator.paginate():
|
|
902
|
+
self._ensure_not_cancelled()
|
|
903
|
+
self._increment_api_call("rds", "describe_db_instances")
|
|
904
|
+
for db in page.get("DBInstances", []):
|
|
905
|
+
self._ensure_not_cancelled()
|
|
906
|
+
arn = db.get("DBInstanceArn", "")
|
|
907
|
+
node_id = self._add_arn_node(arn, label=db.get("DBInstanceIdentifier"), node_type="instance")
|
|
908
|
+
self._node(
|
|
909
|
+
node_id,
|
|
910
|
+
service="rds",
|
|
911
|
+
engine=db.get("Engine"),
|
|
912
|
+
instance_class=db.get("DBInstanceClass"),
|
|
913
|
+
state=db.get("DBInstanceStatus"),
|
|
914
|
+
multi_az=db.get("MultiAZ"),
|
|
915
|
+
)
|
|
916
|
+
# Aurora clusters
|
|
917
|
+
try:
|
|
918
|
+
cluster_paginator = client.get_paginator("describe_db_clusters")
|
|
919
|
+
for page in cluster_paginator.paginate():
|
|
920
|
+
self._ensure_not_cancelled()
|
|
921
|
+
self._increment_api_call("rds", "describe_db_clusters")
|
|
922
|
+
for cluster in page.get("DBClusters", []):
|
|
923
|
+
self._ensure_not_cancelled()
|
|
924
|
+
arn = cluster.get("DBClusterArn", "")
|
|
925
|
+
node_id = self._add_arn_node(arn, label=cluster.get("DBClusterIdentifier"), node_type="cluster")
|
|
926
|
+
self._node(
|
|
927
|
+
node_id,
|
|
928
|
+
service="rds",
|
|
929
|
+
engine=cluster.get("Engine"),
|
|
930
|
+
state=cluster.get("Status"),
|
|
931
|
+
)
|
|
932
|
+
except (ClientError, BotoCoreError) as exc:
|
|
933
|
+
logger.debug("RDS cluster scan skipped: %s", exc)
|
|
934
|
+
|
|
935
|
+
# ── Step Functions ───────────────────────────────────────────────────────
|
|
936
|
+
|
|
937
|
+
def _scan_stepfunctions(self, session: boto3.session.Session) -> None:
|
|
938
|
+
client = self._client(session, "stepfunctions")
|
|
939
|
+
paginator = client.get_paginator("list_state_machines")
|
|
940
|
+
for page in paginator.paginate():
|
|
941
|
+
self._ensure_not_cancelled()
|
|
942
|
+
self._increment_api_call("stepfunctions", "list_state_machines")
|
|
943
|
+
for sm in page.get("stateMachines", []):
|
|
944
|
+
self._ensure_not_cancelled()
|
|
945
|
+
arn = sm.get("stateMachineArn", "")
|
|
946
|
+
node_id = self._add_arn_node(arn, label=sm.get("name"), node_type="state_machine")
|
|
947
|
+
self._node(
|
|
948
|
+
node_id,
|
|
949
|
+
service="stepfunctions",
|
|
950
|
+
sm_type=sm.get("type"),
|
|
951
|
+
creation_date=str(sm.get("creationDate", "")),
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
# ── SNS ──────────────────────────────────────────────────────────────────
|
|
955
|
+
|
|
956
|
+
def _scan_sns(self, session: boto3.session.Session) -> None:
|
|
957
|
+
client = self._client(session, "sns")
|
|
958
|
+
paginator = client.get_paginator("list_topics")
|
|
959
|
+
for page in paginator.paginate():
|
|
960
|
+
self._ensure_not_cancelled()
|
|
961
|
+
self._increment_api_call("sns", "list_topics")
|
|
962
|
+
for topic in page.get("Topics", []):
|
|
963
|
+
self._ensure_not_cancelled()
|
|
964
|
+
arn = topic.get("TopicArn", "")
|
|
965
|
+
topic_name = arn.split(":")[-1]
|
|
966
|
+
node_id = self._add_arn_node(arn, label=topic_name, node_type="topic")
|
|
967
|
+
self._node(node_id, service="sns")
|
|
968
|
+
|
|
969
|
+
# ── Kinesis ──────────────────────────────────────────────────────────────
|
|
970
|
+
|
|
971
|
+
def _scan_kinesis(self, session: boto3.session.Session) -> None:
|
|
972
|
+
client = self._client(session, "kinesis")
|
|
973
|
+
stream_names: List[str] = []
|
|
974
|
+
next_token: Optional[str] = None
|
|
975
|
+
while True:
|
|
976
|
+
self._ensure_not_cancelled()
|
|
977
|
+
kwargs: Dict[str, Any] = {}
|
|
978
|
+
if next_token:
|
|
979
|
+
kwargs["NextToken"] = next_token
|
|
980
|
+
self._increment_api_call("kinesis", "list_streams")
|
|
981
|
+
page = client.list_streams(Limit=100, **kwargs)
|
|
982
|
+
stream_names.extend(page.get("StreamNames", []))
|
|
983
|
+
next_token = page.get("NextToken")
|
|
984
|
+
if not next_token:
|
|
985
|
+
break
|
|
986
|
+
|
|
987
|
+
for name in stream_names:
|
|
988
|
+
self._ensure_not_cancelled()
|
|
989
|
+
arn = f"arn:aws:kinesis:{self._region}:*:stream/{name}"
|
|
990
|
+
node_id = self._make_node_id("kinesis", name)
|
|
991
|
+
self._node(node_id, label=name, service="kinesis", type="stream", arn=arn)
|
|
992
|
+
|
|
993
|
+
# ── IAM ──────────────────────────────────────────────────────────────────
|
|
994
|
+
|
|
995
|
+
def _scan_iam(self, session: boto3.session.Session) -> None:
|
|
996
|
+
# Use us-east-1 since IAM is a global service
|
|
997
|
+
client = session.client("iam", config=self._client_config)
|
|
998
|
+
paginator = client.get_paginator("list_roles")
|
|
999
|
+
count = 0
|
|
1000
|
+
for page in paginator.paginate(MaxItems=200):
|
|
1001
|
+
self._ensure_not_cancelled()
|
|
1002
|
+
self._increment_api_call("iam", "list_roles")
|
|
1003
|
+
for role in page.get("Roles", []):
|
|
1004
|
+
self._ensure_not_cancelled()
|
|
1005
|
+
arn = role.get("Arn", "")
|
|
1006
|
+
node_id = self._add_arn_node(arn, label=role.get("RoleName"), node_type="role")
|
|
1007
|
+
self._node(node_id, service="iam", created=str(role.get("CreateDate", "")))
|
|
1008
|
+
count += 1
|
|
1009
|
+
if count >= 200:
|
|
1010
|
+
self.store.add_warning("IAM: showing first 200 roles only.")
|
|
1011
|
+
return
|
|
1012
|
+
|
|
1013
|
+
# ── Cognito ──────────────────────────────────────────────────────────────
|
|
1014
|
+
|
|
1015
|
+
def _scan_cognito(self, session: boto3.session.Session) -> None:
|
|
1016
|
+
client = self._client(session, "cognito-idp")
|
|
1017
|
+
next_token: Optional[str] = None
|
|
1018
|
+
while True:
|
|
1019
|
+
self._ensure_not_cancelled()
|
|
1020
|
+
kwargs: Dict[str, Any] = {"MaxResults": 60}
|
|
1021
|
+
if next_token:
|
|
1022
|
+
kwargs["NextToken"] = next_token
|
|
1023
|
+
self._increment_api_call("cognito", "list_user_pools")
|
|
1024
|
+
page = client.list_user_pools(**kwargs)
|
|
1025
|
+
for pool in page.get("UserPools", []):
|
|
1026
|
+
self._ensure_not_cancelled()
|
|
1027
|
+
pool_id = pool.get("Id", "")
|
|
1028
|
+
arn = f"arn:aws:cognito-idp:{self._region}:*:userpool/{pool_id}"
|
|
1029
|
+
node_id = self._make_node_id("cognito", pool_id)
|
|
1030
|
+
self._node(node_id, label=pool.get("Name", pool_id), service="cognito", type="user_pool", arn=arn)
|
|
1031
|
+
next_token = page.get("NextToken")
|
|
1032
|
+
if not next_token:
|
|
1033
|
+
break
|
|
1034
|
+
|
|
1035
|
+
# ── CloudFront ───────────────────────────────────────────────────────────
|
|
1036
|
+
|
|
1037
|
+
def _scan_cloudfront(self, session: boto3.session.Session) -> None:
|
|
1038
|
+
# CloudFront is a global service — always query us-east-1
|
|
1039
|
+
client = session.client("cloudfront", config=self._client_config)
|
|
1040
|
+
paginator = client.get_paginator("list_distributions")
|
|
1041
|
+
for page in paginator.paginate():
|
|
1042
|
+
self._ensure_not_cancelled()
|
|
1043
|
+
self._increment_api_call("cloudfront", "list_distributions")
|
|
1044
|
+
dist_list = page.get("DistributionList", {})
|
|
1045
|
+
for dist in dist_list.get("Items", []):
|
|
1046
|
+
self._ensure_not_cancelled()
|
|
1047
|
+
arn = dist.get("ARN", "")
|
|
1048
|
+
domain = dist.get("DomainName", "")
|
|
1049
|
+
node_id = self._add_arn_node(arn, label=domain or dist.get("Id"), node_type="distribution")
|
|
1050
|
+
self._node(
|
|
1051
|
+
node_id,
|
|
1052
|
+
service="cloudfront",
|
|
1053
|
+
state=dist.get("Status"),
|
|
1054
|
+
domain=domain,
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
# ── ElastiCache ──────────────────────────────────────────────────────────
|
|
1058
|
+
|
|
1059
|
+
def _scan_elasticache(self, session: boto3.session.Session) -> None:
|
|
1060
|
+
client = self._client(session, "elasticache")
|
|
1061
|
+
paginator = client.get_paginator("describe_cache_clusters")
|
|
1062
|
+
for page in paginator.paginate():
|
|
1063
|
+
self._ensure_not_cancelled()
|
|
1064
|
+
self._increment_api_call("elasticache", "describe_cache_clusters")
|
|
1065
|
+
for cluster in page.get("CacheClusters", []):
|
|
1066
|
+
self._ensure_not_cancelled()
|
|
1067
|
+
arn = cluster.get("ARN", "")
|
|
1068
|
+
cluster_id = cluster.get("CacheClusterId", "")
|
|
1069
|
+
node_id = self._add_arn_node(arn, label=cluster_id, node_type="cluster") if arn else self._make_node_id("elasticache", cluster_id)
|
|
1070
|
+
if not arn:
|
|
1071
|
+
arn = f"arn:aws:elasticache:{self._region}:*:cluster/{cluster_id}"
|
|
1072
|
+
self._node(node_id, label=cluster_id, service="elasticache", type="cluster", arn=arn)
|
|
1073
|
+
self._node(
|
|
1074
|
+
node_id,
|
|
1075
|
+
service="elasticache",
|
|
1076
|
+
engine=cluster.get("Engine"),
|
|
1077
|
+
engine_version=cluster.get("EngineVersion"),
|
|
1078
|
+
node_type=cluster.get("CacheNodeType"),
|
|
1079
|
+
state=cluster.get("CacheClusterStatus"),
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
# ── Glue ─────────────────────────────────────────────────────────────────
|
|
1083
|
+
|
|
1084
|
+
def _scan_glue(self, session: boto3.session.Session) -> None:
|
|
1085
|
+
client = self._client(session, "glue")
|
|
1086
|
+
# Jobs
|
|
1087
|
+
next_token: Optional[str] = None
|
|
1088
|
+
while True:
|
|
1089
|
+
self._ensure_not_cancelled()
|
|
1090
|
+
kwargs: Dict[str, Any] = {}
|
|
1091
|
+
if next_token:
|
|
1092
|
+
kwargs["NextToken"] = next_token
|
|
1093
|
+
self._increment_api_call("glue", "list_jobs")
|
|
1094
|
+
page = client.list_jobs(**kwargs)
|
|
1095
|
+
for job_name in page.get("JobNames", []):
|
|
1096
|
+
self._ensure_not_cancelled()
|
|
1097
|
+
arn = f"arn:aws:glue:{self._region}:*:job/{job_name}"
|
|
1098
|
+
node_id = self._make_node_id("glue", job_name)
|
|
1099
|
+
self._node(node_id, label=job_name, service="glue", type="job", arn=arn)
|
|
1100
|
+
next_token = page.get("NextToken")
|
|
1101
|
+
if not next_token:
|
|
1102
|
+
break
|
|
1103
|
+
|
|
1104
|
+
# ── AppSync ──────────────────────────────────────────────────────────────
|
|
1105
|
+
|
|
1106
|
+
def _scan_appsync(self, session: boto3.session.Session) -> None:
|
|
1107
|
+
client = self._client(session, "appsync")
|
|
1108
|
+
next_token: Optional[str] = None
|
|
1109
|
+
while True:
|
|
1110
|
+
self._ensure_not_cancelled()
|
|
1111
|
+
kwargs: Dict[str, Any] = {}
|
|
1112
|
+
if next_token:
|
|
1113
|
+
kwargs["nextToken"] = next_token
|
|
1114
|
+
self._increment_api_call("appsync", "list_graphql_apis")
|
|
1115
|
+
page = client.list_graphql_apis(**kwargs)
|
|
1116
|
+
for api in page.get("graphqlApis", []):
|
|
1117
|
+
self._ensure_not_cancelled()
|
|
1118
|
+
arn = api.get("arn", "")
|
|
1119
|
+
node_id = self._add_arn_node(arn, label=api.get("name"), node_type="api")
|
|
1120
|
+
self._node(node_id, service="appsync", auth_type=api.get("authenticationType"))
|
|
1121
|
+
next_token = page.get("nextToken")
|
|
1122
|
+
if not next_token:
|
|
1123
|
+
break
|
|
1124
|
+
|
|
1125
|
+
def _scan_generic_service(self, session: boto3.session.Session, service_name: str) -> None:
|
|
1126
|
+
client = self._client(session, "resourcegroupstaggingapi")
|
|
1127
|
+
paginator = client.get_paginator("get_resources")
|
|
1128
|
+
|
|
1129
|
+
discovered = 0
|
|
1130
|
+
try:
|
|
1131
|
+
page_iterator = paginator.paginate(ResourcesPerPage=100, ResourceTypeFilters=[service_name])
|
|
1132
|
+
for page in page_iterator:
|
|
1133
|
+
self._ensure_not_cancelled()
|
|
1134
|
+
self._increment_api_call("resourcegroupstaggingapi", "get_resources")
|
|
1135
|
+
for entry in page.get("ResourceTagMappingList", []):
|
|
1136
|
+
self._ensure_not_cancelled()
|
|
1137
|
+
arn = entry.get("ResourceARN")
|
|
1138
|
+
if not arn:
|
|
1139
|
+
continue
|
|
1140
|
+
discovered += 1
|
|
1141
|
+
node_id = self._add_arn_node(arn)
|
|
1142
|
+
tags = {item.get("Key"): item.get("Value") for item in entry.get("Tags", [])}
|
|
1143
|
+
self._node(node_id, service=service_name, tags=tags)
|
|
1144
|
+
except (ClientError, BotoCoreError) as exc:
|
|
1145
|
+
logger.warning("Generic service scan failed for %s: %s", service_name, exc)
|
|
1146
|
+
discovered = 0
|
|
1147
|
+
|
|
1148
|
+
if discovered == 0:
|
|
1149
|
+
self.store.add_warning(f"{service_name} scanner is not specialized yet; no resources discovered.")
|