From c4e15400824a784725b2100852db370a38846185 Mon Sep 17 00:00:00 2001 From: ionfwsrijan Date: Fri, 10 Jul 2026 08:17:11 +0530 Subject: [PATCH 1/3] fix: sanitize parser sandbox error messages to prevent sensitive data leaks (#1626) --- backend/secuscan/executor.py | 2 +- backend/secuscan/parser_sandbox.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index a8523a245..eb6cae0ec 100644 --- a/backend/secuscan/executor.py +++ b/backend/secuscan/executor.py @@ -1702,7 +1702,7 @@ def _parse_results(self, plugin, output: str) -> Dict[str, Any]: except Exception as exc: logger.error("Unexpected error running parser sandbox for '%s': %s", plugin.id, exc) raise RuntimeError( - f"Custom parser encountered an unexpected error for plugin '{plugin.id}'" + f"Custom parser encountered an unexpected error for plugin '{plugin.id}': {redact(str(exc))}" ) from exc # 2. Fallback to legacy built-in parsers (only reached when no parser.py exists) diff --git a/backend/secuscan/parser_sandbox.py b/backend/secuscan/parser_sandbox.py index 7d7c4d0d0..15677097b 100644 --- a/backend/secuscan/parser_sandbox.py +++ b/backend/secuscan/parser_sandbox.py @@ -51,6 +51,8 @@ from pathlib import Path from typing import Any, Dict +from .redaction import redact + logger = logging.getLogger(__name__) # Defaults — overridden by the Settings values passed at call time. @@ -84,11 +86,11 @@ class ParserSandboxError(RuntimeError): def __init__(self, plugin_id: str, reason: str, stderr: str = "") -> None: self.plugin_id = plugin_id self.reason = reason - # Keep stderr private; callers must not surface this to API consumers. + sanitized = redact(stderr[:2000]) if stderr else "" self._stderr_diagnostic: str = stderr - self.stderr_excerpt = stderr[:2000] if stderr else "" - # User-facing message: reason only — no stderr content. - super().__init__(f"Parser sandbox failed for '{plugin_id}' ({reason})") + self.stderr_excerpt = sanitized + detail = f": {sanitized[:200]}" if sanitized.strip() else "" + super().__init__(f"Parser sandbox failed for '{plugin_id}' ({reason}){detail}") # --------------------------------------------------------------------------- From 473dff727dbb8abfb0dfea037a4dda5ea42c89c6 Mon Sep 17 00:00:00 2001 From: ionfwsrijan Date: Tue, 14 Jul 2026 21:17:51 +0530 Subject: [PATCH 2/3] fix: remove stderr from API-facing error messages, add regression tests --- backend/secuscan/executor.py | 2 +- backend/secuscan/parser_sandbox.py | 6 ++-- testing/backend/unit/test_parser_sandbox.py | 34 +++++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index eb6cae0ec..a8523a245 100644 --- a/backend/secuscan/executor.py +++ b/backend/secuscan/executor.py @@ -1702,7 +1702,7 @@ def _parse_results(self, plugin, output: str) -> Dict[str, Any]: except Exception as exc: logger.error("Unexpected error running parser sandbox for '%s': %s", plugin.id, exc) raise RuntimeError( - f"Custom parser encountered an unexpected error for plugin '{plugin.id}': {redact(str(exc))}" + f"Custom parser encountered an unexpected error for plugin '{plugin.id}'" ) from exc # 2. Fallback to legacy built-in parsers (only reached when no parser.py exists) diff --git a/backend/secuscan/parser_sandbox.py b/backend/secuscan/parser_sandbox.py index 15677097b..86e420c20 100644 --- a/backend/secuscan/parser_sandbox.py +++ b/backend/secuscan/parser_sandbox.py @@ -86,11 +86,9 @@ class ParserSandboxError(RuntimeError): def __init__(self, plugin_id: str, reason: str, stderr: str = "") -> None: self.plugin_id = plugin_id self.reason = reason - sanitized = redact(stderr[:2000]) if stderr else "" self._stderr_diagnostic: str = stderr - self.stderr_excerpt = sanitized - detail = f": {sanitized[:200]}" if sanitized.strip() else "" - super().__init__(f"Parser sandbox failed for '{plugin_id}' ({reason}){detail}") + self.stderr_excerpt = redact(stderr[:2000]) if stderr else "" + super().__init__(f"Parser sandbox failed for '{plugin_id}' ({reason})") # --------------------------------------------------------------------------- diff --git a/testing/backend/unit/test_parser_sandbox.py b/testing/backend/unit/test_parser_sandbox.py index 6667b6b9f..34de5dd93 100644 --- a/testing/backend/unit/test_parser_sandbox.py +++ b/testing/backend/unit/test_parser_sandbox.py @@ -403,3 +403,37 @@ def test_stderr_excerpt_truncated_to_2000_chars(self): def test_str_contains_plugin_id(self): err = ParserSandboxError("my_plugin", "bad thing") assert "my_plugin" in str(err) + + def test_str_does_not_expose_stderr_secrets(self): + """Regression: API-facing str() must never leak parser stderr contents.""" + secret = "Bearer eyJhbGciOiJIUzI1NiJ9.secret_payload_12345" + api_key = "AKIA1234567890ABCDEF" + err = ParserSandboxError( + "plugin_secret", + "unexpected failure", + stderr=f"secret={secret} api_key={api_key}", + ) + msg = str(err) + assert secret not in msg + assert api_key not in msg + assert "eyJhbGciOiJIUzI1NiJ9" not in msg + assert "AKIA1234567890ABCDEF" not in msg + + def test_str_does_not_expose_arbitrary_stderr(self): + """Regression: arbitrary parser diagnostic output must not appear in str().""" + arbitrary_output = ( + "Password: hunter2\n" + "Internal path: /etc/shadow\n" + "Private key: -----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQ..." + ) + err = ParserSandboxError("plugin_arb", "crash", stderr=arbitrary_output) + msg = str(err) + assert "hunter2" not in msg + assert "/etc/shadow" not in msg + assert "BEGIN RSA PRIVATE KEY" not in msg + + def test_stderr_excerpt_is_available_for_diagnostics(self): + """Internal diagnostic attribute is still accessible for logging.""" + stderr = "diagnostic: segfault at 0xdeadbeef" + err = ParserSandboxError("plugin_diag", "signal", stderr=stderr) + assert err.stderr_excerpt == stderr From 7be9cb460be6f663ecb737ce88128bbdce8c7337 Mon Sep 17 00:00:00 2001 From: ionfwsrijan Date: Tue, 14 Jul 2026 22:50:45 +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 # ---------------------------------------------------------------------------