diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cc1f00..feb17f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -42,3 +44,19 @@ jobs: - name: Verify release config run: python3 scripts/verify_release_config.py + + - name: Test history privacy gate + run: bash tests/verify_history_privacy.sh + + - name: Verify Git history privacy + shell: bash + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + revision="origin/${{ github.base_ref }}..HEAD" + elif [[ "${{ github.event.before }}" =~ ^0+$ ]]; then + revision="HEAD" + else + revision="${{ github.event.before }}..${{ github.sha }}" + fi + python3 scripts/verify_history_privacy.py . "${revision}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae648c1..2866de2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Resolve version id: version @@ -60,6 +62,12 @@ jobs: - name: Verify release config run: python3 scripts/verify_release_config.py + - name: Test history privacy gate + run: bash tests/verify_history_privacy.sh + + - name: Verify Git history privacy + run: python3 scripts/verify_history_privacy.py . "${GITHUB_SHA}" + binaries: name: Build ${{ matrix.target }} needs: validate diff --git a/scripts/verify_history_privacy.py b/scripts/verify_history_privacy.py new file mode 100644 index 0000000..ec40eb0 --- /dev/null +++ b/scripts/verify_history_privacy.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +# ─── How to run ─── +# uv run scripts/verify_history_privacy.py + +from __future__ import annotations + +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Final + + +ROOT: Final = Path(__file__).resolve().parents[1] +UUID_LITERAL: Final = re.compile( + rb"\b[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-" + rb"[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\b" +) +PERSONAL_HOME: Final = re.compile(rb"/(?:Users|home)/[A-Za-z0-9._-]+/") +INTEGER_LITERAL: Final = re.compile(rb"(? bytes: + return subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + ).stdout + + +def commits(repo: Path, revision: str) -> tuple[str, ...]: + return tuple(git("rev-list", "--reverse", revision, cwd=repo).decode().splitlines()) + + +def changed_paths(repo: Path, commit_id: str) -> tuple[str, ...]: + output = git( + "diff-tree", + "--root", + "--no-commit-id", + "--name-only", + "-r", + "-z", + commit_id, + cwd=repo, + ) + return tuple( + raw.decode("utf-8") + for raw in output.split(b"\0") + if raw + ) + + +def rules_for(path: str, content: bytes) -> tuple[str, ...]: + rules: list[str] = [] + if PERSONAL_HOME.search(content): + rules.append("personal-home-path") + if any(match.group(0) not in SAFE_SYNTHETIC_UUIDS for match in UUID_LITERAL.finditer(content)): + rules.append("non-synthetic-uuid") + + identity_path = path.endswith(IDENTITY_PATH_SUFFIXES) + if identity_path and any( + 100_000_000 <= int(match.group(0).replace(b"_", b"")) <= 999_999_999 + for match in INTEGER_LITERAL.finditer(content) + ): + rules.append("plausible-kakao-user-id") + if identity_path and any( + match.group(0) not in SAFE_SYNTHETIC_DB_NAMES + for match in DB_NAME_LITERAL.finditer(content) + ): + rules.append("non-synthetic-db-name") + return tuple(rules) + + +def findings(repo: Path, revision: str) -> tuple[Finding, ...]: + found: list[Finding] = [] + for commit_id in commits(repo, revision): + for path in changed_paths(repo, commit_id): + try: + content = git("show", f"{commit_id}:{path}", cwd=repo) + except subprocess.CalledProcessError: + continue + found.extend( + Finding(commit_id=commit_id, path=path, rule=rule) + for rule in rules_for(path, content) + ) + return tuple(found) + + +def main() -> int: + repo = Path(sys.argv[1]).resolve() if len(sys.argv) >= 2 else ROOT + revision = sys.argv[2] if len(sys.argv) == 3 else "HEAD" + found = findings(repo, revision) + if not found: + print("ok: git-history-privacy: all reachable history uses synthetic fixtures") + return 0 + + for item in found: + print( + f"fail: git-history-privacy: {item.rule}: " + f"{item.commit_id[:12]} {item.path}", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/verify_history_privacy.sh b/tests/verify_history_privacy.sh new file mode 100644 index 0000000..1a05aec --- /dev/null +++ b/tests/verify_history_privacy.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +repo="$(mktemp -d "${TMPDIR:-/tmp}/katok-history-privacy.XXXXXX")" +trap 'rm -rf "${repo}"' EXIT + +git -C "${repo}" init -q +git -C "${repo}" config user.email test@example.invalid +git -C "${repo}" config user.name Test +mkdir -p "${repo}/src/kakao" + +uuid_prefix="AAAAAAAA-BBBB-CCCC" +uuid_suffix="DDDD-EEEEEEEEEEEE" +printf 'const UUID: &str = "%s-%s";\n' \ + "${uuid_prefix}" "${uuid_suffix}" > "${repo}/src/kakao/auth.rs" +git -C "${repo}" add . +git -C "${repo}" commit -qm leaked + +cat > "${repo}/src/kakao/auth.rs" <<'EOF' +const UUID: &str = "00000000-1111-2222-3333-444444444444"; +EOF +git -C "${repo}" commit -qam sanitized-tip + +if python3 "${root}/scripts/verify_history_privacy.py" "${repo}" HEAD >/dev/null 2>&1; then + echo "history scanner missed a sensitive value hidden in an earlier commit" >&2 + exit 1 +fi + +git -C "${repo}" filter-branch -f --tree-filter \ + 'if test -f src/kakao/auth.rs; then + printf "%s\n" "const UUID: &str = \"00000000-1111-2222-3333-444444444444\";" > src/kakao/auth.rs + fi' -- --all >/dev/null 2>&1 +git -C "${repo}" for-each-ref --format='delete %(refname)' refs/original/ | + git -C "${repo}" update-ref --stdin +git -C "${repo}" reflog expire --expire=now --all +git -C "${repo}" gc --prune=now --quiet + +python3 "${root}/scripts/verify_history_privacy.py" "${repo}" HEAD >/dev/null +echo "ok: history privacy scanner rejects old leaks and accepts rewritten history"