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,329 @@
|
|
|
1
|
+
"""Multi-provider, multi-account, multi-region collection orchestrator.
|
|
2
|
+
|
|
3
|
+
Supports simultaneous scanning of AWS + Azure + GCP with:
|
|
4
|
+
- Region auto-discovery via RegionDiscovery
|
|
5
|
+
- Parallel provider execution via asyncio.gather
|
|
6
|
+
- Per-region iteration for Azure and GCP
|
|
7
|
+
- Coverage tracking per provider/region/account
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from cloudg.config import CloudGConfig
|
|
18
|
+
from cloudg.coverage import CollectionCoverage, ServiceStatus
|
|
19
|
+
from cloudg.region_discovery import RegionDiscovery, is_all_regions
|
|
20
|
+
from cloudg.schema.models import CloudAsset, NetworkEdge
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MultiAccountCollector:
|
|
26
|
+
"""Orchestrates collection across multiple providers, accounts, and regions.
|
|
27
|
+
|
|
28
|
+
For AWS: assumes IAM roles in target accounts via STS, iterates regions.
|
|
29
|
+
For Azure: iterates subscriptions × locations.
|
|
30
|
+
For GCP: iterates projects × regions.
|
|
31
|
+
|
|
32
|
+
Uses asyncio.Semaphore to limit concurrent API calls.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: CloudGConfig) -> None:
|
|
36
|
+
self._config = config
|
|
37
|
+
self._semaphore = asyncio.Semaphore(config.concurrency_limit)
|
|
38
|
+
self._coverage: list[CollectionCoverage] = []
|
|
39
|
+
self._region_discovery = RegionDiscovery()
|
|
40
|
+
self._resolved_regions: dict[str, list[str]] = {}
|
|
41
|
+
|
|
42
|
+
async def collect_all(
|
|
43
|
+
self,
|
|
44
|
+
) -> tuple[list[CloudAsset], list[NetworkEdge], list[CollectionCoverage]]:
|
|
45
|
+
"""Collect assets and edges from ALL configured providers simultaneously.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Tuple of (all_assets, all_edges, coverage_records).
|
|
49
|
+
"""
|
|
50
|
+
providers = self._config.providers
|
|
51
|
+
all_assets: list[CloudAsset] = []
|
|
52
|
+
all_edges: list[NetworkEdge] = []
|
|
53
|
+
|
|
54
|
+
# Build tasks for each provider
|
|
55
|
+
tasks: list[asyncio.Task] = []
|
|
56
|
+
task_labels: list[str] = []
|
|
57
|
+
|
|
58
|
+
if "aws" in providers:
|
|
59
|
+
tasks.append(asyncio.ensure_future(self._collect_aws_multi()))
|
|
60
|
+
task_labels.append("aws")
|
|
61
|
+
|
|
62
|
+
if "azure" in providers:
|
|
63
|
+
tasks.append(asyncio.ensure_future(self._collect_azure_multi()))
|
|
64
|
+
task_labels.append("azure")
|
|
65
|
+
|
|
66
|
+
if "gcp" in providers:
|
|
67
|
+
tasks.append(asyncio.ensure_future(self._collect_gcp_multi()))
|
|
68
|
+
task_labels.append("gcp")
|
|
69
|
+
|
|
70
|
+
if not tasks:
|
|
71
|
+
logger.warning("No providers configured for collection")
|
|
72
|
+
return all_assets, all_edges, self._coverage
|
|
73
|
+
|
|
74
|
+
# Execute all providers in parallel
|
|
75
|
+
logger.info("Starting multi-provider collection for: %s", ", ".join(task_labels))
|
|
76
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
77
|
+
|
|
78
|
+
for label, result in zip(task_labels, results):
|
|
79
|
+
if isinstance(result, Exception):
|
|
80
|
+
logger.error("Provider %s collection failed: %s", label, result)
|
|
81
|
+
coverage = CollectionCoverage(provider=label)
|
|
82
|
+
coverage.record(f"{label}_full", ServiceStatus.FAILED, error=str(result))
|
|
83
|
+
self._coverage.append(coverage)
|
|
84
|
+
elif isinstance(result, list):
|
|
85
|
+
for assets, edges in result:
|
|
86
|
+
all_assets.extend(assets)
|
|
87
|
+
all_edges.extend(edges)
|
|
88
|
+
|
|
89
|
+
logger.info(
|
|
90
|
+
"Multi-provider collection complete: %d assets, %d edges across %d coverage records (%s)",
|
|
91
|
+
len(all_assets),
|
|
92
|
+
len(all_edges),
|
|
93
|
+
len(self._coverage),
|
|
94
|
+
", ".join(task_labels),
|
|
95
|
+
)
|
|
96
|
+
return all_assets, all_edges, self._coverage
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# AWS
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
async def _collect_aws_multi(self) -> list[tuple[list[CloudAsset], list[NetworkEdge]]]:
|
|
103
|
+
"""Iterate AWS accounts × regions."""
|
|
104
|
+
cfg = self._config.aws
|
|
105
|
+
accounts = cfg.accounts or [None] # None = current account
|
|
106
|
+
|
|
107
|
+
# Region resolution
|
|
108
|
+
if is_all_regions(cfg.regions):
|
|
109
|
+
logger.info("AWS: discovering all regions...")
|
|
110
|
+
regions = await self._region_discovery.discover_aws()
|
|
111
|
+
else:
|
|
112
|
+
regions = cfg.regions
|
|
113
|
+
|
|
114
|
+
self._resolved_regions["aws"] = regions
|
|
115
|
+
logger.info("AWS: scanning %d regions × %d accounts", len(regions), len(accounts))
|
|
116
|
+
|
|
117
|
+
tasks = []
|
|
118
|
+
for account_id in accounts:
|
|
119
|
+
for region in regions:
|
|
120
|
+
tasks.append(self._collect_aws_single(account_id, region, cfg))
|
|
121
|
+
|
|
122
|
+
return await asyncio.gather(*tasks, return_exceptions=False)
|
|
123
|
+
|
|
124
|
+
async def _collect_aws_single(
|
|
125
|
+
self,
|
|
126
|
+
account_id: str | None,
|
|
127
|
+
region: str,
|
|
128
|
+
cfg: Any,
|
|
129
|
+
) -> tuple[list[CloudAsset], list[NetworkEdge]]:
|
|
130
|
+
"""Collect from a single AWS account/region.
|
|
131
|
+
|
|
132
|
+
Credential priority:
|
|
133
|
+
1. Direct access keys (access_key_id + secret_access_key) from config
|
|
134
|
+
2. AWS profile from config
|
|
135
|
+
3. Environment defaults (AWS_PROFILE, instance metadata, etc.)
|
|
136
|
+
|
|
137
|
+
Role assumption is only used when role_name is set AND account_id is specified.
|
|
138
|
+
"""
|
|
139
|
+
coverage = CollectionCoverage(provider="aws", region=region, account_id=account_id)
|
|
140
|
+
self._coverage.append(coverage)
|
|
141
|
+
|
|
142
|
+
async with self._semaphore:
|
|
143
|
+
try:
|
|
144
|
+
from cloudg.credentials import build_aws_session
|
|
145
|
+
|
|
146
|
+
# Supports direct keys, OIDC web identity, profiles, the
|
|
147
|
+
# default chain (instance/task roles), and AssumeRole with
|
|
148
|
+
# optional ExternalId — see cloudg.credentials.
|
|
149
|
+
try:
|
|
150
|
+
session = build_aws_session(cfg, region, account_id=account_id)
|
|
151
|
+
except RuntimeError as exc:
|
|
152
|
+
logger.error("AWS auth failed for %s/%s: %s", account_id, region, exc)
|
|
153
|
+
coverage.record("sts_assume_role", ServiceStatus.FAILED, error=str(exc))
|
|
154
|
+
return [], []
|
|
155
|
+
|
|
156
|
+
# Resolve account ID if not provided
|
|
157
|
+
if not account_id:
|
|
158
|
+
try:
|
|
159
|
+
account_id = session.client("sts").get_caller_identity().get("Account")
|
|
160
|
+
except Exception:
|
|
161
|
+
account_id = "unknown"
|
|
162
|
+
|
|
163
|
+
from cloudg.collectors.aws import AsyncAWSCollector
|
|
164
|
+
|
|
165
|
+
collector = AsyncAWSCollector(session=session, region=region, account_id=account_id)
|
|
166
|
+
|
|
167
|
+
start = time.time()
|
|
168
|
+
assets = await collector.collect()
|
|
169
|
+
edges = await collector.collect_edges()
|
|
170
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
171
|
+
|
|
172
|
+
coverage.record(
|
|
173
|
+
"aws_full",
|
|
174
|
+
ServiceStatus.SUCCESS,
|
|
175
|
+
asset_count=len(assets),
|
|
176
|
+
duration_ms=duration_ms,
|
|
177
|
+
)
|
|
178
|
+
return assets, edges
|
|
179
|
+
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
logger.error("AWS collection failed for %s/%s: %s", account_id, region, exc)
|
|
182
|
+
coverage.record("aws_full", ServiceStatus.FAILED, error=str(exc))
|
|
183
|
+
return [], []
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
# Azure
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
async def _collect_azure_multi(self) -> list[tuple[list[CloudAsset], list[NetworkEdge]]]:
|
|
190
|
+
"""Iterate Azure subscriptions × locations."""
|
|
191
|
+
sub_ids = self._config.azure.subscription_ids
|
|
192
|
+
if not sub_ids:
|
|
193
|
+
sub_ids = [None]
|
|
194
|
+
|
|
195
|
+
# Region resolution
|
|
196
|
+
azure_regions = self._config.azure.regions
|
|
197
|
+
if is_all_regions(azure_regions):
|
|
198
|
+
logger.info("Azure: discovering all locations...")
|
|
199
|
+
first_sub = sub_ids[0] if sub_ids[0] else None
|
|
200
|
+
try:
|
|
201
|
+
from cloudg.credentials import build_azure_credential
|
|
202
|
+
|
|
203
|
+
cred = build_azure_credential(self._config.azure)
|
|
204
|
+
except ImportError:
|
|
205
|
+
cred = None
|
|
206
|
+
azure_regions = await self._region_discovery.discover_azure(cred, first_sub)
|
|
207
|
+
|
|
208
|
+
self._resolved_regions["azure"] = azure_regions
|
|
209
|
+
logger.info(
|
|
210
|
+
"Azure: scanning %d subscriptions (locations auto-handled by SDK)", len(sub_ids)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Azure SDKs already return resources across all locations within a subscription.
|
|
214
|
+
# We pass the resolved regions as metadata but don't iterate per-region for Azure
|
|
215
|
+
# since list_all() calls return resources from ALL locations.
|
|
216
|
+
tasks = [self._collect_azure_single(sid) for sid in sub_ids]
|
|
217
|
+
return await asyncio.gather(*tasks, return_exceptions=False)
|
|
218
|
+
|
|
219
|
+
async def _collect_azure_single(
|
|
220
|
+
self, subscription_id: str | None
|
|
221
|
+
) -> tuple[list[CloudAsset], list[NetworkEdge]]:
|
|
222
|
+
"""Collect from a single Azure subscription."""
|
|
223
|
+
coverage = CollectionCoverage(provider="azure", account_id=subscription_id)
|
|
224
|
+
self._coverage.append(coverage)
|
|
225
|
+
|
|
226
|
+
async with self._semaphore:
|
|
227
|
+
try:
|
|
228
|
+
from cloudg.collectors.azure import AzureCollector
|
|
229
|
+
from cloudg.credentials import build_azure_credential
|
|
230
|
+
|
|
231
|
+
# Supports workload identity, service principal (secret or
|
|
232
|
+
# certificate), managed identity, and the default chain.
|
|
233
|
+
credential = build_azure_credential(self._config.azure)
|
|
234
|
+
if not subscription_id:
|
|
235
|
+
from azure.mgmt.resource import SubscriptionClient
|
|
236
|
+
|
|
237
|
+
sub_client = SubscriptionClient(credential)
|
|
238
|
+
sub = next(sub_client.subscriptions.list(), None)
|
|
239
|
+
subscription_id = sub.subscription_id if sub else ""
|
|
240
|
+
|
|
241
|
+
collector = AzureCollector(credential=credential, subscription_id=subscription_id)
|
|
242
|
+
|
|
243
|
+
start = time.time()
|
|
244
|
+
assets = await collector.collect()
|
|
245
|
+
edges = await collector.collect_edges()
|
|
246
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
247
|
+
|
|
248
|
+
coverage.record(
|
|
249
|
+
"azure_full",
|
|
250
|
+
ServiceStatus.SUCCESS,
|
|
251
|
+
asset_count=len(assets),
|
|
252
|
+
duration_ms=duration_ms,
|
|
253
|
+
)
|
|
254
|
+
return assets, edges
|
|
255
|
+
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
logger.error("Azure collection failed for %s: %s", subscription_id, exc)
|
|
258
|
+
coverage.record("azure_full", ServiceStatus.FAILED, error=str(exc))
|
|
259
|
+
return [], []
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
# GCP
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
async def _collect_gcp_multi(self) -> list[tuple[list[CloudAsset], list[NetworkEdge]]]:
|
|
266
|
+
"""Iterate GCP projects × regions."""
|
|
267
|
+
project_ids = self._config.gcp.project_ids
|
|
268
|
+
if not project_ids:
|
|
269
|
+
project_ids = [None]
|
|
270
|
+
|
|
271
|
+
# Region resolution
|
|
272
|
+
gcp_regions = self._config.gcp.regions
|
|
273
|
+
if is_all_regions(gcp_regions):
|
|
274
|
+
logger.info("GCP: discovering all regions...")
|
|
275
|
+
try:
|
|
276
|
+
from cloudg.credentials import build_gcp_credentials
|
|
277
|
+
|
|
278
|
+
creds, default_project = build_gcp_credentials(self._config.gcp)
|
|
279
|
+
pid = project_ids[0] or default_project
|
|
280
|
+
except Exception:
|
|
281
|
+
creds, pid = None, None
|
|
282
|
+
gcp_regions = await self._region_discovery.discover_gcp(creds, pid)
|
|
283
|
+
|
|
284
|
+
self._resolved_regions["gcp"] = gcp_regions
|
|
285
|
+
logger.info(
|
|
286
|
+
"GCP: scanning %d projects (Cloud Asset Inventory scans all regions)", len(project_ids)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# GCP Cloud Asset Inventory API returns resources across ALL regions for a project.
|
|
290
|
+
# No per-region iteration needed — the API is project-scoped.
|
|
291
|
+
tasks = [self._collect_gcp_single(pid) for pid in project_ids]
|
|
292
|
+
return await asyncio.gather(*tasks, return_exceptions=False)
|
|
293
|
+
|
|
294
|
+
async def _collect_gcp_single(
|
|
295
|
+
self, project_id: str | None
|
|
296
|
+
) -> tuple[list[CloudAsset], list[NetworkEdge]]:
|
|
297
|
+
"""Collect from a single GCP project."""
|
|
298
|
+
coverage = CollectionCoverage(provider="gcp", account_id=project_id)
|
|
299
|
+
self._coverage.append(coverage)
|
|
300
|
+
|
|
301
|
+
async with self._semaphore:
|
|
302
|
+
try:
|
|
303
|
+
from cloudg.collectors.gcp import GCPCollector
|
|
304
|
+
from cloudg.credentials import build_gcp_credentials
|
|
305
|
+
|
|
306
|
+
# Supports key files, workload identity federation, ADC
|
|
307
|
+
# (inherited GCE/GKE identity), and impersonation.
|
|
308
|
+
credentials, default_project = build_gcp_credentials(self._config.gcp)
|
|
309
|
+
pid = project_id or default_project
|
|
310
|
+
|
|
311
|
+
collector = GCPCollector(project_id=pid, credentials=credentials)
|
|
312
|
+
|
|
313
|
+
start = time.time()
|
|
314
|
+
assets = await collector.collect()
|
|
315
|
+
edges = await collector.collect_edges()
|
|
316
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
317
|
+
|
|
318
|
+
coverage.record(
|
|
319
|
+
"gcp_full",
|
|
320
|
+
ServiceStatus.SUCCESS,
|
|
321
|
+
asset_count=len(assets),
|
|
322
|
+
duration_ms=duration_ms,
|
|
323
|
+
)
|
|
324
|
+
return assets, edges
|
|
325
|
+
|
|
326
|
+
except Exception as exc:
|
|
327
|
+
logger.error("GCP collection failed for %s: %s", project_id, exc)
|
|
328
|
+
coverage.record("gcp_full", ServiceStatus.FAILED, error=str(exc))
|
|
329
|
+
return [], []
|
cloudg/config.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""YAML-based configuration with Pydantic validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AWSConfig(BaseModel):
|
|
15
|
+
"""AWS-specific configuration.
|
|
16
|
+
|
|
17
|
+
Auth priority: direct keys → OIDC web identity (role_arn +
|
|
18
|
+
web_identity_token_file) → profile → default chain (env vars, EC2/ECS
|
|
19
|
+
instance role, SSO cache). role_arn or accounts[]+role_name adds an
|
|
20
|
+
STS AssumeRole hop on top, with optional external_id.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
regions: list[str] = Field(default=["us-east-1"])
|
|
24
|
+
accounts: list[str] = Field(
|
|
25
|
+
default_factory=list, description="Account IDs for multi-account scanning"
|
|
26
|
+
)
|
|
27
|
+
access_key_id: str | None = Field(
|
|
28
|
+
default=None, description="AWS access key ID (direct credential)"
|
|
29
|
+
)
|
|
30
|
+
secret_access_key: str | None = Field(
|
|
31
|
+
default=None, description="AWS secret access key (direct credential)"
|
|
32
|
+
)
|
|
33
|
+
session_token: str | None = Field(
|
|
34
|
+
default=None, description="AWS session token (for temporary credentials)"
|
|
35
|
+
)
|
|
36
|
+
profile: str | None = Field(
|
|
37
|
+
default=None, description="AWS CLI profile name (fallback if no direct keys)"
|
|
38
|
+
)
|
|
39
|
+
role_name: str | None = Field(
|
|
40
|
+
default=None,
|
|
41
|
+
description="IAM role to assume in target accounts (optional, for cross-account)",
|
|
42
|
+
)
|
|
43
|
+
role_arn: str | None = Field(
|
|
44
|
+
default=None,
|
|
45
|
+
description="Full ARN of a role to assume (single-account AssumeRole or OIDC target)",
|
|
46
|
+
)
|
|
47
|
+
external_id: str | None = Field(
|
|
48
|
+
default=None, description="ExternalId for AssumeRole (third-party auditor pattern)"
|
|
49
|
+
)
|
|
50
|
+
web_identity_token_file: str | None = Field(
|
|
51
|
+
default=None,
|
|
52
|
+
description="Path to an OIDC token file for AssumeRoleWithWebIdentity (GitHub Actions, EKS, GitLab CI)",
|
|
53
|
+
)
|
|
54
|
+
role_session_name: str = Field(
|
|
55
|
+
default="cloudg-scan", description="STS session name for assumed roles"
|
|
56
|
+
)
|
|
57
|
+
max_retries: int = Field(default=10, ge=1, le=30)
|
|
58
|
+
retry_mode: str = Field(default="adaptive", pattern="^(legacy|standard|adaptive)$")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AzureConfig(BaseModel):
|
|
62
|
+
"""Azure-specific configuration.
|
|
63
|
+
|
|
64
|
+
Auth priority: workload identity federation (tenant_id + client_id +
|
|
65
|
+
federated_token_file) → service principal secret → service principal
|
|
66
|
+
certificate → managed identity → DefaultAzureCredential chain.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
subscription_ids: list[str] = Field(default_factory=list)
|
|
70
|
+
tenant_id: str | None = None
|
|
71
|
+
client_id: str | None = Field(
|
|
72
|
+
default=None, description="Service principal / workload identity application (client) ID"
|
|
73
|
+
)
|
|
74
|
+
client_secret: str | None = Field(default=None, description="Service principal client secret")
|
|
75
|
+
certificate_path: str | None = Field(
|
|
76
|
+
default=None, description="Path to a service principal certificate (PEM/PKCS12)"
|
|
77
|
+
)
|
|
78
|
+
federated_token_file: str | None = Field(
|
|
79
|
+
default=None, description="Path to a federated OIDC token file (workload identity)"
|
|
80
|
+
)
|
|
81
|
+
use_managed_identity: bool = Field(
|
|
82
|
+
default=False, description="Authenticate with the Azure managed identity of the host"
|
|
83
|
+
)
|
|
84
|
+
managed_identity_client_id: str | None = Field(
|
|
85
|
+
default=None, description="Client ID of a user-assigned managed identity"
|
|
86
|
+
)
|
|
87
|
+
regions: list[str] = Field(
|
|
88
|
+
default=["ALL"],
|
|
89
|
+
description="Azure locations to scan. Use ['ALL'] for auto-discovery.",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GCPConfig(BaseModel):
|
|
94
|
+
"""GCP-specific configuration.
|
|
95
|
+
|
|
96
|
+
Auth priority: credentials_file (service account key or workload
|
|
97
|
+
identity federation config) → application default credentials
|
|
98
|
+
(GOOGLE_APPLICATION_CREDENTIALS, gcloud, GCE/GKE metadata).
|
|
99
|
+
impersonate_service_account adds an impersonation hop on top.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
project_ids: list[str] = Field(default_factory=list)
|
|
103
|
+
organization_id: str | None = None
|
|
104
|
+
credentials_file: str | None = Field(
|
|
105
|
+
default=None,
|
|
106
|
+
description="Path to a service account key JSON or external_account (workload identity federation) JSON",
|
|
107
|
+
)
|
|
108
|
+
impersonate_service_account: str | None = Field(
|
|
109
|
+
default=None, description="Service account email to impersonate"
|
|
110
|
+
)
|
|
111
|
+
regions: list[str] = Field(
|
|
112
|
+
default=["ALL"],
|
|
113
|
+
description="GCP regions to scan. Use ['ALL'] for auto-discovery.",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ScannerConfig(BaseModel):
|
|
118
|
+
"""Scanner selection and configuration."""
|
|
119
|
+
|
|
120
|
+
enabled: list[str] = Field(default=["prowler", "scoutsuite", "checkov", "trivy", "iam"])
|
|
121
|
+
prowler_extra_args: list[str] = Field(default_factory=list)
|
|
122
|
+
scoutsuite_extra_args: list[str] = Field(default_factory=list)
|
|
123
|
+
checkov_extra_args: list[str] = Field(default_factory=list)
|
|
124
|
+
trivy_extra_args: list[str] = Field(default_factory=list)
|
|
125
|
+
checkov_frameworks: list[str] = Field(
|
|
126
|
+
default_factory=list,
|
|
127
|
+
description="Checkov frameworks to scan. Empty = auto-detect all (recommended for cloud infra).",
|
|
128
|
+
)
|
|
129
|
+
trivy_images: list[str] = Field(default_factory=list)
|
|
130
|
+
iac_directories: list[str] = Field(default_factory=list)
|
|
131
|
+
timeout_seconds: int = Field(default=3600, ge=60)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class GraphConfig(BaseModel):
|
|
135
|
+
"""Graph engine configuration."""
|
|
136
|
+
|
|
137
|
+
persist_graphml: bool = Field(default=True)
|
|
138
|
+
max_nodes_warn: int = Field(default=10000)
|
|
139
|
+
compute_attack_paths: bool = Field(default=True)
|
|
140
|
+
export_cytoscape: bool = Field(default=False)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class OntologyConfig(BaseModel):
|
|
144
|
+
"""Ontology engine configuration."""
|
|
145
|
+
|
|
146
|
+
enabled: bool = Field(default=True)
|
|
147
|
+
export_formats: list[str] = Field(default=["turtle", "json-ld"])
|
|
148
|
+
include_raw_metadata: bool = Field(default=False)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class RAGConfig(BaseModel):
|
|
152
|
+
"""RAG export configuration."""
|
|
153
|
+
|
|
154
|
+
enabled: bool = Field(default=True)
|
|
155
|
+
chunk_strategy: str = Field(
|
|
156
|
+
default="hybrid",
|
|
157
|
+
pattern="^(entity|community|relation_group|hybrid)$",
|
|
158
|
+
description="Chunking strategy: entity, community, relation_group, or hybrid (all)",
|
|
159
|
+
)
|
|
160
|
+
max_chunk_tokens: int = Field(default=2000, ge=100)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TerraformConfig(BaseModel):
|
|
164
|
+
"""Terraform export configuration."""
|
|
165
|
+
|
|
166
|
+
enabled: bool = Field(default=False)
|
|
167
|
+
output_dir: str = Field(default="./reports/terraform")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class ReportConfig(BaseModel):
|
|
171
|
+
"""Report generation configuration."""
|
|
172
|
+
|
|
173
|
+
formats: list[str] = Field(default=["html", "json", "svg"])
|
|
174
|
+
inline_js: bool = Field(
|
|
175
|
+
default=True, description="Inline JS libraries for air-gapped environments"
|
|
176
|
+
)
|
|
177
|
+
output_dir: str = Field(default="./reports")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _default_rules_dir() -> str:
|
|
181
|
+
"""Packaged rules directory, falling back to ./rules for old layouts."""
|
|
182
|
+
packaged = Path(__file__).parent / "rules"
|
|
183
|
+
if packaged.is_dir():
|
|
184
|
+
return str(packaged)
|
|
185
|
+
return "./rules"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class RulesetConfig(BaseModel):
|
|
189
|
+
"""External ruleset configuration."""
|
|
190
|
+
|
|
191
|
+
rules_dir: str = Field(
|
|
192
|
+
default_factory=_default_rules_dir,
|
|
193
|
+
description="Directory containing YAML rulesets (defaults to the rulesets shipped in the package)",
|
|
194
|
+
)
|
|
195
|
+
load_external: bool = Field(default=True)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class CloudGConfig(BaseModel):
|
|
199
|
+
"""Root configuration model."""
|
|
200
|
+
|
|
201
|
+
# Multi-provider support: list of providers to scan simultaneously
|
|
202
|
+
providers: list[str] = Field(
|
|
203
|
+
default=["aws"],
|
|
204
|
+
description="Providers to scan: aws, azure, gcp. Use multiple for simultaneous scanning.",
|
|
205
|
+
)
|
|
206
|
+
# Backward-compat alias — single provider string is auto-wrapped
|
|
207
|
+
provider: str | None = Field(
|
|
208
|
+
default=None,
|
|
209
|
+
exclude=True,
|
|
210
|
+
description="Deprecated: use 'providers' list. Kept for backward compat.",
|
|
211
|
+
)
|
|
212
|
+
aws: AWSConfig = Field(default_factory=AWSConfig)
|
|
213
|
+
azure: AzureConfig = Field(default_factory=AzureConfig)
|
|
214
|
+
gcp: GCPConfig = Field(default_factory=GCPConfig)
|
|
215
|
+
scanners: ScannerConfig = Field(default_factory=ScannerConfig)
|
|
216
|
+
graph: GraphConfig = Field(default_factory=GraphConfig)
|
|
217
|
+
ontology: OntologyConfig = Field(default_factory=OntologyConfig)
|
|
218
|
+
rag: RAGConfig = Field(default_factory=RAGConfig)
|
|
219
|
+
terraform: TerraformConfig = Field(default_factory=TerraformConfig)
|
|
220
|
+
report: ReportConfig = Field(default_factory=ReportConfig)
|
|
221
|
+
rulesets: RulesetConfig = Field(default_factory=RulesetConfig)
|
|
222
|
+
log_file: str | None = None
|
|
223
|
+
verbose: bool = False
|
|
224
|
+
concurrency_limit: int = Field(default=5, ge=1, le=50, description="Max concurrent API calls")
|
|
225
|
+
|
|
226
|
+
def model_post_init(self, __context: Any) -> None:
|
|
227
|
+
"""Handle backward-compat: if 'provider' is set but 'providers' is default, migrate."""
|
|
228
|
+
if self.provider is not None and self.providers == ["aws"]:
|
|
229
|
+
self.providers = [self.provider]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def load_config(config_path: str | Path | None = None) -> CloudGConfig:
|
|
233
|
+
"""Load configuration from YAML file with Pydantic validation.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
config_path: Path to config.yaml. If None, uses defaults.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Validated CloudGConfig instance.
|
|
240
|
+
"""
|
|
241
|
+
if config_path is None:
|
|
242
|
+
logger.info("No config file specified, using defaults")
|
|
243
|
+
return CloudGConfig()
|
|
244
|
+
|
|
245
|
+
path = Path(config_path)
|
|
246
|
+
if not path.exists():
|
|
247
|
+
logger.warning("Config file %s not found, using defaults", path)
|
|
248
|
+
return CloudGConfig()
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
import yaml
|
|
252
|
+
except ImportError:
|
|
253
|
+
logger.warning("PyYAML not installed, using defaults. Install with: pip install pyyaml")
|
|
254
|
+
return CloudGConfig()
|
|
255
|
+
|
|
256
|
+
with open(path) as f:
|
|
257
|
+
data: dict[str, Any] = yaml.safe_load(f) or {}
|
|
258
|
+
|
|
259
|
+
config = CloudGConfig.model_validate(data)
|
|
260
|
+
logger.info("Loaded config from %s", path)
|
|
261
|
+
return config
|
cloudg/coverage.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Collection coverage tracking — records which services succeeded/failed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ServiceStatus(str, Enum):
|
|
13
|
+
"""Status of a service collection attempt."""
|
|
14
|
+
|
|
15
|
+
SUCCESS = "SUCCESS"
|
|
16
|
+
FAILED = "FAILED"
|
|
17
|
+
PARTIAL = "PARTIAL"
|
|
18
|
+
SKIPPED = "SKIPPED"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ServiceCoverage(BaseModel):
|
|
22
|
+
"""Coverage record for a single service."""
|
|
23
|
+
|
|
24
|
+
service: str
|
|
25
|
+
status: ServiceStatus
|
|
26
|
+
asset_count: int = 0
|
|
27
|
+
error: str | None = None
|
|
28
|
+
duration_ms: int | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CollectionCoverage(BaseModel):
|
|
32
|
+
"""Aggregate coverage for an entire collection run."""
|
|
33
|
+
|
|
34
|
+
provider: str
|
|
35
|
+
region: str | None = None
|
|
36
|
+
account_id: str | None = None
|
|
37
|
+
started_at: datetime = Field(default_factory=datetime.utcnow)
|
|
38
|
+
completed_at: datetime | None = None
|
|
39
|
+
services: list[ServiceCoverage] = Field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def total_services(self) -> int:
|
|
43
|
+
return len(self.services)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def successful_services(self) -> int:
|
|
47
|
+
return sum(1 for s in self.services if s.status == ServiceStatus.SUCCESS)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def failed_services(self) -> int:
|
|
51
|
+
return sum(1 for s in self.services if s.status == ServiceStatus.FAILED)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def coverage_pct(self) -> float:
|
|
55
|
+
if not self.services:
|
|
56
|
+
return 0.0
|
|
57
|
+
return round(self.successful_services / self.total_services * 100, 1)
|
|
58
|
+
|
|
59
|
+
def record(
|
|
60
|
+
self,
|
|
61
|
+
service: str,
|
|
62
|
+
status: ServiceStatus,
|
|
63
|
+
asset_count: int = 0,
|
|
64
|
+
error: str | None = None,
|
|
65
|
+
duration_ms: int | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Record a service collection result."""
|
|
68
|
+
self.services.append(
|
|
69
|
+
ServiceCoverage(
|
|
70
|
+
service=service,
|
|
71
|
+
status=status,
|
|
72
|
+
asset_count=asset_count,
|
|
73
|
+
error=error,
|
|
74
|
+
duration_ms=duration_ms,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def to_summary(self) -> dict[str, Any]:
|
|
79
|
+
"""Return a summary dict for reporting."""
|
|
80
|
+
return {
|
|
81
|
+
"provider": self.provider,
|
|
82
|
+
"region": self.region,
|
|
83
|
+
"account_id": self.account_id,
|
|
84
|
+
"total_services": self.total_services,
|
|
85
|
+
"successful": self.successful_services,
|
|
86
|
+
"failed": self.failed_services,
|
|
87
|
+
"coverage_pct": self.coverage_pct,
|
|
88
|
+
"failures": [
|
|
89
|
+
{"service": s.service, "error": s.error}
|
|
90
|
+
for s in self.services
|
|
91
|
+
if s.status == ServiceStatus.FAILED
|
|
92
|
+
],
|
|
93
|
+
}
|