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
127 changes: 127 additions & 0 deletions docs/workbench/specs/stable-suppression/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# design.md — #6 라인 이동에 강인한 suppression

## 1. 개요

동일 secret의 **라인 이동**으로 finding_id가 바뀌어 발생하는 verifier 재질문(re-ask)을, **finding_id를 변경하지 않는 additive 방식**으로 해결한다. 라인·commit과 무관한 content 기반 보조 키 `match_key`를 도입하고, 신규 disposition-lookup gate가 finding_id(1순위)→match_key(2순위)로 기존 terminal non-blocking 판정을 조회해 재질문을 skip한다.

핵심 설계 원칙: **finding_id identity 불변(#12 L1 보존), match_key는 보조 suppression 키일 뿐 identity가 아님.**

## 2. 요구사항 참조

- R1–R3: match_key 정의/안정성/구분성 → §4, §5.
- R4: item 기록/GSI → §6.
- R5–R8: lookup gate/승계/멱등/summary → §7, §8.
- N1–N3: 비파괴/단일 query/fail-safe → §10, §11.

## 3. 접근 후보와 선택

| 후보 | 내용 | 라인 안정 | finding_id 영향 | 결정 |
|---|---|---|---|---|
| (a) gitleaks-native fingerprint 선호 | combine_repo_fingerprint 우선 | ✗ (gitleaks FP=`commit:file:rule:startline`, line·commit 포함; context7 확인) | 이미 기본 | **기각** |
| (b) 별도 content 기반 match_key + lookup gate | finding_id 불변, 보조 키로 disposition 조회 | ✓ (salted secret_hash 기반) | **없음** | **채택** |
| (c) versioned fingerprint + migration | fingerprint 버전화 후 재-ID | ✓ | 전체 재-ID | **기각(#12 L1 위반)** |

선택 근거는 requirements.md §7(Q2–Q4). (a)는 context7로 확인한 gitleaks Fingerprint 포맷이 라인·commit을 포함해 라인 안정성이 없어 탈락. (c)는 finding_id 전체 재발급으로 #12 L1·`FINDING#` 파티션 키 위반. (b)는 `occurrence_key_for_finding`의 content fallback 선례를 따른 최소 침습안.

## 4. 아키텍처

```
scanner(mapper) → Finding(finding_id 불변)
┌────────────┴─────────────┐
│ core/finding/model.py │ ← compute_match_key() 신규 (순수, 의존 없음)
└────────────┬─────────────┘
│ match_key
┌─────────────────┴───────────────────┐
│ storage/adapters/nosql_db/items.py │ finding_to_items: matchKey 컬럼 + GSI 키
│ storage/adapters/nosql_db/store.py │ find_disposition_by_match_key() 신규
└─────────────────┬────────────────────┘
┌─────────────────┴───────────────────┐
│ runtime: disposition_lookup gate │ finding_id→match_key 순 조회
│ scan_all / verify_artifact 에서 사용 │ 승계 시 verify skip + inherited event
└──────────────────────────────────────┘
```

## 5. 구성요소

### 5.1 `compute_match_key` (core/finding/model.py, 신규·순수)
```
def compute_match_key(repo_full_name, file_path, rule_id, secret_hash) -> str:
material = {"repo": repo_full_name, "file": file_path,
"rule": rule_id, "secretHash": secret_hash}
encoded = json.dumps(material, sort_keys=True, separators=(",", ":"))
return "mk_" + hashlib.sha256(encoded.encode()).hexdigest()[:32]
```
- `secret_hash`는 `hash_secret()` 산출물(salted SHA-256, 라인·commit 무관).
- 라인/commit/start_line을 **포함하지 않음** → 라인 이동에 안정.
- `Finding`에 `match_key`는 저장 필드로 추가하지 않고(스키마 round-trip 비파괴), storage 레이어에서 `compute_match_key(repo, file, rule, evidence.secret_hash)`로 파생한다(N1 보존). secret_hash가 없으면 match_key 미생성(legacy/edge).

### 5.2 items.py
- `finding_to_items`의 FINDING_STATE·observation에 `matchKey` 추가(additive, `without_none` drop).
- FINDING_STATE에 `match_key→finding_id` 보조 GSI: 예 `gsi3pk = MATCHKEY#<match_key>`, `gsi3sk = STATE#<decidedAt>#<finding_id>` (시간 역순 최신 조회용).

### 5.3 store.py — `find_disposition_by_match_key(match_key) -> dict | None`
- `gsi3pk = MATCHKEY#<match_key>`를 ScanIndexForward=False로 1회 query, terminal non-blocking status인 최신 FINDING_STATE 1건 반환(N2: scan 금지).

### 5.4 runtime disposition_lookup gate (신규 모듈, 예: `runtime/disposition_lookup.py`)
```
def resolve_existing_disposition(store, finding) -> ExistingDisposition | None:
st = store.read_finding_state(finding.finding_id) # 1순위
if st and st["status"] in NON_BLOCKING: return ...
mk = compute_match_key(...); hit = store.find_disposition_by_match_key(mk) # 2순위
if hit and hit["status"] in NON_BLOCKING: return ...(inherited)
return None
```

## 6. 데이터 흐름

1. scan → mapper가 Finding 생성(finding_id 불변).
2. verifier 경로 진입 전, 각 finding에 대해 `resolve_existing_disposition` 호출.
3. (1순위) finding_id에 terminal non-blocking state가 있으면 verify skip(기존에도 동일 위치 재발견 처리).
4. (2순위) 라인 이동으로 finding_id는 새 것이지만 match_key가 일치하면 → verify skip + `set_finding_disposition(new_finding_id, ..., source="inherited")` 멱등 호출로 GLOBAL status 채움.
5. 매치 없음 → 기존대로 verifier 실행.
6. write 시 `finding_to_items`가 matchKey/GSI를 함께 기록해 다음 scan부터 2순위 조회 가능.

## 7. 승계(inherit) 정책

- 승계 status: `FALSE_POSITIVE` / `IGNORED` / `RESOLVED`(gate.py `_NON_BLOCKING_STATUSES`와 일치). `OPEN`/`NEEDS_REVIEW` 비승계.
- 새 finding_id에 disposition event 기록: `source="inherited"`, `reason="라인 이동으로 match_key 일치 판정 승계"`, verdict는 원판정 verdict 보존.
- 멱등: `set_finding_disposition`은 이미 version 기반 optimistic concurrency(`#version` 조건)를 쓰므로, 이미 동일 status로 inherited면 재기록 생략(또는 동일 결과로 수렴). 중복 STATE_EVENT 방지를 위해 inherit 전 `read_finding_state(new_id)`로 이미 채워졌는지 확인.

## 8. 에러 처리

- store/GSI 조회 실패: lookup을 fail-open이 아니라 **fail-safe(미승계=재질문)** 로 처리. 즉 조회 실패 시 verifier가 실행되어 over-ask는 되어도 false-suppression은 없음(N3).
- match_key 산출 불가(secret_hash 부재): 2순위 skip, 기존대로 verify.
- 모든 요약은 public-safe: `_public_safe_exception` 패턴 재사용, raw secret/match 비노출(R9).
- scan_all summary에 `inherited_suppressions` 카운트 추가(기존 ScanAllVerifierSummary 확장, additive).

## 9. 테스트 전략

- **단위(core)**: `compute_match_key` 라인 불변(같은 secret·file, line 다름 → 동일), repo/rule/file/secret 다름 → 상이, raw secret 비포함.
- **단위(items/store)**: finding_to_items가 matchKey/gsi3 기록, `find_disposition_by_match_key`가 terminal non-blocking 최신 1건만 반환, legacy(matchKey 없음) miss.
- **runtime gate**: fake store로 (1) finding_id 직접 히트, (2) 라인 이동 match_key 히트→inherited, (3) OPEN 비승계, (4) 조회 실패 시 fail-safe 재질문, (5) inherit 멱등.
- **회귀**: `test_finding.py` 전체 무변경 통과(N1), 기존 round-trip·gate 테스트 통과.
- 모든 fixture는 synthetic(`AKIAFAKEEXAMPLE*`).

## 10. 마일스톤

1. `compute_match_key`(core) + 단위 테스트(red→green). finding_id 영향 0 증명.
2. items.py matchKey/gsi3 additive 기록 + store `find_disposition_by_match_key` + 단위 테스트.
3. runtime disposition_lookup gate(순수 함수+fake store 테스트).
4. scan_all/verify_artifact 배선: verify 전 lookup, 승계 시 skip + inherited event + summary 카운트.
5. public-safe docs(getting-started)에 라인-이동 suppression·salt 회전 경고 추가.

## 11. 비파괴/Fail-safe 보장 요약

- finding_id/fingerprint/compute_fingerprint 무변경 → 기존 stored state·#12 L1 보존.
- matchKey/gsi3는 additive, legacy item은 miss로 graceful.
- lookup 실패·미존재는 재질문(fail-safe), salt 회전은 over-ask 방향.
- JSONL-only 경로는 비대상(capability 부재) — 문서에 명시.

## 12. 열린 질문

- OQ1. 기존 stored finding에 대한 matchKey 백필을 별도 마이그레이션으로 제공할지(현재 비범위, 미백필 시 라인 이동 1회는 재질문될 수 있음).
- OQ2. match_key를 FINDING_STATE에만 둘지 observation에도 둘지 — residual(#12 L2) 조회와의 결합 여부에 따라 결정(기본: state 중심, observation은 진단용 additive).
- OQ3. inherited 시 새 finding_id의 confidence/severity는 원본 그대로 둘지(기본: 그대로, disposition만 승계).
- OQ4. 라인 이동 + 동시 rule 변경(같은 secret, 다른 rule_id)은 의도적으로 비승계 — 정책 확정 여부.
102 changes: 102 additions & 0 deletions docs/workbench/specs/stable-suppression/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# requirements.md — #6 라인 이동에 강인한 suppression

## 1. 배경 / 문제

`compute_fingerprint(repo, file_path, line_start, rule_id)`는 `line_start`를 포함하고, `finding_id = "finding_" + SHA-1(fingerprint)[:16]`로 파생된다(`core/finding/model.py`). 따라서 **같은 secret이 파일 안에서 라인만 이동하면 finding_id가 바뀐다.** 새 finding_id에는 기존 disposition(예: FALSE_POSITIVE)이 연결되지 않으므로 verifier가 동일 secret을 **다시 묻는다(re-ask)**.

추가로 코드 조사에서 확인된 더 큰 사실:

- `scan_all._run_verifier_disposition_writes`와 `verify_artifact.run_verify_artifact`는 **fresh-scan finding 전부를 무조건 verify**한다. `read_finding_state`로 기존 판정을 조회해 재질문을 skip하는 경로가 **존재하지 않는다**(grep 확인). 즉 라인 이동이 아니어도 매 scan마다 재질문이 발생한다.
- 즉 #6의 "suppression을 라인 이동에 강인하게"는, 라인 안정 키만으로는 효과가 없고 **"기존 disposition을 조회해 재질문을 skip하는 lookup gate"를 함께 도입**해야 의미가 있다.

## 2. CRITICAL 제약 (불변식)

- `compute_fingerprint` / `compute_finding_id` / `Finding.create`의 finding_id 산출 로직은 **변경하지 않는다.** finding_id는 `items.py`의 `FINDING#<finding_id>` identity/state/observation 키와 #12 L1(branch/commit=occurrence, status=GLOBAL) 결정의 뿌리이며, 바꾸면 저장된 모든 FINDING_STATE/STATE_EVENT/observation/disposition ledger가 재-ID되어 dedup·residual·상태가 깨진다.
- 변경은 **additive·non-breaking**이어야 한다. 기존 item·테스트·round-trip 계약을 보존한다.
- public-safe/redacted-only, local-first. target-repo PR gating·알림/엔드포인트 연동 없음.

## 3. 범위 (In Scope)

- 라인 이동에 안정적인 **별도 보조 키 `match_key`**(content 기반, 라인·commit 비포함) 정의 및 산출.
- `match_key`를 FINDING_STATE / observation item에 **additive 컬럼**으로 기록 + `match_key → finding_id` 보조 GSI.
- 신규 **disposition-lookup gate**: `finding_id` 직접 매치 → 실패 시 `match_key` 매치 순으로 기존 terminal non-blocking disposition을 조회.
- verifier 경로(`scan_all`·`verify_artifact`)에서, 이미 terminal non-blocking 판정을 가진 finding은 **재질문 skip**하고 새 finding_id에 `source="inherited"` disposition event를 멱등 기록.

## 4. 비범위 (Out of Scope, YAGNI)

- finding_id/fingerprint 스키마 변경, versioned fingerprint, 전체 마이그레이션.
- 파일 rename/이동 추적, secret 값 변경(키 로테이션) 추적.
- JSONL-only 로컬 경로에 대한 disposition/suppression(현재 미지원 capability).
- 기존 stored item 백필(별도 작업으로 분리).
- 알림·target PR gating·외부 endpoint.

## 5. 기능 요구사항

- R1. `match_key`는 `salted secret_hash`(라인·commit 무관) + `repo_full_name` + `rule_id` + 정규화 `file_path`의 canonical JSON을 SHA-256한 값으로 산출한다. 접두사 `mk_`.
- R2. 같은 secret이 같은 파일·repo·rule에서 라인만 다르면 `match_key`가 **동일**해야 한다. 라인이 달라도 `match_key`는 불변임을 테스트로 증명한다.
- R3. 다른 repo / 다른 rule / 다른 파일 / 다른 secret이면 `match_key`가 **달라야** 한다(over-suppress 방지).
- R4. `finding_to_items`는 `match_key`를 FINDING_STATE·observation item에 기록하고, `match_key→finding_id` 보조 GSI 키를 추가한다. `without_none`으로 누락 시 drop(legacy back-compat).
- R5. lookup gate는 (1) `read_finding_state(finding_id)` → terminal non-blocking이면 그대로 승계, (2) 없으면 `match_key`로 가장 최근 terminal non-blocking disposition을 조회한다.
- R6. 승계 대상 status는 `FALSE_POSITIVE`, `IGNORED`, `RESOLVED`만(gate.py non-blocking 집합과 일치). `OPEN`/`NEEDS_REVIEW`는 승계하지 않는다.
- R7. 라인 이동으로 새 finding_id가 생긴 경우, 그 finding_id에 `set_finding_disposition(..., source="inherited", actor=<원판정 actor 보존 or "match-key-inherit">)`를 호출해 GLOBAL status를 채운다. 동일 finding_id에 대해 멱등이어야 한다(중복 inherit 금지).
- R8. verifier 경로는 승계가 일어나면 해당 finding을 verify 호출에서 **skip**하고 summary에 `inherited_suppressions` 카운트를 노출한다(public-safe).
- R9. `match_key` 값·lookup 결과·event 어디에도 raw secret/match가 노출되지 않는다(redacted-only).

## 6. 비기능 요구사항

- N1. 기존 `test_finding.py`(fingerprint/finding_id 기대값)와 round-trip 계약은 **변경 없이 통과**해야 한다.
- N2. lookup은 NoSQL adapter 단일-테이블 access pattern 내에서 GSI 1회 query로 수행(scan 금지).
- N3. salt 회전 시 match-key suppression이 리셋되어도 **over-ask(fail-safe)** 방향이어야 하며, over-suppress가 되어선 안 된다.

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

### Q1. finding_id를 라인 비포함으로 바꿔 근본 해결할까?
**A. 아니오.** `compute_fingerprint=[repo,path,line,rule]`·`finding_id=SHA-1(fingerprint)`는 `items.py`의 `FINDING#<finding_id>` PK/SK/GSI와 #12 L1의 정체성 뿌리다. 변경 시 저장된 FINDING_STATE/STATE_EVENT/observation/disposition이 전부 재-ID되어 dedup·residual·상태가 붕괴한다. → finding_id **불변** 고정.

### Q2. 옵션(a) gitleaks-native fingerprint 선호로 라인 안정성이 생기나?
**A. 아니오, 기각.** `mapper.py`는 gitleaks `Fingerprint`가 있으면 이미 `fingerprint_override`로 finding_id를 그걸로 derive한다(기본 동작). 그러나 context7로 확인한 gitleaks v8 Fingerprint 포맷은 `commit:file:rule:startline`(예: `6e6ee65...:path:generic-api-key:4`)로 **start line을 포함**하고 git 모드는 **commit까지 포함**한다. 라인 이동 시 fingerprint가 바뀌므로 라인 안정성이 없다. 오히려 commit 때문에 더 불안정.

### Q3. 옵션(c) versioned fingerprint + migration은?
**A. 기각.** 새 version은 신규 finding_id 체계를 만들어 stored state와 단절되고, 마이그레이션은 finding_id 재발급을 요구해 #12 L1과 `FINDING#` 파티션 키를 위반한다. non-breaking·YAGNI 원칙과 충돌.

### Q4. 채택안 — 옵션(b)?
**A. 채택.** finding_id 불변 + 별도 content 기반 `match_key`(라인·commit 무관)를 추가하고 disposition lookup이 이를 2순위로 조회. 근거: `occurrence_key_for_finding`이 이미 fingerprint 부재 시 `secretHash`+`matchHash` content fallback을 갖는 선례(items.py 643–648), `secret_hash`=`hash_secret()`의 salted SHA-256(raw secret)는 라인·commit과 무관.

### Q5. match_key 구성요소?
**A.** `salted secret_hash + repo_full_name + rule_id + 정규화 file_path` → canonical JSON(sort_keys, separators) → SHA-256. file_path를 포함해 '같은 파일 내 라인 이동'으로 scope를 좁혀 다른 파일의 별개 노출을 한꺼번에 suppress하지 않게 한다. delimiter injection은 canonical JSON으로 차단(기존 선례 동일).

### Q6. file_path를 넣으면 rename에 약한데?
**A. 의도된 trade-off.** #6 범위는 '라인 이동'이며 rename은 GHAS `comparison_key`(repo+file+line+secret_type)도 깨지는 상위 범위다. file 제외 시 false-suppression(보안 누락) 위험이 크다. file 포함 고정, rename 내성은 비범위.

### Q7. lookup gate는 신규인가?
**A. 신규.** 현재 verifier 경로 어디에도 `read_finding_state`로 재질문을 skip하는 gate가 없다(grep 확인). #6은 라인 안정 키 + lookup gate를 함께 도입해야 효과가 있다. **최대 리스크로 명시.**

### Q8. lookup 우선순위?
**A.** finding_id 직접 매치(1순위, 정확·저렴) → 없으면 match_key(2순위, 라인 이동 흡수). 2단계화로 false-suppression 최소화.

### Q9. 어떤 판정을 승계?
**A.** terminal non-blocking(`FALSE_POSITIVE`/`IGNORED`/`RESOLVED`)만. gate.py non-blocking 집합과 일치. `OPEN`/`NEEDS_REVIEW`는 비승계. 새 finding_id에는 `source="inherited"` disposition event를 멱등 기록해 GLOBAL status 단일성 유지.

### Q10. 저장/backend 범위?
**A.** NoSQL adapter의 FINDING_STATE+observation에 `matchKey` 컬럼 + `match_key→finding_id` GSI. JSONL store는 disposition 미지원이라 비대상.

### Q11. legacy item 호환?
**A.** `finding_to_items`에서 additive 기록, `without_none`로 None drop. 기존 item은 matchKey 없음 → 2순위 단순 miss(over-suppress 없음). 백필은 비범위.

### Q12. salt 변경 영향?
**A.** match_key는 `SECURITY_SCANNER_HASH_SALT` 의존이라 salt 회전 시 전부 달라져 suppression 리셋 → over-ask(fail-safe). 운영 문서에 'salt 회전 = match-key suppression 리셋' 경고.

## 8. 변경하지 않는 것 (명시)

- `compute_fingerprint`, `combine_repo_fingerprint`, `compute_finding_id`, `Finding.create`의 finding_id 산출.
- 기존 `fingerprint`/`finding_id` 필드 의미·`FINDING#<finding_id>` 키.
- `test_finding.py`의 기대값.
- JSONL store 동작.

## 9. 수용 기준

- AC1. 라인만 다른 동일 secret 두 finding이 서로 다른 finding_id를 갖되 동일 `match_key`를 갖는다.
- AC2. 첫 finding을 FALSE_POSITIVE로 판정 후 라인 이동한 두 번째 finding은 verifier 재질문 없이 inherited disposition을 받는다.
- AC3. 다른 파일/rule/repo/secret은 동일 match_key를 갖지 않아 승계되지 않는다.
- AC4. `test_finding.py` 및 기존 storage round-trip 테스트가 변경 없이 통과.
- AC5. inherited disposition event는 멱등(같은 finding_id 중복 호출에도 1건).
22 changes: 22 additions & 0 deletions src/security_scanner/core/finding/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,28 @@ def compute_finding_id(fingerprint: str) -> str:
return f"finding_{digest}"


def compute_match_key(
repo_full_name: str, file_path: str, rule_id: str, secret_hash: str
) -> str:
"""Return a line/commit-independent suppression key for one secret.

Unlike ``finding_id`` (which includes line_start), this keys on the secret
*content* (``secret_hash``, a salted SHA-256) plus repo/file/rule, so the same
secret that merely moved lines yields the same match_key. It is a secondary
*suppression* key only — NOT an identity; ``finding_id`` is unchanged (#12 L1).
Because secret_hash is content-derived, two distinct secrets get distinct keys,
so suppression never spans different leaks.
"""
material = {
"repo": repo_full_name,
"file": file_path,
"rule": rule_id,
"secretHash": secret_hash,
}
encoded = json.dumps(material, sort_keys=True, separators=(",", ":"))
return "mk_" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:32]
Comment thread
pureliture marked this conversation as resolved.


# ---------------------------------------------------------------------------
# Nested sub-dataclasses
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading