diff --git a/.github/workflows/sbom-generation.yml b/.github/workflows/sbom-generation.yml index 588baefe1e..ff397af849 100644 --- a/.github/workflows/sbom-generation.yml +++ b/.github/workflows/sbom-generation.yml @@ -35,8 +35,15 @@ on: types: [published] concurrency: - group: sbom-generation-${{ github.repository }}-${{ github.event.release.tag_name || github.ref }} - cancel-in-progress: true + group: >- + sbom-generation-${{ github.repository }}-${{ + github.event_name == 'release' && + format('release-{0}', github.event.release.id) || + format('run-{0}', github.run_id) }} + # GitHub's native queue serializes release writes by immutable release ID. + # The queue is bounded at 100; later arrivals are rejected once it is full. + # https://docs.github.com/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency + queue: max permissions: contents: read @@ -44,8 +51,13 @@ permissions: jobs: generate-sbom: runs-on: ubuntu-latest + concurrency: + group: >- + sbom-generation-job-${{ github.repository }}-${{ + github.event_name == 'push' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' }} permissions: - # write is needed for release-asset upload and dependency submission. + # Write is needed for release-asset upload and dependency submission. contents: write steps: - name: Checkout @@ -61,9 +73,10 @@ jobs: output-file: sbom.spdx.json artifact-name: sbom-spdx-json upload-artifact: true - upload-release-assets: true + upload-release-assets: false # Feeds the repo dependency graph -> read back by the org aggregator. - dependency-snapshot: true + # Release runs do not race protected-branch snapshots for this correlator. + dependency-snapshot: ${{ github.event_name == 'push' }} - name: Generate CycloneDX SBOM uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 @@ -73,5 +86,89 @@ jobs: output-file: sbom.cyclonedx.json artifact-name: sbom-cyclonedx-json upload-artifact: true - upload-release-assets: true + upload-release-assets: false dependency-snapshot: false + + - name: Verify current release revision + if: github.event_name == 'release' + # A release may remain mutable. This checks its live release ID, tag, + # and peeled tag commit immediately before publishing, but the GitHub + # API does not make this check and the following upload atomic. + # https://docs.github.com/rest/releases/releases#update-a-release + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_RELEASE_ID: ${{ github.event.release.id }} + EXPECTED_RELEASE_TAG: ${{ github.event.release.tag_name }} + EXPECTED_RELEASE_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + fail_release_revision() { + echo "::error::Current release revision could not be verified." + exit 1 + } + + [[ "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] \ + || fail_release_revision + [[ "$EXPECTED_RELEASE_ID" =~ ^[0-9]+$ ]] || fail_release_revision + [[ "$EXPECTED_RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] || fail_release_revision + git check-ref-format "refs/tags/${EXPECTED_RELEASE_TAG}" >/dev/null \ + 2>&1 || fail_release_revision + + if ! release_json="$( + gh api "repos/${GITHUB_REPOSITORY}/releases/${EXPECTED_RELEASE_ID}" \ + 2>/dev/null + )"; then + fail_release_revision + fi + actual_release_id="$(jq -er '.id | select(type == "number") | tostring' <<<"$release_json")" \ + || fail_release_revision + actual_release_tag="$(jq -er '.tag_name | select(type == "string" and length > 0)' <<<"$release_json")" \ + || fail_release_revision + release_immutable="$(jq -er '.immutable | select(type == "boolean") | tostring' <<<"$release_json")" \ + || fail_release_revision + [[ "$actual_release_id" == "$EXPECTED_RELEASE_ID" ]] || fail_release_revision + [[ "$actual_release_tag" == "$EXPECTED_RELEASE_TAG" ]] || fail_release_revision + + encoded_tag="$(jq -rn --arg tag "$EXPECTED_RELEASE_TAG" '$tag | @uri')" \ + || fail_release_revision + # The commits endpoint resolves both lightweight and annotated tags + # to their commit without duplicating Git tag-peeling logic here. + # https://docs.github.com/rest/commits/commits#get-a-commit + if ! tag_commit_json="$( + gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_tag}" \ + 2>/dev/null + )"; then + fail_release_revision + fi + tag_commit_sha="$(jq -er '.sha | select(type == "string" and test("^[0-9a-f]{40}$"))' <<<"$tag_commit_json")" \ + || fail_release_revision + [[ "$tag_commit_sha" == "$EXPECTED_RELEASE_SHA" ]] \ + || fail_release_revision + printf 'RELEASE_REVISION_VERIFIED release_id=%s sha=%s immutable=%s\n' \ + "$EXPECTED_RELEASE_ID" "$EXPECTED_RELEASE_SHA" "$release_immutable" + + - name: Publish verified release SBOM assets + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + + for sbom_file in sbom.spdx.json sbom.cyclonedx.json; do + test ! -L "$sbom_file" + test -f "$sbom_file" + test -s "$sbom_file" + done + jq -e 'type == "object" and (.spdxVersion | type == "string" and startswith("SPDX-"))' \ + sbom.spdx.json >/dev/null + jq -e 'type == "object" and .bomFormat == "CycloneDX"' \ + sbom.cyclonedx.json >/dev/null + + gh release upload \ + --clobber \ + --repo "$GITHUB_REPOSITORY" \ + -- "$EXPECTED_RELEASE_TAG" \ + "sbom.spdx.json" \ + "sbom.cyclonedx.json" diff --git a/docs/doctoring/sbom-release-concurrency.md b/docs/doctoring/sbom-release-concurrency.md new file mode 100644 index 0000000000..397f665e0b --- /dev/null +++ b/docs/doctoring/sbom-release-concurrency.md @@ -0,0 +1,68 @@ +# SBOM 릴리스 직렬화 계약 + +`sbom-generation.yml`은 보호 브랜치 push의 dependency snapshot과 +`release: published`의 SPDX·CycloneDX asset 게시를 한 job에서 수행한다. +두 이벤트의 부작용과 취소 정책이 달라 concurrency도 두 단계로 나눈다. + +## 확인한 기존 구현 + +- 저장소에는 release ID, tag, commit을 함께 검증하는 helper가 없었다. + `repository-metadata-reconcile.yml`과 `sast-semgrep.yml`의 기존 검사는 + checkout 결과와 기대 SHA만 비교하므로 release의 현재 tag를 확인하지 않는다. +- pinned `anchore/sbom-action@e22c3899`는 release payload의 release ID를 + 대상으로 같은 이름의 asset을 삭제한 뒤 다시 올린다. 같은 release를 + 병렬 실행하면 이 두 호출이 서로 엇갈릴 수 있다. +- pinned `publish-sbom`은 먼저 현재 workflow의 artifact를 찾지만, 이름에 + 맞는 artifact가 없으면 release의 `target_commitish`에서 최신 workflow + run을 찾아 그 run의 artifact로 fallback한다. 하나만 맞아도 그 하나를 + 게시하고, 하나도 없으면 warning만 남기고 성공 반환한다. 이 동작은 현재 + run의 두 산출물이 모두 존재한다는 계약과 맞지 않아 사용하지 않는다. + +## 선택한 경계 + +- workflow-level `release.id` 그룹과 `queue: max`가 같은 release의 실행을 + 직렬화한다. GitHub의 native queue 상한은 100이며 이를 넘은 실행은 + 보존되지 않는다. +- push workflow admission은 run별로 분리하고, 기존 job-level repository/ref + 그룹에서만 `cancel-in-progress: true`를 적용한다. +- release 생성 단계는 현재 checkout에 두 SBOM 파일을 만들고 dependency + snapshot을 제출하지 않는다. 게시 직전 live release ID·tag와 tag가 + 가리키는 commit을 다시 확인한다. 이어 두 로컬 파일이 비어 있지 않은 + 일반 파일인지와 SPDX·CycloneDX의 최소 JSON 표식을 확인한 뒤 기존 + `gh release upload --clobber`로 두 파일을 한 호출에서 게시한다. 다른 run의 + artifact를 조회하지 않으며 `actions: read` 권한도 추가하지 않는다. +- tag는 `git check-ref-format refs/tags/...`로 Git ref 문법과 제어문자를 + 먼저 거른다. 업로드 명령은 옵션 뒤 `--`를 두어 `-`로 시작하는 tag도 + positional 인자로 고정한다. 검증을 통과한 tag는 성공 로그에 출력하지 + 않지만, 이후 GitHub CLI가 반환하는 외부 오류 문구까지 숨긴다는 계약은 + 아니다. +- GitHub commits API는 lightweight tag와 annotated tag를 모두 commit으로 + 해석하므로 별도 tag-peeling 구현은 두지 않는다. + +## 남은 경계 + +- mutable release에서는 검증 직후 외부 주체가 tag를 다시 바꿀 수 있다. + live guard와 다음 upload 호출은 원자적이지 않으며, guard 뒤 release나 + asset이 삭제될 수도 있다. 파일 검사와 upload 사이의 로컬 변경도 하나의 + 원자 연산이 아니다. 따라서 exact-head 원자성이나 게시 성공을 guard만으로 + 보장한다고 주장하지 않는다. immutable release에서는 tag 변경 경로가 + 닫히지만, 실제 payload의 `immutable` 값은 증거로만 기록한다. +- 보호 브랜치 push의 snapshot은 계속 같은 correlator를 사용한다. 취소가 + 늦거나 이미 제출 단계에 들어간 구형 실행이 최신 실행보다 나중에 API에 + 도착하면 GitHub가 구형 snapshot을 latest로 선택할 가능성은 이 변경에서 + 해결하지 않는다. +- `queue: max`는 2026-05-07 추가된 GitHub 공식 문법이다. 현재 고정 + actionlint 빌드는 그 이전인 2026-04-19 소스라 이 키를 모른다. upstream + actionlint의 지원 PR도 아직 열려 있으므로 해당 lint 실패를 숨기거나 + 통과로 바꾸지 않는다. + +## 근거 + +- [GitHub Actions concurrency queue](https://docs.github.com/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) +- [GitHub release 수정 API](https://docs.github.com/rest/releases/releases#update-a-release) +- [GitHub commit 조회 API](https://docs.github.com/rest/commits/commits#get-a-commit) +- [GitHub dependency submission API](https://docs.github.com/rest/dependency-graph/dependency-submission) +- [pinned Anchore release 게시 구현](https://github.com/anchore/sbom-action/blob/e22c389904149dbc22b58101806040fa8d37a610/src/github/SyftGithubAction.ts#L484-L592) +- [pinned Anchore publisher fallback](https://github.com/anchore/sbom-action/blob/e22c389904149dbc22b58101806040fa8d37a610/src/github/SyftGithubAction.ts#L484-L600) +- [GitHub CLI release upload](https://cli.github.com/manual/gh_release_upload) +- [actionlint queue 지원 추적](https://github.com/rhysd/actionlint/pull/654) diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 4055bb5b9d..ebed3866e4 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -12,6 +12,10 @@ import pytest +from tests.test_reusable_default_branch_scorecard_contract import ( + _parse_workflow_contract, +) + REPO_ROOT = Path(__file__).resolve().parents[1] @@ -505,6 +509,303 @@ def test_pr_quality_workflows_isolate_concurrency_by_repository_and_pr() -> None assert "cancel-in-progress: true" in concurrency +def test_sbom_release_runs_queue_by_release_while_pushes_still_coalesce() -> None: + """Pin the two simple native groups without another expression evaluator.""" + workflow = workflow_text("sbom-generation.yml") + workflow_contract = _parse_workflow_contract( + "concurrency:" + + workflow.split("\nconcurrency:", 1)[1].split("\npermissions:", 1)[0] + ) + workflow_group = workflow_level_concurrency_group(workflow) + job = workflow.split("\n generate-sbom:\n", 1)[1] + job_contract = _parse_workflow_contract( + "jobs:\n generate-sbom:\n concurrency:" + + job.split("\n concurrency:", 1)[1].split("\n permissions:", 1)[0] + ) + job_group = workflow_level_concurrency_group("\nconcurrency:" + job.split("\n concurrency:", 1)[1]) + + assert "github.event_name == 'release'" in workflow_group + assert "format('release-{0}', github.event.release.id)" in workflow_group + assert "format('run-{0}', github.run_id)" in workflow_group + assert "github.event_name == 'push' && github.ref || github.run_id" in job_group + assert workflow_contract[("concurrency", "queue")] == "max" + assert job_contract[ + ("jobs", "generate-sbom", "concurrency", "cancel-in-progress") + ] == "${{ github.event_name == 'push' }}" + + assert "dependency-snapshot: ${{ github.event_name == 'push' }}" in workflow + assert workflow.count("upload-release-assets: false") == 2 + assert "anchore/sbom-action/publish-sbom@" not in workflow + publish = workflow_step(workflow, "Publish verified release SBOM assets") + assert "gh release upload" in publish + assert '-- "$EXPECTED_RELEASE_TAG"' in publish + assert '"sbom.spdx.json"' in publish + assert '"sbom.cyclonedx.json"' in publish + assert "--clobber" in publish + assert "actions: read" not in job.split(" steps:", 1)[0] + assert "contents: write" in workflow + + +def _run_sbom_release_guard( + tmp_path: Path, + *, + release_response: str, + ref_response: str, + fail_endpoint: str = "", + expected_tag: str = "v1.2.3", +) -> subprocess.CompletedProcess[str]: + """Execute the production release guard against a bounded fake GitHub API.""" + jq = shutil.which("jq") + if jq is None: + pytest.skip("jq is required to execute the production release guard") + step = workflow_step(workflow_text("sbom-generation.yml"), "Verify current release revision") + script = textwrap.dedent(step.split(" run: |\n", 1)[1]) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_gh = fake_bin / "gh" + fake_gh.write_text( + """#!/usr/bin/env bash +set -euo pipefail +[[ "$1" == "api" ]] +endpoint="$2" +printf '%s\n' "$endpoint" >>"$GH_CALL_LOG" +[[ -z "$FAIL_ENDPOINT" || "$endpoint" != *"$FAIL_ENDPOINT"* ]] || exit 1 +case "$endpoint" in + */releases/*) printf '%s\n' "$RELEASE_RESPONSE" ;; + */commits/*) printf '%s\n' "$REF_RESPONSE" ;; + *) exit 1 ;; +esac +""", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + env = { + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "FAIL_ENDPOINT": fail_endpoint, + "GH_CALL_LOG": str(tmp_path / "gh-call.log"), + "RELEASE_RESPONSE": release_response, + "REF_RESPONSE": ref_response, + "GITHUB_REPOSITORY": "ContextualWisdomLab/example", + "EXPECTED_RELEASE_ID": "77", + "EXPECTED_RELEASE_TAG": expected_tag, + "EXPECTED_RELEASE_SHA": "a" * 40, + } + return subprocess.run( + ["bash", "-c", script], env=env, text=True, capture_output=True, check=False + ) + + +@pytest.mark.parametrize( + "release_response,ref_response,fail_endpoint", + [ + ('{"id":78,"tag_name":"v1.2.3","immutable":false}', '{"sha":"' + "a" * 40 + '"}', ""), + ('{"id":77,"tag_name":"v2.0.0","immutable":false}', '{"sha":"' + "a" * 40 + '"}', ""), + ('{"id":77,"tag_name":"v1.2.3","immutable":false}', '{"sha":"' + "b" * 40 + '"}', ""), + ('{"id":77,"tag_name":"v1.2.3","immutable":false}', '{}', ""), + ('{"id":77,"tag_name":"v1.2.3"}', '{"sha":"' + "a" * 40 + '"}', ""), + ("not-json", '{"sha":"' + "a" * 40 + '"}', ""), + ('{"id":77,"tag_name":"v1.2.3","immutable":false}', '{"sha":"' + "a" * 40 + '"}', "/releases/"), + ('{"id":77,"tag_name":"v1.2.3","immutable":false}', '{"sha":"' + "a" * 40 + '"}', "/commits/"), + ], +) +def test_sbom_release_guard_fails_closed( + tmp_path: Path, release_response: str, ref_response: str, fail_endpoint: str +) -> None: + """Reject mismatched, malformed, and unavailable live release evidence.""" + result = _run_sbom_release_guard( + tmp_path, + release_response=release_response, + ref_response=ref_response, + fail_endpoint=fail_endpoint, + ) + assert result.returncode != 0 + assert "RELEASE_REVISION_VERIFIED" not in result.stdout + + +@pytest.mark.parametrize("immutable", [True, False]) +def test_sbom_release_guard_accepts_current_lightweight_tag( + tmp_path: Path, immutable: bool +) -> None: + """Accept the current commit while reporting release mutability accurately.""" + result = _run_sbom_release_guard( + tmp_path, + release_response=json.dumps({"id": 77, "tag_name": "v1.2.3", "immutable": immutable}), + ref_response=json.dumps({"sha": "a" * 40}), + ) + assert result.returncode == 0, result.stderr + assert f"immutable={str(immutable).lower()}" in result.stdout + + +def test_sbom_release_guard_accepts_matching_commit_endpoint_result(tmp_path: Path) -> None: + """Accept a matching commit endpoint result without claiming tag-object proof.""" + result = _run_sbom_release_guard( + tmp_path, + release_response='{"id":77,"tag_name":"v1.2.3","immutable":false}', + ref_response='{"sha":"' + "a" * 40 + '"}', + ) + assert result.returncode == 0, result.stderr + assert "immutable=false" in result.stdout + + +def test_sbom_release_guard_rejects_tag_resolution_api_failure(tmp_path: Path) -> None: + """Fail closed when a lightweight or annotated tag cannot be resolved.""" + result = _run_sbom_release_guard( + tmp_path, + release_response='{"id":77,"tag_name":"v1.2.3","immutable":false}', + ref_response='{"sha":"' + "a" * 40 + '"}', + fail_endpoint="/commits/", + ) + assert result.returncode != 0 + assert "RELEASE_REVISION_VERIFIED" not in result.stdout + + +@pytest.mark.parametrize("invalid_tag", ["bad tag", "../tag", "tag\nforged"]) +def test_sbom_release_guard_rejects_invalid_tag_before_api( + tmp_path: Path, invalid_tag: str +) -> None: + """Use Git's native ref validator and never print an untrusted tag.""" + result = _run_sbom_release_guard( + tmp_path, + release_response='{"id":77,"tag_name":"v1.2.3","immutable":false}', + ref_response='{"sha":"' + "a" * 40 + '"}', + expected_tag=invalid_tag, + ) + assert result.returncode != 0 + assert invalid_tag not in result.stdout + result.stderr + assert not (tmp_path / "gh-call.log").exists() + + +def test_sbom_release_publish_requires_both_local_documents() -> None: + """Publish local outputs only after both formats pass narrow JSON checks.""" + publish = workflow_step( + workflow_text("sbom-generation.yml"), + "Publish verified release SBOM assets", + ) + + assert "actions/runs" not in publish + assert "actions/artifacts" not in publish + assert "findLatestWorkflowRunForBranch" not in publish + assert "test -s" in publish + assert "test ! -L" in publish + assert "test -f" in publish + assert "spdxVersion" in publish + assert "bomFormat" in publish + + +def _run_sbom_release_publish( + tmp_path: Path, + *, + spdx: str | None = '{"spdxVersion":"SPDX-2.3"}', + cyclonedx: str | None = '{"bomFormat":"CycloneDX"}', + spdx_kind: str = "file", + expected_tag: str = "v1.2.3", +) -> subprocess.CompletedProcess[str]: + """Execute the production publisher with local files and a recording gh fake.""" + jq = shutil.which("jq") + if jq is None: + pytest.skip("jq is required to execute the production SBOM publisher") + step = workflow_step( + workflow_text("sbom-generation.yml"), + "Publish verified release SBOM assets", + ) + script = textwrap.dedent(step.split(" run: |\n", 1)[1]) + if spdx_kind == "directory": + (tmp_path / "sbom.spdx.json").mkdir() + elif spdx_kind == "fifo": + os.mkfifo(tmp_path / "sbom.spdx.json") + elif spdx_kind == "symlink": + target = tmp_path / "spdx-target.json" + target.write_text(spdx or "", encoding="utf-8") + (tmp_path / "sbom.spdx.json").symlink_to(target) + elif spdx_kind == "empty": + (tmp_path / "sbom.spdx.json").touch() + elif spdx is not None: + (tmp_path / "sbom.spdx.json").write_text(spdx, encoding="utf-8") + if cyclonedx is not None: + (tmp_path / "sbom.cyclonedx.json").write_text(cyclonedx, encoding="utf-8") + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + fake_gh = fake_bin / "gh" + fake_gh.write_text( + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" >"$GH_CALL_FILE" +""", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + return subprocess.run( + ["bash", "-c", script], + cwd=tmp_path, + env={ + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "GH_CALL_FILE": str(tmp_path / "gh-call.txt"), + "GITHUB_REPOSITORY": "ContextualWisdomLab/example", + "EXPECTED_RELEASE_TAG": expected_tag, + }, + text=True, + capture_output=True, + check=False, + ) + + +@pytest.mark.parametrize("expected_tag", ["v1.2.3", "-release"]) +def test_sbom_release_publish_uploads_exactly_two_current_local_files( + tmp_path: Path, expected_tag: str +) -> None: + """Avoid the publisher action's cross-run fallback and partial success path.""" + result = _run_sbom_release_publish(tmp_path, expected_tag=expected_tag) + + assert result.returncode == 0, result.stderr + assert (tmp_path / "gh-call.txt").read_text(encoding="utf-8").splitlines() == [ + "release", + "upload", + "--clobber", + "--repo", + "ContextualWisdomLab/example", + "--", + expected_tag, + "sbom.spdx.json", + "sbom.cyclonedx.json", + ] + + +@pytest.mark.parametrize( + "spdx,cyclonedx", + [ + (None, '{"bomFormat":"CycloneDX"}'), + ('{"spdxVersion":"SPDX-2.3"}', None), + ('{"spdxVersion":7}', '{"bomFormat":"CycloneDX"}'), + ('{"spdxVersion":"SPDX-2.3"}', '{"bomFormat":"Other"}'), + ], +) +def test_sbom_release_publish_fails_closed_before_upload( + tmp_path: Path, spdx: str | None, cyclonedx: str | None +) -> None: + """Missing or malformed format evidence must never reach release upload.""" + result = _run_sbom_release_publish(tmp_path, spdx=spdx, cyclonedx=cyclonedx) + + assert result.returncode != 0 + assert not (tmp_path / "gh-call.txt").exists() + + +@pytest.mark.parametrize("spdx_kind", ["directory", "fifo", "symlink", "empty"]) +def test_sbom_release_publish_rejects_non_regular_documents( + tmp_path: Path, spdx_kind: str +) -> None: + """Directories and FIFOs must not reach the release upload command.""" + result = _run_sbom_release_publish( + tmp_path, + spdx=None, + spdx_kind=spdx_kind, + ) + + assert result.returncode != 0 + assert not (tmp_path / "gh-call.txt").exists() + + def test_central_semgrep_logs_every_finding_and_distinguishes_engine_failure() -> None: """Keep Semgrep finding output distinct from scanner-engine failures.""" workflow = workflow_text("sast-semgrep.yml")