Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 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
01ce341
fix(security): bound fixture archive extraction errors
seonghobae Sep 8, 2026
3e065f4
test(security): fail closed on unexpected archive extraction errors
seonghobae Sep 8, 2026
44674eb
test(security): pin fixture directory path redaction
seonghobae Sep 8, 2026
8f43bcb
fix(security): redact missing fixture directory path
seonghobae Sep 8, 2026
9a87d0b
fix(security): preserve unexpected archive failures
seonghobae Sep 8, 2026
4b7b1b7
test(security): require sanitized unexpected archive failure
seonghobae Sep 8, 2026
99c7ef6
fix(security): sanitize unexpected archive extraction failures
seonghobae Sep 8, 2026
43a952b
test: require fail-closed fixture archive rejection
seonghobae Sep 8, 2026
5813a5c
fix: fail closed on rejected fixture archives
seonghobae Sep 8, 2026
41c6dd8
fix: inherit fail-closed fixture archive redaction
seonghobae Sep 8, 2026
9554ceb
test: reject retained archive exception context
seonghobae Sep 8, 2026
3da3ae8
fix: discard sensitive archive exception context
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 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}")`.
5 changes: 3 additions & 2 deletions 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"
)
) from None
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
70 changes: 41 additions & 29 deletions backend/scripts/import_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,11 +31,21 @@
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(f"Extracting {zip_path}...")
extracted_files = await extract_backup_async(zip_path, 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:
extracted_files = None

if extracted_files is None:
raise ArchiveError("Fixture archive extraction failed")

batch_values = []
for file_path in extracted_files:
Expand All @@ -43,8 +54,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("Fixture archive email parsing failed")
continue

chunks = chunk_text(email_data["body"])
Expand All @@ -70,34 +81,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 All @@ -120,20 +130,22 @@ 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")
return True


async def main():
root_dir = Path(__file__).resolve().parent.parent.parent
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:
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__":
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)
Loading
Loading