cloudg 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. cloudg/__init__.py +21 -0
  2. cloudg/api.py +613 -0
  3. cloudg/cli.py +934 -0
  4. cloudg/collectors/__init__.py +5 -0
  5. cloudg/collectors/aws.py +830 -0
  6. cloudg/collectors/azure.py +362 -0
  7. cloudg/collectors/base.py +44 -0
  8. cloudg/collectors/gcp.py +170 -0
  9. cloudg/collectors/multi.py +329 -0
  10. cloudg/config.py +261 -0
  11. cloudg/coverage.py +93 -0
  12. cloudg/credentials.py +451 -0
  13. cloudg/graph/__init__.py +6 -0
  14. cloudg/graph/builder.py +336 -0
  15. cloudg/graph/ontology.py +914 -0
  16. cloudg/graph/rag_export.py +533 -0
  17. cloudg/graph/reachability.py +237 -0
  18. cloudg/normaliser.py +390 -0
  19. cloudg/policies/custodian-aws-security.yml +139 -0
  20. cloudg/policies/custodian-azure.yml +95 -0
  21. cloudg/policies/custodian-gcp.yml +87 -0
  22. cloudg/policies/custodian.yml +131 -0
  23. cloudg/region_discovery.py +330 -0
  24. cloudg/registry.py +142 -0
  25. cloudg/renderers/__init__.py +1 -0
  26. cloudg/renderers/html_report.py +430 -0
  27. cloudg/renderers/json_export.py +65 -0
  28. cloudg/renderers/svg.py +689 -0
  29. cloudg/renderers/terraform_export.py +666 -0
  30. cloudg/retry.py +103 -0
  31. cloudg/rules/cis_aws_v3.yaml +148 -0
  32. cloudg/rules/cis_azure_v2.yaml +142 -0
  33. cloudg/rules/cis_gcp_v3.yaml +133 -0
  34. cloudg/rules/frameworks/aws_foundational_security_best_practices_aws.yaml +1286 -0
  35. cloudg/rules/frameworks/cis_5.0_aws.yaml +280 -0
  36. cloudg/rules/frameworks/cis_5.0_azure.yaml +475 -0
  37. cloudg/rules/frameworks/cis_5.0_gcp.yaml +327 -0
  38. cloudg/rules/frameworks/gdpr_aws.yaml +95 -0
  39. cloudg/rules/frameworks/hipaa_aws.yaml +461 -0
  40. cloudg/rules/frameworks/hipaa_azure.yaml +504 -0
  41. cloudg/rules/frameworks/hipaa_gcp.yaml +211 -0
  42. cloudg/rules/frameworks/iso27001_2022_aws.yaml +789 -0
  43. cloudg/rules/frameworks/iso27001_2022_azure.yaml +477 -0
  44. cloudg/rules/frameworks/iso27001_2022_gcp.yaml +240 -0
  45. cloudg/rules/frameworks/mitre_attack_aws.yaml +596 -0
  46. cloudg/rules/frameworks/mitre_attack_azure.yaml +418 -0
  47. cloudg/rules/frameworks/mitre_attack_gcp.yaml +300 -0
  48. cloudg/rules/frameworks/nist_800_53_revision_5_aws.yaml +3234 -0
  49. cloudg/rules/frameworks/nist_csf_2.0_aws.yaml +828 -0
  50. cloudg/rules/frameworks/pci_4.0_aws.yaml +7108 -0
  51. cloudg/rules/frameworks/pci_4.0_azure.yaml +4696 -0
  52. cloudg/rules/frameworks/pci_4.0_gcp.yaml +5405 -0
  53. cloudg/rules/frameworks/soc2_aws.yaml +421 -0
  54. cloudg/rules/frameworks/soc2_azure.yaml +477 -0
  55. cloudg/rules/frameworks/soc2_gcp.yaml +342 -0
  56. cloudg/rules/gdpr.yaml +56 -0
  57. cloudg/rules/hipaa_security_rule.yaml +83 -0
  58. cloudg/rules/iso_27001_2022.yaml +88 -0
  59. cloudg/rules/nist_800_53.yaml +134 -0
  60. cloudg/rules/pci_dss_v4.yaml +108 -0
  61. cloudg/rules/soc2_tsc.yaml +93 -0
  62. cloudg/scanners/__init__.py +1 -0
  63. cloudg/scanners/checkov.py +174 -0
  64. cloudg/scanners/iam_linter.py +200 -0
  65. cloudg/scanners/prowler.py +237 -0
  66. cloudg/scanners/scoutsuite.py +163 -0
  67. cloudg/scanners/trivy.py +339 -0
  68. cloudg/schema/__init__.py +25 -0
  69. cloudg/schema/models.py +260 -0
  70. cloudg/templates/report.html.j2 +828 -0
  71. cloudg-0.3.0.dist-info/METADATA +330 -0
  72. cloudg-0.3.0.dist-info/RECORD +75 -0
  73. cloudg-0.3.0.dist-info/WHEEL +4 -0
  74. cloudg-0.3.0.dist-info/entry_points.txt +14 -0
  75. cloudg-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,362 @@
1
+ """Azure resource collector using azure-mgmt SDKs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Any
8
+
9
+ from cloudg.collectors.base import BaseCollector
10
+ from cloudg.schema.models import (
11
+ AssetType,
12
+ CloudAsset,
13
+ CloudProvider,
14
+ EdgeType,
15
+ NetworkEdge,
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class AzureCollector(BaseCollector):
22
+ """Collects Azure resources using azure-mgmt-* SDKs.
23
+
24
+ Enumerates: Virtual Machines, VNets, Subnets, NSGs, Storage Accounts,
25
+ SQL Databases, Key Vaults, Load Balancers.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ credential: Any,
31
+ subscription_id: str,
32
+ ) -> None:
33
+ self._credential = credential
34
+ self._subscription_id = subscription_id
35
+ self._cached_assets: list[CloudAsset] = []
36
+
37
+ def _run_sync(self, coro: Any) -> Any:
38
+ """Helper to run sync Azure SDK calls in executor."""
39
+ return coro
40
+
41
+ async def _collect_vms(self) -> list[CloudAsset]:
42
+ """Collect Azure Virtual Machines."""
43
+ assets: list[CloudAsset] = []
44
+ try:
45
+ from azure.mgmt.compute import ComputeManagementClient
46
+
47
+ client = ComputeManagementClient(self._credential, self._subscription_id)
48
+ vm_list = client.virtual_machines.list_all()
49
+
50
+ for vm in vm_list:
51
+ tags = vm.tags or {}
52
+ assets.append(
53
+ CloudAsset(
54
+ arn=vm.id,
55
+ name=vm.name,
56
+ asset_type=AssetType.VIRTUAL_MACHINE,
57
+ provider=CloudProvider.AZURE,
58
+ region=vm.location,
59
+ account_id=self._subscription_id,
60
+ tags=tags,
61
+ metadata={
62
+ "vm_size": vm.hardware_profile.vm_size if vm.hardware_profile else None,
63
+ "os_type": (
64
+ vm.storage_profile.os_disk.os_type
65
+ if vm.storage_profile and vm.storage_profile.os_disk
66
+ else None
67
+ ),
68
+ "provisioning_state": vm.provisioning_state,
69
+ "network_interfaces": [
70
+ nic.id
71
+ for nic in (
72
+ vm.network_profile.network_interfaces
73
+ if vm.network_profile
74
+ else []
75
+ )
76
+ ],
77
+ },
78
+ raw_data={"id": vm.id, "name": vm.name, "location": vm.location},
79
+ )
80
+ )
81
+ except ImportError:
82
+ logger.warning("azure-mgmt-compute not installed, skipping VM collection")
83
+ except Exception as exc:
84
+ logger.error("Failed to collect Azure VMs: %s", exc)
85
+ return assets
86
+
87
+ async def _collect_vnets(self) -> list[CloudAsset]:
88
+ """Collect Azure Virtual Networks and Subnets."""
89
+ assets: list[CloudAsset] = []
90
+ try:
91
+ from azure.mgmt.network import NetworkManagementClient
92
+
93
+ client = NetworkManagementClient(self._credential, self._subscription_id)
94
+ vnet_list = client.virtual_networks.list_all()
95
+
96
+ for vnet in vnet_list:
97
+ tags = vnet.tags or {}
98
+ assets.append(
99
+ CloudAsset(
100
+ arn=vnet.id,
101
+ name=vnet.name,
102
+ asset_type=AssetType.VNET,
103
+ provider=CloudProvider.AZURE,
104
+ region=vnet.location,
105
+ account_id=self._subscription_id,
106
+ tags=tags,
107
+ metadata={
108
+ "address_space": (
109
+ vnet.address_space.address_prefixes if vnet.address_space else []
110
+ ),
111
+ "provisioning_state": vnet.provisioning_state,
112
+ },
113
+ raw_data={"id": vnet.id, "name": vnet.name},
114
+ )
115
+ )
116
+
117
+ # Subnets within VNet
118
+ for subnet in vnet.subnets or []:
119
+ assets.append(
120
+ CloudAsset(
121
+ arn=subnet.id,
122
+ name=subnet.name,
123
+ asset_type=AssetType.SUBNET,
124
+ provider=CloudProvider.AZURE,
125
+ region=vnet.location,
126
+ account_id=self._subscription_id,
127
+ metadata={
128
+ "address_prefix": subnet.address_prefix,
129
+ "vnet_id": vnet.id,
130
+ "nsg_id": subnet.network_security_group.id
131
+ if subnet.network_security_group
132
+ else None,
133
+ },
134
+ raw_data={"id": subnet.id, "name": subnet.name},
135
+ )
136
+ )
137
+ except ImportError:
138
+ logger.warning("azure-mgmt-network not installed, skipping VNet collection")
139
+ except Exception as exc:
140
+ logger.error("Failed to collect Azure VNets: %s", exc)
141
+ return assets
142
+
143
+ async def _collect_nsgs(self) -> list[CloudAsset]:
144
+ """Collect Azure Network Security Groups."""
145
+ assets: list[CloudAsset] = []
146
+ try:
147
+ from azure.mgmt.network import NetworkManagementClient
148
+
149
+ client = NetworkManagementClient(self._credential, self._subscription_id)
150
+ nsg_list = client.network_security_groups.list_all()
151
+
152
+ for nsg in nsg_list:
153
+ ingress_rules = []
154
+ egress_rules = []
155
+ for rule in nsg.security_rules or []:
156
+ rule_data = {
157
+ "name": rule.name,
158
+ "priority": rule.priority,
159
+ "direction": rule.direction,
160
+ "access": rule.access,
161
+ "protocol": rule.protocol,
162
+ "source_address_prefix": rule.source_address_prefix,
163
+ "destination_address_prefix": rule.destination_address_prefix,
164
+ "destination_port_range": rule.destination_port_range,
165
+ }
166
+ if rule.direction == "Inbound":
167
+ ingress_rules.append(rule_data)
168
+ else:
169
+ egress_rules.append(rule_data)
170
+
171
+ assets.append(
172
+ CloudAsset(
173
+ arn=nsg.id,
174
+ name=nsg.name,
175
+ asset_type=AssetType.NSG,
176
+ provider=CloudProvider.AZURE,
177
+ region=nsg.location,
178
+ account_id=self._subscription_id,
179
+ tags=nsg.tags or {},
180
+ metadata={
181
+ "ingress_rules": ingress_rules,
182
+ "egress_rules": egress_rules,
183
+ "provisioning_state": nsg.provisioning_state,
184
+ },
185
+ raw_data={"id": nsg.id, "name": nsg.name},
186
+ )
187
+ )
188
+ except ImportError:
189
+ logger.warning("azure-mgmt-network not installed, skipping NSG collection")
190
+ except Exception as exc:
191
+ logger.error("Failed to collect Azure NSGs: %s", exc)
192
+ return assets
193
+
194
+ async def _collect_storage(self) -> list[CloudAsset]:
195
+ """Collect Azure Storage Accounts."""
196
+ assets: list[CloudAsset] = []
197
+ try:
198
+ from azure.mgmt.storage import StorageManagementClient
199
+
200
+ client = StorageManagementClient(self._credential, self._subscription_id)
201
+ storage_list = client.storage_accounts.list()
202
+
203
+ for account in storage_list:
204
+ assets.append(
205
+ CloudAsset(
206
+ arn=account.id,
207
+ name=account.name,
208
+ asset_type=AssetType.BLOB_STORAGE,
209
+ provider=CloudProvider.AZURE,
210
+ region=account.location,
211
+ account_id=self._subscription_id,
212
+ tags=account.tags or {},
213
+ metadata={
214
+ "kind": account.kind,
215
+ "sku": account.sku.name if account.sku else None,
216
+ "https_only": account.enable_https_traffic_only,
217
+ "access_tier": account.access_tier,
218
+ "provisioning_state": account.provisioning_state,
219
+ },
220
+ raw_data={"id": account.id, "name": account.name},
221
+ )
222
+ )
223
+ except ImportError:
224
+ logger.warning("azure-mgmt-storage not installed, skipping storage collection")
225
+ except Exception as exc:
226
+ logger.error("Failed to collect Azure storage accounts: %s", exc)
227
+ return assets
228
+
229
+ async def _collect_sql(self) -> list[CloudAsset]:
230
+ """Collect Azure SQL databases."""
231
+ assets: list[CloudAsset] = []
232
+ try:
233
+ from azure.mgmt.sql import SqlManagementClient
234
+ from azure.mgmt.resource import ResourceManagementClient
235
+
236
+ resource_client = ResourceManagementClient(self._credential, self._subscription_id)
237
+ sql_client = SqlManagementClient(self._credential, self._subscription_id)
238
+
239
+ # List resource groups first, then servers in each
240
+ for rg in resource_client.resource_groups.list():
241
+ try:
242
+ for server in sql_client.servers.list_by_resource_group(rg.name):
243
+ for db in sql_client.databases.list_by_server(rg.name, server.name):
244
+ assets.append(
245
+ CloudAsset(
246
+ arn=db.id,
247
+ name=db.name,
248
+ asset_type=AssetType.AZURE_SQL,
249
+ provider=CloudProvider.AZURE,
250
+ region=db.location,
251
+ account_id=self._subscription_id,
252
+ metadata={
253
+ "server_name": server.name,
254
+ "resource_group": rg.name,
255
+ "status": db.status,
256
+ "edition": db.sku.tier if db.sku else None,
257
+ },
258
+ raw_data={"id": db.id, "name": db.name},
259
+ )
260
+ )
261
+ except Exception:
262
+ continue
263
+ except ImportError:
264
+ logger.warning("azure-mgmt-sql not installed, skipping SQL collection")
265
+ except Exception as exc:
266
+ logger.error("Failed to collect Azure SQL: %s", exc)
267
+ return assets
268
+
269
+ async def _collect_keyvaults(self) -> list[CloudAsset]:
270
+ """Collect Azure Key Vaults."""
271
+ assets: list[CloudAsset] = []
272
+ try:
273
+ from azure.mgmt.keyvault import KeyVaultManagementClient
274
+
275
+ client = KeyVaultManagementClient(self._credential, self._subscription_id)
276
+ vault_list = client.vaults.list()
277
+
278
+ for vault in vault_list:
279
+ assets.append(
280
+ CloudAsset(
281
+ arn=vault.id,
282
+ name=vault.name,
283
+ asset_type=AssetType.KEY_VAULT,
284
+ provider=CloudProvider.AZURE,
285
+ region=vault.location if hasattr(vault, "location") else "unknown",
286
+ account_id=self._subscription_id,
287
+ metadata={"vault_uri": getattr(vault, "properties", {})},
288
+ raw_data={"id": vault.id, "name": vault.name},
289
+ )
290
+ )
291
+ except ImportError:
292
+ logger.warning("azure-mgmt-keyvault not installed, skipping Key Vault collection")
293
+ except Exception as exc:
294
+ logger.error("Failed to collect Azure Key Vaults: %s", exc)
295
+ return assets
296
+
297
+ # ------------------------------------------------------------------
298
+ # Main interface
299
+ # ------------------------------------------------------------------
300
+
301
+ async def collect(self) -> list[CloudAsset]:
302
+ """Collect all Azure assets."""
303
+ logger.info("Starting Azure asset collection for subscription %s", self._subscription_id)
304
+
305
+ # Azure SDKs are sync, so we use asyncio tasks with run_in_executor pattern
306
+ results = await asyncio.gather(
307
+ self._collect_vms(),
308
+ self._collect_vnets(),
309
+ self._collect_nsgs(),
310
+ self._collect_storage(),
311
+ self._collect_sql(),
312
+ self._collect_keyvaults(),
313
+ return_exceptions=True,
314
+ )
315
+
316
+ all_assets: list[CloudAsset] = []
317
+ for result in results:
318
+ if isinstance(result, Exception):
319
+ logger.error("Azure collector task failed: %s", result)
320
+ elif isinstance(result, list):
321
+ all_assets.extend(result)
322
+
323
+ logger.info("Collected %d Azure assets", len(all_assets))
324
+ self._cached_assets = all_assets
325
+ return all_assets
326
+
327
+ async def collect_edges(self) -> list[NetworkEdge]:
328
+ """Collect Azure network edges from NSG rules."""
329
+ assets = self._cached_assets or await self.collect()
330
+ edges: list[NetworkEdge] = []
331
+
332
+ nsg_assets = [a for a in assets if a.asset_type == AssetType.NSG]
333
+ for nsg in nsg_assets:
334
+ for rule in nsg.metadata.get("ingress_rules", []):
335
+ if rule.get("access") == "Allow":
336
+ edges.append(
337
+ NetworkEdge(
338
+ source_id=rule.get("source_address_prefix", "*"),
339
+ target_id=nsg.id,
340
+ edge_type=EdgeType.SECURITY_GROUP_RULE,
341
+ port_range=rule.get("destination_port_range"),
342
+ protocol=rule.get("protocol", "ALL"),
343
+ cidr=rule.get("source_address_prefix"),
344
+ direction="ingress",
345
+ )
346
+ )
347
+
348
+ # Containment edges: VNet contains Subnet
349
+ vnet_assets = {a.arn: a.id for a in assets if a.asset_type == AssetType.VNET}
350
+ for asset in assets:
351
+ vnet_id = asset.metadata.get("vnet_id")
352
+ if vnet_id and vnet_id in vnet_assets:
353
+ edges.append(
354
+ NetworkEdge(
355
+ source_id=vnet_assets[vnet_id],
356
+ target_id=asset.id,
357
+ edge_type=EdgeType.CONTAINS,
358
+ )
359
+ )
360
+
361
+ logger.info("Collected %d Azure edges", len(edges))
362
+ return edges
@@ -0,0 +1,44 @@
1
+ """Abstract base collector for all cloud providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from cloudg.schema.models import CloudAsset, NetworkEdge
10
+
11
+
12
+ class BaseCollector(abc.ABC):
13
+ """Abstract base class for cloud resource collectors.
14
+
15
+ Each provider-specific collector must implement:
16
+ - collect(): enumerate all cloud assets
17
+ - collect_edges(): enumerate network/IAM relationships
18
+ """
19
+
20
+ @abc.abstractmethod
21
+ async def collect(self) -> list[CloudAsset]:
22
+ """Collect all cloud assets from the provider.
23
+
24
+ Returns:
25
+ A list of CloudAsset objects representing discovered resources.
26
+ """
27
+
28
+ @abc.abstractmethod
29
+ async def collect_edges(self) -> list[NetworkEdge]:
30
+ """Collect all network and IAM relationship edges.
31
+
32
+ Returns:
33
+ A list of NetworkEdge objects representing connectivity.
34
+ """
35
+
36
+ async def run(self) -> tuple[list[CloudAsset], list[NetworkEdge]]:
37
+ """Convenience: collect assets and edges together.
38
+
39
+ Returns:
40
+ Tuple of (assets, edges).
41
+ """
42
+ assets = await self.collect()
43
+ edges = await self.collect_edges()
44
+ return assets, edges
@@ -0,0 +1,170 @@
1
+ """GCP resource collector using google-cloud-asset SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from cloudg.collectors.base import BaseCollector
9
+ from cloudg.schema.models import (
10
+ AssetType,
11
+ CloudAsset,
12
+ CloudProvider,
13
+ EdgeType,
14
+ NetworkEdge,
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Mapping from GCP asset types to our normalised AssetType
20
+ _GCP_ASSET_TYPE_MAP: dict[str, AssetType] = {
21
+ "compute.googleapis.com/Instance": AssetType.GCE_INSTANCE,
22
+ "compute.googleapis.com/Network": AssetType.VPC,
23
+ "compute.googleapis.com/Subnetwork": AssetType.SUBNET,
24
+ "compute.googleapis.com/Firewall": AssetType.SECURITY_GROUP,
25
+ "compute.googleapis.com/ForwardingRule": AssetType.LOAD_BALANCER,
26
+ "compute.googleapis.com/Address": AssetType.ELASTIC_IP,
27
+ "compute.googleapis.com/Router": AssetType.NAT_GATEWAY,
28
+ "storage.googleapis.com/Bucket": AssetType.GCS_BUCKET,
29
+ "sqladmin.googleapis.com/Instance": AssetType.CLOUD_SQL,
30
+ "cloudfunctions.googleapis.com/Function": AssetType.CLOUD_FUNCTION,
31
+ "container.googleapis.com/Cluster": AssetType.GKE_CLUSTER,
32
+ "iam.googleapis.com/ServiceAccount": AssetType.SERVICE_PRINCIPAL,
33
+ "cloudkms.googleapis.com/CryptoKey": AssetType.KMS_KEY,
34
+ "secretmanager.googleapis.com/Secret": AssetType.SECRET,
35
+ }
36
+
37
+
38
+ class GCPCollector(BaseCollector):
39
+ """Collects GCP resources using the Cloud Asset Inventory API.
40
+
41
+ Uses search_all_resources() for efficient batch inventory.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ project_id: str,
47
+ credentials: Any = None,
48
+ ) -> None:
49
+ self._project_id = project_id
50
+ self._credentials = credentials
51
+ self._cached_assets: list[CloudAsset] = []
52
+
53
+ def _resolve_asset_type(self, gcp_type: str) -> AssetType:
54
+ """Map GCP asset type string to normalised AssetType."""
55
+ return _GCP_ASSET_TYPE_MAP.get(gcp_type, AssetType.OTHER)
56
+
57
+ def _extract_location(self, name: str) -> str:
58
+ """Extract region/zone from resource name."""
59
+ parts = name.split("/")
60
+ # Pattern: projects/X/zones/ZONE/... or projects/X/regions/REGION/...
61
+ for i, part in enumerate(parts):
62
+ if part in ("zones", "regions", "locations") and i + 1 < len(parts):
63
+ return parts[i + 1]
64
+ return "global"
65
+
66
+ async def collect(self) -> list[CloudAsset]:
67
+ """Collect all GCP assets via Cloud Asset Inventory."""
68
+ logger.info("Starting GCP asset collection for project %s", self._project_id)
69
+ assets: list[CloudAsset] = []
70
+
71
+ try:
72
+ from google.cloud import asset_v1
73
+
74
+ client = asset_v1.AssetServiceClient(credentials=self._credentials)
75
+ scope = f"projects/{self._project_id}"
76
+
77
+ # Search all resources in the project
78
+ request = asset_v1.SearchAllResourcesRequest(scope=scope)
79
+ results = client.search_all_resources(request=request)
80
+
81
+ for resource in results:
82
+ asset_type = self._resolve_asset_type(resource.asset_type)
83
+ location = resource.location or self._extract_location(resource.name)
84
+
85
+ # Extract labels as tags
86
+ tags = dict(resource.labels) if resource.labels else {}
87
+
88
+ assets.append(
89
+ CloudAsset(
90
+ arn=resource.name,
91
+ name=resource.display_name or resource.name.split("/")[-1],
92
+ asset_type=asset_type,
93
+ provider=CloudProvider.GCP,
94
+ region=location,
95
+ account_id=self._project_id,
96
+ tags=tags,
97
+ metadata={
98
+ "gcp_asset_type": resource.asset_type,
99
+ "project": resource.project,
100
+ "description": resource.description,
101
+ "state": resource.state.name if resource.state else None,
102
+ "parent_asset_type": resource.parent_asset_type,
103
+ "parent_full_resource_name": resource.parent_full_resource_name,
104
+ "network_tags": list(resource.network_tags)
105
+ if resource.network_tags
106
+ else [],
107
+ },
108
+ raw_data={
109
+ "name": resource.name,
110
+ "asset_type": resource.asset_type,
111
+ },
112
+ )
113
+ )
114
+
115
+ logger.info("Collected %d GCP assets", len(assets))
116
+
117
+ except ImportError:
118
+ logger.warning(
119
+ "google-cloud-asset not installed, skipping GCP collection. "
120
+ "Install with: pip install google-cloud-asset"
121
+ )
122
+ except Exception as exc:
123
+ logger.error("Failed to collect GCP assets: %s", exc)
124
+
125
+ self._cached_assets = assets
126
+ return assets
127
+
128
+ async def collect_edges(self) -> list[NetworkEdge]:
129
+ """Collect GCP network edges from firewall rules and containment."""
130
+ assets = self._cached_assets or await self.collect()
131
+ edges: list[NetworkEdge] = []
132
+
133
+ # Containment: VPC → Subnet
134
+ vpc_map: dict[str, str] = {}
135
+ for asset in assets:
136
+ if asset.asset_type == AssetType.VPC:
137
+ vpc_map[asset.arn or asset.id] = asset.id
138
+
139
+ for asset in assets:
140
+ if asset.asset_type == AssetType.SUBNET:
141
+ # GCP subnet names contain network reference
142
+ network = asset.metadata.get("parent_full_resource_name")
143
+ if network and network in vpc_map:
144
+ edges.append(
145
+ NetworkEdge(
146
+ source_id=vpc_map[network],
147
+ target_id=asset.id,
148
+ edge_type=EdgeType.CONTAINS,
149
+ )
150
+ )
151
+
152
+ # Firewall rules as security group edges
153
+ for asset in assets:
154
+ if asset.asset_type == AssetType.SECURITY_GROUP:
155
+ # GCP firewall rules have network_tags for targeting
156
+ network_tags = asset.metadata.get("network_tags", [])
157
+ for tag in network_tags:
158
+ edges.append(
159
+ NetworkEdge(
160
+ source_id="0.0.0.0/0",
161
+ target_id=asset.id,
162
+ edge_type=EdgeType.SECURITY_GROUP_RULE,
163
+ cidr="0.0.0.0/0",
164
+ direction="ingress",
165
+ description=f"GCP firewall rule targeting tag: {tag}",
166
+ )
167
+ )
168
+
169
+ logger.info("Collected %d GCP edges", len(edges))
170
+ return edges