diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index a8523a24..391e6941 100644 --- a/backend/secuscan/executor.py +++ b/backend/secuscan/executor.py @@ -898,8 +898,9 @@ async def read_stream(): line = await stdout.readline() if line: decoded_line = line.decode("utf-8", errors="replace") - output_lines.append(decoded_line) - await self._broadcast(task_id, "output", decoded_line) + safe_line = redact(decoded_line) + output_lines.append(safe_line) + await self._broadcast(task_id, "output", safe_line) try: await asyncio.wait_for(read_stream(), timeout=timeout) diff --git a/testing/backend/unit/test_executor.py b/testing/backend/unit/test_executor.py index 76bd070a..9af759f4 100644 --- a/testing/backend/unit/test_executor.py +++ b/testing/backend/unit/test_executor.py @@ -1446,3 +1446,54 @@ async def fake_command(*args, **kwargs): assert original_inputs_seen.get("domain") == "example.com" assert original_inputs_seen.get("__pinned_ip") == "93.184.216.34" await db.disconnect() + + +@pytest.mark.asyncio +async def test_execute_command_redacts_secrets_before_broadcast(): + executor = TaskExecutor() + task_id = "test-sse-redaction-1624" + queue = executor.subscribe(task_id) + + async def mock_readline(): + for line in [ + b"Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.secret123\n", + b"port 80 is open\n", + b"api_key=supersecretkey12345\n", + None, + ]: + yield line + + mock_stdout = AsyncMock() + mock_stdout.readline = mock_readline().__anext__ + mock_stdout.at_eof = MagicMock(side_effect=[False, False, False, True]) + + with patch.object(executor, "_process_pids", {}): + process = AsyncMock() + process.stdout = mock_stdout + process.returncode = 0 + + async def fake_create_subprocess(*args, **kwargs): + return process + + with patch("asyncio.create_subprocess_exec", side_effect=fake_create_subprocess): + output, exit_code = await executor._execute_command(["echo", "test"], task_id, timeout=30) + + events = [] + while not queue.empty(): + events.append(queue.get_nowait()) + + output_events = [e for e in events if e["type"] == "output"] + assert len(output_events) == 3 + + assert "[REDACTED]" in output_events[0]["data"] + assert "eyJhbGciOiJIUzI1NiJ9.secret123" not in output_events[0]["data"] + assert "Authorization: Bearer" in output_events[0]["data"] + + assert "port 80 is open" in output_events[1]["data"] + + assert "[REDACTED]" in output_events[2]["data"] + assert "supersecretkey12345" not in output_events[2]["data"] + + assert "[REDACTED]" in output + assert "eyJhbGciOiJIUzI1NiJ9.secret123" not in output + assert "supersecretkey12345" not in output diff --git a/testing/backend/unit/test_tls_verification.py b/testing/backend/unit/test_tls_verification.py index be8281d8..82051e80 100644 --- a/testing/backend/unit/test_tls_verification.py +++ b/testing/backend/unit/test_tls_verification.py @@ -52,20 +52,39 @@ def test_env_override_true(self, monkeypatch): class TestCrawlerVerifySsl: - @pytest.mark.asyncio - async def test_crawl_target_passes_verify_ssl(self): + def _make_mock_stream_response(self): mock_response = MagicMock() mock_response.text = "" mock_response.url = "https://example.com/" mock_response.status_code = 200 - mock_response.headers = {} + mock_response.headers = MagicMock() + mock_response.headers.get = MagicMock(return_value=None) + mock_response.headers.get_list = MagicMock(return_value=[]) mock_response.history = [] + return mock_response + def _make_mock_client(self, mock_response): mock_client = AsyncMock() mock_client.get = AsyncMock(return_value=mock_response) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) + mock_stream_ctx = AsyncMock() + mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) + mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) + mock_client.stream = MagicMock(return_value=mock_stream_ctx) + async def _aiter_bytes(): + yield b"" + + mock_response.aiter_bytes = _aiter_bytes + + return mock_client + + @pytest.mark.asyncio + async def test_crawl_target_passes_verify_ssl(self): + mock_response = self._make_mock_stream_response() + mock_client = self._make_mock_client(mock_response) + with patch("backend.secuscan.crawler.httpx.AsyncClient", return_value=mock_client) as mock_cls: with patch("backend.secuscan.crawler.settings") as mock_settings: mock_settings.verify_ssl = True @@ -79,17 +98,8 @@ async def test_crawl_target_passes_verify_ssl(self): @pytest.mark.asyncio async def test_crawl_target_verify_ssl_false_when_disabled(self): - mock_response = MagicMock() - mock_response.text = "" - mock_response.url = "https://example.com/" - mock_response.status_code = 200 - mock_response.headers = {} - mock_response.history = [] - - mock_client = AsyncMock() - mock_client.get = AsyncMock(return_value=mock_response) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) + mock_response = self._make_mock_stream_response() + mock_client = self._make_mock_client(mock_response) with patch("backend.secuscan.crawler.httpx.AsyncClient", return_value=mock_client) as mock_cls: with patch("backend.secuscan.crawler.settings") as mock_settings: @@ -119,16 +129,19 @@ async def test_api_scanner_passes_verify_ssl(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) + mock_crawl = AsyncMock(return_value={"api_hints": []}) + with patch("backend.secuscan.scanners.api_scanner.httpx.AsyncClient", return_value=mock_client) as mock_cls: with patch("backend.secuscan.scanners.api_scanner.settings") as mock_settings: mock_settings.verify_ssl = True - with patch.object(scanner, "_fetch_spec", return_value=None): - with patch.object(scanner, "_probe_graphql", return_value=([], [])): - await scanner.run("https://example.com", {}) + with patch("backend.secuscan.scanners.api_scanner.crawl_target", mock_crawl): + with patch.object(scanner, "_fetch_spec", return_value=None): + with patch.object(scanner, "_probe_graphql", return_value=([], [])): + await scanner.run("https://example.com", {}) - mock_cls.assert_called() - _, kwargs = mock_cls.call_args - assert kwargs["verify"] is True + mock_cls.assert_called() + _, kwargs = mock_cls.call_args + assert kwargs["verify"] is True @pytest.mark.asyncio async def test_api_scanner_verify_ssl_false_when_disabled(self): @@ -141,15 +154,18 @@ async def test_api_scanner_verify_ssl_false_when_disabled(self): mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) + mock_crawl = AsyncMock(return_value={"api_hints": []}) + with patch("backend.secuscan.scanners.api_scanner.httpx.AsyncClient", return_value=mock_client) as mock_cls: with patch("backend.secuscan.scanners.api_scanner.settings") as mock_settings: mock_settings.verify_ssl = False - with patch.object(scanner, "_fetch_spec", return_value=None): - with patch.object(scanner, "_probe_graphql", return_value=([], [])): - await scanner.run("https://example.com", {}) + with patch("backend.secuscan.scanners.api_scanner.crawl_target", mock_crawl): + with patch.object(scanner, "_fetch_spec", return_value=None): + with patch.object(scanner, "_probe_graphql", return_value=([], [])): + await scanner.run("https://example.com", {}) - _, kwargs = mock_cls.call_args - assert kwargs["verify"] is False + _, kwargs = mock_cls.call_args + assert kwargs["verify"] is False # ---------------------------------------------------------------------------