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,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
+ }