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,914 @@
1
+ """Semantic ontology engine for cloud infrastructure.
2
+
3
+ Builds an RDF/OWL knowledge graph from CloudG assets, edges, and findings
4
+ using rdflib. Provides ~60 typed semantic relations across 7 domain groups,
5
+ SPARQL query interface, and multi-format export (Turtle, JSON-LD, RDF/XML).
6
+
7
+ The ontology is designed to be RAG-ready: every triple carries rich metadata
8
+ that downstream chunking and retrieval systems can exploit.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from enum import Enum
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from rdflib import BNode, Graph, Literal, Namespace, URIRef
19
+ from rdflib.namespace import OWL, RDF, RDFS, XSD
20
+
21
+ from cloudg.schema.models import (
22
+ AssetType,
23
+ CloudAsset,
24
+ EdgeType,
25
+ Finding,
26
+ NetworkEdge,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Namespaces
33
+ # ---------------------------------------------------------------------------
34
+
35
+ CM = Namespace("https://cloudg.io/ontology#")
36
+ CMR = Namespace("https://cloudg.io/resource/")
37
+ CMP = Namespace("https://cloudg.io/property/")
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Relation taxonomy — ~62 semantic relation types across 7 groups
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ class RelationGroup(str, Enum):
45
+ """Top-level grouping of relation types."""
46
+
47
+ NETWORK = "NETWORK"
48
+ CONTAINMENT = "CONTAINMENT"
49
+ IAM = "IAM"
50
+ DATA_FLOW = "DATA_FLOW"
51
+ SECURITY = "SECURITY"
52
+ COMPUTE = "COMPUTE"
53
+ GOVERNANCE = "GOVERNANCE"
54
+
55
+
56
+ class RelationType(str, Enum):
57
+ """Fine-grained semantic relation types for cloud asset mapping."""
58
+
59
+ # ── Network (15) ──
60
+ INGRESS_ALLOWED = "INGRESS_ALLOWED"
61
+ INGRESS_DENIED = "INGRESS_DENIED"
62
+ EGRESS_ALLOWED = "EGRESS_ALLOWED"
63
+ EGRESS_DENIED = "EGRESS_DENIED"
64
+ ONLY_HTTP = "ONLY_HTTP"
65
+ ONLY_HTTPS = "ONLY_HTTPS"
66
+ ONLY_SSH = "ONLY_SSH"
67
+ ONLY_RDP = "ONLY_RDP"
68
+ ALL_TRAFFIC = "ALL_TRAFFIC"
69
+ PORT_RESTRICTED = "PORT_RESTRICTED"
70
+ CIDR_RESTRICTED = "CIDR_RESTRICTED"
71
+ INTERNET_REACHABLE = "INTERNET_REACHABLE"
72
+ VPC_PEERED = "VPC_PEERED"
73
+ TRANSIT_ROUTED = "TRANSIT_ROUTED"
74
+ NAT_TRANSLATED = "NAT_TRANSLATED"
75
+ DNS_RESOLVED = "DNS_RESOLVED"
76
+
77
+ # ── Containment (7) ──
78
+ VPC_CONTAINS_SUBNET = "VPC_CONTAINS_SUBNET"
79
+ SUBNET_CONTAINS_INSTANCE = "SUBNET_CONTAINS_INSTANCE"
80
+ REGION_CONTAINS_VPC = "REGION_CONTAINS_VPC"
81
+ ACCOUNT_CONTAINS_REGION = "ACCOUNT_CONTAINS_REGION"
82
+ ORG_CONTAINS_ACCOUNT = "ORG_CONTAINS_ACCOUNT"
83
+ CLUSTER_CONTAINS_SERVICE = "CLUSTER_CONTAINS_SERVICE"
84
+ LB_TARGETS_INSTANCE = "LB_TARGETS_INSTANCE"
85
+
86
+ # ── IAM / Access (10) ──
87
+ ROLE_ASSUMES_ROLE = "ROLE_ASSUMES_ROLE"
88
+ USER_HAS_POLICY = "USER_HAS_POLICY"
89
+ ROLE_HAS_POLICY = "ROLE_HAS_POLICY"
90
+ GROUP_HAS_POLICY = "GROUP_HAS_POLICY"
91
+ POLICY_ALLOWS_ACTION = "POLICY_ALLOWS_ACTION"
92
+ POLICY_DENIES_ACTION = "POLICY_DENIES_ACTION"
93
+ CROSS_ACCOUNT_TRUST = "CROSS_ACCOUNT_TRUST"
94
+ SERVICE_LINKED_ROLE = "SERVICE_LINKED_ROLE"
95
+ PERMISSION_BOUNDARY_LIMITS = "PERMISSION_BOUNDARY_LIMITS"
96
+ SCP_RESTRICTS = "SCP_RESTRICTS"
97
+
98
+ # ── Data Flow (9) ──
99
+ READS_FROM = "READS_FROM"
100
+ WRITES_TO = "WRITES_TO"
101
+ ENCRYPTS_WITH = "ENCRYPTS_WITH"
102
+ DECRYPTS_WITH = "DECRYPTS_WITH"
103
+ LOGS_TO = "LOGS_TO"
104
+ STREAMS_TO = "STREAMS_TO"
105
+ REPLICATES_TO = "REPLICATES_TO"
106
+ BACKUP_TO = "BACKUP_TO"
107
+ CACHE_FOR = "CACHE_FOR"
108
+
109
+ # ── Security (9) ──
110
+ PROTECTED_BY_SG = "PROTECTED_BY_SG"
111
+ PROTECTED_BY_NACL = "PROTECTED_BY_NACL"
112
+ PROTECTED_BY_WAF = "PROTECTED_BY_WAF"
113
+ ENCRYPTED_BY_KMS = "ENCRYPTED_BY_KMS"
114
+ ROTATES_SECRET = "ROTATES_SECRET"
115
+ CERTIFICATE_SECURES = "CERTIFICATE_SECURES"
116
+ FINDING_AFFECTS = "FINDING_AFFECTS"
117
+ VULNERABILITY_EXPLOITS = "VULNERABILITY_EXPLOITS"
118
+ COMPLIANCE_GOVERNS = "COMPLIANCE_GOVERNS"
119
+
120
+ # ── Compute (8) ──
121
+ RUNS_ON = "RUNS_ON"
122
+ TRIGGERED_BY = "TRIGGERED_BY"
123
+ INVOKES = "INVOKES"
124
+ SCALES_WITH = "SCALES_WITH"
125
+ LOAD_BALANCED_BY = "LOAD_BALANCED_BY"
126
+ SCHEDULED_BY = "SCHEDULED_BY"
127
+ DEPENDS_ON = "DEPENDS_ON"
128
+ SERVES_TRAFFIC_TO = "SERVES_TRAFFIC_TO"
129
+
130
+ # ── Governance (4) ──
131
+ TAGGED_WITH = "TAGGED_WITH"
132
+ COST_ALLOCATED_TO = "COST_ALLOCATED_TO"
133
+ OWNED_BY = "OWNED_BY"
134
+ MONITORED_BY = "MONITORED_BY"
135
+
136
+
137
+ # Mapping from RelationType → RelationGroup
138
+ _RELATION_GROUPS: dict[RelationType, RelationGroup] = {}
139
+ _GROUP_RANGES = {
140
+ RelationGroup.NETWORK: [
141
+ RelationType.INGRESS_ALLOWED,
142
+ RelationType.INGRESS_DENIED,
143
+ RelationType.EGRESS_ALLOWED,
144
+ RelationType.EGRESS_DENIED,
145
+ RelationType.ONLY_HTTP,
146
+ RelationType.ONLY_HTTPS,
147
+ RelationType.ONLY_SSH,
148
+ RelationType.ONLY_RDP,
149
+ RelationType.ALL_TRAFFIC,
150
+ RelationType.PORT_RESTRICTED,
151
+ RelationType.CIDR_RESTRICTED,
152
+ RelationType.INTERNET_REACHABLE,
153
+ RelationType.VPC_PEERED,
154
+ RelationType.TRANSIT_ROUTED,
155
+ RelationType.NAT_TRANSLATED,
156
+ RelationType.DNS_RESOLVED,
157
+ ],
158
+ RelationGroup.CONTAINMENT: [
159
+ RelationType.VPC_CONTAINS_SUBNET,
160
+ RelationType.SUBNET_CONTAINS_INSTANCE,
161
+ RelationType.REGION_CONTAINS_VPC,
162
+ RelationType.ACCOUNT_CONTAINS_REGION,
163
+ RelationType.ORG_CONTAINS_ACCOUNT,
164
+ RelationType.CLUSTER_CONTAINS_SERVICE,
165
+ RelationType.LB_TARGETS_INSTANCE,
166
+ ],
167
+ RelationGroup.IAM: [
168
+ RelationType.ROLE_ASSUMES_ROLE,
169
+ RelationType.USER_HAS_POLICY,
170
+ RelationType.ROLE_HAS_POLICY,
171
+ RelationType.GROUP_HAS_POLICY,
172
+ RelationType.POLICY_ALLOWS_ACTION,
173
+ RelationType.POLICY_DENIES_ACTION,
174
+ RelationType.CROSS_ACCOUNT_TRUST,
175
+ RelationType.SERVICE_LINKED_ROLE,
176
+ RelationType.PERMISSION_BOUNDARY_LIMITS,
177
+ RelationType.SCP_RESTRICTS,
178
+ ],
179
+ RelationGroup.DATA_FLOW: [
180
+ RelationType.READS_FROM,
181
+ RelationType.WRITES_TO,
182
+ RelationType.ENCRYPTS_WITH,
183
+ RelationType.DECRYPTS_WITH,
184
+ RelationType.LOGS_TO,
185
+ RelationType.STREAMS_TO,
186
+ RelationType.REPLICATES_TO,
187
+ RelationType.BACKUP_TO,
188
+ RelationType.CACHE_FOR,
189
+ ],
190
+ RelationGroup.SECURITY: [
191
+ RelationType.PROTECTED_BY_SG,
192
+ RelationType.PROTECTED_BY_NACL,
193
+ RelationType.PROTECTED_BY_WAF,
194
+ RelationType.ENCRYPTED_BY_KMS,
195
+ RelationType.ROTATES_SECRET,
196
+ RelationType.CERTIFICATE_SECURES,
197
+ RelationType.FINDING_AFFECTS,
198
+ RelationType.VULNERABILITY_EXPLOITS,
199
+ RelationType.COMPLIANCE_GOVERNS,
200
+ ],
201
+ RelationGroup.COMPUTE: [
202
+ RelationType.RUNS_ON,
203
+ RelationType.TRIGGERED_BY,
204
+ RelationType.INVOKES,
205
+ RelationType.SCALES_WITH,
206
+ RelationType.LOAD_BALANCED_BY,
207
+ RelationType.SCHEDULED_BY,
208
+ RelationType.DEPENDS_ON,
209
+ RelationType.SERVES_TRAFFIC_TO,
210
+ ],
211
+ RelationGroup.GOVERNANCE: [
212
+ RelationType.TAGGED_WITH,
213
+ RelationType.COST_ALLOCATED_TO,
214
+ RelationType.OWNED_BY,
215
+ RelationType.MONITORED_BY,
216
+ ],
217
+ }
218
+ for _group, _types in _GROUP_RANGES.items():
219
+ for _rt in _types:
220
+ _RELATION_GROUPS[_rt] = _group
221
+
222
+
223
+ def get_relation_group(rt: RelationType) -> RelationGroup:
224
+ """Return the group a relation type belongs to."""
225
+ return _RELATION_GROUPS[rt]
226
+
227
+
228
+ def get_relations_for_group(group: RelationGroup) -> list[RelationType]:
229
+ """Return all relation types in a group."""
230
+ return list(_GROUP_RANGES.get(group, []))
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Relation inference from raw CloudG edges
235
+ # ---------------------------------------------------------------------------
236
+
237
+ # Port → protocol-specific relation mapping
238
+ _PORT_RELATIONS: dict[int, RelationType] = {
239
+ 80: RelationType.ONLY_HTTP,
240
+ 443: RelationType.ONLY_HTTPS,
241
+ 22: RelationType.ONLY_SSH,
242
+ 3389: RelationType.ONLY_RDP,
243
+ }
244
+
245
+ # AssetType → OWL class URI mapping
246
+ _ASSET_TYPE_CLASSES: dict[AssetType, str] = {
247
+ AssetType.EC2: "ComputeInstance",
248
+ AssetType.VIRTUAL_MACHINE: "ComputeInstance",
249
+ AssetType.GCE_INSTANCE: "ComputeInstance",
250
+ AssetType.LAMBDA_FUNCTION: "ServerlessFunction",
251
+ AssetType.CLOUD_FUNCTION: "ServerlessFunction",
252
+ AssetType.ECS_CLUSTER: "ContainerCluster",
253
+ AssetType.EKS_CLUSTER: "ContainerCluster",
254
+ AssetType.AKS_CLUSTER: "ContainerCluster",
255
+ AssetType.GKE_CLUSTER: "ContainerCluster",
256
+ AssetType.APP_SERVICE: "ManagedAppService",
257
+ AssetType.VPC: "VirtualNetwork",
258
+ AssetType.VNET: "VirtualNetwork",
259
+ AssetType.SUBNET: "Subnet",
260
+ AssetType.SECURITY_GROUP: "SecurityGroup",
261
+ AssetType.NSG: "SecurityGroup",
262
+ AssetType.NACL: "NetworkACL",
263
+ AssetType.ROUTE_TABLE: "RouteTable",
264
+ AssetType.INTERNET_GATEWAY: "InternetGateway",
265
+ AssetType.NAT_GATEWAY: "NATGateway",
266
+ AssetType.LOAD_BALANCER: "LoadBalancer",
267
+ AssetType.CLOUDFRONT: "CDNDistribution",
268
+ AssetType.CDN: "CDNDistribution",
269
+ AssetType.TRANSIT_GATEWAY: "TransitGateway",
270
+ AssetType.PEERING_CONNECTION: "PeeringConnection",
271
+ AssetType.ELASTIC_IP: "ElasticIP",
272
+ AssetType.S3_BUCKET: "ObjectStorage",
273
+ AssetType.BLOB_STORAGE: "ObjectStorage",
274
+ AssetType.GCS_BUCKET: "ObjectStorage",
275
+ AssetType.EBS_VOLUME: "BlockStorage",
276
+ AssetType.RDS_INSTANCE: "RelationalDatabase",
277
+ AssetType.AURORA_CLUSTER: "RelationalDatabase",
278
+ AssetType.AZURE_SQL: "RelationalDatabase",
279
+ AssetType.CLOUD_SQL: "RelationalDatabase",
280
+ AssetType.DYNAMODB_TABLE: "NoSQLDatabase",
281
+ AssetType.IAM_USER: "IAMUser",
282
+ AssetType.IAM_ROLE: "IAMRole",
283
+ AssetType.IAM_POLICY: "IAMPolicy",
284
+ AssetType.IAM_GROUP: "IAMGroup",
285
+ AssetType.SERVICE_PRINCIPAL: "ServicePrincipal",
286
+ AssetType.KMS_KEY: "EncryptionKey",
287
+ AssetType.SECRET: "Secret",
288
+ AssetType.CERTIFICATE: "Certificate",
289
+ AssetType.KEY_VAULT: "KeyVault",
290
+ AssetType.CLOUDTRAIL: "AuditLog",
291
+ AssetType.FLOW_LOG: "FlowLog",
292
+ AssetType.OTHER: "CloudResource",
293
+ }
294
+
295
+
296
+ def infer_relations(edge: NetworkEdge, assets_by_id: dict[str, CloudAsset]) -> list[RelationType]:
297
+ """Infer semantic relation types from a raw NetworkEdge.
298
+
299
+ Analyses CIDR, ports, protocol, direction, and connected asset types
300
+ to produce a list of semantic relations for the edge.
301
+ """
302
+ relations: list[RelationType] = []
303
+ cidr = edge.cidr or ""
304
+ ports = edge.ports or []
305
+ protocol = (edge.protocol or "").upper()
306
+ direction = (edge.direction or "ingress").lower()
307
+ edge_type = edge.edge_type
308
+
309
+ # --- Network relations ---
310
+ is_internet = cidr in ("0.0.0.0/0", "::/0")
311
+
312
+ if edge_type == EdgeType.SECURITY_GROUP_RULE:
313
+ if direction == "ingress":
314
+ relations.append(RelationType.INGRESS_ALLOWED)
315
+ else:
316
+ relations.append(RelationType.EGRESS_ALLOWED)
317
+
318
+ if is_internet:
319
+ relations.append(RelationType.INTERNET_REACHABLE)
320
+
321
+ # Port-specific relations
322
+ if len(ports) == 1:
323
+ port_rel = _PORT_RELATIONS.get(ports[0])
324
+ if port_rel:
325
+ relations.append(port_rel)
326
+ else:
327
+ relations.append(RelationType.PORT_RESTRICTED)
328
+ elif len(ports) > 1 and len(ports) <= 20:
329
+ # Specific port set (not wide open)
330
+ for p in ports:
331
+ port_rel = _PORT_RELATIONS.get(p)
332
+ if port_rel and port_rel not in relations:
333
+ relations.append(port_rel)
334
+ if not any(
335
+ r in relations
336
+ for r in [
337
+ RelationType.ONLY_HTTP,
338
+ RelationType.ONLY_HTTPS,
339
+ RelationType.ONLY_SSH,
340
+ RelationType.ONLY_RDP,
341
+ ]
342
+ ):
343
+ relations.append(RelationType.PORT_RESTRICTED)
344
+ elif protocol == "ALL" or (edge.port_range and edge.port_range == "0-65535"):
345
+ relations.append(RelationType.ALL_TRAFFIC)
346
+
347
+ if not is_internet and cidr:
348
+ relations.append(RelationType.CIDR_RESTRICTED)
349
+
350
+ elif edge_type == EdgeType.CONTAINS:
351
+ # Infer containment sub-type from asset types
352
+ src = assets_by_id.get(edge.source_id)
353
+ tgt = assets_by_id.get(edge.target_id)
354
+ if src and tgt:
355
+ if (
356
+ src.asset_type in (AssetType.VPC, AssetType.VNET)
357
+ and tgt.asset_type == AssetType.SUBNET
358
+ ):
359
+ relations.append(RelationType.VPC_CONTAINS_SUBNET)
360
+ elif src.asset_type == AssetType.SUBNET:
361
+ relations.append(RelationType.SUBNET_CONTAINS_INSTANCE)
362
+ elif src.asset_type in (
363
+ AssetType.ECS_CLUSTER,
364
+ AssetType.EKS_CLUSTER,
365
+ AssetType.AKS_CLUSTER,
366
+ AssetType.GKE_CLUSTER,
367
+ ):
368
+ relations.append(RelationType.CLUSTER_CONTAINS_SERVICE)
369
+ else:
370
+ relations.append(RelationType.VPC_CONTAINS_SUBNET) # generic containment
371
+
372
+ elif edge_type == EdgeType.IAM_TRUST:
373
+ relations.append(RelationType.ROLE_ASSUMES_ROLE)
374
+ # Check for cross-account trust
375
+ src = assets_by_id.get(edge.source_id)
376
+ tgt = assets_by_id.get(edge.target_id)
377
+ if src and tgt and src.account_id and tgt.account_id:
378
+ if src.account_id != tgt.account_id:
379
+ relations.append(RelationType.CROSS_ACCOUNT_TRUST)
380
+
381
+ elif edge_type == EdgeType.IAM_POLICY_ATTACHMENT:
382
+ tgt = assets_by_id.get(edge.target_id)
383
+ if tgt:
384
+ if tgt.asset_type == AssetType.IAM_USER:
385
+ relations.append(RelationType.USER_HAS_POLICY)
386
+ elif tgt.asset_type == AssetType.IAM_ROLE:
387
+ relations.append(RelationType.ROLE_HAS_POLICY)
388
+ elif tgt.asset_type == AssetType.IAM_GROUP:
389
+ relations.append(RelationType.GROUP_HAS_POLICY)
390
+
391
+ elif edge_type == EdgeType.LOAD_BALANCER_TARGET:
392
+ relations.append(RelationType.LB_TARGETS_INSTANCE)
393
+ relations.append(RelationType.LOAD_BALANCED_BY)
394
+
395
+ elif edge_type == EdgeType.PEERING:
396
+ relations.append(RelationType.VPC_PEERED)
397
+
398
+ elif edge_type == EdgeType.ROUTE:
399
+ relations.append(RelationType.TRANSIT_ROUTED)
400
+
401
+ elif edge_type == EdgeType.INTERNET_EXPOSED:
402
+ relations.append(RelationType.INTERNET_REACHABLE)
403
+
404
+ # --- Security relations from asset metadata ---
405
+ tgt_asset = assets_by_id.get(edge.target_id)
406
+ if tgt_asset:
407
+ # SG protection
408
+ if edge_type == EdgeType.SECURITY_GROUP_RULE:
409
+ relations.append(RelationType.PROTECTED_BY_SG)
410
+ elif edge_type == EdgeType.NACL_RULE:
411
+ relations.append(RelationType.PROTECTED_BY_NACL)
412
+
413
+ # KMS encryption (from metadata)
414
+ if tgt_asset.metadata.get("encryption") or tgt_asset.metadata.get("storage_encrypted"):
415
+ kms_key = tgt_asset.metadata.get("kms_key_id", "")
416
+ if kms_key:
417
+ relations.append(RelationType.ENCRYPTED_BY_KMS)
418
+
419
+ return relations
420
+
421
+
422
+ def infer_asset_relations(
423
+ asset: CloudAsset, all_assets: list[CloudAsset]
424
+ ) -> list[tuple[RelationType, str]]:
425
+ """Infer additional semantic relations from asset metadata alone.
426
+
427
+ Returns list of (RelationType, target_asset_id) tuples.
428
+ """
429
+ relations: list[tuple[RelationType, str]] = []
430
+
431
+ # Tag-based governance
432
+ if asset.tags:
433
+ for tag_key in asset.tags:
434
+ tag_lower = tag_key.lower()
435
+ if tag_lower in ("owner", "team", "department"):
436
+ relations.append((RelationType.OWNED_BY, f"tag:{asset.tags[tag_key]}"))
437
+ elif tag_lower in ("costcenter", "cost-center", "cost_center"):
438
+ relations.append((RelationType.COST_ALLOCATED_TO, f"tag:{asset.tags[tag_key]}"))
439
+ elif tag_lower in ("monitoring", "monitored-by"):
440
+ relations.append((RelationType.MONITORED_BY, f"tag:{asset.tags[tag_key]}"))
441
+
442
+ # VPC/Subnet containment from metadata
443
+ vpc_id = asset.metadata.get("vpc_id")
444
+ if vpc_id and asset.asset_type != AssetType.VPC:
445
+ for other in all_assets:
446
+ if (
447
+ other.asset_type in (AssetType.VPC, AssetType.VNET)
448
+ and other.metadata.get("vpc_id") == vpc_id
449
+ ):
450
+ if asset.asset_type == AssetType.SUBNET:
451
+ relations.append((RelationType.VPC_CONTAINS_SUBNET, other.id))
452
+ else:
453
+ relations.append((RelationType.SUBNET_CONTAINS_INSTANCE, other.id))
454
+ break
455
+
456
+ # Lambda VPC config → DEPENDS_ON VPC
457
+ if asset.asset_type == AssetType.LAMBDA_FUNCTION:
458
+ vpc_config = asset.metadata.get("vpc_config")
459
+ if vpc_config and isinstance(vpc_config, dict) and vpc_config.get("VpcId"):
460
+ relations.append((RelationType.DEPENDS_ON, f"vpc:{vpc_config['VpcId']}"))
461
+
462
+ # Secret rotation
463
+ if asset.asset_type == AssetType.SECRET:
464
+ if asset.metadata.get("rotation_enabled"):
465
+ relations.append((RelationType.ROTATES_SECRET, asset.id))
466
+
467
+ # KMS key rotation
468
+ if asset.asset_type == AssetType.KMS_KEY:
469
+ if asset.metadata.get("rotation_enabled"):
470
+ relations.append((RelationType.ROTATES_SECRET, asset.id))
471
+
472
+ # SG membership from EC2 metadata
473
+ if asset.asset_type == AssetType.EC2:
474
+ sg_ids = asset.metadata.get("security_groups", [])
475
+ for sg_id in sg_ids:
476
+ relations.append((RelationType.PROTECTED_BY_SG, f"sg:{sg_id}"))
477
+
478
+ return relations
479
+
480
+
481
+ # ---------------------------------------------------------------------------
482
+ # OWL Ontology Builder
483
+ # ---------------------------------------------------------------------------
484
+
485
+
486
+ class CloudOntology:
487
+ """Builds and queries a semantic RDF/OWL graph for cloud infrastructure.
488
+
489
+ Constructs OWL class hierarchy, typed object/data properties, and
490
+ individual instances from CloudG's Pydantic models.
491
+ """
492
+
493
+ def __init__(self) -> None:
494
+ self._graph = Graph()
495
+ self._graph.bind("cm", CM)
496
+ self._graph.bind("cmr", CMR)
497
+ self._graph.bind("cmp", CMP)
498
+ self._graph.bind("owl", OWL)
499
+ self._triple_count = 0
500
+
501
+ @property
502
+ def graph(self) -> Graph:
503
+ """Access the underlying rdflib Graph."""
504
+ return self._graph
505
+
506
+ @property
507
+ def triple_count(self) -> int:
508
+ return len(self._graph)
509
+
510
+ # ------------------------------------------------------------------
511
+ # Schema definition (TBox)
512
+ # ------------------------------------------------------------------
513
+
514
+ def _define_schema(self) -> None:
515
+ """Define the OWL class hierarchy and property declarations."""
516
+ g = self._graph
517
+
518
+ # Top-level classes
519
+ for cls_name in [
520
+ "CloudResource",
521
+ "ComputeInstance",
522
+ "ServerlessFunction",
523
+ "ContainerCluster",
524
+ "ManagedAppService",
525
+ "VirtualNetwork",
526
+ "Subnet",
527
+ "SecurityGroup",
528
+ "NetworkACL",
529
+ "RouteTable",
530
+ "InternetGateway",
531
+ "NATGateway",
532
+ "LoadBalancer",
533
+ "CDNDistribution",
534
+ "TransitGateway",
535
+ "PeeringConnection",
536
+ "ElasticIP",
537
+ "ObjectStorage",
538
+ "BlockStorage",
539
+ "RelationalDatabase",
540
+ "NoSQLDatabase",
541
+ "IAMUser",
542
+ "IAMRole",
543
+ "IAMPolicy",
544
+ "IAMGroup",
545
+ "ServicePrincipal",
546
+ "EncryptionKey",
547
+ "Secret",
548
+ "Certificate",
549
+ "KeyVault",
550
+ "AuditLog",
551
+ "FlowLog",
552
+ "SecurityFinding",
553
+ "ComplianceControl",
554
+ "Region",
555
+ "Account",
556
+ "Organization",
557
+ "TagValue",
558
+ ]:
559
+ cls_uri = CM[cls_name]
560
+ g.add((cls_uri, RDF.type, OWL.Class))
561
+ g.add((cls_uri, RDFS.label, Literal(cls_name)))
562
+
563
+ # Class hierarchy
564
+ hierarchy = {
565
+ "ComputeInstance": "CloudResource",
566
+ "ServerlessFunction": "CloudResource",
567
+ "ContainerCluster": "CloudResource",
568
+ "ManagedAppService": "CloudResource",
569
+ "VirtualNetwork": "CloudResource",
570
+ "Subnet": "CloudResource",
571
+ "SecurityGroup": "CloudResource",
572
+ "NetworkACL": "CloudResource",
573
+ "LoadBalancer": "CloudResource",
574
+ "CDNDistribution": "CloudResource",
575
+ "ObjectStorage": "CloudResource",
576
+ "BlockStorage": "CloudResource",
577
+ "RelationalDatabase": "CloudResource",
578
+ "NoSQLDatabase": "CloudResource",
579
+ "IAMUser": "CloudResource",
580
+ "IAMRole": "CloudResource",
581
+ "IAMPolicy": "CloudResource",
582
+ "EncryptionKey": "CloudResource",
583
+ "Secret": "CloudResource",
584
+ }
585
+ for child, parent in hierarchy.items():
586
+ g.add((CM[child], RDFS.subClassOf, CM[parent]))
587
+
588
+ # Object properties (relations)
589
+ for rt in RelationType:
590
+ prop_uri = CMP[rt.value]
591
+ g.add((prop_uri, RDF.type, OWL.ObjectProperty))
592
+ g.add((prop_uri, RDFS.label, Literal(rt.value.replace("_", " ").title())))
593
+ group = get_relation_group(rt)
594
+ g.add((prop_uri, CM["relationGroup"], Literal(group.value)))
595
+
596
+ # Data properties
597
+ for dp_name in [
598
+ "hasARN",
599
+ "hasName",
600
+ "hasRegion",
601
+ "hasProvider",
602
+ "hasAccountId",
603
+ "hasCIDR",
604
+ "hasPort",
605
+ "hasProtocol",
606
+ "hasSeverity",
607
+ "hasRiskScore",
608
+ "isInternetExposed",
609
+ ]:
610
+ dp_uri = CMP[dp_name]
611
+ g.add((dp_uri, RDF.type, OWL.DatatypeProperty))
612
+
613
+ # ------------------------------------------------------------------
614
+ # Instance population (ABox)
615
+ # ------------------------------------------------------------------
616
+
617
+ def build(
618
+ self,
619
+ assets: list[CloudAsset],
620
+ edges: list[NetworkEdge],
621
+ findings: list[Finding] | None = None,
622
+ ) -> Graph:
623
+ """Build the full ontology graph from CloudG data.
624
+
625
+ Args:
626
+ assets: Collected cloud assets.
627
+ edges: Network/relationship edges.
628
+ findings: Optional security findings.
629
+
630
+ Returns:
631
+ Populated rdflib Graph.
632
+ """
633
+ logger.info("Building ontology from %d assets, %d edges", len(assets), len(edges))
634
+
635
+ self._define_schema()
636
+ assets_by_id = {a.id: a for a in assets}
637
+
638
+ # Add asset individuals
639
+ for asset in assets:
640
+ self._add_asset(asset)
641
+
642
+ # Add edges with inferred semantic relations
643
+ for edge in edges:
644
+ self._add_edge(edge, assets_by_id)
645
+
646
+ # Add inferred asset-level relations
647
+ for asset in assets:
648
+ for rel_type, target_id in infer_asset_relations(asset, assets):
649
+ src_uri = CMR[asset.id]
650
+ tgt_uri = (
651
+ CMR[target_id]
652
+ if not target_id.startswith("tag:")
653
+ else CMR[target_id.replace(":", "_")]
654
+ )
655
+ self._graph.add((src_uri, CMP[rel_type.value], tgt_uri))
656
+
657
+ # Add findings
658
+ if findings:
659
+ for finding in findings:
660
+ self._add_finding(finding)
661
+
662
+ logger.info("Ontology built: %d triples", len(self._graph))
663
+ return self._graph
664
+
665
+ def _add_asset(self, asset: CloudAsset) -> None:
666
+ """Add a CloudAsset as an OWL individual."""
667
+ uri = CMR[asset.id]
668
+ owl_class = _ASSET_TYPE_CLASSES.get(asset.asset_type, "CloudResource")
669
+
670
+ self._graph.add((uri, RDF.type, CM[owl_class]))
671
+ self._graph.add((uri, CMP["hasName"], Literal(asset.name)))
672
+ self._graph.add((uri, CMP["hasProvider"], Literal(asset.provider.value)))
673
+ self._graph.add((uri, CMP["hasRegion"], Literal(asset.region)))
674
+ self._graph.add(
675
+ (
676
+ uri,
677
+ CMP["isInternetExposed"],
678
+ Literal(asset.is_internet_exposed, datatype=XSD.boolean),
679
+ )
680
+ )
681
+
682
+ if asset.arn:
683
+ self._graph.add((uri, CMP["hasARN"], Literal(asset.arn)))
684
+ if asset.account_id:
685
+ self._graph.add((uri, CMP["hasAccountId"], Literal(asset.account_id)))
686
+
687
+ # Tags as triples
688
+ for key, value in asset.tags.items():
689
+ tag_uri = CMR[f"tag_{key}_{value}".replace(" ", "_").replace("/", "_")]
690
+ self._graph.add((uri, CMP[RelationType.TAGGED_WITH.value], tag_uri))
691
+ self._graph.add((tag_uri, RDF.type, CM["TagValue"]))
692
+ self._graph.add((tag_uri, RDFS.label, Literal(f"{key}={value}")))
693
+
694
+ def _add_edge(self, edge: NetworkEdge, assets_by_id: dict[str, CloudAsset]) -> None:
695
+ """Add a NetworkEdge as typed semantic triples with metadata."""
696
+ src_uri = CMR[edge.source_id]
697
+ tgt_uri = CMR[edge.target_id]
698
+
699
+ # Ensure external nodes exist
700
+ if edge.source_id not in assets_by_id:
701
+ self._graph.add((src_uri, RDF.type, CM["CloudResource"]))
702
+ self._graph.add((src_uri, CMP["hasName"], Literal(edge.source_id)))
703
+ if edge.target_id not in assets_by_id:
704
+ self._graph.add((tgt_uri, RDF.type, CM["CloudResource"]))
705
+ self._graph.add((tgt_uri, CMP["hasName"], Literal(edge.target_id)))
706
+
707
+ # Infer semantic relations
708
+ inferred = infer_relations(edge, assets_by_id)
709
+
710
+ for rel in inferred:
711
+ self._graph.add((src_uri, CMP[rel.value], tgt_uri))
712
+
713
+ # Add edge metadata as reified statement (blank node)
714
+ if edge.cidr or edge.port_range or edge.protocol:
715
+ stmt = BNode()
716
+ self._graph.add((stmt, RDF.type, CM["EdgeMetadata"]))
717
+ self._graph.add((stmt, CM["fromNode"], src_uri))
718
+ self._graph.add((stmt, CM["toNode"], tgt_uri))
719
+ if edge.cidr:
720
+ self._graph.add((stmt, CMP["hasCIDR"], Literal(edge.cidr)))
721
+ if edge.port_range:
722
+ self._graph.add((stmt, CMP["hasPort"], Literal(edge.port_range)))
723
+ if edge.protocol:
724
+ self._graph.add((stmt, CMP["hasProtocol"], Literal(edge.protocol)))
725
+
726
+ def _add_finding(self, finding: Finding) -> None:
727
+ """Add a Finding as an OWL individual with relations."""
728
+ uri = CMR[f"finding_{finding.id}"]
729
+ self._graph.add((uri, RDF.type, CM["SecurityFinding"]))
730
+ self._graph.add((uri, CMP["hasName"], Literal(finding.title)))
731
+ self._graph.add((uri, CMP["hasSeverity"], Literal(finding.severity.value)))
732
+ self._graph.add((uri, CMP["hasRiskScore"], Literal(finding.risk_score, datatype=XSD.float)))
733
+
734
+ # Link to affected resource
735
+ resource_uri = CMR[finding.resource_id]
736
+ self._graph.add((uri, CMP[RelationType.FINDING_AFFECTS.value], resource_uri))
737
+
738
+ # Link to compliance frameworks
739
+ for fw in finding.compliance_frameworks:
740
+ fw_uri = CMR[f"compliance_{fw}"]
741
+ self._graph.add((fw_uri, RDF.type, CM["ComplianceControl"]))
742
+ self._graph.add((fw_uri, RDFS.label, Literal(fw)))
743
+ self._graph.add((fw_uri, CMP[RelationType.COMPLIANCE_GOVERNS.value], resource_uri))
744
+
745
+ # ------------------------------------------------------------------
746
+ # SPARQL queries
747
+ # ------------------------------------------------------------------
748
+
749
+ def query(self, sparql: str) -> list[dict[str, Any]]:
750
+ """Execute a SPARQL query and return results as list of dicts."""
751
+ results = []
752
+ for row in self._graph.query(sparql):
753
+ results.append({str(var): str(val) for var, val in zip(row.labels, row)})
754
+ return results
755
+
756
+ def query_internet_exposed(self) -> list[dict[str, str]]:
757
+ """Find all internet-reachable resources."""
758
+ sparql = """
759
+ SELECT ?resource ?name ?type WHERE {
760
+ ?source cmp:INTERNET_REACHABLE ?resource .
761
+ ?resource cmp:hasName ?name .
762
+ ?resource a ?type .
763
+ FILTER(?type != owl:NamedIndividual)
764
+ }
765
+ """
766
+ return self.query(sparql)
767
+
768
+ def query_by_relation_group(self, group: RelationGroup) -> list[dict[str, str]]:
769
+ """Find all triples belonging to a specific relation group."""
770
+ relation_types = get_relations_for_group(group)
771
+ if not relation_types:
772
+ return []
773
+
774
+ # Build UNION of all relation types in the group
775
+ patterns = []
776
+ for rt in relation_types:
777
+ patterns.append(f"{{ ?src cmp:{rt.value} ?tgt }}")
778
+ union = " UNION ".join(patterns)
779
+
780
+ sparql = f"""
781
+ SELECT ?src ?tgt ?srcName ?tgtName WHERE {{
782
+ {union}
783
+ OPTIONAL {{ ?src cmp:hasName ?srcName }}
784
+ OPTIONAL {{ ?tgt cmp:hasName ?tgtName }}
785
+ }}
786
+ """
787
+ return self.query(sparql)
788
+
789
+ def query_asset_neighbourhood(self, asset_id: str, hops: int = 1) -> list[dict[str, str]]:
790
+ """Find all resources within N hops of a given asset."""
791
+ # For 1-hop we query direct predicates; for N-hop we use property paths
792
+ if hops == 1:
793
+ sparql = f"""
794
+ SELECT ?predicate ?neighbour ?neighbourName WHERE {{
795
+ {{
796
+ cmr:{asset_id} ?predicate ?neighbour .
797
+ FILTER(STRSTARTS(STR(?predicate), STR(cmp:)))
798
+ }} UNION {{
799
+ ?neighbour ?predicate cmr:{asset_id} .
800
+ FILTER(STRSTARTS(STR(?predicate), STR(cmp:)))
801
+ }}
802
+ OPTIONAL {{ ?neighbour cmp:hasName ?neighbourName }}
803
+ }}
804
+ """
805
+ else:
806
+ # N-hop uses transitive path (up to hops length)
807
+ sparql = f"""
808
+ SELECT DISTINCT ?neighbour ?neighbourName WHERE {{
809
+ cmr:{asset_id} (cmp:INGRESS_ALLOWED|cmp:EGRESS_ALLOWED|cmp:CONTAINS|cmp:VPC_CONTAINS_SUBNET|cmp:SUBNET_CONTAINS_INSTANCE|cmp:INTERNET_REACHABLE|cmp:PROTECTED_BY_SG|cmp:LB_TARGETS_INSTANCE){{1,{hops}}} ?neighbour .
810
+ OPTIONAL {{ ?neighbour cmp:hasName ?neighbourName }}
811
+ }}
812
+ """
813
+ return self.query(sparql)
814
+
815
+ def query_compliance_gaps(self) -> list[dict[str, str]]:
816
+ """Find resources affected by findings with compliance frameworks."""
817
+ sparql = """
818
+ SELECT ?finding ?severity ?resource ?resourceName ?framework WHERE {
819
+ ?finding a cm:SecurityFinding .
820
+ ?finding cmp:hasSeverity ?severity .
821
+ ?finding cmp:FINDING_AFFECTS ?resource .
822
+ ?framework cmp:COMPLIANCE_GOVERNS ?resource .
823
+ ?framework rdfs:label ?frameworkName .
824
+ OPTIONAL { ?resource cmp:hasName ?resourceName }
825
+ }
826
+ """
827
+ return self.query(sparql)
828
+
829
+ # ------------------------------------------------------------------
830
+ # Export
831
+ # ------------------------------------------------------------------
832
+
833
+ def to_turtle(self) -> str:
834
+ """Serialize graph to Turtle format."""
835
+ return self._graph.serialize(format="turtle")
836
+
837
+ def to_jsonld(self) -> str:
838
+ """Serialize graph to JSON-LD format."""
839
+ return self._graph.serialize(format="json-ld")
840
+
841
+ def to_rdfxml(self) -> str:
842
+ """Serialize graph to RDF/XML format."""
843
+ return self._graph.serialize(format="xml")
844
+
845
+ def to_ntriples(self) -> str:
846
+ """Serialize graph to N-Triples format."""
847
+ return self._graph.serialize(format="nt")
848
+
849
+ def save(self, path: str | Path, fmt: str = "turtle") -> Path:
850
+ """Save ontology to file.
851
+
852
+ Args:
853
+ path: Output file path.
854
+ fmt: Serialization format (turtle, json-ld, xml, nt).
855
+
856
+ Returns:
857
+ Path to saved file.
858
+ """
859
+ output = Path(path)
860
+ output.parent.mkdir(parents=True, exist_ok=True)
861
+
862
+ format_map = {
863
+ "turtle": "turtle",
864
+ "ttl": "turtle",
865
+ "json-ld": "json-ld",
866
+ "jsonld": "json-ld",
867
+ "xml": "xml",
868
+ "rdfxml": "xml",
869
+ "nt": "nt",
870
+ "ntriples": "nt",
871
+ }
872
+ rdflib_fmt = format_map.get(fmt.lower(), "turtle")
873
+
874
+ self._graph.serialize(destination=str(output), format=rdflib_fmt)
875
+ logger.info("Saved ontology (%d triples) to %s as %s", len(self._graph), output, rdflib_fmt)
876
+ return output
877
+
878
+ # ------------------------------------------------------------------
879
+ # Statistics
880
+ # ------------------------------------------------------------------
881
+
882
+ def stats(self) -> dict[str, Any]:
883
+ """Return ontology statistics."""
884
+ classes = set()
885
+ individuals = set()
886
+ relation_counts: dict[str, int] = {}
887
+
888
+ for s, p, o in self._graph:
889
+ if p == RDF.type and isinstance(o, URIRef) and str(o).startswith(str(CM)):
890
+ cls_name = str(o).replace(str(CM), "")
891
+ classes.add(cls_name)
892
+ individuals.add(str(s))
893
+
894
+ if isinstance(p, URIRef) and str(p).startswith(str(CMP)):
895
+ prop_name = str(p).replace(str(CMP), "")
896
+ relation_counts[prop_name] = relation_counts.get(prop_name, 0) + 1
897
+
898
+ # Group relation counts
899
+ group_counts: dict[str, int] = {}
900
+ for rt_name, count in relation_counts.items():
901
+ try:
902
+ rt = RelationType(rt_name)
903
+ grp = get_relation_group(rt).value
904
+ group_counts[grp] = group_counts.get(grp, 0) + count
905
+ except ValueError:
906
+ pass # Data properties, not relations
907
+
908
+ return {
909
+ "total_triples": len(self._graph),
910
+ "classes_used": len(classes),
911
+ "individuals": len(individuals),
912
+ "relation_type_counts": relation_counts,
913
+ "relation_group_counts": group_counts,
914
+ }