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