cloudg 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudg/__init__.py +21 -0
- cloudg/api.py +613 -0
- cloudg/cli.py +934 -0
- cloudg/collectors/__init__.py +5 -0
- cloudg/collectors/aws.py +830 -0
- cloudg/collectors/azure.py +362 -0
- cloudg/collectors/base.py +44 -0
- cloudg/collectors/gcp.py +170 -0
- cloudg/collectors/multi.py +329 -0
- cloudg/config.py +261 -0
- cloudg/coverage.py +93 -0
- cloudg/credentials.py +451 -0
- cloudg/graph/__init__.py +6 -0
- cloudg/graph/builder.py +336 -0
- cloudg/graph/ontology.py +914 -0
- cloudg/graph/rag_export.py +533 -0
- cloudg/graph/reachability.py +237 -0
- cloudg/normaliser.py +390 -0
- cloudg/policies/custodian-aws-security.yml +139 -0
- cloudg/policies/custodian-azure.yml +95 -0
- cloudg/policies/custodian-gcp.yml +87 -0
- cloudg/policies/custodian.yml +131 -0
- cloudg/region_discovery.py +330 -0
- cloudg/registry.py +142 -0
- cloudg/renderers/__init__.py +1 -0
- cloudg/renderers/html_report.py +430 -0
- cloudg/renderers/json_export.py +65 -0
- cloudg/renderers/svg.py +689 -0
- cloudg/renderers/terraform_export.py +666 -0
- cloudg/retry.py +103 -0
- cloudg/rules/cis_aws_v3.yaml +148 -0
- cloudg/rules/cis_azure_v2.yaml +142 -0
- cloudg/rules/cis_gcp_v3.yaml +133 -0
- cloudg/rules/frameworks/aws_foundational_security_best_practices_aws.yaml +1286 -0
- cloudg/rules/frameworks/cis_5.0_aws.yaml +280 -0
- cloudg/rules/frameworks/cis_5.0_azure.yaml +475 -0
- cloudg/rules/frameworks/cis_5.0_gcp.yaml +327 -0
- cloudg/rules/frameworks/gdpr_aws.yaml +95 -0
- cloudg/rules/frameworks/hipaa_aws.yaml +461 -0
- cloudg/rules/frameworks/hipaa_azure.yaml +504 -0
- cloudg/rules/frameworks/hipaa_gcp.yaml +211 -0
- cloudg/rules/frameworks/iso27001_2022_aws.yaml +789 -0
- cloudg/rules/frameworks/iso27001_2022_azure.yaml +477 -0
- cloudg/rules/frameworks/iso27001_2022_gcp.yaml +240 -0
- cloudg/rules/frameworks/mitre_attack_aws.yaml +596 -0
- cloudg/rules/frameworks/mitre_attack_azure.yaml +418 -0
- cloudg/rules/frameworks/mitre_attack_gcp.yaml +300 -0
- cloudg/rules/frameworks/nist_800_53_revision_5_aws.yaml +3234 -0
- cloudg/rules/frameworks/nist_csf_2.0_aws.yaml +828 -0
- cloudg/rules/frameworks/pci_4.0_aws.yaml +7108 -0
- cloudg/rules/frameworks/pci_4.0_azure.yaml +4696 -0
- cloudg/rules/frameworks/pci_4.0_gcp.yaml +5405 -0
- cloudg/rules/frameworks/soc2_aws.yaml +421 -0
- cloudg/rules/frameworks/soc2_azure.yaml +477 -0
- cloudg/rules/frameworks/soc2_gcp.yaml +342 -0
- cloudg/rules/gdpr.yaml +56 -0
- cloudg/rules/hipaa_security_rule.yaml +83 -0
- cloudg/rules/iso_27001_2022.yaml +88 -0
- cloudg/rules/nist_800_53.yaml +134 -0
- cloudg/rules/pci_dss_v4.yaml +108 -0
- cloudg/rules/soc2_tsc.yaml +93 -0
- cloudg/scanners/__init__.py +1 -0
- cloudg/scanners/checkov.py +174 -0
- cloudg/scanners/iam_linter.py +200 -0
- cloudg/scanners/prowler.py +237 -0
- cloudg/scanners/scoutsuite.py +163 -0
- cloudg/scanners/trivy.py +339 -0
- cloudg/schema/__init__.py +25 -0
- cloudg/schema/models.py +260 -0
- cloudg/templates/report.html.j2 +828 -0
- cloudg-0.3.0.dist-info/METADATA +330 -0
- cloudg-0.3.0.dist-info/RECORD +75 -0
- cloudg-0.3.0.dist-info/WHEEL +4 -0
- cloudg-0.3.0.dist-info/entry_points.txt +14 -0
- cloudg-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
cloudg/collectors/gcp.py
ADDED
|
@@ -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
|