Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d6751da
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (API 키 등의 민감 정보 노출 방지를 위해 logger.er…
seonghobae Sep 7, 2026
1af9944
test(logging): reject secret-bearing exception text
seonghobae Sep 7, 2026
31bd8ce
feat(logging): add redacted traceback helper
seonghobae Sep 7, 2026
06fc320
test(security): reproduce exception logging disclosure boundaries
seonghobae Sep 7, 2026
ab59c0d
fix(security): redact exception logging boundaries
seonghobae Sep 7, 2026
37e16bc
test(security): keep runtime secret out of traceback source
seonghobae Sep 7, 2026
c1eda54
fix(security): preserve stable LLM error contract
seonghobae Sep 7, 2026
ec921b3
test(security): cover raised LLM exception redaction
seonghobae Sep 7, 2026
2c052be
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (API 키 등의 민감 정보 노출 방지를 위해 logger.er…
seonghobae Sep 7, 2026
10c2cad
fix(security): preserve redaction across concurrent Sentinel repair
seonghobae Sep 7, 2026
3d8efcd
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (API 키 등의 민감 정보 노출 방지를 위해 logger.er…
seonghobae Sep 7, 2026
03e0f48
fix(security): preserve exception redaction regressions
seonghobae Sep 7, 2026
89158d6
test(security): remove unused logging-test import
seonghobae Sep 7, 2026
684fb89
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (검증 완료)
seonghobae Sep 7, 2026
4d6e2b8
test(security): restore safe-logging regression hygiene
seonghobae Sep 7, 2026
15f3a2a
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (검증 완료)
seonghobae Sep 7, 2026
e2b03db
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CodeQL 대기)
seonghobae Sep 7, 2026
d67008d
test(logging): restore lint-safe module spacing
seonghobae Sep 7, 2026
4730fe3
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CodeQL 대기)
seonghobae Sep 7, 2026
d89bdde
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CodeQL 대기)
seonghobae Sep 7, 2026
f652f8c
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CodeQL 대기 2)
seonghobae Sep 7, 2026
82b9053
fix(security): suppress email exception context
seonghobae Sep 8, 2026
f0fbd3c
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CodeQL 대기)
seonghobae Sep 8, 2026
68767ad
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (Noema CI 재실행 대기)
seonghobae Sep 8, 2026
3d62c27
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (OpenCode 리뷰 대기)
seonghobae Sep 8, 2026
8159bf6
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (Noema CI 재실행 대기 2)
seonghobae Sep 8, 2026
2ecdbc2
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (OpenCode 리뷰 대기 2)
seonghobae Sep 8, 2026
ff8247a
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기)
seonghobae Sep 8, 2026
14d2ae4
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 2)
seonghobae Sep 8, 2026
c757c0c
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 3)
seonghobae Sep 8, 2026
3e9b6ad
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 4)
seonghobae Sep 8, 2026
f95553a
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 5)
seonghobae Sep 8, 2026
b2b2544
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 6)
seonghobae Sep 8, 2026
1e0aea0
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 7)
seonghobae Sep 8, 2026
716dfb7
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 8)
seonghobae Sep 8, 2026
f405c7a
test(security): cover email exception context suppression
seonghobae Sep 8, 2026
dcff38e
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 9)
seonghobae Sep 8, 2026
324dc47
test(security): restore email exception-context regression after writ…
seonghobae Sep 8, 2026
6568f3c
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 10)
seonghobae Sep 8, 2026
b3a51ab
test(security): preserve email exception-context regression
seonghobae Sep 8, 2026
8b8bfcf
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 11)
seonghobae Sep 8, 2026
ead0aa3
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 12)
seonghobae Sep 8, 2026
dbcbac6
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 13)
seonghobae Sep 8, 2026
7a54df8
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 14)
seonghobae Sep 8, 2026
8731072
🛡️ Sentinel: [MEDIUM] 예외 정보 유출 방지 (CI 대기 15)
seonghobae Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion backend/api/emails.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -773,7 +774,7 @@ async def send_email_endpoint(
except HTTPException:
raise
except Exception as e:
logger.error(f"Error sending email: {e}", exc_info=True)
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"
)
11 changes: 6 additions & 5 deletions backend/api/prompts.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import json
import logging
import re
from typing import List, Optional

Expand All @@ -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
Expand Down Expand Up @@ -113,14 +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(f"Prompt execution failed: {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()

Expand Down
21 changes: 21 additions & 0 deletions backend/core/safe_logging.py
Original file line number Diff line number Diff line change
@@ -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__
16 changes: 8 additions & 8 deletions backend/import_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("Fixture email parsing failed")
return False

existing = await session.execute(
Expand All @@ -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("Fixture email body embedding failed")
return False

thread_id = await assign_thread_id(
Expand Down Expand Up @@ -93,15 +93,15 @@ 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("Fixture attachment embedding failed")

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("Fixture email commit failed")
return False
logger.info(
f"Imported {eml_file.name} with {len(parsed.get('attachments', []))} attachments."
Expand Down
44 changes: 21 additions & 23 deletions backend/scripts/import_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,15 @@ 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"):
continue

try:
email_data = parse_eml(file_path)
except Exception as e:
logger.error(f"Failed to parse {file_path}: {e}")
except Exception:
logger.error("Fixture archive email parsing failed")
continue

chunks = chunk_text(email_data["body"])
Expand All @@ -70,34 +69,33 @@ async def process_zip_file(zip_path: str | Path, session: AsyncSession):
embeddings[0],
STORAGE_EMBEDDING_DIMENSION,
)
except Exception as e:
logger.error(
f"Failed to generate embedding for {email_data['message_id']}: {e}"
)
except Exception:
logger.error("Fixture archive email embedding failed")

# Upsert into database
thread_id = await assign_thread_id(
session,
email_data,
user_id=IMPORT_USER_ID,
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)
Expand Down
19 changes: 13 additions & 6 deletions backend/services/imap_worker.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from core.safe_logging import redacted_exception_info
import asyncio
import datetime
import logging
Expand Down Expand Up @@ -98,6 +99,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

Expand All @@ -112,7 +114,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
Expand Down Expand Up @@ -163,7 +169,9 @@ async def _run_loop(self):
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in ImapSyncWorker loop: {e}", exc_info=True)
logger.error(
"Error in ImapSyncWorker loop", exc_info=redacted_exception_info(e)
)

# Sleep for 1 minute before the next sync
if self._is_running:
Expand Down Expand Up @@ -217,7 +225,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,
Expand Down Expand Up @@ -252,6 +260,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
Expand Down Expand Up @@ -388,6 +397,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)
74 changes: 44 additions & 30 deletions backend/services/llm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
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__)

Expand Down Expand Up @@ -62,27 +64,30 @@ 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}")
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 error during extraction") from None
finally:
await client.close()

Expand Down Expand Up @@ -146,9 +151,12 @@ async def translate_email_body(
operation_name="translation",
),
)
except Exception as e:
logger.error(f"Error calling LLM API for translation: {e}")
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 error during translation") from None
finally:
await client.close()

Expand Down Expand Up @@ -188,9 +196,12 @@ async def draft_reply(
selected_model,
messages,
)
except Exception as e:
logger.error(f"Error calling LLM API for drafting: {e}")
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 error during drafting") from None
finally:
await http_client.aclose()

Expand All @@ -210,9 +221,12 @@ async def draft_reply(
operation_name="reply drafting",
),
)
except Exception as e:
logger.error(f"Error calling LLM API for drafting: {e}")
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 error during drafting") from None
finally:
await client.close()

Expand Down
Loading
Loading