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,339 @@
1
+ """Trivy container and CVE scanner wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import shutil
8
+ import subprocess
9
+ from typing import Any
10
+
11
+ from cloudg.schema.models import Finding, Severity
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _SEVERITY_MAP = {
16
+ "CRITICAL": Severity.CRITICAL,
17
+ "HIGH": Severity.HIGH,
18
+ "MEDIUM": Severity.MEDIUM,
19
+ "LOW": Severity.LOW,
20
+ "UNKNOWN": Severity.INFO,
21
+ }
22
+
23
+
24
+ class TrivyScanner:
25
+ """Wraps Trivy CLI for container image CVE and secret scanning.
26
+
27
+ Scans container images from ECR/ACR/GCR and maps CVEs to findings.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ extra_args: list[str] | None = None,
33
+ ) -> None:
34
+ self._extra_args = extra_args or []
35
+
36
+ @staticmethod
37
+ def is_available() -> bool:
38
+ """Check if Trivy is installed."""
39
+ return shutil.which("trivy") is not None
40
+
41
+ def scan_image(self, image: str) -> list[Finding]:
42
+ """Scan a single container image for CVEs.
43
+
44
+ Args:
45
+ image: Full image reference (e.g. 123456.dkr.ecr.us-east-1.amazonaws.com/app:latest).
46
+
47
+ Returns:
48
+ List of Finding objects for discovered CVEs.
49
+ """
50
+ if not self.is_available():
51
+ logger.warning(
52
+ "Trivy is not installed. Install from: https://trivy.dev/. Skipping Trivy scan."
53
+ )
54
+ return []
55
+
56
+ cmd = [
57
+ "trivy",
58
+ "image",
59
+ "--format",
60
+ "json",
61
+ "--quiet",
62
+ *self._extra_args,
63
+ image,
64
+ ]
65
+
66
+ logger.info("Running Trivy: %s", " ".join(cmd))
67
+
68
+ try:
69
+ result = subprocess.run(
70
+ cmd,
71
+ capture_output=True,
72
+ text=True,
73
+ timeout=1800,
74
+ check=False,
75
+ )
76
+ except subprocess.TimeoutExpired:
77
+ logger.error("Trivy scan timed out for image %s", image)
78
+ return []
79
+ except Exception as exc:
80
+ logger.error("Failed to run Trivy for %s: %s", image, exc)
81
+ return []
82
+
83
+ return self._parse_output(result.stdout, image)
84
+
85
+ def scan_images(self, images: list[str]) -> list[Finding]:
86
+ """Scan multiple container images.
87
+
88
+ Args:
89
+ images: List of image references.
90
+
91
+ Returns:
92
+ Combined list of Finding objects.
93
+ """
94
+ all_findings: list[Finding] = []
95
+ for image in images:
96
+ all_findings.extend(self.scan_image(image))
97
+ return all_findings
98
+
99
+ def scan_filesystem(self, directories: list[str]) -> list[Finding]:
100
+ """Scan filesystem directories for misconfigurations and vulnerabilities.
101
+
102
+ Uses 'trivy fs' to scan IaC files, lock files, and other artifacts
103
+ without requiring container images.
104
+
105
+ Args:
106
+ directories: List of directory paths to scan.
107
+
108
+ Returns:
109
+ Combined list of Finding objects.
110
+ """
111
+ if not self.is_available():
112
+ logger.warning(
113
+ "Trivy is not installed. Install from: https://trivy.dev/. "
114
+ "Skipping Trivy filesystem scan."
115
+ )
116
+ return []
117
+
118
+ all_findings: list[Finding] = []
119
+ for directory in directories:
120
+ cmd = [
121
+ "trivy",
122
+ "fs",
123
+ "--format",
124
+ "json",
125
+ "--quiet",
126
+ "--scanners",
127
+ "vuln,misconfig,secret",
128
+ *self._extra_args,
129
+ directory,
130
+ ]
131
+
132
+ logger.info("Running Trivy filesystem scan: %s", " ".join(cmd))
133
+
134
+ try:
135
+ result = subprocess.run(
136
+ cmd,
137
+ capture_output=True,
138
+ text=True,
139
+ timeout=1800,
140
+ check=False,
141
+ )
142
+ except subprocess.TimeoutExpired:
143
+ logger.error("Trivy filesystem scan timed out for %s", directory)
144
+ continue
145
+ except Exception as exc:
146
+ logger.error("Failed to run Trivy fs for %s: %s", directory, exc)
147
+ continue
148
+
149
+ findings = self._parse_fs_output(result.stdout, directory)
150
+ all_findings.extend(findings)
151
+
152
+ return all_findings
153
+
154
+ def _parse_fs_output(self, stdout: str, directory: str) -> list[Finding]:
155
+ """Parse Trivy filesystem scan JSON output."""
156
+ findings: list[Finding] = []
157
+
158
+ if not stdout.strip():
159
+ logger.warning("Trivy fs produced no output for %s", directory)
160
+ return []
161
+
162
+ try:
163
+ data = json.loads(stdout)
164
+ except json.JSONDecodeError as exc:
165
+ logger.warning("Failed to parse Trivy fs JSON: %s", exc)
166
+ return []
167
+
168
+ results = data.get("Results", [])
169
+ for result in results:
170
+ target = result.get("Target", "")
171
+ target_type = result.get("Type", "")
172
+
173
+ # Vulnerabilities (from lock files, package manifests)
174
+ for vuln in result.get("Vulnerabilities", []):
175
+ finding = self._parse_vulnerability(vuln, directory, target, target_type)
176
+ if finding:
177
+ findings.append(finding)
178
+
179
+ # Misconfigurations (from IaC files)
180
+ for misconfig in result.get("Misconfigurations", []):
181
+ finding = self._parse_misconfig(misconfig, directory, target)
182
+ if finding:
183
+ findings.append(finding)
184
+
185
+ # Secret findings
186
+ for secret in result.get("Secrets", []):
187
+ finding = self._parse_secret(secret, directory, target)
188
+ if finding:
189
+ findings.append(finding)
190
+
191
+ logger.info("Parsed %d findings from Trivy fs for %s", len(findings), directory)
192
+ return findings
193
+
194
+ def _parse_misconfig(
195
+ self,
196
+ misconfig: dict[str, Any],
197
+ directory: str,
198
+ target: str,
199
+ ) -> Finding | None:
200
+ """Parse a single Trivy misconfiguration finding."""
201
+ try:
202
+ severity_str = misconfig.get("Severity", "UNKNOWN").upper()
203
+ severity = _SEVERITY_MAP.get(severity_str, Severity.MEDIUM)
204
+
205
+ misconfig_id = misconfig.get("ID", "")
206
+ title = misconfig.get("Title", "")
207
+ description = misconfig.get("Description", "")
208
+ if len(description) > 500:
209
+ description = description[:500] + "..."
210
+
211
+ resolution = misconfig.get("Resolution", "")
212
+ primary_url = misconfig.get("PrimaryURL", "")
213
+
214
+ return Finding(
215
+ resource_id=directory,
216
+ resource_arn=directory,
217
+ severity=severity,
218
+ title=f"[Trivy/IaC] {misconfig_id}: {title}",
219
+ description=description,
220
+ evidence=f"Directory: {directory}, Target: {target}",
221
+ remediation=resolution
222
+ if resolution
223
+ else primary_url or f"See Trivy check {misconfig_id}",
224
+ source_tool="trivy",
225
+ source_finding_id=misconfig_id,
226
+ compliance_frameworks=["CIS"],
227
+ )
228
+ except Exception as exc:
229
+ logger.debug("Failed to parse Trivy misconfiguration: %s", exc)
230
+ return None
231
+
232
+ def _parse_output(self, stdout: str, image: str) -> list[Finding]:
233
+ """Parse Trivy JSON output."""
234
+ findings: list[Finding] = []
235
+
236
+ if not stdout.strip():
237
+ return []
238
+
239
+ try:
240
+ data = json.loads(stdout)
241
+ except json.JSONDecodeError as exc:
242
+ logger.warning("Failed to parse Trivy JSON: %s", exc)
243
+ return []
244
+
245
+ results = data.get("Results", [])
246
+ for result in results:
247
+ target = result.get("Target", "")
248
+ target_type = result.get("Type", "")
249
+
250
+ # CVE vulnerabilities
251
+ for vuln in result.get("Vulnerabilities", []):
252
+ finding = self._parse_vulnerability(vuln, image, target, target_type)
253
+ if finding:
254
+ findings.append(finding)
255
+
256
+ # Secret findings
257
+ for secret in result.get("Secrets", []):
258
+ finding = self._parse_secret(secret, image, target)
259
+ if finding:
260
+ findings.append(finding)
261
+
262
+ logger.info("Parsed %d findings from Trivy for %s", len(findings), image)
263
+ return findings
264
+
265
+ def _parse_vulnerability(
266
+ self,
267
+ vuln: dict[str, Any],
268
+ image: str,
269
+ target: str,
270
+ target_type: str,
271
+ ) -> Finding | None:
272
+ """Parse a single Trivy CVE vulnerability."""
273
+ try:
274
+ severity_str = vuln.get("Severity", "UNKNOWN").upper()
275
+ severity = _SEVERITY_MAP.get(severity_str, Severity.MEDIUM)
276
+
277
+ cve_id = vuln.get("VulnerabilityID", "")
278
+ pkg_name = vuln.get("PkgName", "")
279
+ installed_version = vuln.get("InstalledVersion", "")
280
+ fixed_version = vuln.get("FixedVersion", "")
281
+
282
+ description = vuln.get("Description", "")
283
+ if len(description) > 500:
284
+ description = description[:500] + "..."
285
+
286
+ cvss_score = None
287
+ cvss_data = vuln.get("CVSS", {})
288
+ for source in cvss_data.values():
289
+ if isinstance(source, dict) and "V3Score" in source:
290
+ cvss_score = source["V3Score"]
291
+ break
292
+
293
+ remediation = ""
294
+ if fixed_version:
295
+ remediation = f"Update {pkg_name} from {installed_version} to {fixed_version}"
296
+ else:
297
+ remediation = (
298
+ f"No fix available for {cve_id} in {pkg_name}. Consider alternative packages."
299
+ )
300
+
301
+ return Finding(
302
+ resource_id=image,
303
+ resource_arn=image,
304
+ severity=severity,
305
+ title=f"[Trivy] {cve_id}: {pkg_name} ({target_type})",
306
+ description=description,
307
+ evidence=f"Image: {image}, Target: {target}, Package: {pkg_name}@{installed_version}",
308
+ remediation=remediation,
309
+ source_tool="trivy",
310
+ source_finding_id=cve_id,
311
+ cvss_score=cvss_score,
312
+ compliance_frameworks=["CVE"],
313
+ )
314
+ except Exception as exc:
315
+ logger.debug("Failed to parse Trivy vulnerability: %s", exc)
316
+ return None
317
+
318
+ def _parse_secret(
319
+ self,
320
+ secret: dict[str, Any],
321
+ image: str,
322
+ target: str,
323
+ ) -> Finding | None:
324
+ """Parse a Trivy secret finding."""
325
+ try:
326
+ return Finding(
327
+ resource_id=image,
328
+ resource_arn=image,
329
+ severity=Severity.HIGH,
330
+ title=f"[Trivy] Secret found in container image: {secret.get('RuleID', 'unknown')}",
331
+ description=f"Category: {secret.get('Category', '')}, Title: {secret.get('Title', '')}",
332
+ evidence=f"Image: {image}, Target: {target}, Match: {secret.get('Match', '')[:200]}",
333
+ remediation="Remove secrets from container images. Use secret management services (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager).",
334
+ source_tool="trivy",
335
+ source_finding_id=secret.get("RuleID", ""),
336
+ )
337
+ except Exception as exc:
338
+ logger.debug("Failed to parse Trivy secret: %s", exc)
339
+ return None
@@ -0,0 +1,25 @@
1
+ """Schema models for cloud asset normalisation."""
2
+
3
+ from cloudg.schema.models import (
4
+ AssetType,
5
+ CloudAsset,
6
+ CloudProvider,
7
+ ComplianceResult,
8
+ ComplianceStatus,
9
+ Finding,
10
+ NetworkEdge,
11
+ ScanResult,
12
+ Severity,
13
+ )
14
+
15
+ __all__ = [
16
+ "AssetType",
17
+ "CloudAsset",
18
+ "CloudProvider",
19
+ "ComplianceResult",
20
+ "ComplianceStatus",
21
+ "Finding",
22
+ "NetworkEdge",
23
+ "ScanResult",
24
+ "Severity",
25
+ ]
@@ -0,0 +1,260 @@
1
+ """Pydantic v2 schema models for cloud assets, findings, and relationships."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ from typing import Annotated, Any
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, computed_field
11
+
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Enums
15
+ # ---------------------------------------------------------------------------
16
+
17
+
18
+ class Severity(str, Enum):
19
+ """Finding severity levels."""
20
+
21
+ CRITICAL = "CRITICAL"
22
+ HIGH = "HIGH"
23
+ MEDIUM = "MEDIUM"
24
+ LOW = "LOW"
25
+ INFO = "INFO"
26
+
27
+
28
+ class CloudProvider(str, Enum):
29
+ """Supported cloud providers."""
30
+
31
+ AWS = "AWS"
32
+ AZURE = "AZURE"
33
+ GCP = "GCP"
34
+
35
+
36
+ class AssetType(str, Enum):
37
+ """Normalised asset type taxonomy."""
38
+
39
+ # Compute
40
+ EC2 = "EC2"
41
+ VIRTUAL_MACHINE = "VIRTUAL_MACHINE"
42
+ GCE_INSTANCE = "GCE_INSTANCE"
43
+ LAMBDA_FUNCTION = "LAMBDA_FUNCTION"
44
+ CLOUD_FUNCTION = "CLOUD_FUNCTION"
45
+ ECS_CLUSTER = "ECS_CLUSTER"
46
+ EKS_CLUSTER = "EKS_CLUSTER"
47
+ AKS_CLUSTER = "AKS_CLUSTER"
48
+ GKE_CLUSTER = "GKE_CLUSTER"
49
+ APP_SERVICE = "APP_SERVICE"
50
+
51
+ # Networking
52
+ VPC = "VPC"
53
+ VNET = "VNET"
54
+ SUBNET = "SUBNET"
55
+ SECURITY_GROUP = "SECURITY_GROUP"
56
+ NSG = "NSG"
57
+ NACL = "NACL"
58
+ ROUTE_TABLE = "ROUTE_TABLE"
59
+ INTERNET_GATEWAY = "INTERNET_GATEWAY"
60
+ NAT_GATEWAY = "NAT_GATEWAY"
61
+ LOAD_BALANCER = "LOAD_BALANCER"
62
+ CLOUDFRONT = "CLOUDFRONT"
63
+ CDN = "CDN"
64
+ TRANSIT_GATEWAY = "TRANSIT_GATEWAY"
65
+ PEERING_CONNECTION = "PEERING_CONNECTION"
66
+ ELASTIC_IP = "ELASTIC_IP"
67
+
68
+ # Storage
69
+ S3_BUCKET = "S3_BUCKET"
70
+ BLOB_STORAGE = "BLOB_STORAGE"
71
+ GCS_BUCKET = "GCS_BUCKET"
72
+ EBS_VOLUME = "EBS_VOLUME"
73
+
74
+ # Database
75
+ RDS_INSTANCE = "RDS_INSTANCE"
76
+ AURORA_CLUSTER = "AURORA_CLUSTER"
77
+ AZURE_SQL = "AZURE_SQL"
78
+ CLOUD_SQL = "CLOUD_SQL"
79
+ DYNAMODB_TABLE = "DYNAMODB_TABLE"
80
+
81
+ # IAM
82
+ IAM_USER = "IAM_USER"
83
+ IAM_ROLE = "IAM_ROLE"
84
+ IAM_POLICY = "IAM_POLICY"
85
+ IAM_GROUP = "IAM_GROUP"
86
+ SERVICE_PRINCIPAL = "SERVICE_PRINCIPAL"
87
+
88
+ # Secrets / Keys
89
+ KMS_KEY = "KMS_KEY"
90
+ SECRET = "SECRET"
91
+ CERTIFICATE = "CERTIFICATE"
92
+ KEY_VAULT = "KEY_VAULT"
93
+
94
+ # Logging
95
+ CLOUDTRAIL = "CLOUDTRAIL"
96
+ FLOW_LOG = "FLOW_LOG"
97
+
98
+ # Generic
99
+ OTHER = "OTHER"
100
+
101
+
102
+ class EdgeType(str, Enum):
103
+ """Relationship edge types."""
104
+
105
+ SECURITY_GROUP_RULE = "SECURITY_GROUP_RULE"
106
+ NACL_RULE = "NACL_RULE"
107
+ ROUTE = "ROUTE"
108
+ IAM_TRUST = "IAM_TRUST"
109
+ IAM_POLICY_ATTACHMENT = "IAM_POLICY_ATTACHMENT"
110
+ CONTAINS = "CONTAINS" # VPC contains Subnet
111
+ PEERING = "PEERING"
112
+ LOAD_BALANCER_TARGET = "LOAD_BALANCER_TARGET"
113
+ INTERNET_EXPOSED = "INTERNET_EXPOSED"
114
+
115
+
116
+ class ComplianceStatus(str, Enum):
117
+ """Compliance check status."""
118
+
119
+ PASS = "PASS"
120
+ FAIL = "FAIL"
121
+ NOT_APPLICABLE = "NOT_APPLICABLE"
122
+ MANUAL = "MANUAL"
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Helper types
127
+ # ---------------------------------------------------------------------------
128
+
129
+ NonEmptyStr = Annotated[str, Field(min_length=1)]
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Core Models
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ class CloudAsset(BaseModel):
138
+ """Normalised cloud resource representation."""
139
+
140
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
141
+
142
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
143
+ arn: str | None = Field(default=None, description="Cloud-native resource identifier")
144
+ name: str = Field(description="Human-readable resource name")
145
+ asset_type: AssetType
146
+ provider: CloudProvider
147
+ region: str = Field(default="global")
148
+ account_id: str | None = None
149
+ tags: dict[str, str] = Field(default_factory=dict)
150
+ metadata: dict[str, Any] = Field(default_factory=dict)
151
+ raw_data: dict[str, Any] = Field(default_factory=dict, exclude=True)
152
+ collected_at: datetime = Field(default_factory=datetime.utcnow)
153
+ is_internet_exposed: bool = False
154
+
155
+ @computed_field # type: ignore[misc]
156
+ @property
157
+ def display_id(self) -> str:
158
+ """Return the best identifier for display."""
159
+ return self.arn or self.id
160
+
161
+
162
+ class NetworkEdge(BaseModel):
163
+ """Directed edge representing a network relationship or access path."""
164
+
165
+ model_config = ConfigDict(extra="allow")
166
+
167
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
168
+ source_id: str = Field(description="Source node ID")
169
+ target_id: str = Field(description="Target node ID")
170
+ edge_type: EdgeType
171
+ ports: list[int] = Field(default_factory=list)
172
+ port_range: str | None = None # e.g. "80-443"
173
+ protocol: str | None = None # TCP, UDP, ICMP, ALL
174
+ cidr: str | None = None # e.g. "0.0.0.0/0"
175
+ direction: str = "ingress" # ingress | egress
176
+ description: str | None = None
177
+
178
+
179
+ class Finding(BaseModel):
180
+ """Normalised security or governance finding."""
181
+
182
+ model_config = ConfigDict(extra="allow")
183
+
184
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
185
+ resource_id: str = Field(description="Internal asset ID")
186
+ resource_arn: str | None = None
187
+ severity: Severity
188
+ title: str
189
+ description: str
190
+ evidence: str | None = None
191
+ remediation: str | None = None
192
+ source_tool: str = Field(description="Tool that produced this finding")
193
+ source_finding_id: str | None = None
194
+ compliance_frameworks: list[str] = Field(default_factory=list)
195
+ cvss_score: float | None = Field(default=None, ge=0.0, le=10.0)
196
+ detected_at: datetime = Field(default_factory=datetime.utcnow)
197
+ is_suppressed: bool = False
198
+
199
+ @computed_field # type: ignore[misc]
200
+ @property
201
+ def risk_score(self) -> float:
202
+ """Composite risk score (0-10) based on severity + CVSS."""
203
+ severity_scores = {
204
+ Severity.CRITICAL: 9.5,
205
+ Severity.HIGH: 7.5,
206
+ Severity.MEDIUM: 5.0,
207
+ Severity.LOW: 2.5,
208
+ Severity.INFO: 0.5,
209
+ }
210
+ base = severity_scores.get(self.severity, 5.0)
211
+ if self.cvss_score is not None:
212
+ return round((base + self.cvss_score) / 2, 1)
213
+ return base
214
+
215
+
216
+ class ComplianceResult(BaseModel):
217
+ """Mapping of a finding to a compliance framework control."""
218
+
219
+ model_config = ConfigDict(extra="allow")
220
+
221
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
222
+ framework: str = Field(description="e.g. CIS, NIST-800-53, PCI-DSS, GDPR")
223
+ control_id: str = Field(description="e.g. CIS-1.1, NIST-AC-2")
224
+ control_title: str | None = None
225
+ status: ComplianceStatus
226
+ finding_ids: list[str] = Field(default_factory=list)
227
+ resource_arn: str | None = None
228
+
229
+
230
+ class ScanResult(BaseModel):
231
+ """Aggregate pipeline output container."""
232
+
233
+ model_config = ConfigDict(extra="allow")
234
+
235
+ scan_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
236
+ provider: CloudProvider | None = None
237
+ account_id: str | None = None
238
+ region: str | None = None
239
+ started_at: datetime = Field(default_factory=datetime.utcnow)
240
+ completed_at: datetime | None = None
241
+
242
+ assets: list[CloudAsset] = Field(default_factory=list)
243
+ findings: list[Finding] = Field(default_factory=list)
244
+ edges: list[NetworkEdge] = Field(default_factory=list)
245
+ compliance: list[ComplianceResult] = Field(default_factory=list)
246
+
247
+ @computed_field # type: ignore[misc]
248
+ @property
249
+ def summary(self) -> dict[str, Any]:
250
+ """Quick summary statistics."""
251
+ severity_counts: dict[str, int] = {}
252
+ for f in self.findings:
253
+ severity_counts[f.severity.value] = severity_counts.get(f.severity.value, 0) + 1
254
+ return {
255
+ "total_assets": len(self.assets),
256
+ "total_findings": len(self.findings),
257
+ "total_edges": len(self.edges),
258
+ "severity_breakdown": severity_counts,
259
+ "compliance_frameworks": list({c.framework for c in self.compliance}),
260
+ }