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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""IAM policy linter using Parliament and policy-sentry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from cloudg.schema.models import CloudAsset, AssetType, Finding, Severity
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IAMLinter:
|
|
14
|
+
"""Analyses IAM policies using Parliament for linting and policy-sentry for least-privilege scoring.
|
|
15
|
+
|
|
16
|
+
Parliament: Detects wildcard abuse, logical errors, unused conditions.
|
|
17
|
+
Policy-sentry: Scores how over-permissioned policies are.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._parliament_available = False
|
|
22
|
+
self._policy_sentry_available = False
|
|
23
|
+
self._check_availability()
|
|
24
|
+
|
|
25
|
+
def _check_availability(self) -> None:
|
|
26
|
+
"""Check which IAM analysis tools are available."""
|
|
27
|
+
try:
|
|
28
|
+
import parliament # noqa: F401
|
|
29
|
+
|
|
30
|
+
self._parliament_available = True
|
|
31
|
+
except ImportError:
|
|
32
|
+
logger.warning("parliament not installed. Install with: pip install parliament")
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
import policy_sentry # noqa: F401
|
|
36
|
+
|
|
37
|
+
self._policy_sentry_available = True
|
|
38
|
+
except ImportError:
|
|
39
|
+
logger.debug("policy-sentry not installed. Install with: pip install policy-sentry")
|
|
40
|
+
|
|
41
|
+
def analyze_policies(self, assets: list[CloudAsset]) -> list[Finding]:
|
|
42
|
+
"""Analyse all IAM policy assets.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
assets: List of cloud assets (filters for IAM_POLICY and IAM_ROLE types).
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of Finding objects for IAM policy issues.
|
|
49
|
+
"""
|
|
50
|
+
findings: list[Finding] = []
|
|
51
|
+
|
|
52
|
+
iam_assets = [
|
|
53
|
+
a for a in assets if a.asset_type in (AssetType.IAM_POLICY, AssetType.IAM_ROLE)
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
for asset in iam_assets:
|
|
57
|
+
policy_doc = asset.metadata.get("assume_role_policy") or asset.metadata.get(
|
|
58
|
+
"policy_document"
|
|
59
|
+
)
|
|
60
|
+
if not policy_doc:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
# Ensure it's a JSON string
|
|
64
|
+
if isinstance(policy_doc, dict):
|
|
65
|
+
policy_str = json.dumps(policy_doc)
|
|
66
|
+
else:
|
|
67
|
+
policy_str = str(policy_doc)
|
|
68
|
+
|
|
69
|
+
# Run Parliament linting
|
|
70
|
+
if self._parliament_available:
|
|
71
|
+
findings.extend(self._lint_with_parliament(policy_str, asset))
|
|
72
|
+
|
|
73
|
+
# Check for wildcard permissions
|
|
74
|
+
findings.extend(self._check_wildcards(policy_str, asset))
|
|
75
|
+
|
|
76
|
+
logger.info("IAM linter produced %d findings", len(findings))
|
|
77
|
+
return findings
|
|
78
|
+
|
|
79
|
+
def _lint_with_parliament(self, policy_str: str, asset: CloudAsset) -> list[Finding]:
|
|
80
|
+
"""Run Parliament linter on a policy document."""
|
|
81
|
+
findings: list[Finding] = []
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
import parliament
|
|
85
|
+
|
|
86
|
+
analyzed = parliament.analyze_policy_string(policy_str)
|
|
87
|
+
|
|
88
|
+
for finding in analyzed.findings:
|
|
89
|
+
severity = self._map_parliament_severity(finding.severity)
|
|
90
|
+
findings.append(
|
|
91
|
+
Finding(
|
|
92
|
+
resource_id=asset.id,
|
|
93
|
+
resource_arn=asset.arn,
|
|
94
|
+
severity=severity,
|
|
95
|
+
title=f"[Parliament] IAM policy issue: {finding.issue}",
|
|
96
|
+
description=finding.detail if hasattr(finding, "detail") else str(finding),
|
|
97
|
+
evidence=f"Policy: {asset.name}, Issue: {finding.issue}",
|
|
98
|
+
remediation=(
|
|
99
|
+
"Review and fix the IAM policy. Follow least-privilege "
|
|
100
|
+
"principles. Use policy-sentry to generate scoped policies."
|
|
101
|
+
),
|
|
102
|
+
source_tool="parliament",
|
|
103
|
+
compliance_frameworks=["CIS", "NIST-800-53"],
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
logger.debug("Parliament analysis failed for %s: %s", asset.name, exc)
|
|
108
|
+
|
|
109
|
+
return findings
|
|
110
|
+
|
|
111
|
+
def _check_wildcards(self, policy_str: str, asset: CloudAsset) -> list[Finding]:
|
|
112
|
+
"""Check for wildcard permissions (Action: * or Resource: *)."""
|
|
113
|
+
findings: list[Finding] = []
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
policy = json.loads(policy_str)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
return findings
|
|
119
|
+
|
|
120
|
+
statements = policy.get("Statement", [])
|
|
121
|
+
if isinstance(statements, dict):
|
|
122
|
+
statements = [statements]
|
|
123
|
+
|
|
124
|
+
for statement in statements:
|
|
125
|
+
effect = statement.get("Effect", "")
|
|
126
|
+
actions = statement.get("Action", [])
|
|
127
|
+
resources = statement.get("Resource", [])
|
|
128
|
+
|
|
129
|
+
if isinstance(actions, str):
|
|
130
|
+
actions = [actions]
|
|
131
|
+
if isinstance(resources, str):
|
|
132
|
+
resources = [resources]
|
|
133
|
+
|
|
134
|
+
# Check for wildcard actions
|
|
135
|
+
if effect == "Allow" and "*" in actions:
|
|
136
|
+
findings.append(
|
|
137
|
+
Finding(
|
|
138
|
+
resource_id=asset.id,
|
|
139
|
+
resource_arn=asset.arn,
|
|
140
|
+
severity=Severity.HIGH,
|
|
141
|
+
title=f"Wildcard Action (*) in IAM policy: {asset.name}",
|
|
142
|
+
description=(
|
|
143
|
+
f"The IAM policy '{asset.name}' grants 'Action: *' "
|
|
144
|
+
f"(all actions). This violates least-privilege principles."
|
|
145
|
+
),
|
|
146
|
+
evidence=f"Statement: {json.dumps(statement)[:500]}",
|
|
147
|
+
remediation=(
|
|
148
|
+
"Replace 'Action: *' with specific actions required "
|
|
149
|
+
"by the workload. Use AWS Access Advisor to identify "
|
|
150
|
+
"unused permissions."
|
|
151
|
+
),
|
|
152
|
+
source_tool="cloudg-iam",
|
|
153
|
+
compliance_frameworks=["CIS", "NIST-800-53", "SOC2"],
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Check for wildcard resources with sensitive actions
|
|
158
|
+
if effect == "Allow" and "*" in resources:
|
|
159
|
+
sensitive_prefixes = ("iam:", "sts:", "kms:", "s3:", "ec2:", "lambda:")
|
|
160
|
+
has_sensitive = any(
|
|
161
|
+
any(a.startswith(p) for p in sensitive_prefixes)
|
|
162
|
+
for a in actions
|
|
163
|
+
if isinstance(a, str) and a != "*"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if has_sensitive or "*" in actions:
|
|
167
|
+
findings.append(
|
|
168
|
+
Finding(
|
|
169
|
+
resource_id=asset.id,
|
|
170
|
+
resource_arn=asset.arn,
|
|
171
|
+
severity=Severity.HIGH,
|
|
172
|
+
title=f"Wildcard Resource (*) in IAM policy: {asset.name}",
|
|
173
|
+
description=(
|
|
174
|
+
f"The IAM policy '{asset.name}' grants access to "
|
|
175
|
+
f"'Resource: *' (all resources) for sensitive actions."
|
|
176
|
+
),
|
|
177
|
+
evidence=f"Statement: {json.dumps(statement)[:500]}",
|
|
178
|
+
remediation=(
|
|
179
|
+
"Scope 'Resource' to specific ARN patterns. "
|
|
180
|
+
"Use conditions to further restrict access."
|
|
181
|
+
),
|
|
182
|
+
source_tool="cloudg-iam",
|
|
183
|
+
compliance_frameworks=["CIS", "NIST-800-53"],
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return findings
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _map_parliament_severity(severity: str) -> Severity:
|
|
191
|
+
"""Map Parliament severity to our Severity enum."""
|
|
192
|
+
mapping = {
|
|
193
|
+
"CRITICAL": Severity.CRITICAL,
|
|
194
|
+
"HIGH": Severity.HIGH,
|
|
195
|
+
"MEDIUM": Severity.MEDIUM,
|
|
196
|
+
"LOW": Severity.LOW,
|
|
197
|
+
"INFO": Severity.INFO,
|
|
198
|
+
"WARNING": Severity.MEDIUM,
|
|
199
|
+
}
|
|
200
|
+
return mapping.get(str(severity).upper(), Severity.MEDIUM)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Prowler CSPM scanner wrapper — runs Prowler CLI and parses ASFF JSON output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from cloudg.schema.models import Finding, Severity
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Map Prowler severity labels to our enum
|
|
18
|
+
_SEVERITY_MAP = {
|
|
19
|
+
"critical": Severity.CRITICAL,
|
|
20
|
+
"high": Severity.HIGH,
|
|
21
|
+
"medium": Severity.MEDIUM,
|
|
22
|
+
"low": Severity.LOW,
|
|
23
|
+
"informational": Severity.INFO,
|
|
24
|
+
"info": Severity.INFO,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Compliance framework mapping from Prowler check prefixes
|
|
28
|
+
_COMPLIANCE_MAP = {
|
|
29
|
+
"cis": "CIS",
|
|
30
|
+
"nist": "NIST-800-53",
|
|
31
|
+
"pci": "PCI-DSS",
|
|
32
|
+
"gdpr": "GDPR",
|
|
33
|
+
"hipaa": "HIPAA",
|
|
34
|
+
"soc2": "SOC2",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProwlerScanner:
|
|
39
|
+
"""Wraps Prowler CLI to run cloud security checks and parse ASFF JSON output.
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
scanner = ProwlerScanner(provider="aws", profile="default")
|
|
43
|
+
findings = scanner.run()
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
provider: str = "aws",
|
|
49
|
+
profile: str | None = None,
|
|
50
|
+
output_dir: str | None = None,
|
|
51
|
+
extra_args: list[str] | None = None,
|
|
52
|
+
aws_access_key_id: str | None = None,
|
|
53
|
+
aws_secret_access_key: str | None = None,
|
|
54
|
+
aws_region: str | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._provider = provider
|
|
57
|
+
self._profile = profile
|
|
58
|
+
self._output_dir = output_dir or tempfile.mkdtemp(prefix="prowler_")
|
|
59
|
+
self._extra_args = extra_args or []
|
|
60
|
+
self._aws_access_key_id = aws_access_key_id
|
|
61
|
+
self._aws_secret_access_key = aws_secret_access_key
|
|
62
|
+
self._aws_region = aws_region
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def is_available() -> bool:
|
|
66
|
+
"""Check if Prowler CLI is installed."""
|
|
67
|
+
return shutil.which("prowler") is not None
|
|
68
|
+
|
|
69
|
+
def run(self) -> list[Finding]:
|
|
70
|
+
"""Execute Prowler scan and return normalised findings.
|
|
71
|
+
|
|
72
|
+
Streams stdout/stderr live for real-time progress output.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of Finding objects parsed from Prowler ASFF JSON output.
|
|
76
|
+
"""
|
|
77
|
+
import os
|
|
78
|
+
import time
|
|
79
|
+
import threading
|
|
80
|
+
|
|
81
|
+
if not self.is_available():
|
|
82
|
+
logger.warning(
|
|
83
|
+
"Prowler is not installed. Install with: pip install prowler. "
|
|
84
|
+
"Skipping Prowler scan."
|
|
85
|
+
)
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
cmd = [
|
|
89
|
+
"prowler",
|
|
90
|
+
self._provider,
|
|
91
|
+
"-M",
|
|
92
|
+
"json-asff",
|
|
93
|
+
"-o",
|
|
94
|
+
self._output_dir,
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
if self._profile and self._provider == "aws":
|
|
98
|
+
cmd.extend(["-p", self._profile])
|
|
99
|
+
|
|
100
|
+
cmd.extend(self._extra_args)
|
|
101
|
+
|
|
102
|
+
logger.info("[Prowler] Starting: %s", " ".join(cmd))
|
|
103
|
+
|
|
104
|
+
# Build environment with AWS credentials for the subprocess
|
|
105
|
+
env = os.environ.copy()
|
|
106
|
+
if self._aws_access_key_id:
|
|
107
|
+
env["AWS_ACCESS_KEY_ID"] = self._aws_access_key_id
|
|
108
|
+
if self._aws_secret_access_key:
|
|
109
|
+
env["AWS_SECRET_ACCESS_KEY"] = self._aws_secret_access_key
|
|
110
|
+
if self._aws_region:
|
|
111
|
+
env["AWS_DEFAULT_REGION"] = self._aws_region
|
|
112
|
+
|
|
113
|
+
start_time = time.time()
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
# Use Popen for live streaming instead of blocking subprocess.run
|
|
117
|
+
proc = subprocess.Popen(
|
|
118
|
+
cmd,
|
|
119
|
+
stdout=subprocess.PIPE,
|
|
120
|
+
stderr=subprocess.STDOUT,
|
|
121
|
+
text=True,
|
|
122
|
+
env=env,
|
|
123
|
+
bufsize=1, # line-buffered
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Stream output in a reader thread
|
|
127
|
+
def _stream_output():
|
|
128
|
+
check_count = 0
|
|
129
|
+
for line in proc.stdout:
|
|
130
|
+
stripped = line.rstrip()
|
|
131
|
+
if not stripped:
|
|
132
|
+
continue
|
|
133
|
+
# Count checks for progress reporting
|
|
134
|
+
if "PASS" in stripped or "FAIL" in stripped or "WARNING" in stripped:
|
|
135
|
+
check_count += 1
|
|
136
|
+
if check_count % 25 == 0:
|
|
137
|
+
elapsed = int(time.time() - start_time)
|
|
138
|
+
logger.info(
|
|
139
|
+
"[Prowler] %d checks completed (%ds elapsed)", check_count, elapsed
|
|
140
|
+
)
|
|
141
|
+
elif "Executing" in stripped or "Service" in stripped:
|
|
142
|
+
logger.info("[Prowler] %s", stripped[:120])
|
|
143
|
+
|
|
144
|
+
reader = threading.Thread(target=_stream_output, daemon=True)
|
|
145
|
+
reader.start()
|
|
146
|
+
|
|
147
|
+
proc.wait(timeout=3600)
|
|
148
|
+
reader.join(timeout=5)
|
|
149
|
+
|
|
150
|
+
elapsed = int(time.time() - start_time)
|
|
151
|
+
if proc.returncode != 0:
|
|
152
|
+
logger.warning("[Prowler] Exited with code %d after %ds", proc.returncode, elapsed)
|
|
153
|
+
else:
|
|
154
|
+
logger.info("[Prowler] Completed successfully in %ds", elapsed)
|
|
155
|
+
|
|
156
|
+
except subprocess.TimeoutExpired:
|
|
157
|
+
logger.error("[Prowler] Scan timed out after 3600s — killing process")
|
|
158
|
+
proc.kill()
|
|
159
|
+
return []
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
logger.error("[Prowler] Failed to run: %s", exc)
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
return self._parse_output()
|
|
165
|
+
|
|
166
|
+
def _parse_output(self) -> list[Finding]:
|
|
167
|
+
"""Parse Prowler ASFF JSON output files."""
|
|
168
|
+
findings: list[Finding] = []
|
|
169
|
+
output_files = glob.glob(f"{self._output_dir}/**/*.json", recursive=True)
|
|
170
|
+
|
|
171
|
+
for file_path in output_files:
|
|
172
|
+
try:
|
|
173
|
+
with open(file_path) as f:
|
|
174
|
+
content = f.read().strip()
|
|
175
|
+
if not content:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
# Prowler may output JSON array or JSONL
|
|
179
|
+
if content.startswith("["):
|
|
180
|
+
data = json.loads(content)
|
|
181
|
+
else:
|
|
182
|
+
data = [json.loads(line) for line in content.splitlines() if line.strip()]
|
|
183
|
+
|
|
184
|
+
for item in data:
|
|
185
|
+
finding = self._parse_asff_finding(item)
|
|
186
|
+
if finding:
|
|
187
|
+
findings.append(finding)
|
|
188
|
+
|
|
189
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
190
|
+
logger.warning("Failed to parse Prowler output %s: %s", file_path, exc)
|
|
191
|
+
|
|
192
|
+
logger.info("Parsed %d findings from Prowler", len(findings))
|
|
193
|
+
return findings
|
|
194
|
+
|
|
195
|
+
def _parse_asff_finding(self, asff: dict[str, Any]) -> Finding | None:
|
|
196
|
+
"""Convert a single ASFF finding to our Finding model."""
|
|
197
|
+
try:
|
|
198
|
+
# Extract severity
|
|
199
|
+
severity_label = (
|
|
200
|
+
asff.get("Severity", {}).get("Label", "")
|
|
201
|
+
or asff.get("ProductFields", {}).get("Severity", "")
|
|
202
|
+
).lower()
|
|
203
|
+
severity = _SEVERITY_MAP.get(severity_label, Severity.MEDIUM)
|
|
204
|
+
|
|
205
|
+
# Extract resource ARN
|
|
206
|
+
resources = asff.get("Resources", [])
|
|
207
|
+
resource_arn = resources[0].get("Id", "") if resources else ""
|
|
208
|
+
|
|
209
|
+
# Extract compliance frameworks
|
|
210
|
+
compliance = []
|
|
211
|
+
compliance_data = asff.get("Compliance", {})
|
|
212
|
+
for standard in compliance_data.get("RelatedRequirements", []):
|
|
213
|
+
for prefix, framework in _COMPLIANCE_MAP.items():
|
|
214
|
+
if prefix in standard.lower():
|
|
215
|
+
compliance.append(framework)
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
# Extract status — skip PASS findings
|
|
219
|
+
status = asff.get("Compliance", {}).get("Status", "")
|
|
220
|
+
if status.upper() == "PASSED":
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
return Finding(
|
|
224
|
+
resource_id=resource_arn,
|
|
225
|
+
resource_arn=resource_arn,
|
|
226
|
+
severity=severity,
|
|
227
|
+
title=asff.get("Title", "Unknown Prowler Finding"),
|
|
228
|
+
description=asff.get("Description", ""),
|
|
229
|
+
evidence=json.dumps(asff.get("ProductFields", {}), default=str)[:1000],
|
|
230
|
+
remediation=(asff.get("Remediation", {}).get("Recommendation", {}).get("Text", "")),
|
|
231
|
+
source_tool="prowler",
|
|
232
|
+
source_finding_id=asff.get("Id", ""),
|
|
233
|
+
compliance_frameworks=list(set(compliance)),
|
|
234
|
+
)
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
logger.debug("Failed to parse ASFF finding: %s", exc)
|
|
237
|
+
return None
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""ScoutSuite multi-cloud audit scanner wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cloudg.schema.models import Finding, Severity
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_SEVERITY_MAP = {
|
|
19
|
+
"danger": Severity.CRITICAL,
|
|
20
|
+
"warning": Severity.HIGH,
|
|
21
|
+
"caution": Severity.MEDIUM,
|
|
22
|
+
"good": Severity.INFO,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ScoutSuiteScanner:
|
|
27
|
+
"""Wraps ScoutSuite CLI — runs `scout` and parses the results JS file.
|
|
28
|
+
|
|
29
|
+
ScoutSuite produces a `scoutsuite_results.js` file containing a JSON
|
|
30
|
+
object assigned to a JS variable. We strip the variable assignment
|
|
31
|
+
and parse the JSON.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
provider: str = "aws",
|
|
37
|
+
profile: str | None = None,
|
|
38
|
+
report_dir: str | None = None,
|
|
39
|
+
extra_args: list[str] | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._provider = provider
|
|
42
|
+
self._profile = profile
|
|
43
|
+
self._report_dir = report_dir or tempfile.mkdtemp(prefix="scoutsuite_")
|
|
44
|
+
self._extra_args = extra_args or []
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def is_available() -> bool:
|
|
48
|
+
"""Check if ScoutSuite CLI is installed."""
|
|
49
|
+
return shutil.which("scout") is not None
|
|
50
|
+
|
|
51
|
+
def run(self) -> list[Finding]:
|
|
52
|
+
"""Execute ScoutSuite scan and return normalised findings."""
|
|
53
|
+
if not self.is_available():
|
|
54
|
+
logger.warning(
|
|
55
|
+
"ScoutSuite is not installed. Install with: pip install scoutsuite. "
|
|
56
|
+
"Skipping ScoutSuite scan."
|
|
57
|
+
)
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
cmd = ["scout", self._provider, "--report-dir", self._report_dir, "--no-browser"]
|
|
61
|
+
|
|
62
|
+
if self._profile and self._provider == "aws":
|
|
63
|
+
cmd.extend(["--profile", self._profile])
|
|
64
|
+
|
|
65
|
+
cmd.extend(self._extra_args)
|
|
66
|
+
|
|
67
|
+
logger.info("Running ScoutSuite: %s", " ".join(cmd))
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
result = subprocess.run(
|
|
71
|
+
cmd,
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
timeout=3600,
|
|
75
|
+
check=False,
|
|
76
|
+
)
|
|
77
|
+
if result.returncode != 0:
|
|
78
|
+
logger.warning(
|
|
79
|
+
"ScoutSuite exited with code %d: %s",
|
|
80
|
+
result.returncode,
|
|
81
|
+
result.stderr[:500],
|
|
82
|
+
)
|
|
83
|
+
except subprocess.TimeoutExpired:
|
|
84
|
+
logger.error("ScoutSuite scan timed out")
|
|
85
|
+
return []
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
logger.error("Failed to run ScoutSuite: %s", exc)
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
return self._parse_output()
|
|
91
|
+
|
|
92
|
+
def _parse_output(self) -> list[Finding]:
|
|
93
|
+
"""Parse ScoutSuite results JS file."""
|
|
94
|
+
findings: list[Finding] = []
|
|
95
|
+
|
|
96
|
+
report_dir = Path(self._report_dir)
|
|
97
|
+
# ScoutSuite outputs: scoutsuite-results/scoutsuite_results*.js
|
|
98
|
+
results_files = list(report_dir.rglob("scoutsuite_results*.js"))
|
|
99
|
+
|
|
100
|
+
if not results_files:
|
|
101
|
+
logger.warning("No ScoutSuite results found in %s", self._report_dir)
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
for results_file in results_files:
|
|
105
|
+
try:
|
|
106
|
+
content = results_file.read_text()
|
|
107
|
+
|
|
108
|
+
# Strip JS variable assignment: scoutsuite_results = {...}
|
|
109
|
+
# The JSON object starts after the first '='
|
|
110
|
+
match = re.search(r"=\s*({.*})\s*;?\s*$", content, re.DOTALL)
|
|
111
|
+
if not match:
|
|
112
|
+
logger.warning("Could not extract JSON from %s", results_file)
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
data = json.loads(match.group(1))
|
|
116
|
+
findings.extend(self._extract_findings(data))
|
|
117
|
+
|
|
118
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
119
|
+
logger.warning("Failed to parse ScoutSuite results: %s", exc)
|
|
120
|
+
|
|
121
|
+
logger.info("Parsed %d findings from ScoutSuite", len(findings))
|
|
122
|
+
return findings
|
|
123
|
+
|
|
124
|
+
def _extract_findings(self, data: dict[str, Any]) -> list[Finding]:
|
|
125
|
+
"""Extract findings from parsed ScoutSuite JSON data."""
|
|
126
|
+
findings: list[Finding] = []
|
|
127
|
+
|
|
128
|
+
# ScoutSuite organises findings by service
|
|
129
|
+
services = data.get("services", {})
|
|
130
|
+
|
|
131
|
+
for service_name, service_data in services.items():
|
|
132
|
+
if not isinstance(service_data, dict):
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
findings_data = service_data.get("findings", {})
|
|
136
|
+
for finding_key, finding_data in findings_data.items():
|
|
137
|
+
if not isinstance(finding_data, dict):
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
flagged_items = finding_data.get("flagged_items", 0)
|
|
141
|
+
if flagged_items == 0:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
severity_str = finding_data.get("level", "warning").lower()
|
|
145
|
+
severity = _SEVERITY_MAP.get(severity_str, Severity.MEDIUM)
|
|
146
|
+
|
|
147
|
+
items = finding_data.get("items", [])
|
|
148
|
+
for item in items:
|
|
149
|
+
findings.append(
|
|
150
|
+
Finding(
|
|
151
|
+
resource_id=item if isinstance(item, str) else str(item),
|
|
152
|
+
resource_arn=item if isinstance(item, str) else "",
|
|
153
|
+
severity=severity,
|
|
154
|
+
title=f"[ScoutSuite] {service_name}: {finding_data.get('description', finding_key)}",
|
|
155
|
+
description=finding_data.get("rationale", ""),
|
|
156
|
+
remediation=finding_data.get("remediation", ""),
|
|
157
|
+
source_tool="scoutsuite",
|
|
158
|
+
source_finding_id=finding_key,
|
|
159
|
+
compliance_frameworks=finding_data.get("references", []),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return findings
|