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,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