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
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))
|