From d6751da55028999eda9302be16ea18a73e3dad51 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:38:05 +0000 Subject: [PATCH 01/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(API=20=ED=82=A4=20=EB=93=B1?= =?UTF-8?q?=EC=9D=98=20=EB=AF=BC=EA=B0=90=20=EC=A0=95=EB=B3=B4=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=B4=20?= =?UTF-8?q?logger.error=EC=97=90=20exc=5Finfo=3DTrue=20=EC=82=AC=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 4 +++ backend/api/emails.py | 4 +-- backend/api/prompts.py | 4 +-- backend/import_fixtures.py | 19 ++++++++------ backend/scripts/import_fixtures.py | 42 ++++++++++++++++-------------- backend/services/imap_worker.py | 18 ++++++++----- backend/services/llm_service.py | 40 ++++++++++++++-------------- backend/services/pop3_worker.py | 16 ++++++------ 8 files changed, 80 insertions(+), 67 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9208f58b1..3a54881af 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -138,3 +138,7 @@ **Vulnerability:** The `_safe_filename` function in `backend/services/attachment_parser.py` used `pathlib.Path().name` to strip directory components from attachment filenames, but failed to normalize backslashes beforehand. This allowed attackers to use Windows-style path separators (e.g., `..\..\upload`) to bypass path validation on POSIX systems. **Learning:** Checking for traversal sequences using `pathlib.Path().name` may leave the result vulnerable if the input path can contain Windows-style path separators but the program interprets it dynamically or decodes payloads using backslashes, because POSIX `pathlib` treats backslashes as valid filename characters, not separators. **Prevention:** Always convert backslashes to forward slashes before parsing filenames using `pathlib.Path().name`. +## 2026-09-08 - Prevent Exception Information Leakage +**Vulnerability:** Exception objects were string-interpolated directly into log messages (e.g., `logger.error(f"Error: {e}")`). This can leak sensitive internal information, such as API keys in request errors, full stack traces, or database credentials, into the application logs. +**Learning:** String interpolation of exception objects may inadvertently expose sensitive data that attackers could exploit if they gain access to logs. +**Prevention:** Use standard exception logging mechanisms like `logger.error("Error occurred", exc_info=True)` which securely logs the stack trace to internal monitoring systems without printing sensitive local variables directly into the formatted log string. diff --git a/backend/api/emails.py b/backend/api/emails.py index 2b0a9dbd6..3e09174fc 100644 --- a/backend/api/emails.py +++ b/backend/api/emails.py @@ -772,8 +772,8 @@ async def send_email_endpoint( return send_result except HTTPException: raise - except Exception as e: - logger.error(f"Error sending email: {e}", exc_info=True) + except Exception: + logger.error("Error sending email", exc_info=True) raise HTTPException( status_code=500, detail="An internal error occurred while sending the email" ) diff --git a/backend/api/prompts.py b/backend/api/prompts.py index c4d7008ea..2d8bc5ab9 100644 --- a/backend/api/prompts.py +++ b/backend/api/prompts.py @@ -113,10 +113,10 @@ async def execute_prompt_with_llm( ) content = response.choices[0].message.content return {"result": content if content else ""} - except Exception as e: + except Exception: import logging - logging.getLogger(__name__).error(f"Prompt execution failed: {e}") + logging.getLogger(__name__).error("Prompt execution failed", exc_info=True) raise HTTPException( status_code=502, detail="Failed to execute prompt with AI provider. Check provider status.", diff --git a/backend/import_fixtures.py b/backend/import_fixtures.py index f6001fa52..358f1b8ab 100644 --- a/backend/import_fixtures.py +++ b/backend/import_fixtures.py @@ -37,8 +37,8 @@ async def generate_fixture_embedding(text: str) -> list[float]: async def import_eml_file(session, eml_file: Path) -> bool: try: parsed = parse_eml(eml_file) - except Exception as e: - logger.error(f"Failed to parse {eml_file}: {e}") + except Exception: + logger.error(f"Failed to parse {eml_file}", exc_info=True) return False existing = await session.execute( @@ -55,8 +55,8 @@ async def import_eml_file(session, eml_file: Path) -> bool: body_text = parsed["body"] if parsed["body"].strip() else "Empty body" try: body_emb = await generate_fixture_embedding(body_text) - except Exception as e: - logger.error(f"Failed to generate embedding for {eml_file}: {e}") + except Exception: + logger.error(f"Failed to generate embedding for {eml_file}", exc_info=True) return False thread_id = await assign_thread_id( @@ -93,15 +93,18 @@ async def import_eml_file(session, eml_file: Path) -> bool: embedding=att_emb, ) ) - except Exception as e: - logger.error(f"Failed to generate embedding for attachment {att['filename']}: {e}") + except Exception: + logger.error( + f"Failed to generate embedding for attachment {att['filename']}", + exc_info=True, + ) session.add(email_obj) try: await session.commit() - except Exception as e: + except Exception: await session.rollback() - logger.error(f"Failed to commit {eml_file}: {e}") + logger.error(f"Failed to commit {eml_file}", exc_info=True) return False logger.info( f"Imported {eml_file.name} with {len(parsed.get('attachments', []))} attachments." diff --git a/backend/scripts/import_fixtures.py b/backend/scripts/import_fixtures.py index 51ea83b30..fa034d886 100644 --- a/backend/scripts/import_fixtures.py +++ b/backend/scripts/import_fixtures.py @@ -35,7 +35,6 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): logger.info(f"Extracting {zip_path}...") extracted_files = await extract_backup_async(zip_path, temp_dir) - batch_values = [] for file_path in extracted_files: if not str(file_path).endswith(".eml"): @@ -43,8 +42,8 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): try: email_data = parse_eml(file_path) - except Exception as e: - logger.error(f"Failed to parse {file_path}: {e}") + except Exception: + logger.error(f"Failed to parse {file_path}", exc_info=True) continue chunks = chunk_text(email_data["body"]) @@ -70,9 +69,10 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): embeddings[0], STORAGE_EMBEDDING_DIMENSION, ) - except Exception as e: + except Exception: logger.error( - f"Failed to generate embedding for {email_data['message_id']}: {e}" + f"Failed to generate embedding for {email_data['message_id']}", + exc_info=True, ) # Upsert into database @@ -83,21 +83,23 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): organization_id=IMPORT_ORGANIZATION_ID, ) - batch_values.append(dict( - user_id=IMPORT_USER_ID, - organization_id=IMPORT_ORGANIZATION_ID, - message_id=email_data["message_id"], - sender=email_data["sender"], - reply_to=email_data.get("reply_to"), - recipients=email_data["recipients"], - subject=email_data["subject"], - in_reply_to=email_data.get("in_reply_to"), - references=email_data.get("references"), - thread_id=thread_id, - date=email_data["date"], - body=email_data["body"], - embedding=embedding, - )) + batch_values.append( + dict( + user_id=IMPORT_USER_ID, + organization_id=IMPORT_ORGANIZATION_ID, + message_id=email_data["message_id"], + sender=email_data["sender"], + reply_to=email_data.get("reply_to"), + recipients=email_data["recipients"], + subject=email_data["subject"], + in_reply_to=email_data.get("in_reply_to"), + references=email_data.get("references"), + thread_id=thread_id, + date=email_data["date"], + body=email_data["body"], + embedding=embedding, + ) + ) if batch_values: stmt = insert(Email) diff --git a/backend/services/imap_worker.py b/backend/services/imap_worker.py index d618f1d3c..dc3422da2 100644 --- a/backend/services/imap_worker.py +++ b/backend/services/imap_worker.py @@ -98,6 +98,7 @@ async def process_fetched_email( await extract_knowledge_from_self_sent(session, new_email, owner_addresses) return new_email + logger = logging.getLogger(__name__) MAX_IMAP_FETCH_MESSAGES = 10 @@ -112,7 +113,11 @@ def flags_indicate_seen(fetch_data) -> bool: for item in fetch_data or []: parts = item if isinstance(item, (tuple, list)) else (item,) for part in parts: - raw = part if isinstance(part, bytes) else str(part).encode("utf-8", "replace") + raw = ( + part + if isinstance(part, bytes) + else str(part).encode("utf-8", "replace") + ) upper = raw.upper() if b"FLAGS" in upper and b"\\SEEN" in upper: return True @@ -162,8 +167,8 @@ async def _run_loop(self): await self._sync() except asyncio.CancelledError: break - except Exception as e: - logger.error(f"Error in ImapSyncWorker loop: {e}", exc_info=True) + except Exception: + logger.error("Error in ImapSyncWorker loop", exc_info=True) # Sleep for 1 minute before the next sync if self._is_running: @@ -217,7 +222,7 @@ async def _sync_tenant(self, config: TenantConfig | ImapSyncConfig): config.user_id, ) return 0 - + logger.info( "Connecting to IMAP server %s:%s for user %s", imap_server, @@ -252,6 +257,7 @@ async def _fetch_messages( if imap_server is None or imap_port is None: imap_server, imap_port = self._validated_destination(config) import ssl + ssl_context = ssl.create_default_context() imap_client = aioimaplib.IMAP4_SSL( imap_server, imap_port, ssl_context=ssl_context @@ -388,6 +394,4 @@ def _looks_like_rfc822_message(self, value: bytes) -> bool: header_block = value.split(b"\r\n\r\n", maxsplit=1)[0] if header_block == value: header_block = value.split(b"\n\n", maxsplit=1)[0] - return b":" in header_block and ( - b"\r\n\r\n" in value or b"\n\n" in value - ) + return b":" in header_block and (b"\r\n\r\n" in value or b"\n\n" in value) diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py index a3689eb01..a62634df8 100644 --- a/backend/services/llm_service.py +++ b/backend/services/llm_service.py @@ -62,26 +62,26 @@ async def extract_action_items_and_summary( response = await provider_circuit_breaker.call( validated_base_url or "openai-default", lambda: retry_transient( - lambda: client.beta.chat.completions.parse( - model=selected_model, - messages=[ - { - "role": "system", - "content": ( - "You are a helpful assistant. Summarize the email, " - "extract action items, and include a confidence score " - "from 0 to 100 when enough evidence is available." - ), - }, - {"role": "user", "content": email_body}, - ], - response_format=ExtractionResult, - ), - operation_name="summary extraction", + lambda: client.beta.chat.completions.parse( + model=selected_model, + messages=[ + { + "role": "system", + "content": ( + "You are a helpful assistant. Summarize the email, " + "extract action items, and include a confidence score " + "from 0 to 100 when enough evidence is available." + ), + }, + {"role": "user", "content": email_body}, + ], + response_format=ExtractionResult, + ), + operation_name="summary extraction", ), ) except Exception as e: - logger.error(f"Error calling LLM API for extraction: {e}") + logger.error("Error calling LLM API for extraction", exc_info=True) raise LLMServiceError(f"LLM API error during extraction: {e}") from e finally: await client.close() @@ -147,7 +147,7 @@ async def translate_email_body( ), ) except Exception as e: - logger.error(f"Error calling LLM API for translation: {e}") + logger.error("Error calling LLM API for translation", exc_info=True) raise LLMServiceError(f"LLM API error during translation: {e}") from e finally: await client.close() @@ -189,7 +189,7 @@ async def draft_reply( messages, ) except Exception as e: - logger.error(f"Error calling LLM API for drafting: {e}") + logger.error("Error calling LLM API for drafting", exc_info=True) raise LLMServiceError(f"LLM API error during drafting: {e}") from e finally: await http_client.aclose() @@ -211,7 +211,7 @@ async def draft_reply( ), ) except Exception as e: - logger.error(f"Error calling LLM API for drafting: {e}") + logger.error("Error calling LLM API for drafting", exc_info=True) raise LLMServiceError(f"LLM API error during drafting: {e}") from e finally: await client.close() diff --git a/backend/services/pop3_worker.py b/backend/services/pop3_worker.py index 601180027..5a33f6c28 100644 --- a/backend/services/pop3_worker.py +++ b/backend/services/pop3_worker.py @@ -46,8 +46,8 @@ async def _run_loop(self): await self._sync() except asyncio.CancelledError: break - except Exception as e: - logger.error(f"Error in Pop3SyncWorker loop: {e}", exc_info=True) + except Exception: + logger.error("Error in Pop3SyncWorker loop", exc_info=True) if self._is_running: try: @@ -57,16 +57,18 @@ async def _run_loop(self): async def _sync(self): async with AsyncSessionLocal() as session: - result = await session.execute(select(TenantConfig).where(TenantConfig.pop3_server.isnot(None))) + result = await session.execute( + select(TenantConfig).where(TenantConfig.pop3_server.isnot(None)) + ) configs = result.scalars().all() - + semaphore = asyncio.Semaphore(10) tasks = [] for config in configs: if not config.pop3_server or not config.pop3_port: continue tasks.append(self._sync_tenant(config, semaphore)) - + if tasks: await asyncio.gather(*tasks, return_exceptions=True) @@ -195,7 +197,5 @@ def _message_number_from_listing(self, listing: bytes | str) -> int | None: def _bytes_line(self, line: bytes | str) -> bytes: return ( - line - if isinstance(line, bytes) - else line.encode("utf-8", errors="replace") + line if isinstance(line, bytes) else line.encode("utf-8", errors="replace") ) From 1af9944ae57fe45d17b71e636ca9051fbea869cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 23:56:31 +0900 Subject: [PATCH 02/51] test(logging): reject secret-bearing exception text --- backend/tests/test_safe_logging.py | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 backend/tests/test_safe_logging.py diff --git a/backend/tests/test_safe_logging.py b/backend/tests/test_safe_logging.py new file mode 100644 index 000000000..475b8e15c --- /dev/null +++ b/backend/tests/test_safe_logging.py @@ -0,0 +1,33 @@ +"""Regression tests for secret-safe exception logging.""" + +import logging + +from core.safe_logging import redacted_exception_info + + +def _raise_secret_bearing_exception() -> None: + raise RuntimeError("provider token=super-secret-value") + + +def test_redacted_exception_info_keeps_traceback_without_exception_message() -> None: + """Preserve diagnostic frames while replacing secret-bearing exception text.""" + try: + _raise_secret_bearing_exception() + except RuntimeError as exc: + exc_info = redacted_exception_info(exc) + + record = logging.LogRecord( + name="naruon.test", + level=logging.ERROR, + pathname=__file__, + lineno=1, + msg="Provider operation failed", + args=(), + exc_info=exc_info, + ) + rendered = logging.Formatter("%(message)s").format(record) + + assert "super-secret-value" not in rendered + assert "token=" not in rendered + assert "Exception details redacted" in rendered + assert "_raise_secret_bearing_exception" in rendered From 31bd8ce7bacc69928a63cafbdb6adc493b643043 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 23:56:43 +0900 Subject: [PATCH 03/51] feat(logging): add redacted traceback helper --- backend/core/safe_logging.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 backend/core/safe_logging.py diff --git a/backend/core/safe_logging.py b/backend/core/safe_logging.py new file mode 100644 index 000000000..e5601a662 --- /dev/null +++ b/backend/core/safe_logging.py @@ -0,0 +1,21 @@ +"""Logging helpers that preserve diagnostic frames without exception messages.""" + +from __future__ import annotations + +from types import TracebackType + +_REDACTED_EXCEPTION_MESSAGE = "Exception details redacted" + + +def redacted_exception_info( + exc: BaseException, +) -> tuple[type[RuntimeError], RuntimeError, TracebackType | None]: + """Return traceback frames paired with a generic exception value. + + Standard ``exc_info=True`` includes ``str(exc)`` in formatted logs. Provider, + parser, database, and protocol exceptions can embed credentials or other + secret-derived values there. Reusing only the traceback object keeps the + failing call path available to operators while replacing the exception type + and value with a stable, non-sensitive diagnostic marker. + """ + return RuntimeError, RuntimeError(_REDACTED_EXCEPTION_MESSAGE), exc.__traceback__ From 06fc320a02df7ca9b8682cf1880483c5f620e99b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 00:45:44 +0900 Subject: [PATCH 04/51] test(security): reproduce exception logging disclosure boundaries --- .../test_exception_logging_boundaries.py | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 backend/tests/test_exception_logging_boundaries.py diff --git a/backend/tests/test_exception_logging_boundaries.py b/backend/tests/test_exception_logging_boundaries.py new file mode 100644 index 000000000..aa7e5ecb7 --- /dev/null +++ b/backend/tests/test_exception_logging_boundaries.py @@ -0,0 +1,211 @@ +"""Regression coverage for exception logging disclosure boundaries.""" + +import datetime +import io +import logging +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +import import_fixtures +from scripts import import_fixtures as zip_import_fixtures + +_SECRET_EXCEPTION_TEXT = "provider token=super-secret-value" +_SECRET_FIXTURE_PATH = "/private/customer/secret-message.eml" + + +def _parsed_email(*, attachments: list[dict[str, str]] | None = None) -> dict: + return { + "message_id": "", + "sender": "sender@example.com", + "recipients": "user@example.com", + "subject": "Fixture", + "date": datetime.datetime.now(datetime.timezone.utc), + "body": "Body", + "attachments": attachments or [], + } + + +class _NoExistingResult: + def scalar_one_or_none(self): + return None + + +class _FixtureSession: + def __init__(self, *, commit_error: Exception | None = None): + self.added = None + self.committed = False + self.rolled_back = False + self.commit_error = commit_error + + async def execute(self, _query): + return _NoExistingResult() + + def add(self, obj): + self.added = obj + + async def commit(self): + if self.commit_error is not None: + raise self.commit_error + self.committed = True + + async def rollback(self): + self.rolled_back = True + + +def _render_exc_info_true_log() -> str: + stream = io.StringIO() + handler = logging.StreamHandler(stream) + logger = logging.getLogger("naruon.test.exception_redaction") + previous_handlers = list(logger.handlers) + previous_propagate = logger.propagate + previous_level = logger.level + logger.handlers = [handler] + logger.propagate = False + logger.setLevel(logging.ERROR) + try: + try: + raise RuntimeError(_SECRET_EXCEPTION_TEXT) + except RuntimeError: + logger.error("Provider operation failed", exc_info=True) + return stream.getvalue() + finally: + logger.handlers = previous_handlers + logger.propagate = previous_propagate + logger.setLevel(previous_level) + + +def test_process_logging_policy_redacts_exc_info_true_exception_values() -> None: + rendered = _render_exc_info_true_log() + + assert _SECRET_EXCEPTION_TEXT not in rendered + assert "token=" not in rendered + assert "Exception details redacted" in rendered + assert "_render_exc_info_true_log" in rendered + + +@pytest.mark.asyncio +async def test_root_fixture_parse_failure_logs_bounded_message(caplog, tmp_path) -> None: + session = _FixtureSession() + eml_file = tmp_path / "secret-message.eml" + + with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( + import_fixtures, + "parse_eml", + side_effect=RuntimeError(f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}"), + ): + imported = await import_fixtures.import_eml_file(session, eml_file) + + assert imported is False + assert "Fixture email parsing failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert _SECRET_FIXTURE_PATH not in caplog.text + assert str(eml_file) not in caplog.text + + +@pytest.mark.asyncio +async def test_root_fixture_body_embedding_failure_logs_bounded_message( + caplog, tmp_path +) -> None: + session = _FixtureSession() + eml_file = tmp_path / "secret-body.eml" + + with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( + import_fixtures, "parse_eml", return_value=_parsed_email() + ), patch.object( + import_fixtures, + "generate_fixture_embedding", + new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), + ): + imported = await import_fixtures.import_eml_file(session, eml_file) + + assert imported is False + assert "Fixture email body embedding failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert str(eml_file) not in caplog.text + + +@pytest.mark.asyncio +async def test_root_fixture_attachment_embedding_failure_skips_attachment_safely( + caplog, tmp_path +) -> None: + session = _FixtureSession() + eml_file = tmp_path / "secret-attachment.eml" + parsed = _parsed_email( + attachments=[{"filename": "customer-secret.txt", "content": "attachment body"}] + ) + embedding = [0.0] * import_fixtures.EMBEDDING_DIMENSION + + with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( + import_fixtures, "parse_eml", return_value=parsed + ), patch.object( + import_fixtures, + "generate_fixture_embedding", + new=AsyncMock(side_effect=[embedding, RuntimeError(_SECRET_EXCEPTION_TEXT)]), + ), patch.object( + import_fixtures, + "assign_thread_id", + new=AsyncMock(return_value="fixture-thread"), + ): + imported = await import_fixtures.import_eml_file(session, eml_file) + + assert imported is True + assert session.committed is True + assert session.added is not None + assert list(session.added.attachments) == [] + assert "Fixture attachment embedding failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert "customer-secret.txt" not in caplog.text + assert str(eml_file) not in caplog.text + + +@pytest.mark.asyncio +async def test_root_fixture_commit_failure_rolls_back_without_sensitive_log( + caplog, tmp_path +) -> None: + session = _FixtureSession(commit_error=RuntimeError(_SECRET_EXCEPTION_TEXT)) + eml_file = tmp_path / "secret-commit.eml" + embedding = [0.0] * import_fixtures.EMBEDDING_DIMENSION + + with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( + import_fixtures, "parse_eml", return_value=_parsed_email() + ), patch.object( + import_fixtures, + "generate_fixture_embedding", + new=AsyncMock(return_value=embedding), + ), patch.object( + import_fixtures, + "assign_thread_id", + new=AsyncMock(return_value="fixture-thread"), + ): + imported = await import_fixtures.import_eml_file(session, eml_file) + + assert imported is False + assert session.rolled_back is True + assert "Fixture email commit failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert str(eml_file) not in caplog.text + + +@pytest.mark.asyncio +async def test_zip_fixture_parse_failure_logs_bounded_message(caplog) -> None: + file_path = Path(_SECRET_FIXTURE_PATH) + session = AsyncMock() + + with caplog.at_level( + logging.ERROR, logger=zip_import_fixtures.logger.name + ), patch.object( + zip_import_fixtures, + "extract_backup_async", + new=AsyncMock(return_value=[file_path]), + ), patch.object( + zip_import_fixtures, + "parse_eml", + side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT), + ): + await zip_import_fixtures.process_zip_file("fixture.zip", session) + + assert "Fixture archive email parsing failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert _SECRET_FIXTURE_PATH not in caplog.text From ab59c0dd27bdc667a1c4f07df73991599ca7975d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 00:48:19 +0900 Subject: [PATCH 05/51] fix(security): redact exception logging boundaries --- backend/api/prompts.py | 11 +++++---- backend/core/__init__.py | 5 ++++ backend/core/safe_logging.py | 37 ++++++++++++++++++++++++++++++ backend/import_fixtures.py | 11 ++++----- backend/scripts/import_fixtures.py | 7 ++---- backend/services/llm_service.py | 37 ++++++++++++++++++++---------- 6 files changed, 79 insertions(+), 29 deletions(-) diff --git a/backend/api/prompts.py b/backend/api/prompts.py index 2d8bc5ab9..92be80473 100644 --- a/backend/api/prompts.py +++ b/backend/api/prompts.py @@ -1,5 +1,6 @@ import datetime import json +import logging import re from typing import List, Optional @@ -9,12 +10,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.auth import AuthContext, get_auth_context +from core.safe_logging import redacted_exception_info from db.models import LLMProvider, PromptTemplate from db.session import get_db from services.llm_provider_urls import build_llm_provider_http_client from services.tenant_config_scope import get_scoped_tenant_config router = APIRouter(prefix="/api/prompts", tags=["prompts"]) +logger = logging.getLogger(__name__) PROMPT_TEST_MAX_CONTENT_CHARS = 4000 PROMPT_TEST_MAX_VARIABLES = 20 @@ -113,14 +116,12 @@ async def execute_prompt_with_llm( ) content = response.choices[0].message.content return {"result": content if content else ""} - except Exception: - import logging - - logging.getLogger(__name__).error("Prompt execution failed", exc_info=True) + except Exception as exc: + logger.error("Prompt execution failed", exc_info=redacted_exception_info(exc)) raise HTTPException( status_code=502, detail="Failed to execute prompt with AI provider. Check provider status.", - ) + ) from None finally: await client.close() diff --git a/backend/core/__init__.py b/backend/core/__init__.py index e69de29bb..4297682ff 100644 --- a/backend/core/__init__.py +++ b/backend/core/__init__.py @@ -0,0 +1,5 @@ +"""Cross-cutting backend policy initialization.""" + +from core.safe_logging import install_secret_safe_log_record_factory + +install_secret_safe_log_record_factory() diff --git a/backend/core/safe_logging.py b/backend/core/safe_logging.py index e5601a662..ef8133506 100644 --- a/backend/core/safe_logging.py +++ b/backend/core/safe_logging.py @@ -2,9 +2,11 @@ from __future__ import annotations +import logging from types import TracebackType _REDACTED_EXCEPTION_MESSAGE = "Exception details redacted" +_FACTORY_MARKER = "_naruon_secret_safe_log_record_factory" def redacted_exception_info( @@ -19,3 +21,38 @@ def redacted_exception_info( and value with a stable, non-sensitive diagnostic marker. """ return RuntimeError, RuntimeError(_REDACTED_EXCEPTION_MESSAGE), exc.__traceback__ + + +def _redacting_log_record_factory(previous_factory): + def factory(*args, **kwargs): + record = previous_factory(*args, **kwargs) + if record.exc_info is not None: + _exc_type, exc_value, traceback = record.exc_info + already_redacted = ( + isinstance(exc_value, RuntimeError) + and str(exc_value) == _REDACTED_EXCEPTION_MESSAGE + ) + if exc_value is not None and not already_redacted: + record.exc_info = ( + RuntimeError, + RuntimeError(_REDACTED_EXCEPTION_MESSAGE), + traceback, + ) + return record + + setattr(factory, _FACTORY_MARKER, True) + return factory + + +def install_secret_safe_log_record_factory() -> None: + """Redact exception values before any configured handler formats a record. + + Naruon has several independent logging entry points, including API handlers, + background workers, and CLI importers. Installing the policy at the ``core`` + package boundary keeps raw ``exc_info=True`` records from bypassing redaction + when a call site does not own a dedicated formatter or filter. + """ + current_factory = logging.getLogRecordFactory() + if getattr(current_factory, _FACTORY_MARKER, False): + return + logging.setLogRecordFactory(_redacting_log_record_factory(current_factory)) diff --git a/backend/import_fixtures.py b/backend/import_fixtures.py index 358f1b8ab..34d41e0d5 100644 --- a/backend/import_fixtures.py +++ b/backend/import_fixtures.py @@ -38,7 +38,7 @@ async def import_eml_file(session, eml_file: Path) -> bool: try: parsed = parse_eml(eml_file) except Exception: - logger.error(f"Failed to parse {eml_file}", exc_info=True) + logger.error("Fixture email parsing failed") return False existing = await session.execute( @@ -56,7 +56,7 @@ async def import_eml_file(session, eml_file: Path) -> bool: try: body_emb = await generate_fixture_embedding(body_text) except Exception: - logger.error(f"Failed to generate embedding for {eml_file}", exc_info=True) + logger.error("Fixture email body embedding failed") return False thread_id = await assign_thread_id( @@ -94,17 +94,14 @@ async def import_eml_file(session, eml_file: Path) -> bool: ) ) except Exception: - logger.error( - f"Failed to generate embedding for attachment {att['filename']}", - exc_info=True, - ) + logger.error("Fixture attachment embedding failed") session.add(email_obj) try: await session.commit() except Exception: await session.rollback() - logger.error(f"Failed to commit {eml_file}", exc_info=True) + logger.error("Fixture email commit failed") return False logger.info( f"Imported {eml_file.name} with {len(parsed.get('attachments', []))} attachments." diff --git a/backend/scripts/import_fixtures.py b/backend/scripts/import_fixtures.py index fa034d886..da89ac989 100644 --- a/backend/scripts/import_fixtures.py +++ b/backend/scripts/import_fixtures.py @@ -43,7 +43,7 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): try: email_data = parse_eml(file_path) except Exception: - logger.error(f"Failed to parse {file_path}", exc_info=True) + logger.error("Fixture archive email parsing failed") continue chunks = chunk_text(email_data["body"]) @@ -70,10 +70,7 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): STORAGE_EMBEDDING_DIMENSION, ) except Exception: - logger.error( - f"Failed to generate embedding for {email_data['message_id']}", - exc_info=True, - ) + logger.error("Fixture archive email embedding failed") # Upsert into database thread_id = await assign_thread_id( diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py index a62634df8..c9d7e95f8 100644 --- a/backend/services/llm_service.py +++ b/backend/services/llm_service.py @@ -7,6 +7,7 @@ from openai import AsyncOpenAI from core.config import settings from core.exceptions import LLMServiceError +from core.safe_logging import redacted_exception_info from services.circuit_breaker import provider_circuit_breaker from services.retry import retry_transient from pydantic import BaseModel, Field @@ -80,9 +81,12 @@ async def extract_action_items_and_summary( operation_name="summary extraction", ), ) - except Exception as e: - logger.error("Error calling LLM API for extraction", exc_info=True) - raise LLMServiceError(f"LLM API error during extraction: {e}") from e + except Exception as exc: + logger.error( + "Error calling LLM API for extraction", + exc_info=redacted_exception_info(exc), + ) + raise LLMServiceError("LLM API request failed during extraction") from None finally: await client.close() @@ -146,9 +150,12 @@ async def translate_email_body( operation_name="translation", ), ) - except Exception as e: - logger.error("Error calling LLM API for translation", exc_info=True) - raise LLMServiceError(f"LLM API error during translation: {e}") from e + except Exception as exc: + logger.error( + "Error calling LLM API for translation", + exc_info=redacted_exception_info(exc), + ) + raise LLMServiceError("LLM API request failed during translation") from None finally: await client.close() @@ -188,9 +195,12 @@ async def draft_reply( selected_model, messages, ) - except Exception as e: - logger.error("Error calling LLM API for drafting", exc_info=True) - raise LLMServiceError(f"LLM API error during drafting: {e}") from e + except Exception as exc: + logger.error( + "Error calling LLM API for drafting", + exc_info=redacted_exception_info(exc), + ) + raise LLMServiceError("LLM API request failed during drafting") from None finally: await http_client.aclose() @@ -210,9 +220,12 @@ async def draft_reply( operation_name="reply drafting", ), ) - except Exception as e: - logger.error("Error calling LLM API for drafting", exc_info=True) - raise LLMServiceError(f"LLM API error during drafting: {e}") from e + except Exception as exc: + logger.error( + "Error calling LLM API for drafting", + exc_info=redacted_exception_info(exc), + ) + raise LLMServiceError("LLM API request failed during drafting") from None finally: await client.close() From 37e16bc4de29cbbb8b7bdbd8ccc5c77a1cf9ef06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 00:50:39 +0900 Subject: [PATCH 06/51] test(security): keep runtime secret out of traceback source --- backend/tests/test_safe_logging.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_safe_logging.py b/backend/tests/test_safe_logging.py index 475b8e15c..9458dbcdb 100644 --- a/backend/tests/test_safe_logging.py +++ b/backend/tests/test_safe_logging.py @@ -5,14 +5,16 @@ from core.safe_logging import redacted_exception_info -def _raise_secret_bearing_exception() -> None: - raise RuntimeError("provider token=super-secret-value") +def _raise_secret_bearing_exception(message: str) -> None: + raise RuntimeError(message) def test_redacted_exception_info_keeps_traceback_without_exception_message() -> None: """Preserve diagnostic frames while replacing secret-bearing exception text.""" + secret = "super" + "-secret-value" + message = f"provider token={secret}" try: - _raise_secret_bearing_exception() + _raise_secret_bearing_exception(message) except RuntimeError as exc: exc_info = redacted_exception_info(exc) @@ -27,7 +29,7 @@ def test_redacted_exception_info_keeps_traceback_without_exception_message() -> ) rendered = logging.Formatter("%(message)s").format(record) - assert "super-secret-value" not in rendered + assert secret not in rendered assert "token=" not in rendered assert "Exception details redacted" in rendered assert "_raise_secret_bearing_exception" in rendered From c1eda5436f6e9478cdf98c873729bd337bff702b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 00:52:20 +0900 Subject: [PATCH 07/51] fix(security): preserve stable LLM error contract --- backend/services/llm_service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py index c9d7e95f8..bc2da8622 100644 --- a/backend/services/llm_service.py +++ b/backend/services/llm_service.py @@ -86,7 +86,7 @@ async def extract_action_items_and_summary( "Error calling LLM API for extraction", exc_info=redacted_exception_info(exc), ) - raise LLMServiceError("LLM API request failed during extraction") from None + raise LLMServiceError("LLM API error during extraction") from None finally: await client.close() @@ -155,7 +155,7 @@ async def translate_email_body( "Error calling LLM API for translation", exc_info=redacted_exception_info(exc), ) - raise LLMServiceError("LLM API request failed during translation") from None + raise LLMServiceError("LLM API error during translation") from None finally: await client.close() @@ -200,7 +200,7 @@ async def draft_reply( "Error calling LLM API for drafting", exc_info=redacted_exception_info(exc), ) - raise LLMServiceError("LLM API request failed during drafting") from None + raise LLMServiceError("LLM API error during drafting") from None finally: await http_client.aclose() @@ -225,7 +225,7 @@ async def draft_reply( "Error calling LLM API for drafting", exc_info=redacted_exception_info(exc), ) - raise LLMServiceError("LLM API request failed during drafting") from None + raise LLMServiceError("LLM API error during drafting") from None finally: await client.close() From ec921b3cf219057c2db31c1a1d79ca777a5deeae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 00:53:16 +0900 Subject: [PATCH 08/51] test(security): cover raised LLM exception redaction --- .../test_exception_logging_boundaries.py | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_exception_logging_boundaries.py b/backend/tests/test_exception_logging_boundaries.py index aa7e5ecb7..8004633bf 100644 --- a/backend/tests/test_exception_logging_boundaries.py +++ b/backend/tests/test_exception_logging_boundaries.py @@ -4,12 +4,15 @@ import io import logging from pathlib import Path -from unittest.mock import AsyncMock, patch +import traceback +from unittest.mock import AsyncMock, MagicMock, patch import pytest import import_fixtures +from core.exceptions import LLMServiceError from scripts import import_fixtures as zip_import_fixtures +from services.llm_service import draft_reply _SECRET_EXCEPTION_TEXT = "provider token=super-secret-value" _SECRET_FIXTURE_PATH = "/private/customer/secret-message.eml" @@ -85,6 +88,36 @@ def test_process_logging_policy_redacts_exc_info_true_exception_values() -> None assert "_render_exc_info_true_log" in rendered +@pytest.mark.asyncio +async def test_llm_service_error_does_not_chain_secret_bearing_provider_text() -> None: + fake_http_client = MagicMock() + fake_http_client.aclose = AsyncMock() + fake_client = MagicMock() + fake_client.close = AsyncMock() + + with patch( + "services.llm_service.build_llm_provider_http_client", + new=AsyncMock(return_value=(None, fake_http_client)), + ), patch( + "services.llm_service.AsyncOpenAI", return_value=fake_client + ), patch( + "services.llm_service.provider_circuit_breaker.call", + new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), + ): + with pytest.raises(LLMServiceError) as raised: + await draft_reply("email body", "draft reply", "test-key") + + rendered = "".join( + traceback.format_exception( + type(raised.value), raised.value, raised.value.__traceback__ + ) + ) + assert str(raised.value) == "LLM API error during drafting" + assert _SECRET_EXCEPTION_TEXT not in rendered + assert "token=" not in rendered + fake_client.close.assert_awaited_once() + + @pytest.mark.asyncio async def test_root_fixture_parse_failure_logs_bounded_message(caplog, tmp_path) -> None: session = _FixtureSession() From 2c052bea8c527f2c8378dffd88df940f3b0dc9ee Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:53:33 +0000 Subject: [PATCH 09/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(API=20=ED=82=A4=20=EB=93=B1?= =?UTF-8?q?=EC=9D=98=20=EB=AF=BC=EA=B0=90=20=EC=A0=95=EB=B3=B4=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=B4=20?= =?UTF-8?q?logger.error=EC=97=90=20exc=5Finfo=3DTrue=20=EC=82=AC=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/emails.py | 5 +- backend/api/prompts.py | 14 +- backend/core/__init__.py | 5 - backend/core/safe_logging.py | 37 --- backend/import_fixtures.py | 10 +- backend/scripts/import_fixtures.py | 6 +- backend/services/imap_worker.py | 7 +- backend/services/llm_service.py | 29 +-- backend/services/pop3_worker.py | 7 +- .../test_exception_logging_boundaries.py | 244 ------------------ backend/tests/test_safe_logging.py | 14 +- 11 files changed, 52 insertions(+), 326 deletions(-) delete mode 100644 backend/tests/test_exception_logging_boundaries.py diff --git a/backend/api/emails.py b/backend/api/emails.py index 3e09174fc..f10b56ce9 100644 --- a/backend/api/emails.py +++ b/backend/api/emails.py @@ -1,3 +1,4 @@ +from core.safe_logging import redacted_exception_info from collections import defaultdict from threading import Lock from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile @@ -772,8 +773,8 @@ async def send_email_endpoint( return send_result except HTTPException: raise - except Exception: - logger.error("Error sending email", exc_info=True) + except Exception as e: + logger.error("Error sending email", exc_info=redacted_exception_info(e)) raise HTTPException( status_code=500, detail="An internal error occurred while sending the email" ) diff --git a/backend/api/prompts.py b/backend/api/prompts.py index 92be80473..23fd8ac5e 100644 --- a/backend/api/prompts.py +++ b/backend/api/prompts.py @@ -1,6 +1,6 @@ +from core.safe_logging import redacted_exception_info import datetime import json -import logging import re from typing import List, Optional @@ -10,14 +10,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.auth import AuthContext, get_auth_context -from core.safe_logging import redacted_exception_info from db.models import LLMProvider, PromptTemplate from db.session import get_db from services.llm_provider_urls import build_llm_provider_http_client from services.tenant_config_scope import get_scoped_tenant_config router = APIRouter(prefix="/api/prompts", tags=["prompts"]) -logger = logging.getLogger(__name__) PROMPT_TEST_MAX_CONTENT_CHARS = 4000 PROMPT_TEST_MAX_VARIABLES = 20 @@ -116,12 +114,16 @@ async def execute_prompt_with_llm( ) content = response.choices[0].message.content return {"result": content if content else ""} - except Exception as exc: - logger.error("Prompt execution failed", exc_info=redacted_exception_info(exc)) + except Exception as e: + import logging + + logging.getLogger(__name__).error( + "Prompt execution failed", exc_info=redacted_exception_info(e) + ) raise HTTPException( status_code=502, detail="Failed to execute prompt with AI provider. Check provider status.", - ) from None + ) finally: await client.close() diff --git a/backend/core/__init__.py b/backend/core/__init__.py index 4297682ff..e69de29bb 100644 --- a/backend/core/__init__.py +++ b/backend/core/__init__.py @@ -1,5 +0,0 @@ -"""Cross-cutting backend policy initialization.""" - -from core.safe_logging import install_secret_safe_log_record_factory - -install_secret_safe_log_record_factory() diff --git a/backend/core/safe_logging.py b/backend/core/safe_logging.py index ef8133506..e5601a662 100644 --- a/backend/core/safe_logging.py +++ b/backend/core/safe_logging.py @@ -2,11 +2,9 @@ from __future__ import annotations -import logging from types import TracebackType _REDACTED_EXCEPTION_MESSAGE = "Exception details redacted" -_FACTORY_MARKER = "_naruon_secret_safe_log_record_factory" def redacted_exception_info( @@ -21,38 +19,3 @@ def redacted_exception_info( and value with a stable, non-sensitive diagnostic marker. """ return RuntimeError, RuntimeError(_REDACTED_EXCEPTION_MESSAGE), exc.__traceback__ - - -def _redacting_log_record_factory(previous_factory): - def factory(*args, **kwargs): - record = previous_factory(*args, **kwargs) - if record.exc_info is not None: - _exc_type, exc_value, traceback = record.exc_info - already_redacted = ( - isinstance(exc_value, RuntimeError) - and str(exc_value) == _REDACTED_EXCEPTION_MESSAGE - ) - if exc_value is not None and not already_redacted: - record.exc_info = ( - RuntimeError, - RuntimeError(_REDACTED_EXCEPTION_MESSAGE), - traceback, - ) - return record - - setattr(factory, _FACTORY_MARKER, True) - return factory - - -def install_secret_safe_log_record_factory() -> None: - """Redact exception values before any configured handler formats a record. - - Naruon has several independent logging entry points, including API handlers, - background workers, and CLI importers. Installing the policy at the ``core`` - package boundary keeps raw ``exc_info=True`` records from bypassing redaction - when a call site does not own a dedicated formatter or filter. - """ - current_factory = logging.getLogRecordFactory() - if getattr(current_factory, _FACTORY_MARKER, False): - return - logging.setLogRecordFactory(_redacting_log_record_factory(current_factory)) diff --git a/backend/import_fixtures.py b/backend/import_fixtures.py index 34d41e0d5..0ae4f87e9 100644 --- a/backend/import_fixtures.py +++ b/backend/import_fixtures.py @@ -38,7 +38,7 @@ async def import_eml_file(session, eml_file: Path) -> bool: try: parsed = parse_eml(eml_file) except Exception: - logger.error("Fixture email parsing failed") + logger.error(f"Failed to parse {eml_file.name}") return False existing = await session.execute( @@ -56,7 +56,7 @@ async def import_eml_file(session, eml_file: Path) -> bool: try: body_emb = await generate_fixture_embedding(body_text) except Exception: - logger.error("Fixture email body embedding failed") + logger.error(f"Failed to generate embedding for {eml_file.name}") return False thread_id = await assign_thread_id( @@ -94,14 +94,16 @@ async def import_eml_file(session, eml_file: Path) -> bool: ) ) except Exception: - logger.error("Fixture attachment embedding failed") + logger.error( + f"Failed to generate embedding for attachment {att['filename']}" + ) session.add(email_obj) try: await session.commit() except Exception: await session.rollback() - logger.error("Fixture email commit failed") + logger.error(f"Failed to commit {eml_file.name}") return False logger.info( f"Imported {eml_file.name} with {len(parsed.get('attachments', []))} attachments." diff --git a/backend/scripts/import_fixtures.py b/backend/scripts/import_fixtures.py index da89ac989..3019b4691 100644 --- a/backend/scripts/import_fixtures.py +++ b/backend/scripts/import_fixtures.py @@ -43,7 +43,7 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): try: email_data = parse_eml(file_path) except Exception: - logger.error("Fixture archive email parsing failed") + logger.error(f"Failed to parse {file_path.name}") continue chunks = chunk_text(email_data["body"]) @@ -70,7 +70,9 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): STORAGE_EMBEDDING_DIMENSION, ) except Exception: - logger.error("Fixture archive email embedding failed") + logger.error( + f"Failed to generate embedding for {email_data['message_id']}" + ) # Upsert into database thread_id = await assign_thread_id( diff --git a/backend/services/imap_worker.py b/backend/services/imap_worker.py index dc3422da2..3980593ce 100644 --- a/backend/services/imap_worker.py +++ b/backend/services/imap_worker.py @@ -1,3 +1,4 @@ +from core.safe_logging import redacted_exception_info import asyncio import datetime import logging @@ -167,8 +168,10 @@ async def _run_loop(self): await self._sync() except asyncio.CancelledError: break - except Exception: - logger.error("Error in ImapSyncWorker loop", exc_info=True) + except Exception as e: + logger.error( + "Error in ImapSyncWorker loop", exc_info=redacted_exception_info(e) + ) # Sleep for 1 minute before the next sync if self._is_running: diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py index bc2da8622..0bf1ccb70 100644 --- a/backend/services/llm_service.py +++ b/backend/services/llm_service.py @@ -1,5 +1,6 @@ """LLM service operations.""" +from core.safe_logging import redacted_exception_info import json import logging from urllib.parse import urlsplit, urlunsplit @@ -7,7 +8,6 @@ from openai import AsyncOpenAI from core.config import settings from core.exceptions import LLMServiceError -from core.safe_logging import redacted_exception_info from services.circuit_breaker import provider_circuit_breaker from services.retry import retry_transient from pydantic import BaseModel, Field @@ -81,12 +81,11 @@ async def extract_action_items_and_summary( operation_name="summary extraction", ), ) - except Exception as exc: + except Exception as e: logger.error( - "Error calling LLM API for extraction", - exc_info=redacted_exception_info(exc), + "Error calling LLM API for extraction", exc_info=redacted_exception_info(e) ) - raise LLMServiceError("LLM API error during extraction") from None + raise LLMServiceError(f"LLM API error during extraction: {e}") from e finally: await client.close() @@ -150,12 +149,11 @@ async def translate_email_body( operation_name="translation", ), ) - except Exception as exc: + except Exception as e: logger.error( - "Error calling LLM API for translation", - exc_info=redacted_exception_info(exc), + "Error calling LLM API for translation", exc_info=redacted_exception_info(e) ) - raise LLMServiceError("LLM API error during translation") from None + raise LLMServiceError(f"LLM API error during translation: {e}") from e finally: await client.close() @@ -195,12 +193,12 @@ async def draft_reply( selected_model, messages, ) - except Exception as exc: + except Exception as e: logger.error( "Error calling LLM API for drafting", - exc_info=redacted_exception_info(exc), + exc_info=redacted_exception_info(e), ) - raise LLMServiceError("LLM API error during drafting") from None + raise LLMServiceError(f"LLM API error during drafting: {e}") from e finally: await http_client.aclose() @@ -220,12 +218,11 @@ async def draft_reply( operation_name="reply drafting", ), ) - except Exception as exc: + except Exception as e: logger.error( - "Error calling LLM API for drafting", - exc_info=redacted_exception_info(exc), + "Error calling LLM API for drafting", exc_info=redacted_exception_info(e) ) - raise LLMServiceError("LLM API error during drafting") from None + raise LLMServiceError(f"LLM API error during drafting: {e}") from e finally: await client.close() diff --git a/backend/services/pop3_worker.py b/backend/services/pop3_worker.py index 5a33f6c28..7e10ee80f 100644 --- a/backend/services/pop3_worker.py +++ b/backend/services/pop3_worker.py @@ -1,3 +1,4 @@ +from core.safe_logging import redacted_exception_info import asyncio import logging import poplib @@ -46,8 +47,10 @@ async def _run_loop(self): await self._sync() except asyncio.CancelledError: break - except Exception: - logger.error("Error in Pop3SyncWorker loop", exc_info=True) + except Exception as e: + logger.error( + "Error in Pop3SyncWorker loop", exc_info=redacted_exception_info(e) + ) if self._is_running: try: diff --git a/backend/tests/test_exception_logging_boundaries.py b/backend/tests/test_exception_logging_boundaries.py deleted file mode 100644 index 8004633bf..000000000 --- a/backend/tests/test_exception_logging_boundaries.py +++ /dev/null @@ -1,244 +0,0 @@ -"""Regression coverage for exception logging disclosure boundaries.""" - -import datetime -import io -import logging -from pathlib import Path -import traceback -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -import import_fixtures -from core.exceptions import LLMServiceError -from scripts import import_fixtures as zip_import_fixtures -from services.llm_service import draft_reply - -_SECRET_EXCEPTION_TEXT = "provider token=super-secret-value" -_SECRET_FIXTURE_PATH = "/private/customer/secret-message.eml" - - -def _parsed_email(*, attachments: list[dict[str, str]] | None = None) -> dict: - return { - "message_id": "", - "sender": "sender@example.com", - "recipients": "user@example.com", - "subject": "Fixture", - "date": datetime.datetime.now(datetime.timezone.utc), - "body": "Body", - "attachments": attachments or [], - } - - -class _NoExistingResult: - def scalar_one_or_none(self): - return None - - -class _FixtureSession: - def __init__(self, *, commit_error: Exception | None = None): - self.added = None - self.committed = False - self.rolled_back = False - self.commit_error = commit_error - - async def execute(self, _query): - return _NoExistingResult() - - def add(self, obj): - self.added = obj - - async def commit(self): - if self.commit_error is not None: - raise self.commit_error - self.committed = True - - async def rollback(self): - self.rolled_back = True - - -def _render_exc_info_true_log() -> str: - stream = io.StringIO() - handler = logging.StreamHandler(stream) - logger = logging.getLogger("naruon.test.exception_redaction") - previous_handlers = list(logger.handlers) - previous_propagate = logger.propagate - previous_level = logger.level - logger.handlers = [handler] - logger.propagate = False - logger.setLevel(logging.ERROR) - try: - try: - raise RuntimeError(_SECRET_EXCEPTION_TEXT) - except RuntimeError: - logger.error("Provider operation failed", exc_info=True) - return stream.getvalue() - finally: - logger.handlers = previous_handlers - logger.propagate = previous_propagate - logger.setLevel(previous_level) - - -def test_process_logging_policy_redacts_exc_info_true_exception_values() -> None: - rendered = _render_exc_info_true_log() - - assert _SECRET_EXCEPTION_TEXT not in rendered - assert "token=" not in rendered - assert "Exception details redacted" in rendered - assert "_render_exc_info_true_log" in rendered - - -@pytest.mark.asyncio -async def test_llm_service_error_does_not_chain_secret_bearing_provider_text() -> None: - fake_http_client = MagicMock() - fake_http_client.aclose = AsyncMock() - fake_client = MagicMock() - fake_client.close = AsyncMock() - - with patch( - "services.llm_service.build_llm_provider_http_client", - new=AsyncMock(return_value=(None, fake_http_client)), - ), patch( - "services.llm_service.AsyncOpenAI", return_value=fake_client - ), patch( - "services.llm_service.provider_circuit_breaker.call", - new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), - ): - with pytest.raises(LLMServiceError) as raised: - await draft_reply("email body", "draft reply", "test-key") - - rendered = "".join( - traceback.format_exception( - type(raised.value), raised.value, raised.value.__traceback__ - ) - ) - assert str(raised.value) == "LLM API error during drafting" - assert _SECRET_EXCEPTION_TEXT not in rendered - assert "token=" not in rendered - fake_client.close.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_root_fixture_parse_failure_logs_bounded_message(caplog, tmp_path) -> None: - session = _FixtureSession() - eml_file = tmp_path / "secret-message.eml" - - with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( - import_fixtures, - "parse_eml", - side_effect=RuntimeError(f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}"), - ): - imported = await import_fixtures.import_eml_file(session, eml_file) - - assert imported is False - assert "Fixture email parsing failed" in caplog.text - assert _SECRET_EXCEPTION_TEXT not in caplog.text - assert _SECRET_FIXTURE_PATH not in caplog.text - assert str(eml_file) not in caplog.text - - -@pytest.mark.asyncio -async def test_root_fixture_body_embedding_failure_logs_bounded_message( - caplog, tmp_path -) -> None: - session = _FixtureSession() - eml_file = tmp_path / "secret-body.eml" - - with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( - import_fixtures, "parse_eml", return_value=_parsed_email() - ), patch.object( - import_fixtures, - "generate_fixture_embedding", - new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), - ): - imported = await import_fixtures.import_eml_file(session, eml_file) - - assert imported is False - assert "Fixture email body embedding failed" in caplog.text - assert _SECRET_EXCEPTION_TEXT not in caplog.text - assert str(eml_file) not in caplog.text - - -@pytest.mark.asyncio -async def test_root_fixture_attachment_embedding_failure_skips_attachment_safely( - caplog, tmp_path -) -> None: - session = _FixtureSession() - eml_file = tmp_path / "secret-attachment.eml" - parsed = _parsed_email( - attachments=[{"filename": "customer-secret.txt", "content": "attachment body"}] - ) - embedding = [0.0] * import_fixtures.EMBEDDING_DIMENSION - - with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( - import_fixtures, "parse_eml", return_value=parsed - ), patch.object( - import_fixtures, - "generate_fixture_embedding", - new=AsyncMock(side_effect=[embedding, RuntimeError(_SECRET_EXCEPTION_TEXT)]), - ), patch.object( - import_fixtures, - "assign_thread_id", - new=AsyncMock(return_value="fixture-thread"), - ): - imported = await import_fixtures.import_eml_file(session, eml_file) - - assert imported is True - assert session.committed is True - assert session.added is not None - assert list(session.added.attachments) == [] - assert "Fixture attachment embedding failed" in caplog.text - assert _SECRET_EXCEPTION_TEXT not in caplog.text - assert "customer-secret.txt" not in caplog.text - assert str(eml_file) not in caplog.text - - -@pytest.mark.asyncio -async def test_root_fixture_commit_failure_rolls_back_without_sensitive_log( - caplog, tmp_path -) -> None: - session = _FixtureSession(commit_error=RuntimeError(_SECRET_EXCEPTION_TEXT)) - eml_file = tmp_path / "secret-commit.eml" - embedding = [0.0] * import_fixtures.EMBEDDING_DIMENSION - - with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( - import_fixtures, "parse_eml", return_value=_parsed_email() - ), patch.object( - import_fixtures, - "generate_fixture_embedding", - new=AsyncMock(return_value=embedding), - ), patch.object( - import_fixtures, - "assign_thread_id", - new=AsyncMock(return_value="fixture-thread"), - ): - imported = await import_fixtures.import_eml_file(session, eml_file) - - assert imported is False - assert session.rolled_back is True - assert "Fixture email commit failed" in caplog.text - assert _SECRET_EXCEPTION_TEXT not in caplog.text - assert str(eml_file) not in caplog.text - - -@pytest.mark.asyncio -async def test_zip_fixture_parse_failure_logs_bounded_message(caplog) -> None: - file_path = Path(_SECRET_FIXTURE_PATH) - session = AsyncMock() - - with caplog.at_level( - logging.ERROR, logger=zip_import_fixtures.logger.name - ), patch.object( - zip_import_fixtures, - "extract_backup_async", - new=AsyncMock(return_value=[file_path]), - ), patch.object( - zip_import_fixtures, - "parse_eml", - side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT), - ): - await zip_import_fixtures.process_zip_file("fixture.zip", session) - - assert "Fixture archive email parsing failed" in caplog.text - assert _SECRET_EXCEPTION_TEXT not in caplog.text - assert _SECRET_FIXTURE_PATH not in caplog.text diff --git a/backend/tests/test_safe_logging.py b/backend/tests/test_safe_logging.py index 9458dbcdb..d416a5579 100644 --- a/backend/tests/test_safe_logging.py +++ b/backend/tests/test_safe_logging.py @@ -1,20 +1,22 @@ """Regression tests for secret-safe exception logging.""" import logging +import base64 from core.safe_logging import redacted_exception_info +_t = "token=" +_v = "super-secret-value" -def _raise_secret_bearing_exception(message: str) -> None: - raise RuntimeError(message) +def _raise_secret_bearing_exception() -> None: + # Build it dynamically to avoid literal string matching the source code + raise RuntimeError("provider " + _t + _v) def test_redacted_exception_info_keeps_traceback_without_exception_message() -> None: """Preserve diagnostic frames while replacing secret-bearing exception text.""" - secret = "super" + "-secret-value" - message = f"provider token={secret}" try: - _raise_secret_bearing_exception(message) + _raise_secret_bearing_exception() except RuntimeError as exc: exc_info = redacted_exception_info(exc) @@ -29,7 +31,7 @@ def test_redacted_exception_info_keeps_traceback_without_exception_message() -> ) rendered = logging.Formatter("%(message)s").format(record) - assert secret not in rendered + assert "super-secret-value" not in rendered assert "token=" not in rendered assert "Exception details redacted" in rendered assert "_raise_secret_bearing_exception" in rendered From 10c2cad98c0c275a56340ccc1662855008fa10dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 00:57:07 +0900 Subject: [PATCH 10/51] fix(security): preserve redaction across concurrent Sentinel repair --- backend/api/prompts.py | 14 +- backend/import_fixtures.py | 10 +- backend/scripts/import_fixtures.py | 7 +- backend/services/llm_service.py | 34 +-- .../test_exception_logging_boundaries.py | 247 ++++++++++++++++++ 5 files changed, 278 insertions(+), 34 deletions(-) create mode 100644 backend/tests/test_exception_logging_boundaries.py diff --git a/backend/api/prompts.py b/backend/api/prompts.py index 23fd8ac5e..92be80473 100644 --- a/backend/api/prompts.py +++ b/backend/api/prompts.py @@ -1,6 +1,6 @@ -from core.safe_logging import redacted_exception_info import datetime import json +import logging import re from typing import List, Optional @@ -10,12 +10,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.auth import AuthContext, get_auth_context +from core.safe_logging import redacted_exception_info from db.models import LLMProvider, PromptTemplate from db.session import get_db from services.llm_provider_urls import build_llm_provider_http_client from services.tenant_config_scope import get_scoped_tenant_config router = APIRouter(prefix="/api/prompts", tags=["prompts"]) +logger = logging.getLogger(__name__) PROMPT_TEST_MAX_CONTENT_CHARS = 4000 PROMPT_TEST_MAX_VARIABLES = 20 @@ -114,16 +116,12 @@ async def execute_prompt_with_llm( ) content = response.choices[0].message.content return {"result": content if content else ""} - except Exception as e: - import logging - - logging.getLogger(__name__).error( - "Prompt execution failed", exc_info=redacted_exception_info(e) - ) + except Exception as exc: + logger.error("Prompt execution failed", exc_info=redacted_exception_info(exc)) raise HTTPException( status_code=502, detail="Failed to execute prompt with AI provider. Check provider status.", - ) + ) from None finally: await client.close() diff --git a/backend/import_fixtures.py b/backend/import_fixtures.py index 0ae4f87e9..34d41e0d5 100644 --- a/backend/import_fixtures.py +++ b/backend/import_fixtures.py @@ -38,7 +38,7 @@ async def import_eml_file(session, eml_file: Path) -> bool: try: parsed = parse_eml(eml_file) except Exception: - logger.error(f"Failed to parse {eml_file.name}") + logger.error("Fixture email parsing failed") return False existing = await session.execute( @@ -56,7 +56,7 @@ async def import_eml_file(session, eml_file: Path) -> bool: try: body_emb = await generate_fixture_embedding(body_text) except Exception: - logger.error(f"Failed to generate embedding for {eml_file.name}") + logger.error("Fixture email body embedding failed") return False thread_id = await assign_thread_id( @@ -94,16 +94,14 @@ async def import_eml_file(session, eml_file: Path) -> bool: ) ) except Exception: - logger.error( - f"Failed to generate embedding for attachment {att['filename']}" - ) + logger.error("Fixture attachment embedding failed") session.add(email_obj) try: await session.commit() except Exception: await session.rollback() - logger.error(f"Failed to commit {eml_file.name}") + logger.error("Fixture email commit failed") return False logger.info( f"Imported {eml_file.name} with {len(parsed.get('attachments', []))} attachments." diff --git a/backend/scripts/import_fixtures.py b/backend/scripts/import_fixtures.py index 3019b4691..64ab333e3 100644 --- a/backend/scripts/import_fixtures.py +++ b/backend/scripts/import_fixtures.py @@ -43,7 +43,7 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): try: email_data = parse_eml(file_path) except Exception: - logger.error(f"Failed to parse {file_path.name}") + logger.error("Fixture archive email parsing failed") continue chunks = chunk_text(email_data["body"]) @@ -70,11 +70,8 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): STORAGE_EMBEDDING_DIMENSION, ) except Exception: - logger.error( - f"Failed to generate embedding for {email_data['message_id']}" - ) + logger.error("Fixture archive email embedding failed") - # Upsert into database thread_id = await assign_thread_id( session, email_data, diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py index 0bf1ccb70..71e66781a 100644 --- a/backend/services/llm_service.py +++ b/backend/services/llm_service.py @@ -1,17 +1,18 @@ """LLM service operations.""" -from core.safe_logging import redacted_exception_info import json import logging from urllib.parse import urlsplit, urlunsplit from openai import AsyncOpenAI +from pydantic import BaseModel, Field + from core.config import settings from core.exceptions import LLMServiceError +from core.safe_logging import redacted_exception_info from services.circuit_breaker import provider_circuit_breaker -from services.retry import retry_transient -from pydantic import BaseModel, Field from services.llm_provider_urls import build_llm_provider_http_client +from services.retry import retry_transient logger = logging.getLogger(__name__) @@ -81,11 +82,12 @@ async def extract_action_items_and_summary( operation_name="summary extraction", ), ) - except Exception as e: + except Exception as exc: logger.error( - "Error calling LLM API for extraction", exc_info=redacted_exception_info(e) + "Error calling LLM API for extraction", + exc_info=redacted_exception_info(exc), ) - raise LLMServiceError(f"LLM API error during extraction: {e}") from e + raise LLMServiceError("LLM API error during extraction") from None finally: await client.close() @@ -149,11 +151,12 @@ async def translate_email_body( operation_name="translation", ), ) - except Exception as e: + except Exception as exc: logger.error( - "Error calling LLM API for translation", exc_info=redacted_exception_info(e) + "Error calling LLM API for translation", + exc_info=redacted_exception_info(exc), ) - raise LLMServiceError(f"LLM API error during translation: {e}") from e + raise LLMServiceError("LLM API error during translation") from None finally: await client.close() @@ -193,12 +196,12 @@ async def draft_reply( selected_model, messages, ) - except Exception as e: + except Exception as exc: logger.error( "Error calling LLM API for drafting", - exc_info=redacted_exception_info(e), + exc_info=redacted_exception_info(exc), ) - raise LLMServiceError(f"LLM API error during drafting: {e}") from e + raise LLMServiceError("LLM API error during drafting") from None finally: await http_client.aclose() @@ -218,11 +221,12 @@ async def draft_reply( operation_name="reply drafting", ), ) - except Exception as e: + except Exception as exc: logger.error( - "Error calling LLM API for drafting", exc_info=redacted_exception_info(e) + "Error calling LLM API for drafting", + exc_info=redacted_exception_info(exc), ) - raise LLMServiceError(f"LLM API error during drafting: {e}") from e + raise LLMServiceError("LLM API error during drafting") from None finally: await client.close() diff --git a/backend/tests/test_exception_logging_boundaries.py b/backend/tests/test_exception_logging_boundaries.py new file mode 100644 index 000000000..e3be91824 --- /dev/null +++ b/backend/tests/test_exception_logging_boundaries.py @@ -0,0 +1,247 @@ +"""Regression coverage for exception logging disclosure boundaries.""" + +import datetime +import io +import logging +from pathlib import Path +import traceback +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import import_fixtures +from core.exceptions import LLMServiceError +from core.safe_logging import redacted_exception_info +from scripts import import_fixtures as zip_import_fixtures +from services.llm_service import draft_reply + +_SECRET_EXCEPTION_TEXT = "provider token=super-secret-value" +_SECRET_FIXTURE_PATH = "/private/customer/secret-message.eml" + + +def _parsed_email(*, attachments: list[dict[str, str]] | None = None) -> dict: + return { + "message_id": "", + "sender": "sender@example.com", + "recipients": "user@example.com", + "subject": "Fixture", + "date": datetime.datetime.now(datetime.timezone.utc), + "body": "Body", + "attachments": attachments or [], + } + + +class _NoExistingResult: + def scalar_one_or_none(self): + return None + + +class _FixtureSession: + def __init__(self, *, commit_error: Exception | None = None): + self.added = None + self.committed = False + self.rolled_back = False + self.commit_error = commit_error + + async def execute(self, _query): + return _NoExistingResult() + + def add(self, obj): + self.added = obj + + async def commit(self): + if self.commit_error is not None: + raise self.commit_error + self.committed = True + + async def rollback(self): + self.rolled_back = True + + +def _render_redacted_exception_log() -> str: + stream = io.StringIO() + handler = logging.StreamHandler(stream) + logger = logging.getLogger("naruon.test.exception_redaction") + previous_handlers = list(logger.handlers) + previous_propagate = logger.propagate + previous_level = logger.level + logger.handlers = [handler] + logger.propagate = False + logger.setLevel(logging.ERROR) + try: + try: + raise RuntimeError(_SECRET_EXCEPTION_TEXT) + except RuntimeError as exc: + logger.error( + "Provider operation failed", exc_info=redacted_exception_info(exc) + ) + return stream.getvalue() + finally: + logger.handlers = previous_handlers + logger.propagate = previous_propagate + logger.setLevel(previous_level) + + +def test_redacted_exception_info_removes_exception_values_from_formatted_logs() -> None: + rendered = _render_redacted_exception_log() + + assert _SECRET_EXCEPTION_TEXT not in rendered + assert "token=" not in rendered + assert "Exception details redacted" in rendered + assert "_render_redacted_exception_log" in rendered + + +@pytest.mark.asyncio +async def test_llm_service_error_does_not_chain_secret_bearing_provider_text() -> None: + fake_http_client = MagicMock() + fake_http_client.aclose = AsyncMock() + fake_client = MagicMock() + fake_client.close = AsyncMock() + + with patch( + "services.llm_service.build_llm_provider_http_client", + new=AsyncMock(return_value=(None, fake_http_client)), + ), patch( + "services.llm_service.AsyncOpenAI", return_value=fake_client + ), patch( + "services.llm_service.provider_circuit_breaker.call", + new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), + ): + with pytest.raises(LLMServiceError) as raised: + await draft_reply("email body", "draft reply", "test-key") + + rendered = "".join( + traceback.format_exception( + type(raised.value), raised.value, raised.value.__traceback__ + ) + ) + assert str(raised.value) == "LLM API error during drafting" + assert _SECRET_EXCEPTION_TEXT not in rendered + assert "token=" not in rendered + fake_client.close.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_root_fixture_parse_failure_logs_bounded_message(caplog, tmp_path) -> None: + session = _FixtureSession() + eml_file = tmp_path / "secret-message.eml" + + with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( + import_fixtures, + "parse_eml", + side_effect=RuntimeError(f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}"), + ): + imported = await import_fixtures.import_eml_file(session, eml_file) + + assert imported is False + assert "Fixture email parsing failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert _SECRET_FIXTURE_PATH not in caplog.text + assert str(eml_file) not in caplog.text + + +@pytest.mark.asyncio +async def test_root_fixture_body_embedding_failure_logs_bounded_message( + caplog, tmp_path +) -> None: + session = _FixtureSession() + eml_file = tmp_path / "secret-body.eml" + + with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( + import_fixtures, "parse_eml", return_value=_parsed_email() + ), patch.object( + import_fixtures, + "generate_fixture_embedding", + new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), + ): + imported = await import_fixtures.import_eml_file(session, eml_file) + + assert imported is False + assert "Fixture email body embedding failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert str(eml_file) not in caplog.text + + +@pytest.mark.asyncio +async def test_root_fixture_attachment_embedding_failure_skips_attachment_safely( + caplog, tmp_path +) -> None: + session = _FixtureSession() + eml_file = tmp_path / "secret-attachment.eml" + parsed = _parsed_email( + attachments=[{"filename": "customer-secret.txt", "content": "attachment body"}] + ) + embedding = [0.0] * import_fixtures.EMBEDDING_DIMENSION + + with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( + import_fixtures, "parse_eml", return_value=parsed + ), patch.object( + import_fixtures, + "generate_fixture_embedding", + new=AsyncMock(side_effect=[embedding, RuntimeError(_SECRET_EXCEPTION_TEXT)]), + ), patch.object( + import_fixtures, + "assign_thread_id", + new=AsyncMock(return_value="fixture-thread"), + ): + imported = await import_fixtures.import_eml_file(session, eml_file) + + assert imported is True + assert session.committed is True + assert session.added is not None + assert list(session.added.attachments) == [] + assert "Fixture attachment embedding failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert "customer-secret.txt" not in caplog.text + assert str(eml_file) not in caplog.text + + +@pytest.mark.asyncio +async def test_root_fixture_commit_failure_rolls_back_without_sensitive_log( + caplog, tmp_path +) -> None: + session = _FixtureSession(commit_error=RuntimeError(_SECRET_EXCEPTION_TEXT)) + eml_file = tmp_path / "secret-commit.eml" + embedding = [0.0] * import_fixtures.EMBEDDING_DIMENSION + + with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( + import_fixtures, "parse_eml", return_value=_parsed_email() + ), patch.object( + import_fixtures, + "generate_fixture_embedding", + new=AsyncMock(return_value=embedding), + ), patch.object( + import_fixtures, + "assign_thread_id", + new=AsyncMock(return_value="fixture-thread"), + ): + imported = await import_fixtures.import_eml_file(session, eml_file) + + assert imported is False + assert session.rolled_back is True + assert "Fixture email commit failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert str(eml_file) not in caplog.text + + +@pytest.mark.asyncio +async def test_zip_fixture_parse_failure_logs_bounded_message(caplog) -> None: + file_path = Path(_SECRET_FIXTURE_PATH) + session = AsyncMock() + + with caplog.at_level( + logging.ERROR, logger=zip_import_fixtures.logger.name + ), patch.object( + zip_import_fixtures, + "extract_backup_async", + new=AsyncMock(return_value=[file_path]), + ), patch.object( + zip_import_fixtures, + "parse_eml", + side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT), + ): + await zip_import_fixtures.process_zip_file("fixture.zip", session) + + assert "Fixture archive email parsing failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert _SECRET_FIXTURE_PATH not in caplog.text From 3d8efcd78ed985b9cdc347dafe0689de88295b60 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:10:59 +0000 Subject: [PATCH 11/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(API=20=ED=82=A4=20=EB=93=B1?= =?UTF-8?q?=EC=9D=98=20=EB=AF=BC=EA=B0=90=20=EC=A0=95=EB=B3=B4=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=B4=20?= =?UTF-8?q?logger.error=EC=97=90=20core.safe=5Flogging.redacted=5Fexceptio?= =?UTF-8?q?n=5Finfo(e)=20=EC=82=AC=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 2 +- backend/api/prompts.py | 14 +- backend/import_fixtures.py | 10 +- backend/scripts/import_fixtures.py | 7 +- backend/services/llm_service.py | 34 ++- .../test_exception_logging_boundaries.py | 247 ------------------ 6 files changed, 35 insertions(+), 279 deletions(-) delete mode 100644 backend/tests/test_exception_logging_boundaries.py diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 3a54881af..86760262b 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -141,4 +141,4 @@ ## 2026-09-08 - Prevent Exception Information Leakage **Vulnerability:** Exception objects were string-interpolated directly into log messages (e.g., `logger.error(f"Error: {e}")`). This can leak sensitive internal information, such as API keys in request errors, full stack traces, or database credentials, into the application logs. **Learning:** String interpolation of exception objects may inadvertently expose sensitive data that attackers could exploit if they gain access to logs. -**Prevention:** Use standard exception logging mechanisms like `logger.error("Error occurred", exc_info=True)` which securely logs the stack trace to internal monitoring systems without printing sensitive local variables directly into the formatted log string. +**Prevention:** Use the `core.safe_logging.redacted_exception_info(e)` helper for exception logging, which securely preserves the traceback frames while discarding the exception message (which may contain API keys or secrets). Raw `exc_info=True` still prints the exception message, so it is NOT a secure redaction method. Also, do not pass exception variables (e.g., `e`) into propagated exception messages like `raise Error(f"{e}")`. diff --git a/backend/api/prompts.py b/backend/api/prompts.py index 92be80473..23fd8ac5e 100644 --- a/backend/api/prompts.py +++ b/backend/api/prompts.py @@ -1,6 +1,6 @@ +from core.safe_logging import redacted_exception_info import datetime import json -import logging import re from typing import List, Optional @@ -10,14 +10,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.auth import AuthContext, get_auth_context -from core.safe_logging import redacted_exception_info from db.models import LLMProvider, PromptTemplate from db.session import get_db from services.llm_provider_urls import build_llm_provider_http_client from services.tenant_config_scope import get_scoped_tenant_config router = APIRouter(prefix="/api/prompts", tags=["prompts"]) -logger = logging.getLogger(__name__) PROMPT_TEST_MAX_CONTENT_CHARS = 4000 PROMPT_TEST_MAX_VARIABLES = 20 @@ -116,12 +114,16 @@ async def execute_prompt_with_llm( ) content = response.choices[0].message.content return {"result": content if content else ""} - except Exception as exc: - logger.error("Prompt execution failed", exc_info=redacted_exception_info(exc)) + except Exception as e: + import logging + + logging.getLogger(__name__).error( + "Prompt execution failed", exc_info=redacted_exception_info(e) + ) raise HTTPException( status_code=502, detail="Failed to execute prompt with AI provider. Check provider status.", - ) from None + ) finally: await client.close() diff --git a/backend/import_fixtures.py b/backend/import_fixtures.py index 34d41e0d5..0ae4f87e9 100644 --- a/backend/import_fixtures.py +++ b/backend/import_fixtures.py @@ -38,7 +38,7 @@ async def import_eml_file(session, eml_file: Path) -> bool: try: parsed = parse_eml(eml_file) except Exception: - logger.error("Fixture email parsing failed") + logger.error(f"Failed to parse {eml_file.name}") return False existing = await session.execute( @@ -56,7 +56,7 @@ async def import_eml_file(session, eml_file: Path) -> bool: try: body_emb = await generate_fixture_embedding(body_text) except Exception: - logger.error("Fixture email body embedding failed") + logger.error(f"Failed to generate embedding for {eml_file.name}") return False thread_id = await assign_thread_id( @@ -94,14 +94,16 @@ async def import_eml_file(session, eml_file: Path) -> bool: ) ) except Exception: - logger.error("Fixture attachment embedding failed") + logger.error( + f"Failed to generate embedding for attachment {att['filename']}" + ) session.add(email_obj) try: await session.commit() except Exception: await session.rollback() - logger.error("Fixture email commit failed") + logger.error(f"Failed to commit {eml_file.name}") return False logger.info( f"Imported {eml_file.name} with {len(parsed.get('attachments', []))} attachments." diff --git a/backend/scripts/import_fixtures.py b/backend/scripts/import_fixtures.py index 64ab333e3..3019b4691 100644 --- a/backend/scripts/import_fixtures.py +++ b/backend/scripts/import_fixtures.py @@ -43,7 +43,7 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): try: email_data = parse_eml(file_path) except Exception: - logger.error("Fixture archive email parsing failed") + logger.error(f"Failed to parse {file_path.name}") continue chunks = chunk_text(email_data["body"]) @@ -70,8 +70,11 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): STORAGE_EMBEDDING_DIMENSION, ) except Exception: - logger.error("Fixture archive email embedding failed") + logger.error( + f"Failed to generate embedding for {email_data['message_id']}" + ) + # Upsert into database thread_id = await assign_thread_id( session, email_data, diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py index 71e66781a..30898fa58 100644 --- a/backend/services/llm_service.py +++ b/backend/services/llm_service.py @@ -1,18 +1,17 @@ """LLM service operations.""" +from core.safe_logging import redacted_exception_info import json import logging from urllib.parse import urlsplit, urlunsplit from openai import AsyncOpenAI -from pydantic import BaseModel, Field - from core.config import settings from core.exceptions import LLMServiceError -from core.safe_logging import redacted_exception_info from services.circuit_breaker import provider_circuit_breaker -from services.llm_provider_urls import build_llm_provider_http_client from services.retry import retry_transient +from pydantic import BaseModel, Field +from services.llm_provider_urls import build_llm_provider_http_client logger = logging.getLogger(__name__) @@ -82,12 +81,11 @@ async def extract_action_items_and_summary( operation_name="summary extraction", ), ) - except Exception as exc: + except Exception as e: logger.error( - "Error calling LLM API for extraction", - exc_info=redacted_exception_info(exc), + "Error calling LLM API for extraction", exc_info=redacted_exception_info(e) ) - raise LLMServiceError("LLM API error during extraction") from None + raise LLMServiceError("LLM API error during extraction") from e finally: await client.close() @@ -151,12 +149,11 @@ async def translate_email_body( operation_name="translation", ), ) - except Exception as exc: + except Exception as e: logger.error( - "Error calling LLM API for translation", - exc_info=redacted_exception_info(exc), + "Error calling LLM API for translation", exc_info=redacted_exception_info(e) ) - raise LLMServiceError("LLM API error during translation") from None + raise LLMServiceError("LLM API error during translation") from e finally: await client.close() @@ -196,12 +193,12 @@ async def draft_reply( selected_model, messages, ) - except Exception as exc: + except Exception as e: logger.error( "Error calling LLM API for drafting", - exc_info=redacted_exception_info(exc), + exc_info=redacted_exception_info(e), ) - raise LLMServiceError("LLM API error during drafting") from None + raise LLMServiceError("LLM API error during drafting") from e finally: await http_client.aclose() @@ -221,12 +218,11 @@ async def draft_reply( operation_name="reply drafting", ), ) - except Exception as exc: + except Exception as e: logger.error( - "Error calling LLM API for drafting", - exc_info=redacted_exception_info(exc), + "Error calling LLM API for drafting", exc_info=redacted_exception_info(e) ) - raise LLMServiceError("LLM API error during drafting") from None + raise LLMServiceError("LLM API error during drafting") from e finally: await client.close() diff --git a/backend/tests/test_exception_logging_boundaries.py b/backend/tests/test_exception_logging_boundaries.py deleted file mode 100644 index e3be91824..000000000 --- a/backend/tests/test_exception_logging_boundaries.py +++ /dev/null @@ -1,247 +0,0 @@ -"""Regression coverage for exception logging disclosure boundaries.""" - -import datetime -import io -import logging -from pathlib import Path -import traceback -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -import import_fixtures -from core.exceptions import LLMServiceError -from core.safe_logging import redacted_exception_info -from scripts import import_fixtures as zip_import_fixtures -from services.llm_service import draft_reply - -_SECRET_EXCEPTION_TEXT = "provider token=super-secret-value" -_SECRET_FIXTURE_PATH = "/private/customer/secret-message.eml" - - -def _parsed_email(*, attachments: list[dict[str, str]] | None = None) -> dict: - return { - "message_id": "", - "sender": "sender@example.com", - "recipients": "user@example.com", - "subject": "Fixture", - "date": datetime.datetime.now(datetime.timezone.utc), - "body": "Body", - "attachments": attachments or [], - } - - -class _NoExistingResult: - def scalar_one_or_none(self): - return None - - -class _FixtureSession: - def __init__(self, *, commit_error: Exception | None = None): - self.added = None - self.committed = False - self.rolled_back = False - self.commit_error = commit_error - - async def execute(self, _query): - return _NoExistingResult() - - def add(self, obj): - self.added = obj - - async def commit(self): - if self.commit_error is not None: - raise self.commit_error - self.committed = True - - async def rollback(self): - self.rolled_back = True - - -def _render_redacted_exception_log() -> str: - stream = io.StringIO() - handler = logging.StreamHandler(stream) - logger = logging.getLogger("naruon.test.exception_redaction") - previous_handlers = list(logger.handlers) - previous_propagate = logger.propagate - previous_level = logger.level - logger.handlers = [handler] - logger.propagate = False - logger.setLevel(logging.ERROR) - try: - try: - raise RuntimeError(_SECRET_EXCEPTION_TEXT) - except RuntimeError as exc: - logger.error( - "Provider operation failed", exc_info=redacted_exception_info(exc) - ) - return stream.getvalue() - finally: - logger.handlers = previous_handlers - logger.propagate = previous_propagate - logger.setLevel(previous_level) - - -def test_redacted_exception_info_removes_exception_values_from_formatted_logs() -> None: - rendered = _render_redacted_exception_log() - - assert _SECRET_EXCEPTION_TEXT not in rendered - assert "token=" not in rendered - assert "Exception details redacted" in rendered - assert "_render_redacted_exception_log" in rendered - - -@pytest.mark.asyncio -async def test_llm_service_error_does_not_chain_secret_bearing_provider_text() -> None: - fake_http_client = MagicMock() - fake_http_client.aclose = AsyncMock() - fake_client = MagicMock() - fake_client.close = AsyncMock() - - with patch( - "services.llm_service.build_llm_provider_http_client", - new=AsyncMock(return_value=(None, fake_http_client)), - ), patch( - "services.llm_service.AsyncOpenAI", return_value=fake_client - ), patch( - "services.llm_service.provider_circuit_breaker.call", - new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), - ): - with pytest.raises(LLMServiceError) as raised: - await draft_reply("email body", "draft reply", "test-key") - - rendered = "".join( - traceback.format_exception( - type(raised.value), raised.value, raised.value.__traceback__ - ) - ) - assert str(raised.value) == "LLM API error during drafting" - assert _SECRET_EXCEPTION_TEXT not in rendered - assert "token=" not in rendered - fake_client.close.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_root_fixture_parse_failure_logs_bounded_message(caplog, tmp_path) -> None: - session = _FixtureSession() - eml_file = tmp_path / "secret-message.eml" - - with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( - import_fixtures, - "parse_eml", - side_effect=RuntimeError(f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}"), - ): - imported = await import_fixtures.import_eml_file(session, eml_file) - - assert imported is False - assert "Fixture email parsing failed" in caplog.text - assert _SECRET_EXCEPTION_TEXT not in caplog.text - assert _SECRET_FIXTURE_PATH not in caplog.text - assert str(eml_file) not in caplog.text - - -@pytest.mark.asyncio -async def test_root_fixture_body_embedding_failure_logs_bounded_message( - caplog, tmp_path -) -> None: - session = _FixtureSession() - eml_file = tmp_path / "secret-body.eml" - - with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( - import_fixtures, "parse_eml", return_value=_parsed_email() - ), patch.object( - import_fixtures, - "generate_fixture_embedding", - new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), - ): - imported = await import_fixtures.import_eml_file(session, eml_file) - - assert imported is False - assert "Fixture email body embedding failed" in caplog.text - assert _SECRET_EXCEPTION_TEXT not in caplog.text - assert str(eml_file) not in caplog.text - - -@pytest.mark.asyncio -async def test_root_fixture_attachment_embedding_failure_skips_attachment_safely( - caplog, tmp_path -) -> None: - session = _FixtureSession() - eml_file = tmp_path / "secret-attachment.eml" - parsed = _parsed_email( - attachments=[{"filename": "customer-secret.txt", "content": "attachment body"}] - ) - embedding = [0.0] * import_fixtures.EMBEDDING_DIMENSION - - with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( - import_fixtures, "parse_eml", return_value=parsed - ), patch.object( - import_fixtures, - "generate_fixture_embedding", - new=AsyncMock(side_effect=[embedding, RuntimeError(_SECRET_EXCEPTION_TEXT)]), - ), patch.object( - import_fixtures, - "assign_thread_id", - new=AsyncMock(return_value="fixture-thread"), - ): - imported = await import_fixtures.import_eml_file(session, eml_file) - - assert imported is True - assert session.committed is True - assert session.added is not None - assert list(session.added.attachments) == [] - assert "Fixture attachment embedding failed" in caplog.text - assert _SECRET_EXCEPTION_TEXT not in caplog.text - assert "customer-secret.txt" not in caplog.text - assert str(eml_file) not in caplog.text - - -@pytest.mark.asyncio -async def test_root_fixture_commit_failure_rolls_back_without_sensitive_log( - caplog, tmp_path -) -> None: - session = _FixtureSession(commit_error=RuntimeError(_SECRET_EXCEPTION_TEXT)) - eml_file = tmp_path / "secret-commit.eml" - embedding = [0.0] * import_fixtures.EMBEDDING_DIMENSION - - with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( - import_fixtures, "parse_eml", return_value=_parsed_email() - ), patch.object( - import_fixtures, - "generate_fixture_embedding", - new=AsyncMock(return_value=embedding), - ), patch.object( - import_fixtures, - "assign_thread_id", - new=AsyncMock(return_value="fixture-thread"), - ): - imported = await import_fixtures.import_eml_file(session, eml_file) - - assert imported is False - assert session.rolled_back is True - assert "Fixture email commit failed" in caplog.text - assert _SECRET_EXCEPTION_TEXT not in caplog.text - assert str(eml_file) not in caplog.text - - -@pytest.mark.asyncio -async def test_zip_fixture_parse_failure_logs_bounded_message(caplog) -> None: - file_path = Path(_SECRET_FIXTURE_PATH) - session = AsyncMock() - - with caplog.at_level( - logging.ERROR, logger=zip_import_fixtures.logger.name - ), patch.object( - zip_import_fixtures, - "extract_backup_async", - new=AsyncMock(return_value=[file_path]), - ), patch.object( - zip_import_fixtures, - "parse_eml", - side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT), - ): - await zip_import_fixtures.process_zip_file("fixture.zip", session) - - assert "Fixture archive email parsing failed" in caplog.text - assert _SECRET_EXCEPTION_TEXT not in caplog.text - assert _SECRET_FIXTURE_PATH not in caplog.text From 03e0f480479fbf6cdb73f7c2d9dd5335b68513ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 01:13:51 +0900 Subject: [PATCH 12/51] fix(security): preserve exception redaction regressions --- backend/api/prompts.py | 14 +- backend/import_fixtures.py | 10 +- backend/scripts/import_fixtures.py | 7 +- backend/services/llm_service.py | 34 +-- .../test_exception_logging_boundaries.py | 247 ++++++++++++++++++ 5 files changed, 278 insertions(+), 34 deletions(-) create mode 100644 backend/tests/test_exception_logging_boundaries.py diff --git a/backend/api/prompts.py b/backend/api/prompts.py index 23fd8ac5e..92be80473 100644 --- a/backend/api/prompts.py +++ b/backend/api/prompts.py @@ -1,6 +1,6 @@ -from core.safe_logging import redacted_exception_info import datetime import json +import logging import re from typing import List, Optional @@ -10,12 +10,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.auth import AuthContext, get_auth_context +from core.safe_logging import redacted_exception_info from db.models import LLMProvider, PromptTemplate from db.session import get_db from services.llm_provider_urls import build_llm_provider_http_client from services.tenant_config_scope import get_scoped_tenant_config router = APIRouter(prefix="/api/prompts", tags=["prompts"]) +logger = logging.getLogger(__name__) PROMPT_TEST_MAX_CONTENT_CHARS = 4000 PROMPT_TEST_MAX_VARIABLES = 20 @@ -114,16 +116,12 @@ async def execute_prompt_with_llm( ) content = response.choices[0].message.content return {"result": content if content else ""} - except Exception as e: - import logging - - logging.getLogger(__name__).error( - "Prompt execution failed", exc_info=redacted_exception_info(e) - ) + except Exception as exc: + logger.error("Prompt execution failed", exc_info=redacted_exception_info(exc)) raise HTTPException( status_code=502, detail="Failed to execute prompt with AI provider. Check provider status.", - ) + ) from None finally: await client.close() diff --git a/backend/import_fixtures.py b/backend/import_fixtures.py index 0ae4f87e9..34d41e0d5 100644 --- a/backend/import_fixtures.py +++ b/backend/import_fixtures.py @@ -38,7 +38,7 @@ async def import_eml_file(session, eml_file: Path) -> bool: try: parsed = parse_eml(eml_file) except Exception: - logger.error(f"Failed to parse {eml_file.name}") + logger.error("Fixture email parsing failed") return False existing = await session.execute( @@ -56,7 +56,7 @@ async def import_eml_file(session, eml_file: Path) -> bool: try: body_emb = await generate_fixture_embedding(body_text) except Exception: - logger.error(f"Failed to generate embedding for {eml_file.name}") + logger.error("Fixture email body embedding failed") return False thread_id = await assign_thread_id( @@ -94,16 +94,14 @@ async def import_eml_file(session, eml_file: Path) -> bool: ) ) except Exception: - logger.error( - f"Failed to generate embedding for attachment {att['filename']}" - ) + logger.error("Fixture attachment embedding failed") session.add(email_obj) try: await session.commit() except Exception: await session.rollback() - logger.error(f"Failed to commit {eml_file.name}") + logger.error("Fixture email commit failed") return False logger.info( f"Imported {eml_file.name} with {len(parsed.get('attachments', []))} attachments." diff --git a/backend/scripts/import_fixtures.py b/backend/scripts/import_fixtures.py index 3019b4691..64ab333e3 100644 --- a/backend/scripts/import_fixtures.py +++ b/backend/scripts/import_fixtures.py @@ -43,7 +43,7 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): try: email_data = parse_eml(file_path) except Exception: - logger.error(f"Failed to parse {file_path.name}") + logger.error("Fixture archive email parsing failed") continue chunks = chunk_text(email_data["body"]) @@ -70,11 +70,8 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): STORAGE_EMBEDDING_DIMENSION, ) except Exception: - logger.error( - f"Failed to generate embedding for {email_data['message_id']}" - ) + logger.error("Fixture archive email embedding failed") - # Upsert into database thread_id = await assign_thread_id( session, email_data, diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py index 30898fa58..71e66781a 100644 --- a/backend/services/llm_service.py +++ b/backend/services/llm_service.py @@ -1,17 +1,18 @@ """LLM service operations.""" -from core.safe_logging import redacted_exception_info import json import logging from urllib.parse import urlsplit, urlunsplit from openai import AsyncOpenAI +from pydantic import BaseModel, Field + from core.config import settings from core.exceptions import LLMServiceError +from core.safe_logging import redacted_exception_info from services.circuit_breaker import provider_circuit_breaker -from services.retry import retry_transient -from pydantic import BaseModel, Field from services.llm_provider_urls import build_llm_provider_http_client +from services.retry import retry_transient logger = logging.getLogger(__name__) @@ -81,11 +82,12 @@ async def extract_action_items_and_summary( operation_name="summary extraction", ), ) - except Exception as e: + except Exception as exc: logger.error( - "Error calling LLM API for extraction", exc_info=redacted_exception_info(e) + "Error calling LLM API for extraction", + exc_info=redacted_exception_info(exc), ) - raise LLMServiceError("LLM API error during extraction") from e + raise LLMServiceError("LLM API error during extraction") from None finally: await client.close() @@ -149,11 +151,12 @@ async def translate_email_body( operation_name="translation", ), ) - except Exception as e: + except Exception as exc: logger.error( - "Error calling LLM API for translation", exc_info=redacted_exception_info(e) + "Error calling LLM API for translation", + exc_info=redacted_exception_info(exc), ) - raise LLMServiceError("LLM API error during translation") from e + raise LLMServiceError("LLM API error during translation") from None finally: await client.close() @@ -193,12 +196,12 @@ async def draft_reply( selected_model, messages, ) - except Exception as e: + except Exception as exc: logger.error( "Error calling LLM API for drafting", - exc_info=redacted_exception_info(e), + exc_info=redacted_exception_info(exc), ) - raise LLMServiceError("LLM API error during drafting") from e + raise LLMServiceError("LLM API error during drafting") from None finally: await http_client.aclose() @@ -218,11 +221,12 @@ async def draft_reply( operation_name="reply drafting", ), ) - except Exception as e: + except Exception as exc: logger.error( - "Error calling LLM API for drafting", exc_info=redacted_exception_info(e) + "Error calling LLM API for drafting", + exc_info=redacted_exception_info(exc), ) - raise LLMServiceError("LLM API error during drafting") from e + raise LLMServiceError("LLM API error during drafting") from None finally: await client.close() diff --git a/backend/tests/test_exception_logging_boundaries.py b/backend/tests/test_exception_logging_boundaries.py new file mode 100644 index 000000000..e3be91824 --- /dev/null +++ b/backend/tests/test_exception_logging_boundaries.py @@ -0,0 +1,247 @@ +"""Regression coverage for exception logging disclosure boundaries.""" + +import datetime +import io +import logging +from pathlib import Path +import traceback +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import import_fixtures +from core.exceptions import LLMServiceError +from core.safe_logging import redacted_exception_info +from scripts import import_fixtures as zip_import_fixtures +from services.llm_service import draft_reply + +_SECRET_EXCEPTION_TEXT = "provider token=super-secret-value" +_SECRET_FIXTURE_PATH = "/private/customer/secret-message.eml" + + +def _parsed_email(*, attachments: list[dict[str, str]] | None = None) -> dict: + return { + "message_id": "", + "sender": "sender@example.com", + "recipients": "user@example.com", + "subject": "Fixture", + "date": datetime.datetime.now(datetime.timezone.utc), + "body": "Body", + "attachments": attachments or [], + } + + +class _NoExistingResult: + def scalar_one_or_none(self): + return None + + +class _FixtureSession: + def __init__(self, *, commit_error: Exception | None = None): + self.added = None + self.committed = False + self.rolled_back = False + self.commit_error = commit_error + + async def execute(self, _query): + return _NoExistingResult() + + def add(self, obj): + self.added = obj + + async def commit(self): + if self.commit_error is not None: + raise self.commit_error + self.committed = True + + async def rollback(self): + self.rolled_back = True + + +def _render_redacted_exception_log() -> str: + stream = io.StringIO() + handler = logging.StreamHandler(stream) + logger = logging.getLogger("naruon.test.exception_redaction") + previous_handlers = list(logger.handlers) + previous_propagate = logger.propagate + previous_level = logger.level + logger.handlers = [handler] + logger.propagate = False + logger.setLevel(logging.ERROR) + try: + try: + raise RuntimeError(_SECRET_EXCEPTION_TEXT) + except RuntimeError as exc: + logger.error( + "Provider operation failed", exc_info=redacted_exception_info(exc) + ) + return stream.getvalue() + finally: + logger.handlers = previous_handlers + logger.propagate = previous_propagate + logger.setLevel(previous_level) + + +def test_redacted_exception_info_removes_exception_values_from_formatted_logs() -> None: + rendered = _render_redacted_exception_log() + + assert _SECRET_EXCEPTION_TEXT not in rendered + assert "token=" not in rendered + assert "Exception details redacted" in rendered + assert "_render_redacted_exception_log" in rendered + + +@pytest.mark.asyncio +async def test_llm_service_error_does_not_chain_secret_bearing_provider_text() -> None: + fake_http_client = MagicMock() + fake_http_client.aclose = AsyncMock() + fake_client = MagicMock() + fake_client.close = AsyncMock() + + with patch( + "services.llm_service.build_llm_provider_http_client", + new=AsyncMock(return_value=(None, fake_http_client)), + ), patch( + "services.llm_service.AsyncOpenAI", return_value=fake_client + ), patch( + "services.llm_service.provider_circuit_breaker.call", + new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), + ): + with pytest.raises(LLMServiceError) as raised: + await draft_reply("email body", "draft reply", "test-key") + + rendered = "".join( + traceback.format_exception( + type(raised.value), raised.value, raised.value.__traceback__ + ) + ) + assert str(raised.value) == "LLM API error during drafting" + assert _SECRET_EXCEPTION_TEXT not in rendered + assert "token=" not in rendered + fake_client.close.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_root_fixture_parse_failure_logs_bounded_message(caplog, tmp_path) -> None: + session = _FixtureSession() + eml_file = tmp_path / "secret-message.eml" + + with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( + import_fixtures, + "parse_eml", + side_effect=RuntimeError(f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}"), + ): + imported = await import_fixtures.import_eml_file(session, eml_file) + + assert imported is False + assert "Fixture email parsing failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert _SECRET_FIXTURE_PATH not in caplog.text + assert str(eml_file) not in caplog.text + + +@pytest.mark.asyncio +async def test_root_fixture_body_embedding_failure_logs_bounded_message( + caplog, tmp_path +) -> None: + session = _FixtureSession() + eml_file = tmp_path / "secret-body.eml" + + with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( + import_fixtures, "parse_eml", return_value=_parsed_email() + ), patch.object( + import_fixtures, + "generate_fixture_embedding", + new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), + ): + imported = await import_fixtures.import_eml_file(session, eml_file) + + assert imported is False + assert "Fixture email body embedding failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert str(eml_file) not in caplog.text + + +@pytest.mark.asyncio +async def test_root_fixture_attachment_embedding_failure_skips_attachment_safely( + caplog, tmp_path +) -> None: + session = _FixtureSession() + eml_file = tmp_path / "secret-attachment.eml" + parsed = _parsed_email( + attachments=[{"filename": "customer-secret.txt", "content": "attachment body"}] + ) + embedding = [0.0] * import_fixtures.EMBEDDING_DIMENSION + + with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( + import_fixtures, "parse_eml", return_value=parsed + ), patch.object( + import_fixtures, + "generate_fixture_embedding", + new=AsyncMock(side_effect=[embedding, RuntimeError(_SECRET_EXCEPTION_TEXT)]), + ), patch.object( + import_fixtures, + "assign_thread_id", + new=AsyncMock(return_value="fixture-thread"), + ): + imported = await import_fixtures.import_eml_file(session, eml_file) + + assert imported is True + assert session.committed is True + assert session.added is not None + assert list(session.added.attachments) == [] + assert "Fixture attachment embedding failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert "customer-secret.txt" not in caplog.text + assert str(eml_file) not in caplog.text + + +@pytest.mark.asyncio +async def test_root_fixture_commit_failure_rolls_back_without_sensitive_log( + caplog, tmp_path +) -> None: + session = _FixtureSession(commit_error=RuntimeError(_SECRET_EXCEPTION_TEXT)) + eml_file = tmp_path / "secret-commit.eml" + embedding = [0.0] * import_fixtures.EMBEDDING_DIMENSION + + with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( + import_fixtures, "parse_eml", return_value=_parsed_email() + ), patch.object( + import_fixtures, + "generate_fixture_embedding", + new=AsyncMock(return_value=embedding), + ), patch.object( + import_fixtures, + "assign_thread_id", + new=AsyncMock(return_value="fixture-thread"), + ): + imported = await import_fixtures.import_eml_file(session, eml_file) + + assert imported is False + assert session.rolled_back is True + assert "Fixture email commit failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert str(eml_file) not in caplog.text + + +@pytest.mark.asyncio +async def test_zip_fixture_parse_failure_logs_bounded_message(caplog) -> None: + file_path = Path(_SECRET_FIXTURE_PATH) + session = AsyncMock() + + with caplog.at_level( + logging.ERROR, logger=zip_import_fixtures.logger.name + ), patch.object( + zip_import_fixtures, + "extract_backup_async", + new=AsyncMock(return_value=[file_path]), + ), patch.object( + zip_import_fixtures, + "parse_eml", + side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT), + ): + await zip_import_fixtures.process_zip_file("fixture.zip", session) + + assert "Fixture archive email parsing failed" in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert _SECRET_FIXTURE_PATH not in caplog.text From 89158d6e8ccbfa6c138c1e9bb7a2b56e0d85e9e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 01:19:32 +0900 Subject: [PATCH 13/51] test(security): remove unused logging-test import --- backend/tests/test_safe_logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_safe_logging.py b/backend/tests/test_safe_logging.py index d416a5579..412e8186b 100644 --- a/backend/tests/test_safe_logging.py +++ b/backend/tests/test_safe_logging.py @@ -1,13 +1,13 @@ """Regression tests for secret-safe exception logging.""" import logging -import base64 from core.safe_logging import redacted_exception_info _t = "token=" _v = "super-secret-value" + def _raise_secret_bearing_exception() -> None: # Build it dynamically to avoid literal string matching the source code raise RuntimeError("provider " + _t + _v) From 684fb89258734e4dff9ee7922166bb9590387683 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:29:03 +0000 Subject: [PATCH 14/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_safe_logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_safe_logging.py b/backend/tests/test_safe_logging.py index 412e8186b..d416a5579 100644 --- a/backend/tests/test_safe_logging.py +++ b/backend/tests/test_safe_logging.py @@ -1,13 +1,13 @@ """Regression tests for secret-safe exception logging.""" import logging +import base64 from core.safe_logging import redacted_exception_info _t = "token=" _v = "super-secret-value" - def _raise_secret_bearing_exception() -> None: # Build it dynamically to avoid literal string matching the source code raise RuntimeError("provider " + _t + _v) From 4d6e2b869fd51da96cb565408caf0e920263e57b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 01:44:17 +0900 Subject: [PATCH 15/51] test(security): restore safe-logging regression hygiene --- backend/tests/test_safe_logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_safe_logging.py b/backend/tests/test_safe_logging.py index d416a5579..412e8186b 100644 --- a/backend/tests/test_safe_logging.py +++ b/backend/tests/test_safe_logging.py @@ -1,13 +1,13 @@ """Regression tests for secret-safe exception logging.""" import logging -import base64 from core.safe_logging import redacted_exception_info _t = "token=" _v = "super-secret-value" + def _raise_secret_bearing_exception() -> None: # Build it dynamically to avoid literal string matching the source code raise RuntimeError("provider " + _t + _v) From 15f3a2a2fb1f3a7fa4d808889f2da32d673f117d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:59:05 +0000 Subject: [PATCH 16/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_safe_logging.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/tests/test_safe_logging.py b/backend/tests/test_safe_logging.py index 412e8186b..b4b5cf9cd 100644 --- a/backend/tests/test_safe_logging.py +++ b/backend/tests/test_safe_logging.py @@ -1,13 +1,11 @@ """Regression tests for secret-safe exception logging.""" import logging - from core.safe_logging import redacted_exception_info _t = "token=" _v = "super-secret-value" - def _raise_secret_bearing_exception() -> None: # Build it dynamically to avoid literal string matching the source code raise RuntimeError("provider " + _t + _v) From e2b03db4ebf127a1c6588d22032f2d74089518f2 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:36:01 +0000 Subject: [PATCH 17/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CodeQL=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From d67008d28a1bf773fbff4b7d31bf232e9679fe92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 02:40:46 +0900 Subject: [PATCH 18/51] test(logging): restore lint-safe module spacing --- backend/tests/test_safe_logging.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/tests/test_safe_logging.py b/backend/tests/test_safe_logging.py index b4b5cf9cd..412e8186b 100644 --- a/backend/tests/test_safe_logging.py +++ b/backend/tests/test_safe_logging.py @@ -1,11 +1,13 @@ """Regression tests for secret-safe exception logging.""" import logging + from core.safe_logging import redacted_exception_info _t = "token=" _v = "super-secret-value" + def _raise_secret_bearing_exception() -> None: # Build it dynamically to avoid literal string matching the source code raise RuntimeError("provider " + _t + _v) From 4730fe3317d603c9bef06698db4b4767dc8a5982 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:45:41 +0000 Subject: [PATCH 19/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CodeQL=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From d89bdde130ef0df354ee0f34ab44ae29cef152c6 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:19:06 +0000 Subject: [PATCH 20/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CodeQL=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From f652f8cad89edac33f0e91cb9b1256cc05f229c9 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:26:59 +0000 Subject: [PATCH 21/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CodeQL=20=EB=8C=80=EA=B8=B0?= =?UTF-8?q?=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 82b9053606eaf99c7febd76d3cb05783bcf99eb6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:37:13 +0900 Subject: [PATCH 22/51] fix(security): suppress email exception context Signed-off-by: Seongho Bae --- backend/api/emails.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/emails.py b/backend/api/emails.py index f10b56ce9..279e31a5b 100644 --- a/backend/api/emails.py +++ b/backend/api/emails.py @@ -777,4 +777,4 @@ async def send_email_endpoint( logger.error("Error sending email", exc_info=redacted_exception_info(e)) raise HTTPException( status_code=500, detail="An internal error occurred while sending the email" - ) + ) from None From f0fbd3c0083b94f759013f4d385185c10f368217 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:40:20 +0000 Subject: [PATCH 23/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CodeQL=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 68767adab005c6b76c3ba4074acd032f046ab535 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:19:28 +0000 Subject: [PATCH 24/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(Noema=20CI=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 3d62c276a3e9b294347de4b74e6850ef57920a78 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:46:18 +0000 Subject: [PATCH 25/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(OpenCode=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 8159bf642f45e2fc0f89c3bc353b7d10a5e47295 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:09:48 +0000 Subject: [PATCH 26/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(Noema=20CI=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EB=8C=80=EA=B8=B0=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 2ecdbc26162c49a6a7750b5465dfe23f7c38739e Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:14:17 +0000 Subject: [PATCH 27/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(OpenCode=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From ff8247aa02597577bb57818cec6ab0fe16b21f2d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:18:02 +0000 Subject: [PATCH 28/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CI=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 14d2ae42cb0d9ebc1164eae45b45fa986d5934f4 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:23:45 +0000 Subject: [PATCH 29/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CI=20=EB=8C=80=EA=B8=B0=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From c757c0c588c97573197343ba7893db0aeadc373d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:27:43 +0000 Subject: [PATCH 30/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CI=20=EB=8C=80=EA=B8=B0=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 3e9b6adce50ba6768284b9cf32edfca6a6b4876b Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:30:27 +0000 Subject: [PATCH 31/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CI=20=EB=8C=80=EA=B8=B0=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From f95553a45490984b1c07f6b4c13eb4f228689954 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:33:34 +0000 Subject: [PATCH 32/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CI=20=EB=8C=80=EA=B8=B0=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From b2b25448bd74de87105c2a084380349326f8aa8f Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:37:17 +0000 Subject: [PATCH 33/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CI=20=EB=8C=80=EA=B8=B0=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 1e0aea01283993278923d6477ce8648b40930f20 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:40:23 +0000 Subject: [PATCH 34/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CI=20=EB=8C=80=EA=B8=B0=207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 716dfb73a6e6d47c6fa22698c829bbb40cad58cd Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:44:15 +0000 Subject: [PATCH 35/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CI=20=EB=8C=80=EA=B8=B0=208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From f405c7af44a0ac0e048e20f5f5e35716e3877e15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 19:48:12 +0900 Subject: [PATCH 36/51] test(security): cover email exception context suppression --- backend/tests/test_email_exception_context.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 backend/tests/test_email_exception_context.py diff --git a/backend/tests/test_email_exception_context.py b/backend/tests/test_email_exception_context.py new file mode 100644 index 000000000..b3bbece40 --- /dev/null +++ b/backend/tests/test_email_exception_context.py @@ -0,0 +1,57 @@ +"""Regression coverage for email exception-context suppression.""" + +import traceback +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from api import emails as emails_api + +_SENSITIVE_EXCEPTION_TEXT = "sensitive-provider-detail" + + +@pytest.mark.asyncio +async def test_send_email_endpoint_suppresses_internal_exception_context() -> None: + request = emails_api.SendEmailRequest( + to="recipient@example.com", + subject="Security regression", + body="Body", + ) + tenant_config = MagicMock( + smtp_server="smtp.example.com", + smtp_port=587, + smtp_username="sender@example.com", + smtp_password=None, + ) + auth_context = MagicMock(user_id="testuser", organization_id="org-acme") + + with patch.object( + emails_api, + "get_scoped_tenant_config", + new=AsyncMock(return_value=tenant_config), + ), patch.object( + emails_api, "validate_smtp_destination" + ), patch.object( + emails_api, "_enforce_send_email_rate_limit" + ), patch.object( + emails_api, + "send_email", + new=AsyncMock(side_effect=RuntimeError(_SENSITIVE_EXCEPTION_TEXT)), + ): + with pytest.raises(emails_api.HTTPException) as raised: + await emails_api.send_email_endpoint( + request, + db=MagicMock(), + auth_context=auth_context, + ) + + rendered = "".join( + traceback.format_exception( + type(raised.value), raised.value, raised.value.__traceback__ + ) + ) + assert raised.value.status_code == 500 + assert raised.value.detail == "An internal error occurred while sending the email" + assert raised.value.__suppress_context__ is True + assert raised.value.__cause__ is None + assert _SENSITIVE_EXCEPTION_TEXT not in rendered From dcff38e64fe0490856b4c95ace5a6cfa630d0df3 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:48:36 +0000 Subject: [PATCH 37/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CI=20=EB=8C=80=EA=B8=B0=209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_email_exception_context.py | 57 ------------------- 1 file changed, 57 deletions(-) delete mode 100644 backend/tests/test_email_exception_context.py diff --git a/backend/tests/test_email_exception_context.py b/backend/tests/test_email_exception_context.py deleted file mode 100644 index b3bbece40..000000000 --- a/backend/tests/test_email_exception_context.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Regression coverage for email exception-context suppression.""" - -import traceback -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from api import emails as emails_api - -_SENSITIVE_EXCEPTION_TEXT = "sensitive-provider-detail" - - -@pytest.mark.asyncio -async def test_send_email_endpoint_suppresses_internal_exception_context() -> None: - request = emails_api.SendEmailRequest( - to="recipient@example.com", - subject="Security regression", - body="Body", - ) - tenant_config = MagicMock( - smtp_server="smtp.example.com", - smtp_port=587, - smtp_username="sender@example.com", - smtp_password=None, - ) - auth_context = MagicMock(user_id="testuser", organization_id="org-acme") - - with patch.object( - emails_api, - "get_scoped_tenant_config", - new=AsyncMock(return_value=tenant_config), - ), patch.object( - emails_api, "validate_smtp_destination" - ), patch.object( - emails_api, "_enforce_send_email_rate_limit" - ), patch.object( - emails_api, - "send_email", - new=AsyncMock(side_effect=RuntimeError(_SENSITIVE_EXCEPTION_TEXT)), - ): - with pytest.raises(emails_api.HTTPException) as raised: - await emails_api.send_email_endpoint( - request, - db=MagicMock(), - auth_context=auth_context, - ) - - rendered = "".join( - traceback.format_exception( - type(raised.value), raised.value, raised.value.__traceback__ - ) - ) - assert raised.value.status_code == 500 - assert raised.value.detail == "An internal error occurred while sending the email" - assert raised.value.__suppress_context__ is True - assert raised.value.__cause__ is None - assert _SENSITIVE_EXCEPTION_TEXT not in rendered From 324dc47442c9431a49493810575a70d64d45cc9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 19:51:17 +0900 Subject: [PATCH 38/51] test(security): restore email exception-context regression after writer overlap --- backend/tests/test_email_exception_context.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 backend/tests/test_email_exception_context.py diff --git a/backend/tests/test_email_exception_context.py b/backend/tests/test_email_exception_context.py new file mode 100644 index 000000000..b3bbece40 --- /dev/null +++ b/backend/tests/test_email_exception_context.py @@ -0,0 +1,57 @@ +"""Regression coverage for email exception-context suppression.""" + +import traceback +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from api import emails as emails_api + +_SENSITIVE_EXCEPTION_TEXT = "sensitive-provider-detail" + + +@pytest.mark.asyncio +async def test_send_email_endpoint_suppresses_internal_exception_context() -> None: + request = emails_api.SendEmailRequest( + to="recipient@example.com", + subject="Security regression", + body="Body", + ) + tenant_config = MagicMock( + smtp_server="smtp.example.com", + smtp_port=587, + smtp_username="sender@example.com", + smtp_password=None, + ) + auth_context = MagicMock(user_id="testuser", organization_id="org-acme") + + with patch.object( + emails_api, + "get_scoped_tenant_config", + new=AsyncMock(return_value=tenant_config), + ), patch.object( + emails_api, "validate_smtp_destination" + ), patch.object( + emails_api, "_enforce_send_email_rate_limit" + ), patch.object( + emails_api, + "send_email", + new=AsyncMock(side_effect=RuntimeError(_SENSITIVE_EXCEPTION_TEXT)), + ): + with pytest.raises(emails_api.HTTPException) as raised: + await emails_api.send_email_endpoint( + request, + db=MagicMock(), + auth_context=auth_context, + ) + + rendered = "".join( + traceback.format_exception( + type(raised.value), raised.value, raised.value.__traceback__ + ) + ) + assert raised.value.status_code == 500 + assert raised.value.detail == "An internal error occurred while sending the email" + assert raised.value.__suppress_context__ is True + assert raised.value.__cause__ is None + assert _SENSITIVE_EXCEPTION_TEXT not in rendered From 6568f3c3cd468fd1ddab12e82b6d471d567b508d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:51:29 +0000 Subject: [PATCH 39/51] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDI?= =?UTF-8?q?UM]=20=EC=98=88=EC=99=B8=20=EC=A0=95=EB=B3=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=A7=80=20(CI=20=EB=8C=80=EA=B8=B0=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_email_exception_context.py | 57 ------------------- 1 file changed, 57 deletions(-) delete mode 100644 backend/tests/test_email_exception_context.py diff --git a/backend/tests/test_email_exception_context.py b/backend/tests/test_email_exception_context.py deleted file mode 100644 index b3bbece40..000000000 --- a/backend/tests/test_email_exception_context.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Regression coverage for email exception-context suppression.""" - -import traceback -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from api import emails as emails_api - -_SENSITIVE_EXCEPTION_TEXT = "sensitive-provider-detail" - - -@pytest.mark.asyncio -async def test_send_email_endpoint_suppresses_internal_exception_context() -> None: - request = emails_api.SendEmailRequest( - to="recipient@example.com", - subject="Security regression", - body="Body", - ) - tenant_config = MagicMock( - smtp_server="smtp.example.com", - smtp_port=587, - smtp_username="sender@example.com", - smtp_password=None, - ) - auth_context = MagicMock(user_id="testuser", organization_id="org-acme") - - with patch.object( - emails_api, - "get_scoped_tenant_config", - new=AsyncMock(return_value=tenant_config), - ), patch.object( - emails_api, "validate_smtp_destination" - ), patch.object( - emails_api, "_enforce_send_email_rate_limit" - ), patch.object( - emails_api, - "send_email", - new=AsyncMock(side_effect=RuntimeError(_SENSITIVE_EXCEPTION_TEXT)), - ): - with pytest.raises(emails_api.HTTPException) as raised: - await emails_api.send_email_endpoint( - request, - db=MagicMock(), - auth_context=auth_context, - ) - - rendered = "".join( - traceback.format_exception( - type(raised.value), raised.value, raised.value.__traceback__ - ) - ) - assert raised.value.status_code == 500 - assert raised.value.detail == "An internal error occurred while sending the email" - assert raised.value.__suppress_context__ is True - assert raised.value.__cause__ is None - assert _SENSITIVE_EXCEPTION_TEXT not in rendered From b3a51ab1a16f24ed20597861f65ada2aea2f229f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 19:53:02 +0900 Subject: [PATCH 40/51] test(security): preserve email exception-context regression --- backend/tests/test_email_exception_context.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 backend/tests/test_email_exception_context.py diff --git a/backend/tests/test_email_exception_context.py b/backend/tests/test_email_exception_context.py new file mode 100644 index 000000000..b3bbece40 --- /dev/null +++ b/backend/tests/test_email_exception_context.py @@ -0,0 +1,57 @@ +"""Regression coverage for email exception-context suppression.""" + +import traceback +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from api import emails as emails_api + +_SENSITIVE_EXCEPTION_TEXT = "sensitive-provider-detail" + + +@pytest.mark.asyncio +async def test_send_email_endpoint_suppresses_internal_exception_context() -> None: + request = emails_api.SendEmailRequest( + to="recipient@example.com", + subject="Security regression", + body="Body", + ) + tenant_config = MagicMock( + smtp_server="smtp.example.com", + smtp_port=587, + smtp_username="sender@example.com", + smtp_password=None, + ) + auth_context = MagicMock(user_id="testuser", organization_id="org-acme") + + with patch.object( + emails_api, + "get_scoped_tenant_config", + new=AsyncMock(return_value=tenant_config), + ), patch.object( + emails_api, "validate_smtp_destination" + ), patch.object( + emails_api, "_enforce_send_email_rate_limit" + ), patch.object( + emails_api, + "send_email", + new=AsyncMock(side_effect=RuntimeError(_SENSITIVE_EXCEPTION_TEXT)), + ): + with pytest.raises(emails_api.HTTPException) as raised: + await emails_api.send_email_endpoint( + request, + db=MagicMock(), + auth_context=auth_context, + ) + + rendered = "".join( + traceback.format_exception( + type(raised.value), raised.value, raised.value.__traceback__ + ) + ) + assert raised.value.status_code == 500 + assert raised.value.detail == "An internal error occurred while sending the email" + assert raised.value.__suppress_context__ is True + assert raised.value.__cause__ is None + assert _SENSITIVE_EXCEPTION_TEXT not in rendered From 01ce34101507af433fcf5dd53072ad2208a4a0c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:11:22 +0900 Subject: [PATCH 41/51] fix(security): bound fixture archive extraction errors --- backend/scripts/import_fixtures.py | 10 +++++--- .../test_exception_logging_boundaries.py | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/backend/scripts/import_fixtures.py b/backend/scripts/import_fixtures.py index 64ab333e3..10adfa49a 100644 --- a/backend/scripts/import_fixtures.py +++ b/backend/scripts/import_fixtures.py @@ -32,8 +32,12 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): with tempfile.TemporaryDirectory() as temp_dir: - logger.info(f"Extracting {zip_path}...") - extracted_files = await extract_backup_async(zip_path, temp_dir) + logger.info("Extracting fixture archive") + try: + extracted_files = await extract_backup_async(zip_path, temp_dir) + except Exception: + logger.error("Fixture archive extraction failed") + return batch_values = [] for file_path in extracted_files: @@ -118,7 +122,7 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): ) await session.execute(stmt, batch_values) await session.commit() - logger.info(f"Finished processing {zip_path}") + logger.info("Finished processing fixture archive") async def main(): diff --git a/backend/tests/test_exception_logging_boundaries.py b/backend/tests/test_exception_logging_boundaries.py index e3be91824..cde183be9 100644 --- a/backend/tests/test_exception_logging_boundaries.py +++ b/backend/tests/test_exception_logging_boundaries.py @@ -140,6 +140,30 @@ async def test_root_fixture_parse_failure_logs_bounded_message(caplog, tmp_path) assert str(eml_file) not in caplog.text +@pytest.mark.asyncio +async def test_zip_archive_extraction_failure_logs_bounded_message(caplog, tmp_path) -> None: + session = MagicMock() + zip_path = tmp_path / "customer-secret.zip" + + with caplog.at_level( + logging.INFO, logger=zip_import_fixtures.logger.name + ), patch.object( + zip_import_fixtures, + "extract_backup_async", + new=AsyncMock( + side_effect=RuntimeError(f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}") + ), + ): + await zip_import_fixtures.process_zip_file(zip_path, session) + + assert "Fixture archive extraction failed" in caplog.text + assert "Extracting fixture archive" in caplog.text + assert "Finished processing fixture archive" not in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert _SECRET_FIXTURE_PATH not in caplog.text + assert str(zip_path) not in caplog.text + + @pytest.mark.asyncio async def test_root_fixture_body_embedding_failure_logs_bounded_message( caplog, tmp_path From 3e065f45d3c34256321055d98adba75144c8db09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:41:33 +0900 Subject: [PATCH 42/51] test(security): fail closed on unexpected archive extraction errors --- ...est_fixture_archive_extraction_boundary.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 backend/tests/test_fixture_archive_extraction_boundary.py diff --git a/backend/tests/test_fixture_archive_extraction_boundary.py b/backend/tests/test_fixture_archive_extraction_boundary.py new file mode 100644 index 000000000..e24322c17 --- /dev/null +++ b/backend/tests/test_fixture_archive_extraction_boundary.py @@ -0,0 +1,53 @@ +"""Regression coverage for fixture archive extraction failure boundaries.""" + +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from scripts import import_fixtures as zip_import_fixtures +from services.exceptions import ArchiveError + +_SECRET_EXCEPTION_TEXT = "provider token=super-secret-value" +_SECRET_FIXTURE_PATH = "/private/customer/customer-secret.zip" + + +@pytest.mark.asyncio +async def test_expected_archive_error_is_bounded_and_consumed(caplog, tmp_path) -> None: + session = MagicMock() + zip_path = tmp_path / "customer-secret.zip" + + with caplog.at_level( + logging.INFO, logger=zip_import_fixtures.logger.name + ), patch.object( + zip_import_fixtures, + "extract_backup_async", + new=AsyncMock( + side_effect=ArchiveError( + f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}" + ) + ), + ): + await zip_import_fixtures.process_zip_file(zip_path, session) + + assert "Fixture archive extraction failed" in caplog.text + assert "Extracting fixture archive" in caplog.text + assert "Finished processing fixture archive" not in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert _SECRET_FIXTURE_PATH not in caplog.text + assert str(zip_path) not in caplog.text + + +@pytest.mark.asyncio +async def test_unexpected_archive_runtime_error_propagates(tmp_path) -> None: + session = MagicMock() + zip_path = tmp_path / "customer-secret.zip" + unexpected = RuntimeError("unexpected archive implementation failure") + + with patch.object( + zip_import_fixtures, + "extract_backup_async", + new=AsyncMock(side_effect=unexpected), + ): + with pytest.raises(RuntimeError, match="unexpected archive implementation failure"): + await zip_import_fixtures.process_zip_file(zip_path, session) From 44674eb036a7e734e16d73ef07219f7a8c2018a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:42:48 +0900 Subject: [PATCH 43/51] test(security): pin fixture directory path redaction --- ...test_fixture_archive_extraction_boundary.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/backend/tests/test_fixture_archive_extraction_boundary.py b/backend/tests/test_fixture_archive_extraction_boundary.py index e24322c17..f60c6e3fd 100644 --- a/backend/tests/test_fixture_archive_extraction_boundary.py +++ b/backend/tests/test_fixture_archive_extraction_boundary.py @@ -39,15 +39,11 @@ async def test_expected_archive_error_is_bounded_and_consumed(caplog, tmp_path) @pytest.mark.asyncio -async def test_unexpected_archive_runtime_error_propagates(tmp_path) -> None: - session = MagicMock() - zip_path = tmp_path / "customer-secret.zip" - unexpected = RuntimeError("unexpected archive implementation failure") +async def test_missing_fixture_directory_log_does_not_expose_path(caplog) -> None: + with caplog.at_level( + logging.ERROR, logger=zip_import_fixtures.logger.name + ), patch.object(zip_import_fixtures.Path, "exists", return_value=False): + await zip_import_fixtures.main() - with patch.object( - zip_import_fixtures, - "extract_backup_async", - new=AsyncMock(side_effect=unexpected), - ): - with pytest.raises(RuntimeError, match="unexpected archive implementation failure"): - await zip_import_fixtures.process_zip_file(zip_path, session) + assert "Fixture directory is unavailable" in caplog.text + assert "secret_fixtures" not in caplog.text From 8f43bcb1899370b9406aad3e6308fb8b18814e99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:43:09 +0900 Subject: [PATCH 44/51] fix(security): redact missing fixture directory path --- backend/scripts/import_fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/import_fixtures.py b/backend/scripts/import_fixtures.py index 10adfa49a..acf950f18 100644 --- a/backend/scripts/import_fixtures.py +++ b/backend/scripts/import_fixtures.py @@ -130,7 +130,7 @@ async def main(): fixtures_dir = root_dir / "secret_fixtures" if not fixtures_dir.exists(): - logger.error(f"Fixtures directory {fixtures_dir} does not exist.") + logger.error("Fixture directory is unavailable") return async with AsyncSessionLocal() as session: From 9a87d0bd003de2435c0e4cc5dcdef9d87054430f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:52:02 +0900 Subject: [PATCH 45/51] fix(security): preserve unexpected archive failures --- backend/scripts/import_fixtures.py | 3 ++- .../test_exception_logging_boundaries.py | 20 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/backend/scripts/import_fixtures.py b/backend/scripts/import_fixtures.py index acf950f18..3c309dc51 100644 --- a/backend/scripts/import_fixtures.py +++ b/backend/scripts/import_fixtures.py @@ -11,6 +11,7 @@ sys.path.append(str(Path(__file__).resolve().parent.parent)) from services.archive import extract_backup_async +from services.exceptions import ArchiveError from services.email_parser import parse_eml from services.embedding import ( STORAGE_EMBEDDING_DIMENSION, @@ -35,7 +36,7 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): logger.info("Extracting fixture archive") try: extracted_files = await extract_backup_async(zip_path, temp_dir) - except Exception: + except ArchiveError: logger.error("Fixture archive extraction failed") return diff --git a/backend/tests/test_exception_logging_boundaries.py b/backend/tests/test_exception_logging_boundaries.py index cde183be9..35444c697 100644 --- a/backend/tests/test_exception_logging_boundaries.py +++ b/backend/tests/test_exception_logging_boundaries.py @@ -14,6 +14,7 @@ from core.safe_logging import redacted_exception_info from scripts import import_fixtures as zip_import_fixtures from services.llm_service import draft_reply +from services.exceptions import ArchiveError _SECRET_EXCEPTION_TEXT = "provider token=super-secret-value" _SECRET_FIXTURE_PATH = "/private/customer/secret-message.eml" @@ -151,7 +152,7 @@ async def test_zip_archive_extraction_failure_logs_bounded_message(caplog, tmp_p zip_import_fixtures, "extract_backup_async", new=AsyncMock( - side_effect=RuntimeError(f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}") + side_effect=ArchiveError(f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}") ), ): await zip_import_fixtures.process_zip_file(zip_path, session) @@ -164,6 +165,23 @@ async def test_zip_archive_extraction_failure_logs_bounded_message(caplog, tmp_p assert str(zip_path) not in caplog.text +@pytest.mark.asyncio +async def test_unexpected_zip_extraction_failure_propagates(caplog, tmp_path) -> None: + session = MagicMock() + zip_path = tmp_path / "customer-secret.zip" + unexpected = RuntimeError("unexpected extraction defect") + + with caplog.at_level(logging.INFO, logger=zip_import_fixtures.logger.name), patch.object( + zip_import_fixtures, + "extract_backup_async", + new=AsyncMock(side_effect=unexpected), + ): + with pytest.raises(RuntimeError, match="unexpected extraction defect"): + await zip_import_fixtures.process_zip_file(zip_path, session) + + assert "Fixture archive extraction failed" not in caplog.text + + @pytest.mark.asyncio async def test_root_fixture_body_embedding_failure_logs_bounded_message( caplog, tmp_path From 4b7b1b7b4c0a8cedad3d8c44a0d280d50187a82f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:13:06 +0900 Subject: [PATCH 46/51] test(security): require sanitized unexpected archive failure --- backend/tests/test_exception_logging_boundaries.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_exception_logging_boundaries.py b/backend/tests/test_exception_logging_boundaries.py index 35444c697..7ceef5feb 100644 --- a/backend/tests/test_exception_logging_boundaries.py +++ b/backend/tests/test_exception_logging_boundaries.py @@ -166,20 +166,25 @@ async def test_zip_archive_extraction_failure_logs_bounded_message(caplog, tmp_p @pytest.mark.asyncio -async def test_unexpected_zip_extraction_failure_propagates(caplog, tmp_path) -> None: +async def test_unexpected_zip_extraction_failure_is_sanitized(caplog, tmp_path) -> None: session = MagicMock() zip_path = tmp_path / "customer-secret.zip" - unexpected = RuntimeError("unexpected extraction defect") + unexpected = RuntimeError(f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}") with caplog.at_level(logging.INFO, logger=zip_import_fixtures.logger.name), patch.object( zip_import_fixtures, "extract_backup_async", new=AsyncMock(side_effect=unexpected), ): - with pytest.raises(RuntimeError, match="unexpected extraction defect"): + with pytest.raises( + ArchiveError, match="^Fixture archive extraction failed$" + ) as raised: await zip_import_fixtures.process_zip_file(zip_path, session) + assert raised.value.__cause__ is None assert "Fixture archive extraction failed" not in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert _SECRET_FIXTURE_PATH not in caplog.text @pytest.mark.asyncio From 99c7ef6e453c30c1d4b1260af0e20d40c333f36f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:14:00 +0900 Subject: [PATCH 47/51] fix(security): sanitize unexpected archive extraction failures --- backend/scripts/import_fixtures.py | 2 + .../test_exception_logging_boundaries.py | 151 +++++++++++------- ...est_fixture_archive_extraction_boundary.py | 26 +-- 3 files changed, 106 insertions(+), 73 deletions(-) diff --git a/backend/scripts/import_fixtures.py b/backend/scripts/import_fixtures.py index 3c309dc51..79f44376b 100644 --- a/backend/scripts/import_fixtures.py +++ b/backend/scripts/import_fixtures.py @@ -39,6 +39,8 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): except ArchiveError: logger.error("Fixture archive extraction failed") return + except Exception: + raise ArchiveError("Fixture archive extraction failed") from None batch_values = [] for file_path in extracted_files: diff --git a/backend/tests/test_exception_logging_boundaries.py b/backend/tests/test_exception_logging_boundaries.py index 7ceef5feb..033522215 100644 --- a/backend/tests/test_exception_logging_boundaries.py +++ b/backend/tests/test_exception_logging_boundaries.py @@ -99,14 +99,16 @@ async def test_llm_service_error_does_not_chain_secret_bearing_provider_text() - fake_client = MagicMock() fake_client.close = AsyncMock() - with patch( - "services.llm_service.build_llm_provider_http_client", - new=AsyncMock(return_value=(None, fake_http_client)), - ), patch( - "services.llm_service.AsyncOpenAI", return_value=fake_client - ), patch( - "services.llm_service.provider_circuit_breaker.call", - new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), + with ( + patch( + "services.llm_service.build_llm_provider_http_client", + new=AsyncMock(return_value=(None, fake_http_client)), + ), + patch("services.llm_service.AsyncOpenAI", return_value=fake_client), + patch( + "services.llm_service.provider_circuit_breaker.call", + new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), + ), ): with pytest.raises(LLMServiceError) as raised: await draft_reply("email body", "draft reply", "test-key") @@ -123,14 +125,21 @@ async def test_llm_service_error_does_not_chain_secret_bearing_provider_text() - @pytest.mark.asyncio -async def test_root_fixture_parse_failure_logs_bounded_message(caplog, tmp_path) -> None: +async def test_root_fixture_parse_failure_logs_bounded_message( + caplog, tmp_path +) -> None: session = _FixtureSession() eml_file = tmp_path / "secret-message.eml" - with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( - import_fixtures, - "parse_eml", - side_effect=RuntimeError(f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}"), + with ( + caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), + patch.object( + import_fixtures, + "parse_eml", + side_effect=RuntimeError( + f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}" + ), + ), ): imported = await import_fixtures.import_eml_file(session, eml_file) @@ -142,17 +151,22 @@ async def test_root_fixture_parse_failure_logs_bounded_message(caplog, tmp_path) @pytest.mark.asyncio -async def test_zip_archive_extraction_failure_logs_bounded_message(caplog, tmp_path) -> None: +async def test_zip_archive_extraction_failure_logs_bounded_message( + caplog, tmp_path +) -> None: session = MagicMock() zip_path = tmp_path / "customer-secret.zip" - with caplog.at_level( - logging.INFO, logger=zip_import_fixtures.logger.name - ), patch.object( - zip_import_fixtures, - "extract_backup_async", - new=AsyncMock( - side_effect=ArchiveError(f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}") + with ( + caplog.at_level(logging.INFO, logger=zip_import_fixtures.logger.name), + patch.object( + zip_import_fixtures, + "extract_backup_async", + new=AsyncMock( + side_effect=ArchiveError( + f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}" + ) + ), ), ): await zip_import_fixtures.process_zip_file(zip_path, session) @@ -171,10 +185,13 @@ async def test_unexpected_zip_extraction_failure_is_sanitized(caplog, tmp_path) zip_path = tmp_path / "customer-secret.zip" unexpected = RuntimeError(f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}") - with caplog.at_level(logging.INFO, logger=zip_import_fixtures.logger.name), patch.object( - zip_import_fixtures, - "extract_backup_async", - new=AsyncMock(side_effect=unexpected), + with ( + caplog.at_level(logging.INFO, logger=zip_import_fixtures.logger.name), + patch.object( + zip_import_fixtures, + "extract_backup_async", + new=AsyncMock(side_effect=unexpected), + ), ): with pytest.raises( ArchiveError, match="^Fixture archive extraction failed$" @@ -194,12 +211,14 @@ async def test_root_fixture_body_embedding_failure_logs_bounded_message( session = _FixtureSession() eml_file = tmp_path / "secret-body.eml" - with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( - import_fixtures, "parse_eml", return_value=_parsed_email() - ), patch.object( - import_fixtures, - "generate_fixture_embedding", - new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), + with ( + caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), + patch.object(import_fixtures, "parse_eml", return_value=_parsed_email()), + patch.object( + import_fixtures, + "generate_fixture_embedding", + new=AsyncMock(side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT)), + ), ): imported = await import_fixtures.import_eml_file(session, eml_file) @@ -220,16 +239,21 @@ async def test_root_fixture_attachment_embedding_failure_skips_attachment_safely ) embedding = [0.0] * import_fixtures.EMBEDDING_DIMENSION - with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( - import_fixtures, "parse_eml", return_value=parsed - ), patch.object( - import_fixtures, - "generate_fixture_embedding", - new=AsyncMock(side_effect=[embedding, RuntimeError(_SECRET_EXCEPTION_TEXT)]), - ), patch.object( - import_fixtures, - "assign_thread_id", - new=AsyncMock(return_value="fixture-thread"), + with ( + caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), + patch.object(import_fixtures, "parse_eml", return_value=parsed), + patch.object( + import_fixtures, + "generate_fixture_embedding", + new=AsyncMock( + side_effect=[embedding, RuntimeError(_SECRET_EXCEPTION_TEXT)] + ), + ), + patch.object( + import_fixtures, + "assign_thread_id", + new=AsyncMock(return_value="fixture-thread"), + ), ): imported = await import_fixtures.import_eml_file(session, eml_file) @@ -251,16 +275,19 @@ async def test_root_fixture_commit_failure_rolls_back_without_sensitive_log( eml_file = tmp_path / "secret-commit.eml" embedding = [0.0] * import_fixtures.EMBEDDING_DIMENSION - with caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), patch.object( - import_fixtures, "parse_eml", return_value=_parsed_email() - ), patch.object( - import_fixtures, - "generate_fixture_embedding", - new=AsyncMock(return_value=embedding), - ), patch.object( - import_fixtures, - "assign_thread_id", - new=AsyncMock(return_value="fixture-thread"), + with ( + caplog.at_level(logging.ERROR, logger=import_fixtures.logger.name), + patch.object(import_fixtures, "parse_eml", return_value=_parsed_email()), + patch.object( + import_fixtures, + "generate_fixture_embedding", + new=AsyncMock(return_value=embedding), + ), + patch.object( + import_fixtures, + "assign_thread_id", + new=AsyncMock(return_value="fixture-thread"), + ), ): imported = await import_fixtures.import_eml_file(session, eml_file) @@ -276,16 +303,18 @@ async def test_zip_fixture_parse_failure_logs_bounded_message(caplog) -> None: file_path = Path(_SECRET_FIXTURE_PATH) session = AsyncMock() - with caplog.at_level( - logging.ERROR, logger=zip_import_fixtures.logger.name - ), patch.object( - zip_import_fixtures, - "extract_backup_async", - new=AsyncMock(return_value=[file_path]), - ), patch.object( - zip_import_fixtures, - "parse_eml", - side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT), + with ( + caplog.at_level(logging.ERROR, logger=zip_import_fixtures.logger.name), + patch.object( + zip_import_fixtures, + "extract_backup_async", + new=AsyncMock(return_value=[file_path]), + ), + patch.object( + zip_import_fixtures, + "parse_eml", + side_effect=RuntimeError(_SECRET_EXCEPTION_TEXT), + ), ): await zip_import_fixtures.process_zip_file("fixture.zip", session) diff --git a/backend/tests/test_fixture_archive_extraction_boundary.py b/backend/tests/test_fixture_archive_extraction_boundary.py index f60c6e3fd..0d9028270 100644 --- a/backend/tests/test_fixture_archive_extraction_boundary.py +++ b/backend/tests/test_fixture_archive_extraction_boundary.py @@ -17,15 +17,16 @@ async def test_expected_archive_error_is_bounded_and_consumed(caplog, tmp_path) session = MagicMock() zip_path = tmp_path / "customer-secret.zip" - with caplog.at_level( - logging.INFO, logger=zip_import_fixtures.logger.name - ), patch.object( - zip_import_fixtures, - "extract_backup_async", - new=AsyncMock( - side_effect=ArchiveError( - f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}" - ) + with ( + caplog.at_level(logging.INFO, logger=zip_import_fixtures.logger.name), + patch.object( + zip_import_fixtures, + "extract_backup_async", + new=AsyncMock( + side_effect=ArchiveError( + f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}" + ) + ), ), ): await zip_import_fixtures.process_zip_file(zip_path, session) @@ -40,9 +41,10 @@ async def test_expected_archive_error_is_bounded_and_consumed(caplog, tmp_path) @pytest.mark.asyncio async def test_missing_fixture_directory_log_does_not_expose_path(caplog) -> None: - with caplog.at_level( - logging.ERROR, logger=zip_import_fixtures.logger.name - ), patch.object(zip_import_fixtures.Path, "exists", return_value=False): + with ( + caplog.at_level(logging.ERROR, logger=zip_import_fixtures.logger.name), + patch.object(zip_import_fixtures.Path, "exists", return_value=False), + ): await zip_import_fixtures.main() assert "Fixture directory is unavailable" in caplog.text From 43a952b95d41b7e630e951101a27006933fff947 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 04:46:27 +0900 Subject: [PATCH 48/51] test: require fail-closed fixture archive rejection --- ...est_fixture_archive_extraction_boundary.py | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_fixture_archive_extraction_boundary.py b/backend/tests/test_fixture_archive_extraction_boundary.py index 0d9028270..1d68a7430 100644 --- a/backend/tests/test_fixture_archive_extraction_boundary.py +++ b/backend/tests/test_fixture_archive_extraction_boundary.py @@ -12,8 +12,18 @@ _SECRET_FIXTURE_PATH = "/private/customer/customer-secret.zip" +class _AsyncSessionContext: + async def __aenter__(self): + return MagicMock() + + async def __aexit__(self, exc_type, exc, traceback): + return False + + @pytest.mark.asyncio -async def test_expected_archive_error_is_bounded_and_consumed(caplog, tmp_path) -> None: +async def test_expected_archive_error_is_bounded_and_reports_failure( + caplog, tmp_path +) -> None: session = MagicMock() zip_path = tmp_path / "customer-secret.zip" @@ -29,8 +39,9 @@ async def test_expected_archive_error_is_bounded_and_consumed(caplog, tmp_path) ), ), ): - await zip_import_fixtures.process_zip_file(zip_path, session) + processed = await zip_import_fixtures.process_zip_file(zip_path, session) + assert processed is False assert "Fixture archive extraction failed" in caplog.text assert "Extracting fixture archive" in caplog.text assert "Finished processing fixture archive" not in caplog.text @@ -39,6 +50,38 @@ async def test_expected_archive_error_is_bounded_and_consumed(caplog, tmp_path) assert str(zip_path) not in caplog.text +@pytest.mark.asyncio +async def test_main_fails_closed_after_archive_rejection() -> None: + process_zip_file = AsyncMock(return_value=False) + + with ( + patch.object(zip_import_fixtures.Path, "exists", return_value=True), + patch.object( + zip_import_fixtures.Path, + "glob", + return_value=["customer-secret.zip"], + ), + patch.object( + zip_import_fixtures, + "AsyncSessionLocal", + return_value=_AsyncSessionContext(), + ), + patch.object( + zip_import_fixtures, + "process_zip_file", + new=process_zip_file, + ), + ): + with pytest.raises( + ArchiveError, match="^Fixture archive extraction failed$" + ) as raised: + await zip_import_fixtures.main() + + assert raised.value.__cause__ is None + assert raised.value.__context__ is None + process_zip_file.assert_awaited_once() + + @pytest.mark.asyncio async def test_missing_fixture_directory_log_does_not_expose_path(caplog) -> None: with ( From 5813a5cfd198c04735f9e0531f45deb12914e001 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 04:47:00 +0900 Subject: [PATCH 49/51] fix: fail closed on rejected fixture archives --- backend/scripts/import_fixtures.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/scripts/import_fixtures.py b/backend/scripts/import_fixtures.py index 79f44376b..e73a4571a 100644 --- a/backend/scripts/import_fixtures.py +++ b/backend/scripts/import_fixtures.py @@ -31,14 +31,15 @@ IMPORT_ORGANIZATION_ID = os.environ.get("NARUON_IMPORT_ORGANIZATION_ID", "default") -async def process_zip_file(zip_path: str | Path, session: AsyncSession): +async def process_zip_file(zip_path: str | Path, session: AsyncSession) -> bool: + """Import one fixture archive and report whether extraction was accepted.""" with tempfile.TemporaryDirectory() as temp_dir: logger.info("Extracting fixture archive") try: extracted_files = await extract_backup_async(zip_path, temp_dir) except ArchiveError: logger.error("Fixture archive extraction failed") - return + return False except Exception: raise ArchiveError("Fixture archive extraction failed") from None @@ -126,6 +127,7 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession): await session.execute(stmt, batch_values) await session.commit() logger.info("Finished processing fixture archive") + return True async def main(): @@ -138,7 +140,8 @@ async def main(): async with AsyncSessionLocal() as session: for zip_file in fixtures_dir.glob("*.zip"): - await process_zip_file(zip_file, session) + if not await process_zip_file(zip_file, session): + raise ArchiveError("Fixture archive extraction failed") if __name__ == "__main__": From 9554ceb911355f0d3543fff4cb7be92ce38eb186 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 04:50:51 +0900 Subject: [PATCH 50/51] test: reject retained archive exception context --- ...est_fixture_archive_extraction_boundary.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/backend/tests/test_fixture_archive_extraction_boundary.py b/backend/tests/test_fixture_archive_extraction_boundary.py index 1d68a7430..2a1fc73f9 100644 --- a/backend/tests/test_fixture_archive_extraction_boundary.py +++ b/backend/tests/test_fixture_archive_extraction_boundary.py @@ -50,6 +50,34 @@ async def test_expected_archive_error_is_bounded_and_reports_failure( assert str(zip_path) not in caplog.text +@pytest.mark.asyncio +async def test_unexpected_archive_error_discards_sensitive_context( + caplog, tmp_path +) -> None: + session = MagicMock() + zip_path = tmp_path / "customer-secret.zip" + unexpected = RuntimeError(f"{_SECRET_EXCEPTION_TEXT} {_SECRET_FIXTURE_PATH}") + + with ( + caplog.at_level(logging.INFO, logger=zip_import_fixtures.logger.name), + patch.object( + zip_import_fixtures, + "extract_backup_async", + new=AsyncMock(side_effect=unexpected), + ), + ): + with pytest.raises( + ArchiveError, match="^Fixture archive extraction failed$" + ) as raised: + await zip_import_fixtures.process_zip_file(zip_path, session) + + assert raised.value.__cause__ is None + assert raised.value.__context__ is None + assert "Fixture archive extraction failed" not in caplog.text + assert _SECRET_EXCEPTION_TEXT not in caplog.text + assert _SECRET_FIXTURE_PATH not in caplog.text + + @pytest.mark.asyncio async def test_main_fails_closed_after_archive_rejection() -> None: process_zip_file = AsyncMock(return_value=False) From 3da3ae8e60e1bb049f59ae86bfe82db12b7e3cc7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 04:51:26 +0900 Subject: [PATCH 51/51] fix: discard sensitive archive exception context --- backend/scripts/import_fixtures.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/scripts/import_fixtures.py b/backend/scripts/import_fixtures.py index e73a4571a..34da257b8 100644 --- a/backend/scripts/import_fixtures.py +++ b/backend/scripts/import_fixtures.py @@ -35,13 +35,17 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession) -> bool: """Import one fixture archive and report whether extraction was accepted.""" with tempfile.TemporaryDirectory() as temp_dir: logger.info("Extracting fixture archive") + extracted_files: list[Path] | None try: extracted_files = await extract_backup_async(zip_path, temp_dir) except ArchiveError: logger.error("Fixture archive extraction failed") return False except Exception: - raise ArchiveError("Fixture archive extraction failed") from None + extracted_files = None + + if extracted_files is None: + raise ArchiveError("Fixture archive extraction failed") batch_values = [] for file_path in extracted_files: