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/ontology.py
ADDED
|
@@ -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
|
+
}
|