|
| 1 | +"""ScoutSuite executor for running cloud security scans. |
| 2 | +
|
| 3 | +Extends BaseExecutor to provide ScoutSuite-specific CLI argument building |
| 4 | +and scan execution methods. |
| 5 | +""" |
| 6 | + |
| 7 | +from typing import Optional |
| 8 | +import tempfile |
| 9 | +from pathlib import Path |
| 10 | + |
| 11 | +from ..base_executor import BaseExecutor |
| 12 | +from .config import ScoutSuiteConfig |
| 13 | + |
| 14 | + |
| 15 | +class ScoutSuiteExecutor(BaseExecutor): |
| 16 | + """Executes ScoutSuite security scans. |
| 17 | +
|
| 18 | + ScoutSuite CLI pattern: `scout <provider> [options]` |
| 19 | + |
| 20 | + Example: |
| 21 | + scout aws --report-dir ./aws-scan-2023-12-18 \\ |
| 22 | + --report-name aws-report \\ |
| 23 | + --result-format json \\ |
| 24 | + --access-key-id ACCESS_KEY_ID \\ |
| 25 | + --secret-access-key SECRET_KEY |
| 26 | + """ |
| 27 | + |
| 28 | + def __init__(self, config: Optional[ScoutSuiteConfig] = None): |
| 29 | + """Initialize the ScoutSuite executor. |
| 30 | +
|
| 31 | + Args: |
| 32 | + config: ScoutSuite configuration. Uses defaults if not provided. |
| 33 | + """ |
| 34 | + super().__init__(config or ScoutSuiteConfig()) |
| 35 | + self.config: ScoutSuiteConfig = self.config # type narrowing |
| 36 | + self.expected_exit_codes = [0, 1] # Scout returned 1 on findings sometimes |
| 37 | + |
| 38 | + def _default_cli_name(self) -> str: |
| 39 | + """Return the default ScoutSuite CLI binary name.""" |
| 40 | + return "scout" |
| 41 | + |
| 42 | + def _build_cli_args(self, **kwargs) -> list[str]: |
| 43 | + """Build ScoutSuite CLI arguments from configuration. |
| 44 | +
|
| 45 | + Args: |
| 46 | + **kwargs: Override config values for this invocation. |
| 47 | +
|
| 48 | + Returns: |
| 49 | + List of CLI argument strings. |
| 50 | + """ |
| 51 | + provider = kwargs.get("provider", self.config.provider) |
| 52 | + args = [provider] |
| 53 | + |
| 54 | + # Region filtering |
| 55 | + regions = kwargs.get("regions", self.config.regions) |
| 56 | + if regions: |
| 57 | + args.extend(["--regions", ",".join(regions)]) |
| 58 | + |
| 59 | + # Services to scan |
| 60 | + services = kwargs.get("services", self.config.services) |
| 61 | + if services: |
| 62 | + args.extend(["--services", ",".join(services)]) |
| 63 | + |
| 64 | + # Output options |
| 65 | + report_dir = kwargs.get("report_dir", self.config.report_dir or self.config.results_dir) |
| 66 | + if report_dir: |
| 67 | + args.extend(["--report-dir", str(report_dir)]) |
| 68 | + |
| 69 | + report_name = kwargs.get("report_name", self.config.report_name) |
| 70 | + if report_name: |
| 71 | + args.extend(["--report-name", str(report_name)]) |
| 72 | + |
| 73 | + result_format = kwargs.get("result_format", self.config.result_format) |
| 74 | + if result_format: |
| 75 | + args.extend(["--result-format", result_format]) |
| 76 | + |
| 77 | + # Passing credentials explicitly if needed by the AWS provider, though |
| 78 | + # BaseExecutor already injects these effectively as environment vars. |
| 79 | + # However, the user explicitly requested `--access-key-id` and `--secret-access-key`. |
| 80 | + if provider == "aws": |
| 81 | + aws_access_key_id = self.config.aws_access_key_id |
| 82 | + if aws_access_key_id: |
| 83 | + args.extend(["--access-key-id", aws_access_key_id]) |
| 84 | + |
| 85 | + aws_secret_access_key = self.config.aws_secret_access_key |
| 86 | + if aws_secret_access_key: |
| 87 | + args.extend(["--secret-access-key", aws_secret_access_key]) |
| 88 | + |
| 89 | + aws_session_token = self.config.aws_session_token |
| 90 | + if aws_session_token: |
| 91 | + args.extend(["--session-token", aws_session_token]) |
| 92 | + |
| 93 | + aws_profile = self.config.aws_profile |
| 94 | + if aws_profile: |
| 95 | + args.extend(["--profile", aws_profile]) |
| 96 | + |
| 97 | + return args |
| 98 | + |
| 99 | + def _build_scan_kwargs( |
| 100 | + self, |
| 101 | + provider: Optional[str] = None, |
| 102 | + services: Optional[list[str]] = None, |
| 103 | + regions: Optional[list[str]] = None, |
| 104 | + report_name: Optional[str] = None, |
| 105 | + report_dir: Optional[str] = None, |
| 106 | + ) -> dict: |
| 107 | + """Build kwargs dict from scan parameters.""" |
| 108 | + kwargs: dict = {} |
| 109 | + if provider: |
| 110 | + kwargs["provider"] = provider |
| 111 | + if services: |
| 112 | + kwargs["services"] = services |
| 113 | + if regions: |
| 114 | + kwargs["regions"] = regions |
| 115 | + if report_name: |
| 116 | + kwargs["report_name"] = report_name |
| 117 | + if report_dir: |
| 118 | + kwargs["report_dir"] = report_dir |
| 119 | + return kwargs |
| 120 | + |
| 121 | + async def run_scan( |
| 122 | + self, |
| 123 | + provider: Optional[str] = None, |
| 124 | + services: Optional[list[str]] = None, |
| 125 | + regions: Optional[list[str]] = None, |
| 126 | + report_name: Optional[str] = None, |
| 127 | + report_dir: Optional[str] = None, |
| 128 | + ) -> tuple[str, str, int]: |
| 129 | + """Run a ScoutSuite security scan. |
| 130 | +
|
| 131 | + Returns: |
| 132 | + Tuple of (stdout, stderr, exit_code). |
| 133 | + """ |
| 134 | + kwargs = self._build_scan_kwargs( |
| 135 | + provider, services, regions, report_name, report_dir |
| 136 | + ) |
| 137 | + return await self._execute_with_json_capture(self.execute, **kwargs) |
| 138 | + |
| 139 | + async def run_scan_streaming( |
| 140 | + self, |
| 141 | + progress_callback=None, |
| 142 | + provider: Optional[str] = None, |
| 143 | + services: Optional[list[str]] = None, |
| 144 | + regions: Optional[list[str]] = None, |
| 145 | + report_name: Optional[str] = None, |
| 146 | + report_dir: Optional[str] = None, |
| 147 | + ) -> tuple[str, str, int]: |
| 148 | + """Run a ScoutSuite scan with real-time stderr streaming.""" |
| 149 | + kwargs = self._build_scan_kwargs( |
| 150 | + provider, services, regions, report_name, report_dir |
| 151 | + ) |
| 152 | + return await self._execute_with_json_capture( |
| 153 | + self.execute_streaming, progress_callback=progress_callback, **kwargs |
| 154 | + ) |
| 155 | + |
| 156 | + async def _execute_with_json_capture(self, execute_func, *args, **kwargs) -> tuple[str, str, int]: |
| 157 | + """Run execution and capture JSON output. |
| 158 | + |
| 159 | + Uses a temporary directory to manage reports if the user doesn't pass one explicitly. |
| 160 | + """ |
| 161 | + # If user provided a report directory, use it directly |
| 162 | + explicit_report_dir = kwargs.get("report_dir", self.config.report_dir or self.config.results_dir) |
| 163 | + |
| 164 | + with tempfile.TemporaryDirectory() as temp_dir: |
| 165 | + temp_path = Path(temp_dir) |
| 166 | + active_report_dir = explicit_report_dir or str(temp_path) |
| 167 | + kwargs["report_dir"] = active_report_dir |
| 168 | + |
| 169 | + # Make sure we use JSON format to parse results |
| 170 | + if "result_format" not in kwargs: |
| 171 | + kwargs["result_format"] = "json" |
| 172 | + |
| 173 | + # Run the scan |
| 174 | + stdout, stderr, exit_code = await execute_func(*args, **kwargs) |
| 175 | + |
| 176 | + # Find the generated JSON result in the scoutsuite-report directory |
| 177 | + report_name = kwargs.get("report_name", self.config.report_name) |
| 178 | + report_filename = f"{report_name}.js" if kwargs.get("result_format") == "json" else f"{report_name}.json" |
| 179 | + |
| 180 | + # ScoutSuite typically generates JSON inside an HTML wrapper called scoutsuite_results_...js |
| 181 | + # Let's search broadly for .js or .json in the output dir |
| 182 | + target_dir = Path(active_report_dir) |
| 183 | + |
| 184 | + # Scout creates scoutsuite-report/scoutsuite-results by default if report_name is not fully mapping |
| 185 | + results_files = list(target_dir.rglob("scoutsuite_results*.js")) |
| 186 | + if not results_files: |
| 187 | + results_files = list(target_dir.rglob("*.json")) |
| 188 | + |
| 189 | + if results_files: |
| 190 | + # Read the latest file found |
| 191 | + json_content = results_files[-1].read_text(encoding="utf-8") |
| 192 | + # ScoutSuite .js files start with `scoutsuite_results = { ... }` which needs to be cleaned for pure JSON |
| 193 | + if json_content.startswith("scoutsuite_results ="): |
| 194 | + json_content = json_content.replace("scoutsuite_results =", "", 1).strip().strip(";") |
| 195 | + return json_content, stderr, exit_code |
| 196 | + else: |
| 197 | + self.logger.warning("No JSON result file found in ScoutSuite output") |
| 198 | + return stdout, stderr, exit_code |
0 commit comments