Skip to content
Merged
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
180 changes: 180 additions & 0 deletions docs/workbench/specs/residual-diff/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# Residual-Diff (브랜치 간 잔여 secret 차이) — Design

## 개요

security-scanner의 per-branch residual(issue #12) 위에 "두 브랜치 사이의 잔여 secret 차이"를 얹는다. base/head 두 브랜치의 residual을 finding_id 집합으로 보고 added = residual(head) − residual(base), removed = residual(base) − residual(head)를 계산한다. 새 storage/index/PR 엔티티 없이, 순수 helper `residual_diff(...)`와 CLI 서브커맨드 `residual-diff --base <branch> --head <branch> --repo <id>`만 추가한다. design exclusions(타깃 repo PR 게이팅 없음, 알림/엔드포인트 통합 없음, public-safe redacted-only, local-first)를 모두 유지한다.

핵심 전제(코드 확인): `finding_id`는 `[repo, path, line, rule_id]`(또는 `[repo, gitleaks_fingerprint]`)의 SHA-1이며(model.py:115-148) branch/commit이 식별자에 포함되지 않는다. 따라서 동일 secret은 어느 브랜치에서 발견돼도 같은 finding_id를 가지며 두 residual을 직접 집합 비교할 수 있다.

## 요구사항 참조

- Source of truth: `requirements.md`
- Preview companion: `requirements.html`
- 재사용 대상: `runtime/branch_residual.py`의 `residual_by_branch`/`residual_for_repo`/`BranchResidual`/`_ResidualStore`.
- 불변식: finding_id는 branch/commit과 무관(model.py:115-148). 이 전제가 깨지면 diff 의미가 붕괴된다.
- 제약: 새 저장소/인덱스/PR 엔티티 없음. design-only.

## 접근 후보 + 선택

### 후보 A (선택): residual_for_repo 재사용 + 순수 diff helper

`residual_for_repo(store, repo_id)`로 전 브랜치 residual을 1회 read로 얻고, base/head를 골라 순수 helper `residual_diff(BranchResidual, BranchResidual)`에 넘긴다.

- 장점: store read 경로/매칭 로직 중복 0, 동일 스냅샷 일관성, 기존 테스트 자산 재사용, helper는 순수 집합 연산이라 테스트 쉬움.
- 단점: repo 전 브랜치 residual을 계산하지만 2개만 사용(전 브랜치 계산은 기존 `residual_for_repo`가 이미 하던 비용이라 신규 회귀 아님).

### 후보 B (기각): 브랜치별 전용 store read 2회

base/head 각각에 대해 ref_state + observation을 따로 읽는 새 store 메서드 추가.

- 기각 이유: store Protocol에 브랜치 단위 read가 없고(repo 단위만), 새 메서드/인덱스 추가는 '새 저장소/인덱스 없음' 제약 위반. 같은 partition 2회 read는 스냅샷 불일치(서로 다른 시점) 위험.

### 후보 C (기각): residual_diff가 원시 ref_states/observations를 받음

`residual_diff(ref_states, observations, base_branch, head_branch)`.

- 기각 이유: `residual_by_branch`의 commit-매칭 로직(branch_residual.py:92-113)을 helper 안에서 중복 구현하게 됨. 계산은 검증된 함수에 위임하고 diff는 집합 연산만 책임지는 편이 단일 책임에 맞음.

선택: 후보 A.

## 아키텍처

```
CLI residual-diff --base B --head H --repo R
residual_for_repo(store, R) ──1회 read──► store(REF_STATE + GSI1 observations, dynamodb)
│ list[BranchResidual] (전 브랜치, branch명 정렬)
{branch: BranchResidual} 인덱싱 → base/head 선택
│ (둘 중 하나라도 없으면 fail-closed: 에러 + exit 2)
residual_diff(base_residual, head_residual) → ResidualDiff
│ added = head.ids − base.ids, removed = base.ids − head.ids
_render_residual_diff(...) → 텍스트 출력 + exit 0
```

## 구성요소

### `ResidualDiff` (domain, `runtime/branch_residual.py`)

```python
@dataclass(frozen=True)
class ResidualDiff:
base_branch: str
head_branch: str
base_commit: str
head_commit: str
added: list[str] # head − base, 정렬
removed: list[str] # base − head, 정렬

@property
def added_count(self) -> int: ...
@property
def removed_count(self) -> int: ...
@property
def unchanged_count(self) -> int: # |base ∩ head|
...
```

- `BranchResidual`과 동격의 frozen dataclass. commit 두 개를 함께 보존해 '비교 시점' 추적 가능.
- added/removed는 정렬 리스트(기존 `finding_ids` 정렬 관례 일치).

### `residual_diff` helper (`runtime/branch_residual.py`)

```python
def residual_diff(base: BranchResidual, head: BranchResidual) -> ResidualDiff:
base_ids = set(base.finding_ids)
head_ids = set(head.finding_ids)
return ResidualDiff(
base_branch=base.branch,
head_branch=head.branch,
base_commit=base.commit,
head_commit=head.commit,
added=sorted(head_ids - base_ids),
removed=sorted(base_ids - head_ids),
)
```

- 순수 함수, store/IO 의존 없음. base==head면 added/removed 공집합(자명).

### CLI 서브커맨드 (`cli/commands/scan.py`)

- `register()`에 `residual` 등록 직후 `residual-diff` 추가: `--base`/`--head`/`--repo`(모두 required) + `add_incremental_storage_args` + `set_defaults(func=cmd_residual_diff)`.
- `cmd_residual_diff(args)`:
1. `args.storage_backend != "dynamodb"` → 'residual-diff supports --storage-backend dynamodb only' + exit 2.
2. `store = store_from_args(args)`; `residuals = residual_for_repo(store, args.repo)` (try/except로 fatal은 'error: residual-diff failed' + exit 1).
3. `by_branch = {r.branch: r for r in residuals}`.
4. base 또는 head가 `by_branch`에 없으면 → 어느 브랜치가 없는지 명시한 stderr 에러 + exit 2 (fail-closed; '0건'과 구분).
5. `diff = residual_diff(by_branch[args.base], by_branch[args.head])`; `print(_render_residual_diff(args.repo, diff), end="")`; exit 0.
- `_render_residual_diff(repo, diff)`: 기존 `_render_residual` 스타일 텍스트.
```
repo: R
base: B @ <base_commit>
head: H @ <head_commit>
added (N):
- <finding_id>
removed (M):
- <finding_id>
unchanged: K
```

## 데이터 흐름

1. Operator가 `residual-diff --base main --head feat --repo R --storage-backend dynamodb ...` 실행.
2. CLI가 dynamodb backend 확인 → store 생성.
3. `residual_for_repo(store, R)`가 REF_STATE(`PK=REPO#R, SK begins_with REF#`)와 GSI1 observation(`gsi1pk=REPO#R, begins_with RUN#`)을 각 1회 읽어 전 브랜치 `BranchResidual` 리스트 반환(store.py:484-520).
4. CLI가 branch명으로 인덱싱, base/head 존재 검증(미존재 → exit 2).
5. `residual_diff`가 finding_id 집합 차이 계산.
6. 텍스트 렌더 → stdout, exit 0.

## 에러 처리

- dynamodb 외 backend: 'dynamodb only' + exit 2 (기존 residual 관례).
- store 조회/런타임 fatal: 'error: residual-diff failed: ...' + exit 1 (기존 `cmd_residual` try/except 관례, scan.py:443-448). public-safe 메시지만, raw 데이터 노출 금지.
- base/head 브랜치 미발견(RefState 부재): 어느 브랜치가 없는지 stderr 명시 + exit 2. residual 0건('RefState 있고 observation 없음', finding_ids=[])과 명확히 구분 — silent staleness 방지.
- base==head: 에러 아님, added/removed 빈 정상 출력 exit 0.

## 테스트 전략 (TDD, FakeResidualStore 재사용)

helper 단위(`tests/test_branch_residual.py` 확장):
- added = head − base, removed = base − head 방향 검증.
- 동일 secret이 두 브랜치에서 같은 finding_id → unchanged로 분류(추가/제거 0).
- base==head → added/removed 공집합.
- 빈 residual(finding_ids=[]) 끼리/한쪽만 빈 경우 카운트 정확.
- added/removed 정렬 보장.

CLI(`tests/test_cli_residual.py` 또는 신규 `test_cli_residual_diff.py`):
- 정상 diff 렌더(repo/base/head/added/removed/unchanged, 올바른 finding_id 노출).
- base 또는 head 미존재 브랜치 → exit 2 + 어느 브랜치인지 에러 메시지.
- residual 0건(RefState 존재, observation 없음) base/head → exit 0, added/removed 빈 출력(에러 아님).
- jsonl backend 거부 → exit 2.
- 출력에 raw secret/내부 식별정보 부재 확인(public-safety).

서브커맨드 순서(`tests/test_cli.py:703-725`):
- 'residual' 다음에 'residual-diff' 추가하도록 기대 리스트 갱신.

governance:
- `uv run pytest`
- `uv run python -m governance.public_safety --diff origin/main...HEAD` (finding_id hash/branch/commit만 출력됨을 보장)

## 마일스톤

- M1: `ResidualDiff` 도메인 + `residual_diff` helper + helper 단위 테스트(red→green).
- M2: `cmd_residual_diff` + `_render_residual_diff` + `register()` 등록.
- M3: CLI 테스트(정상/미발견/0건/jsonl 거부/public-safety) + 서브커맨드 순서 테스트 갱신.
- M4: governance 체크 통과, PR 준비.

## 열린 질문

- base/head 비대칭 스캔 시점: base와 head가 서로 다른 rule pack 버전으로 마지막 스캔되면 룰 변경발 finding_id가 added/removed에 섞일 수 있다. 이는 incremental scan 모델의 본질적 한계이며 스캔 일관성 보장은 scan-worker/ledger 책임 — 이번 범위 밖. CLI가 base/head commit을 함께 노출해 운영자가 비교 시점을 인지하게 하는 선에서 완화.
- unchanged finding_id 상세 출력 필요 여부: 현재 카운트만. 실사용 피드백 전까지 YAGNI.

## 셀프 리뷰

- 새 저장소/인덱스/PR 엔티티를 추가하지 않는다(요구사항 제약 충족).
- `residual_for_repo`를 1회 read로 재사용해 스냅샷 일관성과 코드 중복 제거를 동시에 만족.
- '브랜치 미발견'(에러)과 'residual 0건'(정상)을 분리해 silent staleness를 방지.
- finding_id 안정성 전제를 명시적 불변식으로 기록.
- design exclusions(PR 게이팅/알림/엔드포인트/redaction/local-first) 모두 유지.
94 changes: 94 additions & 0 deletions docs/workbench/specs/residual-diff/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Residual-Diff (브랜치 간 잔여 secret 차이) Requirements

## 승인 대상

- Source of truth: `requirements.md`
- Preview companion: `requirements.html`
- 범위: security-scanner 단일 기능 — 두 브랜치 residual의 finding_id 집합 차이를 산출하는 helper + CLI 서브커맨드.
- 설계 전용(design-only). 구현/배포(rollout)는 본 문서 범위 밖.

## 배경 (코드 근거)

- 이미 머지된 per-branch residual(issue #12)이 존재한다: `runtime/branch_residual.py`의 `residual_by_branch`, `residual_for_repo`, `BranchResidual`, `_ResidualStore`.
- `BranchResidual.finding_ids`는 `sorted(set(...))`로 이미 중복 제거·정렬되어 있다(branch_residual.py:110).
- `finding_id`는 `[repo, path, line, rule_id]`(또는 `[repo, gitleaks_fingerprint]`)의 SHA-1 앞 16자다(model.py:115-148). branch/commit은 식별자 입력이 아니다 → 두 브랜치 residual의 finding_id는 동일 secret에 대해 같은 값을 가지며 직접 비교 가능하다.
- `residual_for_repo(store, repo_id)`는 repo 파티션의 REF_STATE + GSI1 observation을 1회씩 읽어 전 브랜치 residual을 계산한다(branch_residual.py:124-138, store.py:484-520).
- 기존 `residual` CLI(scan.py:171-182, 434-465)는 `--repo` + `add_incremental_storage_args`, dynamodb-only(아니면 exit 2), 텍스트 렌더 관례를 갖는다.

## 확정 결정 (자문자답)

### Q: 순수 helper의 입력 시그니처는 무엇으로 할까 — 원시 ref_states/observations를 받을까, 계산된 BranchResidual 두 개를 받을까?

두 개의 `BranchResidual`을 받는 `residual_diff(base: BranchResidual, head: BranchResidual) -> ResidualDiff`로 한다. `BranchResidual.finding_ids`가 이미 정렬·중복제거된 집합 형태(branch_residual.py:110)이므로 집합 차이에 바로 쓸 수 있다. ref_states/observations 원시 입력을 받으면 `residual_by_branch`의 commit-매칭 로직을 helper 안에서 중복 구현하게 된다. residual 계산은 검증된 `residual_by_branch`/`residual_for_repo`에 위임하고, diff helper는 순수 집합 연산만 책임진다(단일 책임).

### Q: store에서 두 브랜치를 어떻게 읽을까 — 브랜치별 2회 read vs repo 1회 read 후 선택?

`residual_for_repo(store, repo_id)`를 1회 호출해 전 브랜치 `list[BranchResidual]`을 받고, 그중 base/head 브랜치를 골라 `residual_diff`에 넘긴다. `residual_for_repo`는 이미 repo 파티션을 1회씩 읽어 전 브랜치 residual을 계산하므로(store.py:484-520), 같은 partition을 두 번 읽는 것보다 1회 읽고 메모리에서 선택하는 편이 저비용·일관적이며 동일 스캔 스냅샷을 보장한다. 브랜치 단위 store read API는 Protocol에 없다(`list_ref_states`/`read_observations_for_repo`는 repo 단위). 새 store 메서드/인덱스/엔티티를 추가하지 않는다(YAGNI, '브랜치 인덱스 없음' L2 존중).

### Q: 요청한 브랜치가 RefState에 없을 때(미스캔/오타) 빈 diff로 볼까, 에러로 볼까?

fail-closed 에러로 구분한다. `residual_for_repo` 결과에 base 또는 head 브랜치 키가 없으면(= 해당 ref의 RefState row 부재) 명시적 에러 메시지 + 비-0 exit(2)로 끝낸다. 코드상 RefState 없는 브랜치는 결과에 아예 나타나지 않고(branch_residual.py:101-113), RefState는 있고 매칭 observation이 없는 브랜치는 `finding_ids=[]`로 나타난다(test_branch_residual.py:115-121). 둘을 같은 '빈 diff'로 뭉개면 '이 브랜치는 깨끗하다'와 '스캔된 적 없다'를 혼동시켜 added=0을 'secret 추가 없음'으로 오인하게 한다(scan-run-health silent-staleness 사고와 동형). 따라서 '브랜치 미발견'은 에러, 'residual 0건'은 정상 출력으로 분리한다.

### Q: diff 방향(added/removed) 정의는?

- added = head.finding_ids − base.finding_ids (head에만 있는 잔여 = 이 변경이 추가한 secret)
- removed = base.finding_ids − head.finding_ids (base에만 있는 잔여 = 이 변경이 제거한 secret)

finding_id가 branch/commit 무관(model.py:115-148)이라 동일 secret은 두 브랜치에서 같은 id를 가지므로 집합 차이 정의가 명확하다. unchanged(교집합)는 기본 출력에서 카운트로만 노출한다(노이즈 억제).

### Q: 출력 shape는?

`ResidualDiff`(frozen dataclass): `base_branch`, `head_branch`, `base_commit`, `head_commit`, `added: list[str]`, `removed: list[str]`, 파생 프로퍼티 `added_count`/`removed_count`/`unchanged_count`. CLI는 기존 `_render_residual` 스타일 텍스트 렌더(repo/base/head 헤더 + added/removed 블록 + finding_id 목록). 각 브랜치의 비교 commit을 함께 노출해 '어느 시점 비교'인지 추적 가능하게 한다(`BranchResidual.commit` 재사용). JSON 출력은 미요청이라 추가하지 않는다(YAGNI).

### Q: CLI는 어디에, 어떤 인자로 등록할까?

`cli/commands/scan.py`에 `residual-diff` 서브커맨드를 `residual` 바로 다음에 등록한다. 인자: `--base`(required), `--head`(required), `--repo`(required), 그리고 `add_incremental_storage_args`. 기존 `residual`이 동일 파일·동일 storage args·dynamodb-only 패턴을 쓰므로(scan.py:171-182) 인접 등록이 일관적이다. `test_cli.py:703-725`가 서브커맨드 순서를 핀하므로 'residual' 다음에 'residual-diff'를 넣고 이 순서 테스트를 함께 갱신한다.

### Q: jsonl backend는 어떻게 처리할까?

`residual`과 동일하게 dynamodb 외 backend는 명시적 거부(exit 2). residual 계산은 REF_STATE row + GSI1 observation 조회에 의존하며 dynamodb 어댑터에만 구현돼 있다(store.py:484-520). 기존 `cmd_residual`의 'dynamodb only' + exit 2 관례(scan.py:436-441, test_cli_residual.py:48-52)를 그대로 따른다.

### Q: base == head 같은 브랜치를 비교하면?

added/removed 모두 빈 정상 출력(exit 0). 같은 집합의 자기 차이는 공집합이며 에러가 아니라 '변화 없음'이다. 별도 사전 검증을 두지 않아 helper를 순수 집합 연산으로 단순 유지한다(YAGNI).

### Q: residual_for_repo를 그대로 재사용할까, residual-diff 전용 경로를 새로 만들까?

그대로 재사용한다. 새 storage/index/PR 엔티티를 만들지 않는 것이 본 기능 범위 제약이며, `residual_for_repo`가 정확히 필요한 입력(전 브랜치 residual)을 1회 read로 제공한다. 재사용은 코드 중복 제거 + 동일 스냅샷 일관성 + 기존 테스트 자산 활용의 세 이점이 있다.

## 기능 요구사항

- 순수 helper `residual_diff(base: BranchResidual, head: BranchResidual) -> ResidualDiff`를 `runtime/branch_residual.py`에 추가한다.
- `residual_diff`는 finding_id 집합 차이로 added/removed를 계산한다(added = head − base, removed = base − head).
- `added`/`removed`는 정렬된 finding_id 리스트로 반환한다(기존 `BranchResidual.finding_ids` 정렬 관례 일치).
- `ResidualDiff`는 base/head 브랜치명과 각 비교 commit(`BranchResidual.commit`)을 포함한다.
- `ResidualDiff`는 added/removed/unchanged 카운트를 제공한다(unchanged는 카운트만).
- CLI 서브커맨드 `residual-diff --base <branch> --head <branch> --repo <id>`를 추가한다.
- CLI는 `residual_for_repo(store, repo_id)`를 1회 호출해 전 브랜치 residual을 얻고, base/head 두 브랜치를 선택해 `residual_diff`에 넘긴다.
- base 또는 head 브랜치가 `residual_for_repo` 결과에 없으면(RefState 부재) 명시적 에러 + exit 2로 끝낸다.
- base와 head 모두 존재하면(빈 residual 포함) added/removed/카운트를 텍스트로 렌더하고 exit 0.
- CLI는 dynamodb 외 backend를 명시적으로 거부한다(exit 2, 'dynamodb only').
- 서브커맨드 등록 순서 테스트(`test_cli.py`)를 'residual' 다음 'residual-diff'로 갱신한다.

## 비기능 요구사항

| 항목 | 요구값 |
| --- | --- |
| 새 저장소/인덱스/엔티티 | 추가하지 않는다. 기존 `residual_for_repo` read 경로만 재사용. |
| Public safety | finding_id(redacted hash), branch명, commit sha 외 식별정보를 출력하지 않는다. raw secret/match/내부 URL 금지. |
| Fail-closed | 브랜치 미발견·미지원 backend는 비-0 exit. residual 0건은 정상 출력. |
| 식별자 안정성 | finding_id는 branch/commit과 무관(model.py)하다는 불변식에 의존. helper 테스트에서 동일 secret = 동일 finding_id 가정을 픽스처로 고정. |
| Local-first | DynamoDB-compatible 로컬 store 기준. 원격 PR 게이팅/알림/엔드포인트 통합 없음. |
| YAGNI | JSON 출력·unchanged 상세·브랜치별 별도 store API는 추가하지 않는다. |
| Source of truth | `requirements.md` 승인 전 `design.md` 미작성. |

## 사용자 시나리오

- Operator가 `residual-diff --base main --head feature-x --repo <id>`로 feature-x가 main 대비 추가/제거한 잔여 secret(finding_id)을 확인한다.
- Operator가 added 목록으로 '이 브랜치가 새로 들여온 secret'을, removed 목록으로 '정리된 secret'을 구분한다.
- Operator가 오타/미스캔 브랜치를 넣으면 '0건'이 아닌 명시적 에러를 받아 silent staleness를 피한다.
- Maintainer가 public PR에서 synthetic 픽스처 기반 helper/CLI 테스트만 검토한다.

## 미결정 항목

- base/head 비대칭 스캔 시점(서로 다른 rule pack 버전으로 마지막 스캔된 경우) 처리: 이번 범위 밖(스캔 일관성은 scan-worker/ledger 책임). design.md Open Questions에 한계로 기록.
Loading
Loading