Skip to content
Open
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
d948528
feat(email): preserve dedupe provenance from genuine Date headers
seonghobae Aug 4, 2026
8682434
chore(pr1195): materialize reviewed provenance fixes
seonghobae Aug 4, 2026
e249788
ci: fix source-bound email deduplication
seonghobae Aug 4, 2026
a2124fb
chore(pr1195): remove superseded review-fix workflow
seonghobae Aug 4, 2026
6580d40
chore(pr1195): repair source-dedupe materializer
seonghobae Aug 4, 2026
138157b
fix(ci): trigger PR 1195 materializer repair on review transition
seonghobae Aug 4, 2026
f9efcf7
fix(ci): make PR 1195 materializer repair structural
seonghobae Aug 4, 2026
1c4c781
fix(ci): preserve escaped POP3 assertion in PR 1195 repair
seonghobae Aug 4, 2026
112c2d5
ci: make source-bound dedupe repair deterministic
seonghobae Aug 4, 2026
7a76bb4
ci: replace brittle PR 1195 materializer
seonghobae Aug 4, 2026
cd68cbe
ci: remove superseded PR 1195 repair helper
seonghobae Aug 4, 2026
08a7cb1
ci: finalize PR 1195 source-bound dedupe
seonghobae Aug 4, 2026
3a35907
ci: harden PR 1195 source-identity finalizer
seonghobae Aug 4, 2026
97bf6d4
ci: repair PR 1195 source-identity finalizer
seonghobae Aug 4, 2026
3b7fa3c
ci: remove superseded PR 1195 repair helper
seonghobae Aug 4, 2026
1f72d4d
ci: repair PR 1195 finalizer date cleanup
seonghobae Aug 4, 2026
0cc519f
fix(ci): execute PR 1195 date cleanup helper
seonghobae Aug 4, 2026
8557cad
ci(pr-1195): bootstrap finalizer guard repair
seonghobae Aug 4, 2026
d05a500
ci(pr-1195): activate finalizer bootstrap
seonghobae Aug 4, 2026
0508cc7
fix(ci): trigger PR 1195 cleanup on synchronization
seonghobae Aug 4, 2026
1ab3939
fix(ci): consolidate PR 1195 finalizer repair
seonghobae Aug 4, 2026
4d584bf
fix(ci): make PR 1195 helper workflow valid
seonghobae Aug 4, 2026
e4b0c59
ci: repair empty replacement handling in PR 1195 finalizer
seonghobae Aug 4, 2026
121d5ac
chore(pr-1195): remove temporary finalizer bootstrap
seonghobae Aug 4, 2026
fa48028
chore(pr-1195): remove temporary finalizer hotfix
seonghobae Aug 4, 2026
ca63640
chore(pr-1195): remove temporary date repair workflow
seonghobae Aug 4, 2026
7fd7d48
chore(pr-1195): remove temporary source finalizer
seonghobae Aug 4, 2026
c51131c
test(email): prove source-bound fallback identity
seonghobae Aug 5, 2026
6e69b8e
chore(email): add one-shot source identity repair script
seonghobae Aug 5, 2026
96fd422
test(email): add source-bound fallback regressions
seonghobae Aug 5, 2026
64d5a84
fix(ci): make source-identity repair workflow valid
seonghobae Aug 5, 2026
898201e
docs(email): record source-bound identity contract
seonghobae Aug 5, 2026
2412770
ci(email): repair POP3 source-byte reconstruction
seonghobae Aug 5, 2026
4c6b21a
fix(email): bind fallback dedupe to immutable source
github-actions[bot] Aug 5, 2026
6622f65
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
opencode-agent[bot] Aug 5, 2026
4369f80
test(email): reject unsupported canonical source values
seonghobae Aug 5, 2026
375d6c5
fix(email): validate canonical source value types
seonghobae Aug 5, 2026
24efad3
docs(email): summarize record-linkage evidence model
seonghobae Aug 5, 2026
596aeb6
Merge protected develop into email dedupe provenance
seonghobae Aug 14, 2026
26dfa22
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
opencode-agent[bot] Aug 15, 2026
bee6519
merge(develop): refresh email dedupe provenance
seonghobae Aug 15, 2026
510ec77
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
seonghobae Aug 17, 2026
f6e60ef
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
seonghobae Aug 17, 2026
f98c6fe
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
cursoragent Aug 17, 2026
6aeda8f
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
opencode-agent[bot] Aug 20, 2026
c7aedc6
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 21, 2026
0b74bef
Merge branch 'develop' into claude/contextualwisdomlab-audit-governan…
seonghobae Aug 26, 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
19 changes: 16 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
## [Unreleased]

### Fixed
- Bound missing/invalid-Date email deduplication fallbacks to immutable RFC822 source bytes (or a deterministic Date-free canonical projection), including import, IMAP, and POP3 paths, so collection timestamps cannot create false duplicate identities.
### 이메일 메타데이터 provenance 기반 (dedupe 근거 분리 · naruon#1086 파서 계층)

- `backend/services/email_parser.py`가 RFC822 `Date`·`Message-ID` 근거를 명시적으로 노출합니다. `date_provenance`(`parsed`/`missing`/`invalid`)와 원본 헤더 날짜(`header_date`, 부재·파싱 실패 시 `None`)를 저장용 `date`(파싱값 또는 수집 시각 fallback)와 분리하고, `message_id_provenance`(`embedded`/`missing`)를 추가했습니다. 합성 수집 시각이 원본 발신 메타데이터로 오인되지 않으므로 fingerprint dedupe가 진짜 발신 근거에만 의존할 수 있습니다. `date`의 기존 의미(파싱값-또는-fallback)는 그대로 유지되어 하위 호환입니다.
- `email_import_service._email_fingerprint`가 이제 `date_provenance == "parsed"`일 때만 strong(자동 중복 판정용) fingerprint를 생성합니다. `Date` 헤더가 없거나 잘못돼 `persisted_date`가 합성 수집 시각인 경우 strong key를 만들지 않고 weak fallback fingerprint(수집 시각이 매번 달라 거짓 중복을 만들 수 없음)로 내려갑니다 — 합성 시각이 strong-duplicate 근거로 승격되지 않습니다. `persisted_date`는 파서의 `date`에서만 오고 업로드 파일명에서 오지 않으므로, 날짜형 파일명이 `Date` 근거로 승격되지 않는 계약도 구조적으로 유지됩니다.
- stored-side와 IMAP 수집 경로까지 provenance gating을 완성했습니다. `email_records.date_provenance` 컬럼(모델 + Alembic `0018_email_date_provenance`, 기존 행은 `"unknown"`으로 안전 backfill)을 추가하고, `email_dedupe_service.email_strong_fingerprint(email_row)`는 `date_provenance == "parsed"`인 행만 strong fingerprint를 만들며, `imap_worker`도 import 경로와 동일하게 parsed Date일 때만 strong fingerprint를 seed합니다. import·IMAP 두 생성 경로 모두 파서 provenance를 영속화합니다. backfill이 `"unknown"`이므로 기존 행은 strong 근거에서 제외되어(보수적: 중복을 만들 수 없고 review만 넓힘) 안전합니다.
- 검증: 파서 provenance 로직을 5개 시나리오(valid/missing/invalid `Date`, embedded/missing `Message-ID`)와 whitespace-only `Date` 분기까지 검증하고, incoming·stored 양측 strong-fingerprint provenance gating 회귀 테스트를 추가했습니다. alembic single-head 가드·email-model reconciliation·전체 email/dedupe/import/imap 스위트 `333 passed, 1 skipped`(`PYTHONWARNINGS=error`), ruff clean으로 통과했습니다. 남은 것은 불완전 근거 import를 `dedupe_review_required` 상태·reason code로 API에 노출하는 UX 계층으로, 후속 원자적 PR로 이어집니다.
- 위 UX 계층의 **도메인 판정 코어**를 `email_dedupe_service`에 선행 구현했습니다. Fellegi & Sunter(1969) record-linkage 3구간 결정 규칙(A1 positive link · A2 possible match/clerical review · A3 non-link)을 이메일 dedupe에 대응시킨 `classify_dedupe_decision(candidate, existing_row)`가, (a) 신뢰 가능한 동일성 링크(정규화 `Message-ID` 일치, 또는 양측 `parsed` Date의 genuine strong fingerprint 일치)는 `auto_link`, (b) provenance-독립 content fingerprint(`sender`+`subject`+`body`, Date 제외)는 일치하지만 신뢰 링크가 없는 — 즉 한쪽 Date provenance가 합성/`unknown`이라 strong key가 보류된 — 개연적 중복은 `review_required`(임의 병합·임의 보존이 아니라 clerical-review 구간으로 격리), (c) 그 외는 `distinct`로 분류합니다. Date 비의존 식별 신호인 `content_email_fingerprint`와 `EmailDedupeCandidate.date_provenance` 필드(기본값 `"unknown"`, 하위 호환)를 함께 추가했습니다. 이 순수 함수 계층은 후속 PR에서 `dedupe_review_required` 상태·reason code 및 리뷰 큐 API로 표면화됩니다. 검증: 3구간·content fingerprint 전 분기 회귀 테스트를 추가하고 전체 email/dedupe/import/imap 스위트 `94 passed`(`PYTHONWARNINGS=error`), ruff clean으로 통과했습니다.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
- 근거 문헌(APA 7): Fellegi, I. P., & Sunter, A. B. (1969). A theory for record linkage. *Journal of the American Statistical Association, 64*(328), 1183–1210. https://doi.org/10.1080/01621459.1969.10501049
- 도메인 코어에 **1:N 결정 해소**와 두 건의 정합성 수정을 더했습니다. (1) `email_dedupe_service.resolve_candidate_disposition(candidate, existing_rows)`는 후보 하나를 저장된 여러 행 집합과 비교해 Fellegi & Sunter(1969) 구간 우선순위 A1>A2>A3로 단일 disposition으로 축약합니다(첫 `auto_link` 행이 즉시 우선, 없으면 첫 `review_required` 행을 clerical review로 격리, 아무 신호도 없으면 `distinct`). 링크/리뷰를 유발한 저장 행을 함께 반환해 import/IMAP 경로가 대상 이메일을 다시 유도하지 않아도 되게 했습니다 — 스키마·API 변경 없는 리뷰 큐의 순수 로직 기반. (2) `imap_worker`의 `Email(...)` 생성이 `date_provenance`를 영속화하지 않아 IMAP 수집 행이 항상 모델 기본값 `"unknown"`으로 저장되어 stored-side strong fingerprint에서 영구 제외되던 결함을 수정했습니다(import 경로와 동일하게 `email_data.get("date_provenance", "unknown")` 전달). 이로써 "import·IMAP 두 생성 경로 모두 파서 provenance를 영속화한다"는 계약이 구조적으로 성립합니다. (3) `email_parser._extract_date_with_provenance`가 RFC 5322 `-0000`("시간대 정보 없음")에 대해 `parsedate_to_datetime`이 반환하는 naive datetime을 UTC로 정규화해, 문서화된 timezone-aware 계약이 모든 parsed 헤더에서 성립하도록 했습니다.
- 검증(이 커밋): `resolve_candidate_disposition` 로직 5개(빈 corpus→distinct, 전부 distinct, content-only→review+행, A1이 앞선 A2 우선, 첫 auto_link 우선)와 파서 date-provenance 시나리오(`-0000`→parsed·UTC, whitespace-only `Date`→missing, `+0900` 보존, invalid)를 직접 검증했고, 파서 회귀 테스트 2건(`-0000`·whitespace-only)을 추가했습니다. ruff 0.15.21(CI 핀) clean, py_compile OK. (앞선 두 불릿의 `333 passed, 1 skipped`는 파서/import/stored 커밋의 전체 스위트, `94 passed`는 dedupe-core 커밋의 email/dedupe 하위 스위트 기준입니다.)

### 보안 패치 (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`으로 복구해 다시 해석·설치 가능하게 했습니다.
- CodeQL `extended` 기본 설정이 current `develop`에서 확인한 Critical 8건·High 21건·Medium 1건을 코드 경계에서 제거합니다. 서버 요청은 검증된 loopback/HTTPS origin, 동일 OIDC issuer origin, 허용 API 경로·쿼리만 재구성하고 redirect를 자동 추종하지 않으며, 공개 IPv6 authority를 보존합니다. UI smoke는 고정 Node/Next 실행 파일과 인자, localhost:3001 allowlist, private `mkdtemp` artifact 디렉터리 및 containment 검사만 사용합니다.
- OIDC token endpoint는 운영 환경에서 서버 전용 `OIDC_ALLOWED_HOSTS` 정확 호스트 allowlist를 필수로 적용합니다. hostname의 모든 DNS 결과가 공인 주소인지 검증한 뒤 해당 주소 집합을 native HTTP(S) 연결의 `lookup`에 고정하고, 원래 issuer hostname은 Host/TLS SNI로 유지해 사설 주소 해석과 DNS rebinding 사이의 TOCTOU를 차단합니다. 실패 로그는 입력 URL·token 대신 고정된 configuration/DNS·transport/response/backend-verification reason code만 남깁니다.
- Trivy 2026-07-26 DB에서 새로 확인된 Next.js High 4건·Medium 5건(`CVE-2026-64641`–`CVE-2026-64649`)과 PostCSS High 1건(`GHSA-r28c-9q8g-f849`)을 제거하기 위해 Next.js/`eslint-config-next`를 `16.2.11`, PostCSS를 `8.5.18`로 갱신했습니다. 이후 2026-08-04 DB가 `8.5.18`에서 추가 탐지한 PostCSS Medium(`CVE-2026-69153`, 최초 수정 `8.5.23`)도 제거하도록 manifest·workspace override·lock을 `8.5.24`로 동기화했으며 저장소의 release-age 정책을 우회하지 않습니다.
- `pnpm audit`가 개발 도구 체인에서 추가 탐지한 `brace-expansion <=5.0.7` High DoS(`GHSA-mh99-v99m-4gvg`)와 이후 `5.0.8`까지 영향을 주는 우회형 High DoS(`GHSA-rgw5-rvv9-x895`)는 `5.0.9` 전역 override로 제거했습니다. CommonJS default export를 기대하는 legacy `minimatch 3.1.5`에는 `expand` named export도 수용하는 최소 pnpm 패치를 적용해 ESLint/glob 동작을 보존합니다. 같은 감사에서 확인된 `undici 7.28.0`의 High 1건·Moderate 4건(`GHSA-4cwx-7wf7-3272` 등)은 `jsdom 30.0.1` 및 release-age 정책을 통과하는 `undici 8.9.0`으로 갱신했습니다.
- Trivy 2026-07-26 DB에서 새로 확인된 Next.js High 4건·Medium 5건(`CVE-2026-64641`–`CVE-2026-64649`)과 PostCSS High 1건(`GHSA-r28c-9q8g-f849`)을 제거하기 위해 Next.js/`eslint-config-next`를 `16.2.11`, PostCSS를 `8.5.18`로 갱신했습니다. 두 버전은 각 취약점의 최초 수정 버전이며 저장소의 release-age 정책을 우회하지 않습니다.
- `pnpm audit`가 개발 도구 체인에서 추가 탐지한 `brace-expansion <=5.0.7` High DoS(`GHSA-mh99-v99m-4gvg`)`5.0.8` 전역 override로 제거했습니다. CommonJS default export를 기대하는 legacy `minimatch 3.1.5`에는 `expand` named export도 수용하는 최소 pnpm 패치를 적용해 ESLint/glob 동작을 보존합니다.
- root·frontend Docker build의 frozen install 계층이 pnpm manifest와 함께 `frontend/patches`를 먼저 복사하도록 수정해, 이미지 검증에서도 lockfile의 patched dependency를 동일하게 재현합니다.
- Scorecard SARIF normalizer는 고정 workspace artifact로 정규화되는 `./scorecard-results.sarif`와 절대 경로를 동일하게 허용하면서 symlink·workspace 이탈은 계속 거부합니다. 도구 실행 실패 API는 CR/LF·제어 문자를 escape하고 500자로 제한하며, 로그에는 raw 도구 코드·예외 text 대신 SHA-256 기반 코드·traceback 상관 식별자만 기록합니다.
- 백엔드 origin 보안 경계를 `frontend/src/lib/backend-url.ts`의 단일 생성기로 통합해 API proxy·session·OIDC callback이 같은 검증을 사용합니다. UI smoke의 새 `NARUON_FULL_PRODUCT_SCREENSHOT_PROFILE` 이름은 실제 selector 의미를 드러내며, 기존 `..._SCREENSHOT_DIR`은 호환 alias로 계속 지원합니다.
Expand Down
49 changes: 49 additions & 0 deletions backend/alembic/versions/0018_email_date_provenance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""add date_provenance to email_records

Revision ID: 0018_email_date_provenance
Revises: 0017_merge_newsdom_carddav_heads
Create Date: 2026-07-30 00:00:00.000000

Records the provenance of each stored email ``date`` so a synthetic
collection-time fallback (missing/invalid RFC822 Date header) is never treated
as original sender metadata when seeding a strong auto-dedupe fingerprint
(naruon#1086). Nullable-free with a ``"unknown"`` server default so existing
rows backfill safely: their date provenance is genuinely unknown, and only
``"parsed"`` rows are eligible to seed a strong fingerprint, so the backfill is
conservative (it can only widen review, never manufacture a duplicate).
"""

from alembic import op
import sqlalchemy as sa

revision = "0018_email_date_provenance"
down_revision = "0017_merge_newsdom_carddav_heads"

_EMAIL_TABLE = "email_records"
_PROVENANCE_COLUMN = "date_provenance"


def upgrade() -> None:
"""Add the ``date_provenance`` column, backfilling existing rows to unknown."""
connection = op.get_bind()
inspector = sa.inspect(connection)
columns = {column["name"] for column in inspector.get_columns(_EMAIL_TABLE)}
if _PROVENANCE_COLUMN not in columns:
op.add_column(
_EMAIL_TABLE,
sa.Column(
_PROVENANCE_COLUMN,
sa.String(),
nullable=False,
server_default="unknown",
),
)


def downgrade() -> None:
"""Drop the ``date_provenance`` column if present."""
connection = op.get_bind()
inspector = sa.inspect(connection)
columns = {column["name"] for column in inspector.get_columns(_EMAIL_TABLE)}
if _PROVENANCE_COLUMN in columns:
op.drop_column(_EMAIL_TABLE, _PROVENANCE_COLUMN)
7 changes: 7 additions & 0 deletions backend/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,13 @@ def owner_filters(cls, user_id: str, organization_id: str | None):
in_reply_to: Mapped[str | None] = mapped_column(String, nullable=True)
references: Mapped[str | None] = mapped_column(String, nullable=True)
date: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), index=True)
# Provenance of the stored ``date``: "parsed" (genuine RFC822 Date header),
# "missing"/"invalid" (a synthetic collection-time fallback), or "unknown"
# (rows stored before provenance tracking). Only "parsed" rows may seed a
# strong auto-dedupe fingerprint (naruon#1086).
date_provenance: Mapped[str] = mapped_column(
String, nullable=False, server_default="unknown", default="unknown"
)
body: Mapped[str] = mapped_column(Text)
# IMAP \Seen read state; defaults read so historical/file imports don't nag.
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
Expand Down
Loading
Loading