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.
@@ -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.")