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/scanners/trivy.py
ADDED
|
@@ -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
|
+
]
|
cloudg/schema/models.py
ADDED
|
@@ -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
|
+
}
|