diff --git a/backend/secuscan/parser_sandbox.py b/backend/secuscan/parser_sandbox.py index 7d7c4d0d..86e420c2 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,10 +86,8 @@ 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. self._stderr_diagnostic: str = stderr - self.stderr_excerpt = stderr[:2000] if stderr else "" - # User-facing message: reason only — no stderr content. + 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 6667b6b9..34de5dd9 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 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 # ---------------------------------------------------------------------------