Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
54 changes: 46 additions & 8 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
security-events: write
actions: read
env:
Expand Down Expand Up @@ -462,25 +463,62 @@ 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}" \
--redact \
--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: |
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.d/20260908-gitleaks-live-base-merge-range.md
Original file line number Diff line number Diff line change
@@ -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을 약화하지 못하게 합니다.
41 changes: 41 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
137 changes: 136 additions & 1 deletion tests/test_gitleaks_pr_consolidation.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -41,14 +49,141 @@ 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
assert "if: steps.gitleaks.outputs.rc != '0'" in job
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")
Expand Down
Loading