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
cloudg/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """cloudg — cloud graphing: multi-cloud mapping and security intelligence."""
2
+
3
+ __version__ = "0.3.0"
4
+
5
+ from cloudg.api import (
6
+ AnalysisResult,
7
+ CloudGEngine,
8
+ CollectionResult,
9
+ PipelineResult,
10
+ )
11
+ from cloudg.config import CloudGConfig, load_config
12
+
13
+ __all__ = [
14
+ "__version__",
15
+ "AnalysisResult",
16
+ "CloudGEngine",
17
+ "CloudGConfig",
18
+ "CollectionResult",
19
+ "PipelineResult",
20
+ "load_config",
21
+ ]
cloudg/api.py ADDED
@@ -0,0 +1,613 @@
1
+ """CloudG programmatic API — integration-ready entry point.
2
+
3
+ Designed for embedding CloudG in larger systems. Provides:
4
+ - Single `CloudGEngine` class for full pipeline control
5
+ - `PipelineResult` dataclass for structured output consumption
6
+ - Event hooks for real-time integration callbacks
7
+ - Async-first with sync wrapper for non-async callers
8
+
9
+ Usage:
10
+ from cloudg.api import CloudGEngine
11
+ from cloudg.config import CloudGConfig
12
+
13
+ config = CloudGConfig(providers=["aws", "azure"])
14
+ engine = CloudGEngine(config)
15
+
16
+ # Full pipeline
17
+ result = await engine.run_pipeline()
18
+
19
+ # Or step-by-step
20
+ collection = await engine.collect()
21
+ findings = await engine.scan(collection.assets, collection.edges)
22
+ analysis = await engine.analyze(collection.assets, collection.edges, findings)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import logging
29
+ import time
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Any, Callable
33
+
34
+ from cloudg.config import CloudGConfig
35
+ from cloudg.coverage import CollectionCoverage
36
+ from cloudg.schema.models import (
37
+ CloudAsset,
38
+ Finding,
39
+ NetworkEdge,
40
+ ScanResult,
41
+ )
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Result dataclasses
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ @dataclass
52
+ class CollectionResult:
53
+ """Output of the collection phase."""
54
+
55
+ assets: list[CloudAsset] = field(default_factory=list)
56
+ edges: list[NetworkEdge] = field(default_factory=list)
57
+ coverage: list[CollectionCoverage] = field(default_factory=list)
58
+ providers_scanned: list[str] = field(default_factory=list)
59
+ regions_scanned: dict[str, list[str]] = field(default_factory=dict)
60
+ duration_ms: int = 0
61
+
62
+
63
+ @dataclass
64
+ class AnalysisResult:
65
+ """Output of the analysis phase (graph, ontology, RAG)."""
66
+
67
+ graph_nodes: int = 0
68
+ graph_edges: int = 0
69
+ ontology_triples: int = 0
70
+ ontology_path: Path | None = None
71
+ rag_chunks_path: Path | None = None
72
+ rag_chunk_count: int = 0
73
+ terraform_paths: dict[str, Path] = field(default_factory=dict)
74
+ attack_paths: list[Any] = field(default_factory=list)
75
+ reachability_findings: list[Finding] = field(default_factory=list)
76
+
77
+
78
+ @dataclass
79
+ class PipelineResult:
80
+ """Complete pipeline output — single object for downstream consumption.
81
+
82
+ This is the primary return type for `CloudGEngine.run_pipeline()`.
83
+ Designed for easy serialisation and integration with larger systems.
84
+ """
85
+
86
+ # Core data
87
+ assets: list[CloudAsset] = field(default_factory=list)
88
+ edges: list[NetworkEdge] = field(default_factory=list)
89
+ findings: list[Finding] = field(default_factory=list)
90
+ scan_result: ScanResult | None = None
91
+
92
+ # Analysis outputs
93
+ graph_nodes: int = 0
94
+ graph_edges: int = 0
95
+ ontology_triples: int = 0
96
+ rag_chunks_path: Path | None = None
97
+ terraform_paths: dict[str, Path] = field(default_factory=dict)
98
+ attack_paths: list[Any] = field(default_factory=list)
99
+
100
+ # Metadata
101
+ providers_scanned: list[str] = field(default_factory=list)
102
+ regions_scanned: dict[str, list[str]] = field(default_factory=dict)
103
+ coverage: list[CollectionCoverage] = field(default_factory=list)
104
+ report_paths: dict[str, Path] = field(default_factory=dict)
105
+ duration_ms: int = 0
106
+ errors: list[str] = field(default_factory=list)
107
+
108
+ @property
109
+ def total_assets(self) -> int:
110
+ return len(self.assets)
111
+
112
+ @property
113
+ def total_findings(self) -> int:
114
+ return len(self.findings)
115
+
116
+ @property
117
+ def severity_breakdown(self) -> dict[str, int]:
118
+ counts: dict[str, int] = {}
119
+ for f in self.findings:
120
+ sev = f.severity.value
121
+ counts[sev] = counts.get(sev, 0) + 1
122
+ return counts
123
+
124
+ def to_summary(self) -> dict[str, Any]:
125
+ """Return a summary dict for downstream consumption."""
126
+ return {
127
+ "total_assets": self.total_assets,
128
+ "total_findings": self.total_findings,
129
+ "severity_breakdown": self.severity_breakdown,
130
+ "providers_scanned": self.providers_scanned,
131
+ "regions_scanned": self.regions_scanned,
132
+ "graph_nodes": self.graph_nodes,
133
+ "graph_edges": self.graph_edges,
134
+ "ontology_triples": self.ontology_triples,
135
+ "attack_paths_count": len(self.attack_paths),
136
+ "duration_ms": self.duration_ms,
137
+ "errors": self.errors,
138
+ }
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Event hook types
143
+ # ---------------------------------------------------------------------------
144
+
145
+ # Callback signatures (all optional)
146
+ OnCollectionComplete = Callable[[CollectionResult], None]
147
+ OnScanComplete = Callable[[list[Finding]], None]
148
+ OnFinding = Callable[[Finding], None]
149
+ OnAnalysisComplete = Callable[[AnalysisResult], None]
150
+ OnPhaseStart = Callable[[str], None] # phase name
151
+ OnError = Callable[[str, Exception], None] # phase name, exception
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Engine
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ class CloudGEngine:
160
+ """Integration-ready programmatic API for CloudG.
161
+
162
+ Provides fine-grained control over the pipeline for embedding in
163
+ larger security orchestration systems.
164
+
165
+ Example:
166
+ engine = CloudGEngine(config)
167
+ engine.on_finding = lambda f: send_to_siem(f)
168
+ result = await engine.run_pipeline()
169
+ """
170
+
171
+ def __init__(self, config: CloudGConfig) -> None:
172
+ self.config = config
173
+
174
+ # Event hooks — set these before calling run_pipeline()
175
+ self.on_collection_complete: OnCollectionComplete | None = None
176
+ self.on_scan_complete: OnScanComplete | None = None
177
+ self.on_finding: OnFinding | None = None
178
+ self.on_analysis_complete: OnAnalysisComplete | None = None
179
+ self.on_phase_start: OnPhaseStart | None = None
180
+ self.on_error: OnError | None = None
181
+
182
+ def _emit_phase_start(self, phase: str) -> None:
183
+ if self.on_phase_start:
184
+ try:
185
+ self.on_phase_start(phase)
186
+ except Exception:
187
+ pass
188
+
189
+ def _emit_error(self, phase: str, exc: Exception) -> None:
190
+ if self.on_error:
191
+ try:
192
+ self.on_error(phase, exc)
193
+ except Exception:
194
+ pass
195
+
196
+ # ------------------------------------------------------------------
197
+ # Phase 1: Collection
198
+ # ------------------------------------------------------------------
199
+
200
+ async def collect(self) -> CollectionResult:
201
+ """Run multi-provider asset collection.
202
+
203
+ Returns:
204
+ CollectionResult with assets, edges, and coverage.
205
+ """
206
+ self._emit_phase_start("collection")
207
+ start = time.time()
208
+
209
+ from cloudg.collectors.multi import MultiAccountCollector
210
+
211
+ collector = MultiAccountCollector(self.config)
212
+
213
+ try:
214
+ assets, edges, coverage = await collector.collect_all()
215
+ except Exception as exc:
216
+ logger.error("Collection failed: %s", exc)
217
+ self._emit_error("collection", exc)
218
+ return CollectionResult(duration_ms=int((time.time() - start) * 1000))
219
+
220
+ result = CollectionResult(
221
+ assets=assets,
222
+ edges=edges,
223
+ coverage=coverage,
224
+ providers_scanned=self.config.providers,
225
+ regions_scanned=getattr(collector, "_resolved_regions", {}),
226
+ duration_ms=int((time.time() - start) * 1000),
227
+ )
228
+
229
+ if self.on_collection_complete:
230
+ try:
231
+ self.on_collection_complete(result)
232
+ except Exception:
233
+ pass
234
+
235
+ return result
236
+
237
+ # ------------------------------------------------------------------
238
+ # Phase 2: Scanning
239
+ # ------------------------------------------------------------------
240
+
241
+ async def scan(
242
+ self,
243
+ assets: list[CloudAsset],
244
+ edges: list[NetworkEdge],
245
+ iac_dir: str | None = None,
246
+ images: list[str] | None = None,
247
+ profile: str | None = None,
248
+ output_dir: str | Path = "./reports",
249
+ ) -> list[Finding]:
250
+ """Run security scanners and graph analysis.
251
+
252
+ Runs all scanners enabled in config.scanners.enabled in parallel.
253
+
254
+ Args:
255
+ assets: Collected cloud assets.
256
+ edges: Network edges between assets.
257
+ iac_dir: IaC directory for Checkov (falls back to config).
258
+ images: Container images for Trivy (falls back to config).
259
+ profile: AWS profile name for scanner auth.
260
+ output_dir: Directory for scanner output files.
261
+
262
+ Returns:
263
+ List of all findings.
264
+ """
265
+ import concurrent.futures
266
+
267
+ self._emit_phase_start("scanning")
268
+ all_findings: list[Finding] = []
269
+ out = Path(output_dir)
270
+ out.mkdir(parents=True, exist_ok=True)
271
+
272
+ scanner_list = [s.strip().lower() for s in self.config.scanners.enabled]
273
+
274
+ # Graph-based reachability analysis
275
+ try:
276
+ from cloudg.graph.builder import GraphBuilder
277
+ from cloudg.graph.reachability import ReachabilityAnalyzer
278
+
279
+ builder = GraphBuilder()
280
+ graph = builder.build(assets, edges)
281
+ analyzer = ReachabilityAnalyzer(graph)
282
+ reachability = analyzer.generate_findings()
283
+ all_findings.extend(reachability)
284
+ except Exception as exc:
285
+ logger.error("Graph analysis failed: %s", exc)
286
+ self._emit_error("graph_analysis", exc)
287
+
288
+ # Resolve IaC directories
289
+ resolved_iac_dirs: list[str] = []
290
+ if iac_dir:
291
+ resolved_iac_dirs = [iac_dir]
292
+ elif self.config.scanners.iac_directories:
293
+ resolved_iac_dirs = list(self.config.scanners.iac_directories)
294
+ else:
295
+ resolved_iac_dirs = ["."]
296
+
297
+ # Resolve images
298
+ resolved_images: list[str] = images or list(self.config.scanners.trivy_images)
299
+
300
+ # Run all scanners concurrently
301
+ def _run_prowler(prov: str) -> list[Finding]:
302
+ from cloudg.scanners.prowler import ProwlerScanner
303
+
304
+ region = self.config.aws.regions[0] if self.config.aws.regions else None
305
+ scanner = ProwlerScanner(
306
+ provider=prov,
307
+ profile=profile,
308
+ output_dir=str(out / "prowler" / prov),
309
+ extra_args=self.config.scanners.prowler_extra_args or [],
310
+ aws_access_key_id=self.config.aws.access_key_id if prov == "aws" else None,
311
+ aws_secret_access_key=self.config.aws.secret_access_key if prov == "aws" else None,
312
+ aws_region=region if prov == "aws" else None,
313
+ )
314
+ return scanner.run()
315
+
316
+ def _run_scoutsuite(prov: str) -> list[Finding]:
317
+ from cloudg.scanners.scoutsuite import ScoutSuiteScanner
318
+
319
+ scanner = ScoutSuiteScanner(
320
+ provider=prov,
321
+ profile=profile if prov == "aws" else None,
322
+ report_dir=str(out / "scoutsuite" / prov),
323
+ extra_args=self.config.scanners.scoutsuite_extra_args or [],
324
+ )
325
+ return scanner.run()
326
+
327
+ def _run_checkov(target_dir: str) -> list[Finding]:
328
+ from cloudg.scanners.checkov import CheckovScanner
329
+
330
+ fw = (
331
+ self.config.scanners.checkov_frameworks[0]
332
+ if self.config.scanners.checkov_frameworks
333
+ else None
334
+ )
335
+ scanner = CheckovScanner(
336
+ target_dir=target_dir,
337
+ framework=fw,
338
+ extra_args=self.config.scanners.checkov_extra_args or [],
339
+ )
340
+ return scanner.run()
341
+
342
+ def _run_trivy(image_list: list[str]) -> list[Finding]:
343
+ from cloudg.scanners.trivy import TrivyScanner
344
+
345
+ scanner = TrivyScanner(extra_args=self.config.scanners.trivy_extra_args or [])
346
+ return scanner.scan_images(image_list)
347
+
348
+ def _run_iam_linter() -> list[Finding]:
349
+ from cloudg.scanners.iam_linter import IAMLinter
350
+
351
+ linter = IAMLinter()
352
+ return linter.analyze_policies(assets)
353
+
354
+ scanner_findings: list[Finding] = []
355
+ max_workers = len(scanner_list) + len(self.config.providers) + 1
356
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max(max_workers, 2)) as executor:
357
+ future_to_name: dict[concurrent.futures.Future, str] = {}
358
+
359
+ if "prowler" in scanner_list:
360
+ for prov in self.config.providers:
361
+ future_to_name[executor.submit(_run_prowler, prov)] = f"prowler-{prov}"
362
+
363
+ if "scoutsuite" in scanner_list:
364
+ for prov in self.config.providers:
365
+ future_to_name[executor.submit(_run_scoutsuite, prov)] = f"scoutsuite-{prov}"
366
+
367
+ if "checkov" in scanner_list:
368
+ for d in resolved_iac_dirs:
369
+ future_to_name[executor.submit(_run_checkov, d)] = f"checkov-{d}"
370
+
371
+ if "trivy" in scanner_list and resolved_images:
372
+ future_to_name[executor.submit(_run_trivy, resolved_images)] = "trivy"
373
+
374
+ # IAM linter always runs if assets exist
375
+ if assets:
376
+ future_to_name[executor.submit(_run_iam_linter)] = "iam"
377
+
378
+ for future in concurrent.futures.as_completed(future_to_name):
379
+ name = future_to_name[future]
380
+ try:
381
+ findings = future.result(timeout=self.config.scanners.timeout_seconds)
382
+ scanner_findings.extend(findings)
383
+ logger.info("[%s] %d findings", name, len(findings))
384
+ except concurrent.futures.TimeoutError:
385
+ logger.error(
386
+ "[%s] timed out after %ds", name, self.config.scanners.timeout_seconds
387
+ )
388
+ self._emit_error(name, TimeoutError(f"{name} timed out"))
389
+ except Exception as exc:
390
+ logger.error("[%s] failed: %s", name, exc)
391
+ self._emit_error(name, exc)
392
+
393
+ all_findings.extend(scanner_findings)
394
+
395
+ # Emit per-finding callbacks
396
+ if self.on_finding:
397
+ for f in all_findings:
398
+ try:
399
+ self.on_finding(f)
400
+ except Exception:
401
+ pass
402
+
403
+ if self.on_scan_complete:
404
+ try:
405
+ self.on_scan_complete(all_findings)
406
+ except Exception:
407
+ pass
408
+
409
+ return all_findings
410
+
411
+ # ------------------------------------------------------------------
412
+ # Phase 3: Analysis (ontology, RAG, terraform)
413
+ # ------------------------------------------------------------------
414
+
415
+ async def analyze(
416
+ self,
417
+ assets: list[CloudAsset],
418
+ edges: list[NetworkEdge],
419
+ findings: list[Finding],
420
+ output_dir: str | Path = "./reports",
421
+ ) -> AnalysisResult:
422
+ """Run ontology, RAG, and Terraform analysis.
423
+
424
+ Returns:
425
+ AnalysisResult with all analysis outputs.
426
+ """
427
+ self._emit_phase_start("analysis")
428
+ out = Path(output_dir)
429
+ out.mkdir(parents=True, exist_ok=True)
430
+ result = AnalysisResult()
431
+
432
+ # Graph
433
+ try:
434
+ from cloudg.graph.builder import GraphBuilder
435
+
436
+ builder = GraphBuilder()
437
+ graph = builder.build(assets, edges)
438
+ result.graph_nodes = graph.number_of_nodes()
439
+ result.graph_edges = graph.number_of_edges()
440
+
441
+ # Reachability
442
+ from cloudg.graph.reachability import ReachabilityAnalyzer
443
+
444
+ analyzer = ReachabilityAnalyzer(graph)
445
+ result.reachability_findings = analyzer.generate_findings()
446
+
447
+ # Attack paths
448
+ try:
449
+ result.attack_paths = builder.find_lateral_movement_paths()
450
+ except Exception:
451
+ pass
452
+ except Exception as exc:
453
+ logger.error("Graph build failed: %s", exc)
454
+ self._emit_error("graph_build", exc)
455
+ graph = None
456
+
457
+ # Ontology
458
+ if self.config.ontology.enabled:
459
+ try:
460
+ from cloudg.graph.ontology import CloudOntology
461
+
462
+ ontology = CloudOntology()
463
+ ontology.build(assets, edges, findings)
464
+ stats = ontology.stats()
465
+ result.ontology_triples = stats["total_triples"]
466
+
467
+ for fmt in self.config.ontology.export_formats:
468
+ ext_map = {"turtle": "ttl", "json-ld": "jsonld", "xml": "rdf"}
469
+ ext = ext_map.get(fmt, "ttl")
470
+ path = ontology.save(out / f"ontology.{ext}", fmt=fmt)
471
+ result.ontology_path = path
472
+ except Exception as exc:
473
+ logger.error("Ontology build failed: %s", exc)
474
+ self._emit_error("ontology", exc)
475
+
476
+ # RAG export
477
+ if self.config.rag.enabled and graph is not None:
478
+ try:
479
+ from cloudg.graph.rag_export import RAGExporter
480
+
481
+ rag = RAGExporter(max_chunk_tokens=self.config.rag.max_chunk_tokens)
482
+ rag_paths = rag.export_all(assets, edges, graph, findings, output_dir=out)
483
+ result.rag_chunks_path = rag_paths["chunks"]
484
+ except Exception as exc:
485
+ logger.error("RAG export failed: %s", exc)
486
+ self._emit_error("rag_export", exc)
487
+
488
+ # Terraform
489
+ if self.config.terraform.enabled:
490
+ try:
491
+ from cloudg.renderers.terraform_export import TerraformExporter
492
+
493
+ tf_dir = self.config.terraform.output_dir or str(out / "terraform")
494
+ tf = TerraformExporter(output_dir=tf_dir)
495
+ result.terraform_paths = tf.export(assets, edges)
496
+ except Exception as exc:
497
+ logger.error("Terraform export failed: %s", exc)
498
+ self._emit_error("terraform", exc)
499
+
500
+ if self.on_analysis_complete:
501
+ try:
502
+ self.on_analysis_complete(result)
503
+ except Exception:
504
+ pass
505
+
506
+ return result
507
+
508
+ # ------------------------------------------------------------------
509
+ # Full pipeline
510
+ # ------------------------------------------------------------------
511
+
512
+ async def run_pipeline(self, output_dir: str | Path = "./reports") -> PipelineResult:
513
+ """Run the complete CloudG pipeline.
514
+
515
+ Phases:
516
+ 1. Collection (multi-provider, multi-region)
517
+ 2. Scanning (reachability, IAM linting)
518
+ 3. Analysis (ontology, RAG, Terraform)
519
+ 4. Normalisation
520
+ 5. Report generation
521
+
522
+ Returns:
523
+ PipelineResult with all outputs.
524
+ """
525
+ start = time.time()
526
+ out = Path(output_dir)
527
+ out.mkdir(parents=True, exist_ok=True)
528
+ errors: list[str] = []
529
+
530
+ # Phase 1: Collect
531
+ collection = await self.collect()
532
+
533
+ # Phase 2: Scan (all enabled scanners in parallel)
534
+ findings = await self.scan(collection.assets, collection.edges, output_dir=out)
535
+
536
+ # Phase 3: Analyse
537
+ analysis = await self.analyze(collection.assets, collection.edges, findings, output_dir=out)
538
+
539
+ # Phase 4: Normalise
540
+ self._emit_phase_start("normalisation")
541
+ scan_result = None
542
+ all_findings = findings + analysis.reachability_findings
543
+ try:
544
+ from cloudg.normaliser import FindingsNormaliser
545
+
546
+ normaliser = FindingsNormaliser(rules_dir=self.config.rulesets.rules_dir)
547
+ scan_result = normaliser.normalise(
548
+ analysis.reachability_findings,
549
+ findings,
550
+ [],
551
+ assets=collection.assets,
552
+ )
553
+ scan_result.edges = collection.edges
554
+ all_findings = scan_result.findings
555
+ except Exception as exc:
556
+ logger.error("Normalisation failed: %s", exc)
557
+ errors.append(f"normalisation: {exc}")
558
+ self._emit_error("normalisation", exc)
559
+
560
+ # Phase 5: Reports
561
+ self._emit_phase_start("reporting")
562
+ report_paths: dict[str, Path] = {}
563
+ if scan_result:
564
+ try:
565
+ from cloudg.graph.builder import GraphBuilder
566
+
567
+ builder = GraphBuilder()
568
+ builder.build(collection.assets, collection.edges)
569
+ graph_json = builder.to_d3_json()
570
+
571
+ from cloudg.renderers.json_export import JSONExporter
572
+
573
+ exporter = JSONExporter(output_dir=str(out))
574
+ report_paths["json"] = Path(exporter.export(scan_result, graph_json=graph_json))
575
+
576
+ from cloudg.renderers.html_report import HTMLReportGenerator
577
+
578
+ html_gen = HTMLReportGenerator(output_dir=str(out))
579
+ report_paths["html"] = Path(html_gen.generate(scan_result, graph_json=graph_json))
580
+ except Exception as exc:
581
+ logger.error("Report generation failed: %s", exc)
582
+ errors.append(f"reporting: {exc}")
583
+ self._emit_error("reporting", exc)
584
+
585
+ return PipelineResult(
586
+ assets=collection.assets,
587
+ edges=collection.edges,
588
+ findings=all_findings,
589
+ scan_result=scan_result,
590
+ graph_nodes=analysis.graph_nodes,
591
+ graph_edges=analysis.graph_edges,
592
+ ontology_triples=analysis.ontology_triples,
593
+ rag_chunks_path=analysis.rag_chunks_path,
594
+ terraform_paths=analysis.terraform_paths,
595
+ attack_paths=analysis.attack_paths,
596
+ providers_scanned=collection.providers_scanned,
597
+ regions_scanned=collection.regions_scanned,
598
+ coverage=collection.coverage,
599
+ report_paths=report_paths,
600
+ duration_ms=int((time.time() - start) * 1000),
601
+ errors=errors,
602
+ )
603
+
604
+ # ------------------------------------------------------------------
605
+ # Sync wrapper
606
+ # ------------------------------------------------------------------
607
+
608
+ def run_pipeline_sync(self, output_dir: str | Path = "./reports") -> PipelineResult:
609
+ """Synchronous wrapper for `run_pipeline()`.
610
+
611
+ Convenience for non-async callers (e.g. scripts, notebooks).
612
+ """
613
+ return asyncio.run(self.run_pipeline(output_dir))