Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 12 additions & 48 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,18 @@ jobs:
- component: backend
image: ai_email_client-backend
dockerfile: Dockerfile
base_dockerfile: Dockerfile
context: .
build_args: |
BUILDKIT_INLINE_CACHE=1
- component: naruon
image: naruon
dockerfile: Dockerfile
base_dockerfile: Dockerfile
context: .
build_args: |
BUILDKIT_INLINE_CACHE=1
- component: frontend
image: ai_email_client-frontend
dockerfile: frontend/Dockerfile
base_dockerfile: frontend/Dockerfile
context: .
build_args: |
BUILDKIT_INLINE_CACHE=1
Expand All @@ -67,28 +64,9 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0

- name: Resolve pinned Ollama base manifest
if: matrix.component == 'naruon'
run: |
base_image="$(awk 'toupper($1) == "FROM" { print $2; exit }' Dockerfile.ollama)"
if ! printf '%s\n' "$base_image" | grep -Eq '^ollama/ollama@sha256:[0-9a-f]{64}$'; then
printf '::error file=Dockerfile.ollama,line=1::Expected an exact ollama/ollama sha256 base pin; found %s\n' "$base_image"
exit 1
fi
printf 'Resolving pinned Ollama base manifest: %s\n' "$base_image"
manifest_output="$(docker buildx imagetools inspect "$base_image")"
printf '%s\n' "$manifest_output"
for platform in linux/amd64 linux/arm64; do
if ! printf '%s\n' "$manifest_output" | grep -Eq "^[[:space:]]*Platform:[[:space:]]+${platform}[[:space:]]*$"; then
printf '::error file=Dockerfile.ollama,line=1::Pinned Ollama manifest is missing %s\n' "$platform"
exit 1
fi
done

- name: Prepare OCI annotation values
id: oci
env:
BASE_DOCKERFILE: ${{ matrix.base_dockerfile }}
GIT_REF_NAME: ${{ github.ref_name }}
IMAGE_COMPONENT: ${{ matrix.component }}
IMAGE_NAME: ${{ matrix.image }}
Expand All @@ -98,29 +76,24 @@ jobs:
version="$(cat VERSION)"
created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
vendor="${REPOSITORY%%/*}"
base_reference="$(awk 'toupper($1) == "FROM" { print $2; exit }' "$BASE_DOCKERFILE")"
if ! printf '%s\n' "$base_reference" | grep -Eq '^[A-Za-z0-9._/-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$'; then
printf '::error file=%s,line=1::Expected an exact tagged sha256 base pin; found %s\n' "$BASE_DOCKERFILE" "$base_reference"
exit 1
fi
base_digest="${base_reference##*@}"
base_repository="${base_reference%@*}"
case "$base_repository" in
*/*) base_name="$base_reference" ;;
*) base_name="docker.io/library/$base_reference" ;;
esac
case "$IMAGE_COMPONENT" in
frontend)
title="naruon frontend"
description="Naruon Next.js frontend runtime image"
base_digest="sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1"
base_name="docker.io/library/node:26-slim@${base_digest}"
;;
backend)
title="naruon backend"
description="Naruon FastAPI backend runtime image"
base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061"
base_name="docker.io/library/python:3.14-slim@${base_digest}"
;;
*)
title="naruon"
description="Naruon combined FastAPI and Next.js runtime image"
base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061"
base_name="docker.io/library/python:3.14-slim@${base_digest}"
;;
esac
{
Expand Down Expand Up @@ -185,21 +158,18 @@ jobs:
- component: backend
image: ai_email_client-backend
dockerfile: Dockerfile
base_dockerfile: Dockerfile
context: .
build_args: |
BUILDKIT_INLINE_CACHE=1
- component: naruon
image: naruon
dockerfile: Dockerfile
base_dockerfile: Dockerfile
context: .
build_args: |
BUILDKIT_INLINE_CACHE=1
- component: frontend
image: ai_email_client-frontend
dockerfile: frontend/Dockerfile
base_dockerfile: frontend/Dockerfile
context: .
build_args: |
BUILDKIT_INLINE_CACHE=1
Expand Down Expand Up @@ -234,7 +204,6 @@ jobs:
- name: Prepare OCI annotation values
id: oci
env:
BASE_DOCKERFILE: ${{ matrix.base_dockerfile }}
GIT_REF_NAME: ${{ github.ref_name }}
IMAGE_COMPONENT: ${{ matrix.component }}
IMAGE_NAME: ${{ matrix.image }}
Expand All @@ -245,29 +214,24 @@ jobs:
version="${VERSION_VALUE:-$(cat VERSION)}"
created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
vendor="${REPOSITORY%%/*}"
base_reference="$(awk 'toupper($1) == "FROM" { print $2; exit }' "$BASE_DOCKERFILE")"
if ! printf '%s\n' "$base_reference" | grep -Eq '^[A-Za-z0-9._/-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$'; then
printf '::error file=%s,line=1::Expected an exact tagged sha256 base pin; found %s\n' "$BASE_DOCKERFILE" "$base_reference"
exit 1
fi
base_digest="${base_reference##*@}"
base_repository="${base_reference%@*}"
case "$base_repository" in
*/*) base_name="$base_reference" ;;
*) base_name="docker.io/library/$base_reference" ;;
esac
case "$IMAGE_COMPONENT" in
frontend)
title="naruon frontend"
description="Naruon Next.js frontend runtime image"
base_digest="sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1"
base_name="docker.io/library/node:26-slim@${base_digest}"
;;
backend)
title="naruon backend"
description="Naruon FastAPI backend runtime image"
base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061"
base_name="docker.io/library/python:3.14-slim@${base_digest}"
;;
*)
title="naruon"
description="Naruon combined FastAPI and Next.js runtime image"
base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061"
base_name="docker.io/library/python:3.14-slim@${base_digest}"
;;
esac
{
Expand Down
7 changes: 0 additions & 7 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,3 @@

**Learning:** When using a dictionary purely to track the presence of keys (e.g. `has_sent_message[key] = True`), checking for presence with `.get(key, False)` carries unnecessary semantic and memory overhead. Sets in Python provide a cleaner `key in set_name` syntax for boolean presence checks and slightly reduced memory footprint, while maintaining O(1) time complexity.
**Action:** When tracking unique occurrences or boolean presence of items where the value itself doesn't carry additional information, use a `set` and its `.add()` and `in` operators instead of a `dict` mapping to `True` or `False`.
## 2025-02-12 - Replaced O(N) Array Lookups with O(1) Maps in Loops

**Learning:** When generating derived UI state in `useMemo` that joins separate data arrays (like graph edges referencing node IDs), calling helper functions that use `Array.prototype.find()` for every item creates an `O(M * N)` bottleneck.
**Action:** When a loop needs to repeatedly look up related items from another array by ID, pre-compute an `O(N)` `Map` before the loop and use `map.get()` for `O(1)` lookups instead of inline array `.find()` calls.
## 2024-05-24 - [React Component Memoization]
**Learning:** In React components like `WorkspaceHome`, when layout state or polling changes trigger parent re-renders, expensive child components like `EmailDetail` will also re-render unnecessarily if not memoized.
**Action:** Always consider `React.memo` for heavy child components that rely on stable props (like IDs) when the parent component has frequent unrelated state updates.
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,6 @@
## 2025-05-19 - Dynamic ARIA labels and robust disabled states for sidebar actions
**Learning:** Hardcoded ARIA labels in mockups (like "출시 회의 일정 삭제") are often left intact during implementation, leading to incorrect screen reader announcements when different items are selected. In addition, action buttons that depend on selection state often lack correct visual and functional disabled states.
**Action:** When implementing detail views or sidebars, always replace hardcoded mockup ARIA labels with dynamic data (e.g. `${event.title} 삭제`), and ensure action buttons are explicitly disabled (both functionally via `disabled` and visually via `opacity-50 cursor-not-allowed`) when their prerequisites (like a selected item or specific properties like location) are unmet.
## 2025-02-14 - Loading State Accessibility Improvement
**Learning:** Found multiple instances where buttons are disabled during asynchronous operations (e.g. `isDocumentActionLoading`, `correctionSubmitting`) without explicitly setting `aria-busy` to inform screen reader users that a background task is running. Setting `aria-busy` along with `disabled` provides essential context to AT users, differentiating an active process from a statically unavailable action. However, `aria-busy={false}` should not be applied to statically disabled elements.
**Action:** Always include `aria-busy={loadingStateVariable}` when disabling buttons during async operations.
4 changes: 0 additions & 4 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,3 @@
**Vulnerability:** The URL validation logic correctly blocked non-global IP addresses and `localhost`, but failed to block internal domain extensions such as `.internal` or `.local` (or exact matches for `internal`). This could allow attackers to bypass SSRF protections by resolving these internal top-level domains.
**Learning:** Checking for `localhost` alone is insufficient to prevent SSRF against internal network resources, as modern environments and protocols utilize `.internal` and `.local` domains for internal routing.
**Prevention:** Always explicitly check and block domains matching `.internal`, `.local`, or `internal` (alongside `localhost`) when validating URLs for global reachability to prevent SSRF bypasses.
## 2025-02-23 - CRLF Injection in Email Headers
**Vulnerability:** The `in_reply_to` and `references` fields on the `SendEmailRequest` model lacked explicit validation, opening up an opportunity for header injection by appending `\r\n`.
**Learning:** While the email service internally checks some headers, relying on the API boundary's Pydantic model ensures bad input is stopped early and consistently. Pydantic regex patterns aren't sufficient on their own for all string contexts due to encoding/decoding inconsistencies.
**Prevention:** Always use `@field_validator` with explicit `mode="before"` string matching to reject `chr(10)` and `chr(13)` across all user-controlled email header fields. Use `isinstance(value, str)` before string operations to prevent runtime errors if input is missing or malformed.
2 changes: 0 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
## [Unreleased]
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.
- UUID V4 제너레이터(`uuid_v4_generator`) 도구를 추가하여 런타임에서 범용 고유 식별자 버전 4를 랜덤으로 생성할 수 있게 하였습니다. 테스트 커버리지 100%를 보장합니다.
### 보안 패치 (CodeQL extended current-head)

- `cryptography`를 `50.0.0`으로 갱신해 공격자 제공 PKCS#7 EnvelopedData 복호화 결과의 오류·타이밍 차이로 발생하는 Bleichenbacher oracle(`CVE-2026-69247`, `GHSA-g6cj-pr64-35w5`)을 제거하고, backend·uv lock·hash lock·Strix CI 의존성 증거를 같은 버전으로 동기화했습니다. Strix 잠금은 `google-cloud-aiplatform==1.160.0`의 `<7` 제약을 위반하던 `protobuf==7.35.1`을 이미 검증된 `6.33.6`으로 복구해 다시 해석·설치 가능하게 했습니다.
Expand Down
13 changes: 4 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Stage 1: Backend runtime for local Compose and backend-only deployments
FROM python:3.14-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc AS backend-runtime
FROM python:3.14-slim@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1 AS backend-runtime
WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1
Expand All @@ -25,7 +25,7 @@ EXPOSE 8000
CMD ["python", "scripts/start_backend.py", "--host", "0.0.0.0", "--port", "8000"]

# Stage 2: Build Frontend
FROM node:26-slim@sha256:4ebb5ace66f15a24c14c492e01a8beeed4fddf970a856109f5126e703e5fe503 AS frontend-builder
FROM node:26-slim@sha256:ffc78385a788964bb3cbab5e434ff79a10bdc25b8ae6db03fe5fe6cb14053c09 AS frontend-builder
WORKDIR /app
ENV NPM_CONFIG_UPDATE_NOTIFIER=false
ENV PNPM_VERSION=11.5.3
Expand Down Expand Up @@ -63,13 +63,8 @@ ARG OCI_IMAGE_LICENSES="LicenseRef-Naruon-Proprietary"
ARG OCI_IMAGE_REF_NAME=""
ARG OCI_IMAGE_TITLE="naruon"
ARG OCI_IMAGE_DESCRIPTION="Naruon combined FastAPI and Next.js runtime image"
ARG OCI_IMAGE_BASE_DIGEST="sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc"
ARG OCI_IMAGE_BASE_NAME="docker.io/library/python:3.14-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc"

# Defaults keep local builds provenance-complete. The publishing workflow derives
# and overrides both values from the exact first FROM instruction, while
# repository governance tests prevent the reviewed defaults from drifting.
RUN test -n "$OCI_IMAGE_BASE_DIGEST" && test -n "$OCI_IMAGE_BASE_NAME"
ARG OCI_IMAGE_BASE_DIGEST="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061"
ARG OCI_IMAGE_BASE_NAME="docker.io/library/python:3.14-slim@sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061"

LABEL org.opencontainers.image.created="${OCI_IMAGE_CREATED}" \
org.opencontainers.image.authors="${OCI_IMAGE_AUTHORS}" \
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.ollama
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM ollama/ollama@sha256:b88c73ace3e115f8ec53dc8761ae1c0aabfa675406e3681786b98757ce050f42
FROM ollama/ollama@sha256:509fdf54e23bd50d87af646cb51c0a7a203d6a83cc4d6695b3b08c5be1c62c0a

ENV OLLAMA_MODELS=/usr/share/ollama/.ollama/models

Expand Down
10 changes: 1 addition & 9 deletions backend/api/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from sqlalchemy import func, or_, select
from db.session import get_db
from db.models import Email
from pydantic import BaseModel, EmailStr, Field, field_validator
from pydantic import BaseModel, EmailStr, Field
import datetime
import time
from typing import Literal
Expand Down Expand Up @@ -693,14 +693,6 @@ class SendEmailRequest(BaseModel):
in_reply_to: str | None = None # O3: email threading support
references: str | None = None

@field_validator("to", "subject", "in_reply_to", "references", mode="before")
@classmethod
def reject_crlf(cls, v: str | None) -> str | None:
if isinstance(v, str):
if chr(10) in v or chr(13) in v:
raise ValueError("CR/LF injection detected")
return v


@router.post("/send")
async def send_email_endpoint(
Expand Down
44 changes: 4 additions & 40 deletions backend/api/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import re
import unicodedata
import urllib.parse
import uuid
from collections import Counter
from collections.abc import Callable
from typing import Any, Dict, List, Optional
Expand Down Expand Up @@ -190,7 +189,6 @@ def _validate_parameters(self, code: str, params: Dict[str, Any]) -> Dict[str, A

# Initialize default tools


async def mock_handler(params: Dict[str, Any]) -> str:
encoded = json.dumps(params, ensure_ascii=False, sort_keys=True)
return f"Mock execution successful with params: {encoded}"
Expand Down Expand Up @@ -247,7 +245,6 @@ async def tone_analyzer_handler(params: Dict[str, Any]) -> Any:
"tone_score": 85,
}


def _detect_text_language(text: str) -> str:
if any("\uac00" <= char <= "\ud7a3" for char in text):
return "ko"
Expand Down Expand Up @@ -275,10 +272,7 @@ async def email_translator_handler(params: Dict[str, Any]) -> Any:
]
translated_terms: list[str] = []
for source_phrase, translated_phrase in phrase_map:
if (
source_phrase in lowered_text
and translated_phrase not in translated_terms
):
if source_phrase in lowered_text and translated_phrase not in translated_terms:
translated_terms.append(translated_phrase)
translated_text = " ".join(translated_terms) if translated_terms else text
confidence = 0.9 if translated_terms else 0.45
Expand All @@ -297,9 +291,7 @@ async def spam_phishing_detector_handler(params: Dict[str, Any]) -> Any:
normalized_domain = sender_domain.lower()
phishing_terms = {"password", "bank", "login", "verify", "account", "credential"}
spam_terms = {"urgent", "now", "free", "winner", "click", "limited"}
phishing_hits = sorted(
term for term in phishing_terms if term in normalized_content
)
phishing_hits = sorted(term for term in phishing_terms if term in normalized_content)
spam_hits = sorted(term for term in spam_terms if term in normalized_content)
suspicious_domain = (
normalized_domain.endswith((".ru", ".zip", ".tk"))
Expand All @@ -322,9 +314,7 @@ async def spam_phishing_detector_handler(params: Dict[str, Any]) -> Any:
warnings.append(f"sender domain looks suspicious: {sender_domain}")
return {
"is_spam": bool(spam_hits or suspicious_domain),
"is_phishing": bool(
len(phishing_hits) >= 2 or (phishing_hits and suspicious_domain)
),
"is_phishing": bool(len(phishing_hits) >= 2 or (phishing_hits and suspicious_domain)),
"risk_score": risk_score,
"warnings": warnings,
}
Expand All @@ -349,15 +339,7 @@ async def sentiment_analyzer_handler(params: Dict[str, Any]) -> Any:
text = params.get("text", "")
normalized_text = text.lower()
positive_terms = {"thank", "thanks", "great", "good", "excellent", "감사", "좋"}
negative_terms = {
"disappointed",
"urgent",
"issue",
"problem",
"bad",
"불만",
"문제",
}
negative_terms = {"disappointed", "urgent", "issue", "problem", "bad", "불만", "문제"}
positive_hits = [term for term in positive_terms if term in normalized_text]
negative_hits = [term for term in negative_terms if term in normalized_text]
if negative_hits and len(negative_hits) >= len(positive_hits):
Expand Down Expand Up @@ -551,7 +533,6 @@ def _parameter_matches_type(value: Any, expected_type: str) -> bool:
tone_analyzer_handler,
)


async def text_analyzer_handler(params: Dict[str, Any]) -> Dict[str, int]:
text = params.get("text", "")
char_count = len(text)
Expand All @@ -564,7 +545,6 @@ async def text_analyzer_handler(params: Dict[str, Any]) -> Dict[str, int]:
"word_count": len(text.split()),
}


registry.register(
ToolInfo(
code="text_analyzer",
Expand Down Expand Up @@ -841,22 +821,6 @@ async def meeting_agenda_generator_handler(params: Dict[str, Any]) -> Any:
)


async def uuid_v4_generator_handler(params: Dict[str, Any]) -> Dict[str, str]:
return {"uuid": str(uuid.uuid4())}


registry.register(
ToolInfo(
code="uuid_v4_generator",
name="UUID V4 생성기 (UUID v4 Generator)",
description="범용 고유 식별자(UUID) 버전 4를 무작위로 생성합니다.",
category="유틸리티",
parameters={},
),
uuid_v4_generator_handler,
)


@router.get("/tools", response_model=list[ToolInfo])
def get_tools() -> list[ToolInfo]:
"""
Expand Down
Loading
Loading