diff --git a/docs/workbench/specs/residual-diff/design.md b/docs/workbench/specs/residual-diff/design.md new file mode 100644 index 0000000..3a2e059 --- /dev/null +++ b/docs/workbench/specs/residual-diff/design.md @@ -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 --head --repo `만 추가한다. 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 @ + head: H @ + added (N): + - + removed (M): + - + 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) 모두 유지. \ No newline at end of file diff --git a/docs/workbench/specs/residual-diff/requirements.md b/docs/workbench/specs/residual-diff/requirements.md new file mode 100644 index 0000000..7efbad0 --- /dev/null +++ b/docs/workbench/specs/residual-diff/requirements.md @@ -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 --head --repo `를 추가한다. +- 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 `로 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에 한계로 기록. \ No newline at end of file diff --git a/src/security_scanner/cli/commands/scan.py b/src/security_scanner/cli/commands/scan.py index 19e835a..ccdd328 100644 --- a/src/security_scanner/cli/commands/scan.py +++ b/src/security_scanner/cli/commands/scan.py @@ -16,7 +16,12 @@ DISCOVERY_SCANNER_VERSION, ) from security_scanner.cli._store import dynamodb_config_from_args, store_from_args -from security_scanner.runtime.branch_residual import BranchResidual, residual_for_repo +from security_scanner.runtime.branch_residual import ( + BranchResidual, + ResidualDiff, + residual_diff, + residual_for_repo, +) from security_scanner.runtime.incremental_discovery import ( DEFAULT_REF_PATTERNS, DISCOVERY_MODE_ENQUEUE, @@ -182,6 +187,25 @@ def register(subparsers) -> None: add_incremental_storage_args(residual_parser) residual_parser.set_defaults(func=cmd_residual) + residual_diff_parser = subparsers.add_parser( + "residual-diff", + help="Diff residual findings between two branches (added/removed secrets).", + ) + residual_diff_parser.add_argument( + "--repo", required=True, metavar="REPO_ID", + help="Repository id (incrementally-scanned repo_id).", + ) + residual_diff_parser.add_argument( + "--base", required=True, metavar="BRANCH", + help="Base branch (e.g. main).", + ) + residual_diff_parser.add_argument( + "--head", required=True, metavar="BRANCH", + help="Head branch to compare against base.", + ) + add_incremental_storage_args(residual_diff_parser) + residual_diff_parser.set_defaults(func=cmd_residual_diff) + scan_all_parser = subparsers.add_parser( "scan-all", help="Fetch and scan every URL registered in the SCAN_TARGET catalog.", @@ -471,6 +495,56 @@ def _render_residual(repo: str, residuals: list[BranchResidual]) -> str: return "\n".join(lines) + "\n" +def cmd_residual_diff(args: argparse.Namespace) -> int: + """Diff residual findings between two branches (added/removed secrets).""" + if args.storage_backend != "dynamodb": + print( + "error: residual-diff supports --storage-backend dynamodb only", + file=sys.stderr, + ) + return 2 + + try: + store = store_from_args(args) + residuals = residual_for_repo(store, args.repo) + except Exception as exc: # noqa: BLE001 - fatal storage/runtime error. + print(f"error: residual-diff failed: {exc}", file=sys.stderr) + return 1 + + by_branch = {residual.branch: residual for residual in residuals} + # dict.fromkeys dedupes so base==head reports the branch once. + missing = [b for b in dict.fromkeys((args.base, args.head)) if b not in by_branch] + if missing: + # fail-closed: an unscanned branch is NOT the same as "0 added/removed". + print( + "error: no residual for branch(es) " + f"{', '.join(missing)} in repo {args.repo} " + "(branch not incrementally scanned?)", + file=sys.stderr, + ) + return 2 + + diff = residual_diff(by_branch[args.base], by_branch[args.head]) + print(_render_residual_diff(args.repo, diff), end="") + return 0 + + +def _render_residual_diff(repo: str, diff: ResidualDiff) -> str: + lines = [ + f"repo: {repo}", + f"base: {diff.base_branch} @ {diff.base_commit}", + f"head: {diff.head_branch} @ {diff.head_commit}", + f"added ({diff.added_count}):", + ] + for finding_id in diff.added: + lines.append(f" - {finding_id}") + lines.append(f"removed ({diff.removed_count}):") + for finding_id in diff.removed: + lines.append(f" - {finding_id}") + lines.append(f"unchanged: {diff.unchanged_count}") + return "\n".join(lines) + "\n" + + def _env_truthy(value: str | None) -> bool: """Return True for truthy env strings (1/true/yes/on, case-insensitive). diff --git a/src/security_scanner/runtime/branch_residual.py b/src/security_scanner/runtime/branch_residual.py index b008987..4c36086 100644 --- a/src/security_scanner/runtime/branch_residual.py +++ b/src/security_scanner/runtime/branch_residual.py @@ -145,3 +145,45 @@ def residual_for_repo( store.list_ref_states(repo_id), store.read_observations_for_repo(repo_id, include_legacy=include_legacy), ) + + +@dataclass(frozen=True) +class ResidualDiff: + """Difference in residual findings between two branches at their latest scans. + + ``added`` are findings residual on ``head`` but not ``base`` (what this branch + introduced); ``removed`` are residual on ``base`` but not ``head``. Both are + sorted finding_id lists. finding_id is branch/commit-stable (issue #12 L1), so + a plain set difference is well-defined. + """ + + base_branch: str + head_branch: str + base_commit: str + head_commit: str + added: list[str] + removed: list[str] + unchanged_count: int + + @property + def added_count(self) -> int: + return len(self.added) + + @property + def removed_count(self) -> int: + return len(self.removed) + + +def residual_diff(base: BranchResidual, head: BranchResidual) -> ResidualDiff: + """Compute the residual finding-id diff between two branches (pure).""" + 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), + unchanged_count=len(base_ids & head_ids), + ) diff --git a/tests/test_branch_residual.py b/tests/test_branch_residual.py index b48febc..050b27b 100644 --- a/tests/test_branch_residual.py +++ b/tests/test_branch_residual.py @@ -10,6 +10,7 @@ branch_from_ref, finding_with_context, residual_by_branch, + residual_diff, ) from security_scanner.storage.base import RefState @@ -119,3 +120,39 @@ def test_residual_branch_with_no_matching_observations_is_empty_list(): result = residual_by_branch(refs, observations) assert result == [BranchResidual(branch="main", commit="S9", finding_ids=[])] + + +def test_residual_diff_added_removed_unchanged(): + base = BranchResidual( + branch="main", commit="S1", finding_ids=["f_shared", "f_base"] + ) + head = BranchResidual( + branch="feat", commit="S2", finding_ids=["f_shared", "f_add"] + ) + + diff = residual_diff(base, head) + + assert diff.added == ["f_add"] + assert diff.removed == ["f_base"] + assert diff.unchanged_count == 1 + assert diff.added_count == 1 and diff.removed_count == 1 + assert (diff.base_branch, diff.head_branch) == ("main", "feat") + assert (diff.base_commit, diff.head_commit) == ("S1", "S2") + + +def test_residual_diff_identical_branches_empty(): + r = BranchResidual(branch="main", commit="S1", finding_ids=["a", "b"]) + diff = residual_diff(r, r) + assert diff.added == [] and diff.removed == [] + assert diff.unchanged_count == 2 + + +def test_residual_diff_is_directional(): + base = BranchResidual(branch="main", commit="S1", finding_ids=["only_base"]) + head = BranchResidual(branch="feat", commit="S2", finding_ids=["only_head"]) + diff = residual_diff(base, head) + assert diff.added == ["only_head"] + assert diff.removed == ["only_base"] + rev = residual_diff(head, base) + assert rev.added == ["only_base"] + assert rev.removed == ["only_head"] diff --git a/tests/test_cli.py b/tests/test_cli.py index c5ac8bc..8adf766 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -706,6 +706,7 @@ def test_subcommand_registration_order_is_stable(): "scan-worker", "queue-status", "residual", + "residual-diff", "scan-all", "scan-health", "report", diff --git a/tests/test_cli_residual_diff.py b/tests/test_cli_residual_diff.py new file mode 100644 index 0000000..c66153d --- /dev/null +++ b/tests/test_cli_residual_diff.py @@ -0,0 +1,79 @@ +"""CLI tests for the residual-diff subcommand (#23 follow-on #5).""" + +from __future__ import annotations + +import datetime as dt + +from security_scanner.cli import main +from security_scanner.storage.base import RefState + +NOW = dt.datetime(2026, 6, 16, tzinfo=dt.UTC) + + +class FakeTwoBranchStore: + """main @ Smain and feat @ Sfeat with overlapping + distinct findings.""" + + def list_ref_states(self, repo_id: str) -> list[RefState]: + return [ + RefState(repo_id=repo_id, repo_url="https://e/r", + ref_name="refs/heads/main", last_seen_sha="Smain", updated_at=NOW), + RefState(repo_id=repo_id, repo_url="https://e/r", + ref_name="refs/heads/feat", last_seen_sha="Sfeat", updated_at=NOW), + ] + + def read_observations_for_repo( + self, repo_id: str, *, include_legacy: bool = False + ) -> list[dict]: + return [ + {"branch": "main", "commit": "Smain", "findingId": "f_shared"}, + {"branch": "main", "commit": "Smain", "findingId": "f_base_only"}, + {"branch": "feat", "commit": "Sfeat", "findingId": "f_shared"}, + {"branch": "feat", "commit": "Sfeat", "findingId": "f_added"}, + ] + + +def _patch(monkeypatch, store): + monkeypatch.setattr( + "security_scanner.cli._store.create_finding_store", + lambda backend, **kwargs: store, + ) + + +def test_residual_diff_reports_added_removed_unchanged(monkeypatch, capsys): + _patch(monkeypatch, FakeTwoBranchStore()) + + exit_code = main([ + "residual-diff", "--repo", "repo_x", + "--base", "main", "--head", "feat", "--storage-backend", "dynamodb", + ]) + + out = capsys.readouterr().out + assert exit_code == 0 + assert "repo: repo_x" in out + assert "base: main @ Smain" in out + assert "head: feat @ Sfeat" in out + assert "added (1):" in out and "- f_added" in out + assert "removed (1):" in out and "- f_base_only" in out + assert "unchanged: 1" in out + + +def test_residual_diff_missing_branch_fails_closed(monkeypatch, capsys): + _patch(monkeypatch, FakeTwoBranchStore()) + + exit_code = main([ + "residual-diff", "--repo", "repo_x", + "--base", "main", "--head", "nope", "--storage-backend", "dynamodb", + ]) + + err = capsys.readouterr().err + assert exit_code == 2 + assert "nope" in err and "no residual for branch" in err + + +def test_residual_diff_rejects_jsonl_backend(capsys): + exit_code = main([ + "residual-diff", "--repo", "repo_x", + "--base", "main", "--head", "feat", "--storage-backend", "jsonl", + ]) + assert exit_code == 2 + assert "dynamodb only" in capsys.readouterr().err