From 311aa00877a160e20697746f4ee4362f27b3fa8c Mon Sep 17 00:00:00 2001 From: ionfwsrijan <201338831+ionfwsrijan@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:38:54 +0530 Subject: [PATCH 1/3] fix: apply redaction before SSE broadcast in _execute_command (#1624) - Redact each decoded line before appending to output_lines and before broadcasting to SSE stream listeners - Add test_execute_command_redacts_secrets_before_broadcast to verify that known secret patterns are redacted from stream events - The raw_output_path file redaction (line 599) remains as defense-in-depth --- backend/secuscan/executor.py | 5 +-- testing/backend/unit/test_executor.py | 49 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index a8523a245..391e6941b 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 76bd070a5..4d70b12cb 100644 --- a/testing/backend/unit/test_executor.py +++ b/testing/backend/unit/test_executor.py @@ -1446,3 +1446,52 @@ 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 = AsyncMock(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) == 2 + + 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 "[REDACTED]" in output_events[1]["data"] + assert "supersecretkey12345" not in output_events[1]["data"] + + assert "[REDACTED]" in output + assert "eyJhbGciOiJIUzI1NiJ9.secret123" not in output + assert "supersecretkey12345" not in output From a75da0e5d1cfb79f7ab2c47f272e8ccafbc5f16b Mon Sep 17 00:00:00 2001 From: ionfwsrijan Date: Fri, 10 Jul 2026 08:27:58 +0530 Subject: [PATCH 2/3] fix: correct SSE redaction test mock for sync at_eof call --- testing/backend/unit/test_executor.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/testing/backend/unit/test_executor.py b/testing/backend/unit/test_executor.py index 4d70b12cb..9af759f4c 100644 --- a/testing/backend/unit/test_executor.py +++ b/testing/backend/unit/test_executor.py @@ -1465,7 +1465,7 @@ async def mock_readline(): mock_stdout = AsyncMock() mock_stdout.readline = mock_readline().__anext__ - mock_stdout.at_eof = AsyncMock(side_effect=[False, False, False, True]) + mock_stdout.at_eof = MagicMock(side_effect=[False, False, False, True]) with patch.object(executor, "_process_pids", {}): process = AsyncMock() @@ -1483,14 +1483,16 @@ async def fake_create_subprocess(*args, **kwargs): events.append(queue.get_nowait()) output_events = [e for e in events if e["type"] == "output"] - assert len(output_events) == 2 + 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 "[REDACTED]" in output_events[1]["data"] - assert "supersecretkey12345" not in output_events[1]["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 From 14d616c3808b3ef1a181ff52646d0fbcd5720e8d Mon Sep 17 00:00:00 2001 From: ionfwsrijan Date: Tue, 14 Jul 2026 21:18:43 +0530 Subject: [PATCH 3/3] fix: mock client.stream() as async context manager in TLS verification tests --- testing/backend/unit/test_tls_verification.py | 66 ++++++++++++------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/testing/backend/unit/test_tls_verification.py b/testing/backend/unit/test_tls_verification.py index be8281d83..82051e80f 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 # ---------------------------------------------------------------------------