Skip to content

fix(account-unification): enforce unverified-email hard rule on explicit_link merges - #34

Merged
seonghobae merged 4 commits into
mainfrom
claude/inkspan-pr-audit-ci-q1u4uj
Aug 3, 2026
Merged

fix(account-unification): enforce unverified-email hard rule on explicit_link merges#34
seonghobae merged 4 commits into
mainfrom
claude/inkspan-pr-audit-ci-q1u4uj

Conversation

@seonghobae

@seonghobae seonghobae commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes an account-takeover vector in UnificationService.merge_accounts: the documented hard rule "never link or merge accounts on an unverified email" was only enforced inside the if not decision.matched: branch. When an operator sets explicit_link=True, decide_match classifies the pair as EXPLICIT_LINK (matched=True), so the guard was skipped — two accounts coinciding only on an unverified (attacker-registerable) email were merged (survivor absorbs the duplicate's identities/roles/groups; duplicate tombstoned).

Three authoritative sources require the refusal, all violated by the old code path:

  • app/models.py MergeRequest.explicit_link docstring: "Even so, the service refuses if the only tie is an UNVERIFIED email."
  • docs/merge-unification-flow.md: hard rule "If the accounts share only an unverified email, the merge is refused with 422" (unconditional).
  • CLAUDE.md: "Never link or merge accounts on an unverified email."

Fix

Hoist the check so it runs regardless of decision.matched: refuse with UnverifiedEmailMergeError when the accounts share a non-empty, non-mutually-verified email and the match is not backed by a strong tie (EXACT_IDP_SUBJECT or VERIFIED_EMAIL). The legitimate explicit_link override (no shared email, or a strong tie) still merges; the reject-unverified-before-reject-no-match ordering from the merge-flow pseudocode is preserved.

Behavior matrix (unchanged except the fixed case):

Case Before After
exact idp subject / mutually verified email merge merge
explicit_link, different or no shared email merge merge
explicit_link, shared UNVERIFIED email merge (bug) 422 (fixed)
no match, shared unverified email 422 422

Verification (via uv)

  • TDD: test_explicit_link_cannot_override_shared_unverified_email + ..._case_variant_unverified_email fail before (DID NOT RAISE UnverifiedEmailMergeError) and pass after; the case-variant test also asserts nothing mutated ("dup" not in api.deactivated).
  • uv run pytest54 passed (was 52; +2 regression tests, 0 regressions).
  • uv run ruff check app tests tools → clean; uv run interrogate . → 100%.

The fuzz seam (fuzz/fuzz_matching.py) targets decide_match invariants (correct/unchanged); this bug is in the service enforcement layer, so pytest is the right coverage.


Generated by Claude Code

Summary by CodeRabbit

  • 버그 수정

    • 검증되지 않은 이메일만 일치하는 계정은 명시적 연결 정보가 있어도 병합되지 않도록 보호 기능을 강화했습니다.
    • 이메일의 대소문자 차이로 인해 보호 기능이 우회되지 않습니다.
    • 병합이 거부된 중복 계정은 비활성화되거나 삭제 표시되지 않습니다.
  • 안정성

    • 서비스 상태 확인 절차의 보안 검사 호환성을 개선했습니다.

…cit_link merges

The merge-safety hard rule ("never link or merge accounts on an unverified
email"; docs/merge-unification-flow.md, CLAUDE.md, and the
MergeRequest.explicit_link contract "Even so, the service refuses if the only
tie is an UNVERIFIED email") was only enforced inside the `not decision.matched`
branch of UnificationService.merge_accounts. When explicit_link=True,
decide_match classifies the pair as EXPLICIT_LINK (matched=True), so the guard
was skipped: two accounts coinciding only on an UNVERIFIED — i.e.
attacker-registerable — email were merged (survivor absorbed the duplicate,
duplicate tombstoned/disabled). That is an account-takeover vector the hard rule
exists to close.

Fix: compute the shared-unverified-email condition and refuse with
UnverifiedEmailMergeError whenever it holds and the match is not backed by a
strong tie (exact idp subject or a mutually verified email), regardless of
explicit_link. The legitimate explicit_link override (no shared email, or a
strong tie) still merges; the merge-flow pseudocode ordering (reject-unverified
before reject-no-match) is preserved.

Regression tests: explicit_link cannot override a shared unverified email
(exact and case-variant); existing explicit-link-without-shared-signal merge
still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REEc4WtvMHbGD23XK6xbLK
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@seonghobae, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 28 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8008cee0-b74e-4026-b93a-e79b9c5a2016

📥 Commits

Reviewing files that changed from the base of the PR and between c7aeca9 and e28302a.

📒 Files selected for processing (1)
  • services/account_unification/app/healthcheck.py
📝 Walkthrough

Walkthrough

계정 병합은 명시적 연결이 있어도 비검증 이메일만 일치하면 거부하도록 변경되었으며, 해당 동작을 테스트한다. 헬스체크의 고정 루프백 URL 사용 근거와 Semgrep 억제가 문서화되었다.

Changes

계정 병합 가드 강화

Layer / File(s) Summary
비검증 이메일 병합 가드와 검증
services/account_unification/app/service.py, services/account_unification/tests/test_merge.py
MatchReason을 기준으로 강한 일치 여부를 판별하며, explicit_link=True인 경우에도 비검증 이메일만 공유하면 UnverifiedEmailMergeError를 발생시킨다. 공유 이메일과 대소문자 변형 사례에서 중복 계정이 비활성화되지 않는지 검증한다.

헬스체크 감사 경고 처리

Layer / File(s) Summary
URL 감사 예외 문서화
services/account_unification/app/healthcheck.py
고정 루프백 URL이 공격자 제어 대상이 아님을 설명하는 주석과 nosemgrep 억제를 urlopen 호출에 추가한다.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목이 explicit_link 병합에서 공유된 비검증 이메일 차단이라는 핵심 변경을 정확히 요약합니다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/inkspan-pr-audit-ci-q1u4uj

Comment @coderabbitai help to get the list of available commands.

Copy link
Copy Markdown
Contributor Author

Semgrep (multi-language SAST) failure — pre-existing, unrelated to this PR, non-blocking

The single finding is python.lang.security.audit.dynamic-urllib-use-detected (WARNING) at services/account_unification/app/healthcheck.py:18 — a by-design health probe of a configured URL. It is not in this PR's diff (which touches only app/service.py and tests/test_merge.py); Semgrep scans the whole repo, so it surfaces on any current-head scan.

This does not block merge: ContextualWisdomLab/.github's sast-semgrep.yml documents the Semgrep job "does not affect auto-merge" (merge gating is CodeQL-only; Semgrep uploads SARIF to the semgrep code-scanning category). Tracked for a focused base pass adding a justified # nosemgrep suppression alongside the existing handling.


Generated by Claude Code

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

OpenCode could not approve from deterministic current-head evidence because GitHub Checks have failed.

Findings

1. HIGH Current-head GitHub Checks - Fix failed required checks before approval

  • Problem: Failed same-head checks remain for aa5a9ea912f6c7cefd7e2457fa560a85c2370239.
  • Root cause: The model-unavailable evidence fallback is allowed only when peer GitHub Checks are complete and clean.
  • Fix: Read and fix the failed check logs below, then rerun the current-head checks.
  • Regression test: Keep the model-unavailable fallback gated on an empty failed-check rollup.

Failed checks:

Changed-File Evidence Map

flowchart LR
  PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
  Evidence --> S1["Changed file: service.py"]
  S1 --> I1["repository behavior"]
  I1 --> R1["Review risk: Changed file: service.py"]
  R1 --> V1["required checks"]
  Evidence --> S2["Test: test_merge.py"]
  S2 --> I2["regression suite"]
  I2 --> R2["Review risk: Test: test_merge.py"]
  R2 --> V2["targeted test run"]
Loading

@opencode-agent

Copy link
Copy Markdown
Contributor

OpenCode Review Overview

  • Head SHA: aa5a9ea912f6c7cefd7e2457fa560a85c2370239
  • Workflow run: 30514797997
  • Workflow attempt: 1
  • Gate result: REQUEST_CHANGES (approval step)

Pull request overview

OpenCode could not approve from deterministic current-head evidence because GitHub Checks have failed.

Findings

1. HIGH Current-head GitHub Checks - Fix failed required checks before approval

  • Problem: Failed same-head checks remain for aa5a9ea912f6c7cefd7e2457fa560a85c2370239.
  • Root cause: The model-unavailable evidence fallback is allowed only when peer GitHub Checks are complete and clean.
  • Fix: Read and fix the failed check logs below, then rerun the current-head checks.
  • Regression test: Keep the model-unavailable fallback gated on an empty failed-check rollup.

Failed checks:

Changed-File Evidence Map

flowchart LR
  PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
  Evidence --> S1["Changed file: service.py"]
  S1 --> I1["repository behavior"]
  I1 --> R1["Review risk: Changed file: service.py"]
  R1 --> V1["required checks"]
  Evidence --> S2["Test: test_merge.py"]
  S2 --> I2["regression suite"]
  I2 --> R2["Review risk: Test: test_merge.py"]
  R2 --> V2["targeted test run"]
Loading

…n healthcheck

The central Semgrep SAST gate (sast-semgrep.yml, --severity WARNING/ERROR
--error) blocked PR #34 on a single WARNING finding, rule
python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
at services/account_unification/app/healthcheck.py:18. Semgrep does not honor
the existing bandit-style `# noqa: S310`.

This is a pre-existing finding (healthcheck.py), not in the PR #34 merge-guard
diff (service.py). It is a verified-safe false positive: the urlopen target is
the hardcoded loopback constant DEFAULT_URL; the module entrypoint calls main()
with no argument, and the `url` parameter exists only for test injection of
trusted URLs, so no untrusted/file:// value can reach urlopen. Because the
finding is a genuine false positive for an .audit. advisory rule (no code fix
removes it without breaking test injection, since any non-literal argument
trips the pattern), it is scope-suppressed with a precise inline
`# nosemgrep: <exact-rule-id>` plus a justification comment above the line;
the bandit `# noqa: S310` is preserved. Suppression verified offline with a
rule reproducing the finding (rc 1 -> 0). ruff, interrogate (100%), and pytest
all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REEc4WtvMHbGD23XK6xbLK
@seonghobae
seonghobae dismissed opencode-agent[bot]’s stale review August 3, 2026 01:58

Dismissed as stale: it reviewed obsolete head aa5a9ea. Current head 60ab98b has successful CI, CodeQL, Semgrep, and Security Scan runs, and the two explicit-link/unverified-email regression tests are present.

@seonghobae
seonghobae merged commit 4e2cf58 into main Aug 3, 2026
28 checks passed
@seonghobae
seonghobae deleted the claude/inkspan-pr-audit-ci-q1u4uj branch August 3, 2026 01:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants