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/graph/builder.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""NetworkX graph construction from cloud assets and edges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import networkx as nx
|
|
11
|
+
|
|
12
|
+
from cloudg.schema.models import CloudAsset, NetworkEdge
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GraphBuilder:
|
|
18
|
+
"""Builds a NetworkX directed graph from collected cloud assets and edges.
|
|
19
|
+
|
|
20
|
+
Nodes represent cloud resources with full CloudAsset data as attributes.
|
|
21
|
+
Edges represent network connectivity, IAM trust, or containment relationships.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._graph = nx.DiGraph()
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def graph(self) -> nx.DiGraph:
|
|
29
|
+
"""Access the underlying NetworkX graph."""
|
|
30
|
+
return self._graph
|
|
31
|
+
|
|
32
|
+
def build(
|
|
33
|
+
self,
|
|
34
|
+
assets: list[CloudAsset],
|
|
35
|
+
edges: list[NetworkEdge],
|
|
36
|
+
) -> nx.DiGraph:
|
|
37
|
+
"""Build the graph from assets and edges.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
assets: List of normalised cloud assets (nodes).
|
|
41
|
+
edges: List of network/IAM edges.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Populated NetworkX DiGraph.
|
|
45
|
+
"""
|
|
46
|
+
self._graph.clear()
|
|
47
|
+
|
|
48
|
+
if len(assets) > 10000:
|
|
49
|
+
logger.warning(
|
|
50
|
+
"Large graph: %d assets. Consider filtering by region or service "
|
|
51
|
+
"to reduce memory usage.",
|
|
52
|
+
len(assets),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Add asset nodes
|
|
56
|
+
for asset in assets:
|
|
57
|
+
self._graph.add_node(
|
|
58
|
+
asset.id,
|
|
59
|
+
name=asset.name,
|
|
60
|
+
asset_type=asset.asset_type.value,
|
|
61
|
+
provider=asset.provider.value,
|
|
62
|
+
region=asset.region,
|
|
63
|
+
arn=asset.arn,
|
|
64
|
+
tags=json.dumps(asset.tags) if asset.tags else "{}",
|
|
65
|
+
is_internet_exposed=asset.is_internet_exposed,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Add edges
|
|
69
|
+
for edge in edges:
|
|
70
|
+
# Ensure source/target nodes exist (create placeholder if needed)
|
|
71
|
+
if edge.source_id not in self._graph:
|
|
72
|
+
self._graph.add_node(
|
|
73
|
+
edge.source_id,
|
|
74
|
+
name=edge.source_id,
|
|
75
|
+
asset_type="EXTERNAL",
|
|
76
|
+
provider="EXTERNAL",
|
|
77
|
+
is_external=True,
|
|
78
|
+
)
|
|
79
|
+
if edge.target_id not in self._graph:
|
|
80
|
+
self._graph.add_node(
|
|
81
|
+
edge.target_id,
|
|
82
|
+
name=edge.target_id,
|
|
83
|
+
asset_type="EXTERNAL",
|
|
84
|
+
provider="EXTERNAL",
|
|
85
|
+
is_external=True,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
self._graph.add_edge(
|
|
89
|
+
edge.source_id,
|
|
90
|
+
edge.target_id,
|
|
91
|
+
edge_type=edge.edge_type.value,
|
|
92
|
+
port_range=edge.port_range or "",
|
|
93
|
+
protocol=edge.protocol or "",
|
|
94
|
+
cidr=edge.cidr or "",
|
|
95
|
+
direction=edge.direction or "",
|
|
96
|
+
description=edge.description or "",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
logger.info(
|
|
100
|
+
"Built graph with %d nodes and %d edges",
|
|
101
|
+
self._graph.number_of_nodes(),
|
|
102
|
+
self._graph.number_of_edges(),
|
|
103
|
+
)
|
|
104
|
+
return self._graph
|
|
105
|
+
|
|
106
|
+
def compute_centrality(self) -> dict[str, dict[str, float]]:
|
|
107
|
+
"""Compute graph centrality metrics for blast-radius scoring.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Dict mapping node_id -> {degree, betweenness, in_degree, out_degree}
|
|
111
|
+
"""
|
|
112
|
+
metrics: dict[str, dict[str, float]] = {}
|
|
113
|
+
|
|
114
|
+
degree_centrality = nx.degree_centrality(self._graph)
|
|
115
|
+
in_degree = nx.in_degree_centrality(self._graph)
|
|
116
|
+
out_degree = nx.out_degree_centrality(self._graph)
|
|
117
|
+
|
|
118
|
+
# Betweenness only makes sense for connected graphs
|
|
119
|
+
try:
|
|
120
|
+
betweenness = nx.betweenness_centrality(self._graph)
|
|
121
|
+
except Exception:
|
|
122
|
+
betweenness = {n: 0.0 for n in self._graph.nodes()}
|
|
123
|
+
|
|
124
|
+
for node in self._graph.nodes():
|
|
125
|
+
metrics[node] = {
|
|
126
|
+
"degree": degree_centrality.get(node, 0.0),
|
|
127
|
+
"betweenness": betweenness.get(node, 0.0),
|
|
128
|
+
"in_degree": in_degree.get(node, 0.0),
|
|
129
|
+
"out_degree": out_degree.get(node, 0.0),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return metrics
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Graph persistence
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def save_graphml(self, path: str | Path) -> Path:
|
|
139
|
+
"""Save graph to GraphML format for persistence/offline analysis.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
path: Output file path.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Path to saved file.
|
|
146
|
+
"""
|
|
147
|
+
output = Path(path)
|
|
148
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
|
|
150
|
+
# Patch numpy 2.0 compatibility (np.float_ removed)
|
|
151
|
+
try:
|
|
152
|
+
import numpy as np
|
|
153
|
+
|
|
154
|
+
if not hasattr(np, "float_"):
|
|
155
|
+
np.float_ = np.float64 # type: ignore[attr-defined]
|
|
156
|
+
except ImportError:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
nx.write_graphml_xml(self._graph, str(output))
|
|
160
|
+
logger.info("Saved graph to %s", output)
|
|
161
|
+
return output
|
|
162
|
+
|
|
163
|
+
def load_graphml(self, path: str | Path) -> nx.DiGraph:
|
|
164
|
+
"""Load graph from GraphML file.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
path: Path to GraphML file.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Loaded DiGraph.
|
|
171
|
+
"""
|
|
172
|
+
self._graph = nx.read_graphml(str(path))
|
|
173
|
+
logger.info(
|
|
174
|
+
"Loaded graph from %s (%d nodes, %d edges)",
|
|
175
|
+
path,
|
|
176
|
+
self._graph.number_of_nodes(),
|
|
177
|
+
self._graph.number_of_edges(),
|
|
178
|
+
)
|
|
179
|
+
return self._graph
|
|
180
|
+
|
|
181
|
+
def subgraph(self, node_ids: set[str]) -> nx.DiGraph:
|
|
182
|
+
"""Extract a subgraph containing only the specified nodes.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
node_ids: Set of node IDs to include.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Subgraph as new DiGraph.
|
|
189
|
+
"""
|
|
190
|
+
return self._graph.subgraph(node_ids).copy()
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
# Attack path analysis
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def find_attack_paths(self, source: str, target: str, max_depth: int = 10) -> list[list[str]]:
|
|
197
|
+
"""Find all simple paths between source and target nodes.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
source: Source node ID (e.g., internet entry point).
|
|
201
|
+
target: Target node ID (e.g., database).
|
|
202
|
+
max_depth: Maximum path length.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
List of paths (each path is a list of node IDs).
|
|
206
|
+
"""
|
|
207
|
+
if source not in self._graph or target not in self._graph:
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
paths = list(nx.all_simple_paths(self._graph, source, target, cutoff=max_depth))
|
|
212
|
+
logger.info("Found %d attack paths from %s to %s", len(paths), source, target)
|
|
213
|
+
return paths
|
|
214
|
+
except nx.NetworkXError as exc:
|
|
215
|
+
logger.warning("Attack path search failed: %s", exc)
|
|
216
|
+
return []
|
|
217
|
+
|
|
218
|
+
def find_lateral_movement_paths(self) -> list[list[str]]:
|
|
219
|
+
"""Find paths that traverse IAM trust edges.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
List of paths that include at least one IAM_TRUST edge.
|
|
223
|
+
"""
|
|
224
|
+
# Find IAM trust edges
|
|
225
|
+
iam_edges = [
|
|
226
|
+
(u, v) for u, v, d in self._graph.edges(data=True) if d.get("edge_type") == "IAM_TRUST"
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
if not iam_edges:
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
paths: list[list[str]] = []
|
|
233
|
+
# Find internet-exposed entry points
|
|
234
|
+
entry_points = [
|
|
235
|
+
n
|
|
236
|
+
for n, d in self._graph.nodes(data=True)
|
|
237
|
+
if d.get("is_internet_exposed") or d.get("is_external")
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
# For each IAM trust target, check if reachable from internet
|
|
241
|
+
for _, trust_target in iam_edges:
|
|
242
|
+
for entry in entry_points:
|
|
243
|
+
try:
|
|
244
|
+
for path in nx.all_simple_paths(self._graph, entry, trust_target, cutoff=8):
|
|
245
|
+
paths.append(path)
|
|
246
|
+
if len(paths) > 100: # Cap to prevent combinatorial explosion
|
|
247
|
+
break
|
|
248
|
+
except nx.NetworkXError:
|
|
249
|
+
continue
|
|
250
|
+
if len(paths) > 100:
|
|
251
|
+
break
|
|
252
|
+
|
|
253
|
+
logger.info("Found %d lateral movement paths", len(paths))
|
|
254
|
+
return paths
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
# Export formats
|
|
258
|
+
# ------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def to_d3_json(self) -> dict[str, Any]:
|
|
261
|
+
"""Export graph to D3.js-compatible JSON format.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Dict with 'nodes' and 'links' arrays for D3.js force-directed graph.
|
|
265
|
+
"""
|
|
266
|
+
nodes = []
|
|
267
|
+
for node_id, data in self._graph.nodes(data=True):
|
|
268
|
+
nodes.append(
|
|
269
|
+
{
|
|
270
|
+
"id": node_id,
|
|
271
|
+
"name": data.get("name", node_id),
|
|
272
|
+
"type": data.get("asset_type", "UNKNOWN"),
|
|
273
|
+
"provider": data.get("provider", "UNKNOWN"),
|
|
274
|
+
"region": data.get("region", ""),
|
|
275
|
+
"arn": data.get("arn", ""),
|
|
276
|
+
"is_internet_exposed": data.get("is_internet_exposed", False),
|
|
277
|
+
"is_external": data.get("is_external", False),
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
links = []
|
|
282
|
+
for source, target, data in self._graph.edges(data=True):
|
|
283
|
+
links.append(
|
|
284
|
+
{
|
|
285
|
+
"source": source,
|
|
286
|
+
"target": target,
|
|
287
|
+
"type": data.get("edge_type", "UNKNOWN"),
|
|
288
|
+
"port_range": data.get("port_range"),
|
|
289
|
+
"protocol": data.get("protocol"),
|
|
290
|
+
"cidr": data.get("cidr"),
|
|
291
|
+
"direction": data.get("direction"),
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return {"nodes": nodes, "links": links}
|
|
296
|
+
|
|
297
|
+
def to_cytoscape_json(self) -> dict[str, Any]:
|
|
298
|
+
"""Export graph to Cytoscape.js-compatible JSON format.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Dict with 'elements' containing 'nodes' and 'edges' arrays.
|
|
302
|
+
"""
|
|
303
|
+
elements: dict[str, list[dict[str, Any]]] = {"nodes": [], "edges": []}
|
|
304
|
+
|
|
305
|
+
for node_id, data in self._graph.nodes(data=True):
|
|
306
|
+
elements["nodes"].append(
|
|
307
|
+
{
|
|
308
|
+
"data": {
|
|
309
|
+
"id": node_id,
|
|
310
|
+
"label": data.get("name", node_id),
|
|
311
|
+
"type": data.get("asset_type", "UNKNOWN"),
|
|
312
|
+
"provider": data.get("provider", "UNKNOWN"),
|
|
313
|
+
"region": data.get("region", ""),
|
|
314
|
+
"internet_exposed": data.get("is_internet_exposed", False),
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
for source, target, data in self._graph.edges(data=True):
|
|
320
|
+
elements["edges"].append(
|
|
321
|
+
{
|
|
322
|
+
"data": {
|
|
323
|
+
"source": source,
|
|
324
|
+
"target": target,
|
|
325
|
+
"type": data.get("edge_type", "UNKNOWN"),
|
|
326
|
+
"port_range": data.get("port_range", ""),
|
|
327
|
+
"protocol": data.get("protocol", ""),
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return {"elements": elements}
|
|
333
|
+
|
|
334
|
+
def to_json_str(self, indent: int = 2) -> str:
|
|
335
|
+
"""Serialize graph to JSON string."""
|
|
336
|
+
return json.dumps(self.to_d3_json(), indent=indent, default=str)
|