diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 500e22b4ab..d90452068f 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -435,6 +435,7 @@ jobs: runs-on: ubuntu-24.04 permissions: contents: read + pull-requests: read security-events: write actions: read env: @@ -462,16 +463,52 @@ jobs: - name: Run gitleaks on PR commit range id: gitleaks env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - set +e + set -euo pipefail + live_pr="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")" + state="$(jq -r ".state" <<<"${live_pr}")" + base_repository="$(jq -r ".base.repo.full_name" <<<"${live_pr}")" + base_ref="$(jq -r ".base.ref" <<<"${live_pr}")" + BASE_SHA="$(jq -r ".base.sha" <<<"${live_pr}")" + HEAD_SHA="$(jq -r ".head.sha" <<<"${live_pr}")" + + if [ "${state}" != "open" ] \ + || [ "${base_repository}" != "${GITHUB_REPOSITORY}" ] \ + || [ "${base_ref}" != "${EVENT_BASE_REF}" ] \ + || [ "${HEAD_SHA}" != "${EVENT_HEAD_SHA}" ] \ + || ! [[ "${BASE_SHA}" =~ ^[0-9a-f]{40}$ ]] \ + || ! [[ "${HEAD_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Gitleaks could not authenticate the open PR at its event head and canonical live base. Failing closed." + exit 1 + fi + + git fetch --no-tags --no-recurse-submodules origin \ + "+refs/heads/${base_ref}:refs/remotes/origin/gitleaks-pr-base" + fetched_base_sha="$(git rev-parse refs/remotes/origin/gitleaks-pr-base)" + if [ "${fetched_base_sha}" != "${BASE_SHA}" ]; then + echo "::error::The live base advanced while Gitleaks prepared its range. Rerun on the current base." + exit 1 + fi + + merge_base="$(git merge-base "${BASE_SHA}" "${HEAD_SHA}")" + if ! [[ "${merge_base}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Gitleaks could not derive an authenticated PR merge base. Failing closed." + exit 1 + fi + config_args=() - if [ -f .gitleaks.toml ]; then - config_args=(--config .gitleaks.toml) + trusted_config="${RUNNER_TEMP}/gitleaks-live-base-config.toml" + if git cat-file -e "${BASE_SHA}:.gitleaks.toml" 2>/dev/null; then + git show "${BASE_SHA}:.gitleaks.toml" > "${trusted_config}" + config_args=(--config "${trusted_config}") fi - log_opts="${BASE_SHA}..${HEAD_SHA}" - echo "::notice::gitleaks scanning pull request commit range ${log_opts}." + log_opts="${merge_base}..${HEAD_SHA}" + echo "::notice::gitleaks scanning exact pull request commit range ${log_opts} from live base ${BASE_SHA}." + set +e ./gitleaks git . \ "${config_args[@]}" \ --log-opts="${log_opts}" \ @@ -479,8 +516,9 @@ jobs: --report-format sarif \ --report-path gitleaks-results.sarif \ --exit-code 2 - echo "rc=$?" >> "$GITHUB_OUTPUT" + rc="$?" set -e + echo "rc=${rc}" >> "$GITHUB_OUTPUT" - name: Summarize redacted gitleaks findings if: always() && hashFiles('gitleaks-results.sarif') != '' run: | diff --git a/CHANGELOG.d/20260908-gitleaks-live-base-merge-range.md b/CHANGELOG.d/20260908-gitleaks-live-base-merge-range.md new file mode 100644 index 0000000000..ad74ed5ac4 --- /dev/null +++ b/CHANGELOG.d/20260908-gitleaks-live-base-merge-range.md @@ -0,0 +1,9 @@ +## Fixed + +- 중앙 `Security Scan`의 Gitleaks PR 범위를 오래된 webhook base SHA가 아니라 + 인증된 live PR base와 exact head의 merge base에서 계산하도록 수정했습니다. +- base가 API 조회와 fetch 사이에 이동하거나 PR/head/repository identity가 + 일치하지 않으면 비밀 검사를 우회하지 않고 명시적으로 fail closed 합니다. + +- PR checkout의 `.gitleaks.toml`은 실행 정책으로 신뢰하지 않고, 인증된 live base의 + 설정만 별도 파일로 materialize하여 PR이 secret rule을 약화하지 못하게 합니다. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 2970e1b28c..93007634e1 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3483,6 +3483,47 @@ their change was safe because they had scoped it narrowly, not because they had collision — which is the more useful lesson: **a job name is unique only within one workflow file, and the same name in another file can carry the opposite safety property.** +## Gitleaks live-base commit-range binding — 2026-09-08 + +**Problem and exact evidence.** `ContextualWisdomLab/.github#1639` exact head +`6a8e8b2c279779ec7516a67ee96a02c7b9048468` failed Security Scan run +`34195535497`, job `101962314786`, because Gitleaks scanned +`9330d41c92b1e6ab35261f3f5189936ea1ad8bff..6a8e8b2c279779ec7516a67ee96a02c7b9048468`. +The event base was 248 commits behind protected `main` +`7fd571dbcdbae6acf29d8f4ee704d7ba6297e4db`; the PR had already integrated that +current base, and its effective live-base delta was only +`config/repository-metadata.json`, +`scripts/ci/reconcile_repository_metadata.py`, and +`tests/test_repository_metadata_reconciliation.py`. The two reported +`generic-api-key` results came from a test fixture already present on `main`, not +from the metadata delta. + +**Constraints and boundary.** `.github` owns the required security workflow and +must keep Gitleaks fail closed for every PR file type. The metadata writer must not +suppress a real rule, delete unrelated fixture evidence, trust a mutable head, or +use a stale webhook snapshot as current merge authority. + +**Alternatives.** Suppressing the fixture was rejected because it hides valid test +evidence and does not repair the range. Using only the event base was rejected +because long-lived PRs can carry an old snapshot. Fetching a branch without +binding it to API evidence was rejected because the branch can advance during +preparation. + +**Selected action and tests.** The workflow reads the open PR through the GitHub +API, authenticates the canonical base repository and the event exact head while +keeping fork heads scannable as untrusted source. It also materializes only the +authenticated live base's `.gitleaks.toml`; PR checkout policy is never trusted, +fetches the live base ref, rejects an API/fetch race, derives `git merge-base`, and +scans only `merge_base..exact_head`. A dedicated regression contract first failed +against the stale-base implementation and then passed after the workflow repair. + +**Risk, effect, and follow-up.** A base movement during setup now produces a clear +rerun-required failure instead of an ambiguous scan. Operators retain redacted +SARIF and hard-gate behavior; contributors no longer receive a metadata PR failure +for secrets introduced only by already-merged base history. Hosted exact-head +checks and an independent review remain required before ordinary merge. After the +owner repair reaches protected `main`, rerun `#1639` and confirm its effective +three-file delta is the only Gitleaks history examined. ### Central Actions inventory credential routing diff --git a/tests/test_gitleaks_pr_consolidation.py b/tests/test_gitleaks_pr_consolidation.py index a2ba0ded6f..53a377a80e 100644 --- a/tests/test_gitleaks_pr_consolidation.py +++ b/tests/test_gitleaks_pr_consolidation.py @@ -1,7 +1,15 @@ """Contracts for consolidating the central repository's PR Gitleaks scan.""" +import json +import os from pathlib import Path import re +import shutil +import subprocess + +import pytest + +from tests.test_opencode_workflow_shell_syntax import _extract_run_block WORKFLOWS = Path(__file__).parents[1] / ".github/workflows" @@ -41,7 +49,7 @@ def test_security_scan_owns_the_fail_closed_pr_gitleaks_job() -> None: assert "github.repository == 'ContextualWisdomLab/.github'" in job assert 'GITLEAKS_VERSION: "8.30.1"' in job assert 'GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb"' in job - assert 'log_opts="${BASE_SHA}..${HEAD_SHA}"' in job + assert 'log_opts="${merge_base}..${HEAD_SHA}"' in job assert '--log-opts="${log_opts}"' in job assert "gitleaks-results.upload.sarif" in job assert "github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9" in job @@ -49,6 +57,133 @@ def test_security_scan_owns_the_fail_closed_pr_gitleaks_job() -> None: assert "exit 1" in job +def test_gitleaks_binds_commit_range_to_live_base_merge_base() -> None: + """A stale PR event base must not make Gitleaks rescan merged main history.""" + job = _gitleaks_job(_workflow("security-scan.yml")) + permissions = job.split(" steps:\n", 1)[0] + + assert "pull-requests: read" in permissions + assert 'gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}"' in job + assert 'BASE_SHA="$(jq -r ".base.sha" <<<"${live_pr}")"' in job + assert 'HEAD_SHA="$(jq -r ".head.sha" <<<"${live_pr}")"' in job + assert 'merge_base="$(git merge-base "${BASE_SHA}" "${HEAD_SHA}")"' in job + assert 'log_opts="${merge_base}..${HEAD_SHA}"' in job + assert 'log_opts="${{ github.event.pull_request.base.sha }}..' not in job + assert 'log_opts="${BASE_SHA}..${HEAD_SHA}"' not in job + + +def test_gitleaks_uses_only_the_authenticated_live_base_config() -> None: + """A PR must not weaken secret policy through its own Gitleaks config.""" + job = _gitleaks_job(_workflow("security-scan.yml")) + + assert 'git cat-file -e "${BASE_SHA}:.gitleaks.toml"' in job + assert 'git show "${BASE_SHA}:.gitleaks.toml"' in job + assert 'config_args=(--config "${trusted_config}")' in job + assert "if [ -f .gitleaks.toml ]" not in job + assert "config_args=(--config .gitleaks.toml)" not in job + + +def test_gitleaks_keeps_fork_pull_requests_scannable() -> None: + """A canonical base and exact head suffice; the head may live in a fork.""" + job = _gitleaks_job(_workflow("security-scan.yml")) + + assert 'base_repository="$(jq -r ".base.repo.full_name"' in job + assert 'head_repository="$(jq -r ".head.repo.full_name"' not in job + assert '[ "${head_repository}" != "${GITHUB_REPOSITORY}" ]' not in job + + +def test_gitleaks_executes_only_the_live_merge_base_range(tmp_path: Path) -> None: + """Execute the workflow shell against a stale event-base repository graph.""" + git = shutil.which("git") + bash = shutil.which("bash") + if git is None or bash is None: + pytest.skip("git and bash are required") + + origin = tmp_path / "origin.git" + checkout = tmp_path / "checkout" + subprocess.run([git, "init", "--bare", str(origin)], check=True, capture_output=True) + subprocess.run([git, "init", "-b", "main", str(checkout)], check=True, capture_output=True) + + def run_git(*args: str) -> str: + result = subprocess.run( + [git, *args], cwd=checkout, check=True, capture_output=True, text=True + ) + return result.stdout.strip() + + run_git("config", "user.name", "Gitleaks Contract") + run_git("config", "user.email", "gitleaks-contract@example.invalid") + run_git("remote", "add", "origin", str(origin)) + (checkout / "README.md").write_text("old event base\n", encoding="utf-8") + run_git("add", "README.md") + run_git("commit", "-m", "old event base") + stale_base = run_git("rev-parse", "HEAD") + (checkout / "base-fixture.txt").write_text("already merged\n", encoding="utf-8") + run_git("add", "base-fixture.txt") + run_git("commit", "-m", "current base") + live_base = run_git("rev-parse", "HEAD") + run_git("push", "origin", "main") + run_git("switch", "-c", "fork-feature") + (checkout / "metadata.json").write_text("{}\n", encoding="utf-8") + run_git("add", "metadata.json") + run_git("commit", "-m", "metadata delta") + head_sha = run_git("rev-parse", "HEAD") + + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + gh = fake_bin / "gh" + gh.write_text( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' \"$FAKE_PULL_JSON\"\n", + encoding="utf-8", + ) + gh.chmod(0o755) + gitleaks = checkout / "gitleaks" + gitleaks.write_text( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' \"$@\" >\"$FAKE_GITLEAKS_ARGS\"\n", + encoding="utf-8", + ) + gitleaks.chmod(0o755) + + script = _extract_run_block( + _workflow("security-scan.yml"), "Run gitleaks on PR commit range" + ) + args_file = tmp_path / "gitleaks-args.txt" + env = { + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "GH_TOKEN": "test-token", + "GITHUB_REPOSITORY": "ContextualWisdomLab/.github", + "PR_NUMBER": "2041", + "EVENT_BASE_REF": "main", + "EVENT_HEAD_SHA": head_sha, + "GITHUB_OUTPUT": str(tmp_path / "github-output.txt"), + "RUNNER_TEMP": str(tmp_path), + "FAKE_GITLEAKS_ARGS": str(args_file), + "FAKE_PULL_JSON": json.dumps( + { + "state": "open", + "base": { + "repo": {"full_name": "ContextualWisdomLab/.github"}, + "ref": "main", + "sha": live_base, + }, + "head": { + "repo": {"full_name": "outside/fork"}, + "sha": head_sha, + }, + } + ), + } + result = subprocess.run( + [bash], cwd=checkout, env=env, input=script, text=True, + capture_output=True, check=False, + ) + + assert result.returncode == 0, result.stderr + result.stdout + args = args_file.read_text(encoding="utf-8").splitlines() + assert f"--log-opts={live_base}..{head_sha}" in args + assert all(stale_base not in arg for arg in args) + + def test_document_only_prs_still_admit_gitleaks() -> None: """Gitleaks remains independent from the document-only changed-scope gate.""" workflow = _workflow("security-scan.yml")