diff --git a/README.md b/README.md index 6111dbf9d..848bdf92b 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ When reviewing an action (new or updated), watch for these potential issues in t - **File-system tampering**: writing to locations outside the workspace (`$GITHUB_WORKSPACE`), modifying `$GITHUB_ENV`, `$GITHUB_PATH`, or `$GITHUB_OUTPUT` in unexpected ways to influence subsequent workflow steps. - **Compiled JS mismatch**: any unexplained diff between the published `dist/` and a clean rebuild — this is the primary check the verification script performs. - **Pre-compiled native binaries shipped in-tree**: actions that commit Go/Rust/C-style binaries (`main-linux-amd64`, `*.exe`, `*.dll`, `*.so`, `*.dylib`, `*.jar`, `*.wasm`, etc.) directly in the repo and exec them from a small launcher are running opaque executable code on the runner. The JS-rebuild check verifies the launcher but **cannot** reconcile the binaries with source on its own. `verify-action-build`'s **In-tree binary check** tries to close the gap automatically: each detected binary is verified first via `gh attestation verify --owner ` (the SLSA attestation transparency log populated by [`actions/attest-build-provenance`](https://github.com/actions/attest-build-provenance)), then by SHA256-comparing each binary against the release's `SHA256SUMS` asset. Binaries that pass either check are ✓; binaries that pass neither are a hard reject. Push back on actions in this shape until upstream adds attestation or `SHA256SUMS` so the chain from release to artifact can be verified. +- **Runtime binary downloads without an in-source checksum**: some actions pull their tool binary at runtime via `tc.downloadTool` / `curl` / `fetch` and rely on the publishing pipeline (GitHub release immutability + Sigstore attestation) for integrity rather than an inline `sha256sum -c` / `cosign verify-blob`. The **Binary Download Verification** check fails these by default. A per-action escape hatch lives in `utils/verify_action_build/security.py` as the `TRUSTED_DOWNLOAD_PROVENANCE` dict — an entry asserts that the configured `release_repo` publishes immutable releases AND emits Sigstore attestations via `actions/attest-build-provenance`. Adding an entry is a security review decision and the rationale must link the upstream confirmation (e.g. a maintainer comment). The config alone is not enough: at scan time the verify pipeline GETs `releases/latest` of the configured `release_repo`, confirms `release.immutable` is true, downloads one small attested asset (`.sbom.json` preferred), and runs `gh attestation verify` against it. Only when both halves pass are the action's unverified-download findings reclassified as warnings; if the runtime check fails, failures stay failures and the reason is printed. For the full approval policy and requirements, see the [ASF GitHub Actions Policy](https://infra.apache.org/github-actions-policy.html). diff --git a/utils/tests/verify_action_build/test_security.py b/utils/tests/verify_action_build/test_security.py index 0916653fc..82454a7d4 100644 --- a/utils/tests/verify_action_build/test_security.py +++ b/utils/tests/verify_action_build/test_security.py @@ -29,10 +29,12 @@ analyze_repo_metadata, ) from verify_action_build.security import ( + _fetch_release_asset_bytes, _file_is_pure_data_fetch, _find_binary_downloads_js, _looks_like_in_tree_binary, _parse_sha256sums, + verify_trusted_download_provenance, ) @@ -1668,6 +1670,322 @@ def test_many_binaries_truncates_in_message(self): assert "17 more" in msg +class TestVerifyTrustedDownloadProvenance: + """Runtime check that backs the TRUSTED_DOWNLOAD_PROVENANCE escape + hatch. The config alone is not enough — at scan time we must confirm + the release repo publishes immutable releases AND has a valid + Sigstore attestation on at least one asset. + """ + + _CONFIG = { + "testorg/testaction": { + "release_repo": "testorg/testtool", + "rationale": "test rationale", + }, + } + + def test_no_config_returns_no_opinion(self): + # Action without an entry: returns (False, "") so the caller + # leaves the existing failure as-is. + passed, reason = verify_trusted_download_provenance("unknown", "action") + assert passed is False + assert reason == "" + + def test_release_fetch_failure(self): + with mock.patch.dict( + "verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE", + self._CONFIG, clear=True, + ): + with mock.patch( + "verify_action_build.security._fetch_release_metadata", + return_value=None, + ): + passed, reason = verify_trusted_download_provenance( + "testorg", "testaction", + ) + assert passed is False + assert "could not fetch latest release" in reason + assert "testorg/testtool" in reason + + def test_release_not_immutable_fails(self): + release = { + "tag_name": "v1.2.3", + "immutable": False, + "assets": [{"name": "tool.sbom.json", "size": 1024, "url": "u"}], + } + with mock.patch.dict( + "verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE", + self._CONFIG, clear=True, + ): + with mock.patch( + "verify_action_build.security._fetch_release_metadata", + return_value=release, + ): + passed, reason = verify_trusted_download_provenance( + "testorg", "testaction", + ) + assert passed is False + assert "NOT marked immutable" in reason + assert "v1.2.3" in reason + + def test_no_asset_downloadable_fails(self): + # Release is immutable but every asset download attempt fails — + # we can't run the attestation spot-check, so we can't accept + # the trust anchor. + release = {"tag_name": "v1.2.3", "immutable": True, "assets": []} + with mock.patch.dict( + "verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE", + self._CONFIG, clear=True, + ): + with mock.patch( + "verify_action_build.security._fetch_release_metadata", + return_value=release, + ): + with mock.patch( + "verify_action_build.security._fetch_release_asset_bytes", + return_value=(None, None), + ): + passed, reason = verify_trusted_download_provenance( + "testorg", "testaction", + ) + assert passed is False + assert "no asset could be downloaded" in reason + + def test_attestation_verify_failure(self): + release = { + "tag_name": "v2.12.2", + "immutable": True, + "assets": [{"name": "tool.sbom.json", "size": 340_000, "url": "u"}], + } + with mock.patch.dict( + "verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE", + self._CONFIG, clear=True, + ): + with mock.patch( + "verify_action_build.security._fetch_release_metadata", + return_value=release, + ): + with mock.patch( + "verify_action_build.security._fetch_release_asset_bytes", + return_value=("tool.sbom.json", b"sbom-bytes"), + ): + with mock.patch( + "verify_action_build.security._verify_via_gh_attestation", + return_value=False, + ): + passed, reason = verify_trusted_download_provenance( + "testorg", "testaction", + ) + assert passed is False + assert "gh attestation verify" in reason + assert "tool.sbom.json" in reason + + def test_happy_path(self): + # Immutable release + asset downloads + gh attestation verifies + # → escape hatch accepts the trust anchor. + release = { + "tag_name": "v2.12.2", + "immutable": True, + "assets": [{"name": "tool.sbom.json", "size": 340_000, "url": "u"}], + } + with mock.patch.dict( + "verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE", + self._CONFIG, clear=True, + ): + with mock.patch( + "verify_action_build.security._fetch_release_metadata", + return_value=release, + ): + with mock.patch( + "verify_action_build.security._fetch_release_asset_bytes", + return_value=("tool.sbom.json", b"sbom-bytes"), + ): + with mock.patch( + "verify_action_build.security._verify_via_gh_attestation", + return_value=True, + ): + passed, reason = verify_trusted_download_provenance( + "testorg", "testaction", + ) + assert passed is True + assert "GitHub-immutable" in reason + assert "Sigstore provenance" in reason + assert "tool.sbom.json" in reason + + +class TestFetchReleaseAssetBytes: + """Asset selection for the attestation spot-check: name-preference + ordering picks the cheapest valid probe (sbom > intoto > tarball > + zip); smallest-asset fallback only fires when no preference matches. + """ + + def test_prefers_sbom_over_tarball(self): + release = { + "assets": [ + {"name": "tool-linux-amd64.tar.gz", "size": 50_000_000, + "url": "https://api/tar"}, + {"name": "tool.sbom.json", "size": 340_000, + "url": "https://api/sbom"}, + ], + } + + captured_url = {} + + def fake_get(url, headers=None, timeout=None): + captured_url["url"] = url + resp = mock.Mock() + resp.ok = True + resp.content = b"sbom-bytes" + return resp + + with mock.patch( + "verify_action_build.security.requests.get", + side_effect=fake_get, + ): + name, content = _fetch_release_asset_bytes( + "org", "repo", release, (".sbom.json", ".tar.gz"), + ) + assert name == "tool.sbom.json" + assert content == b"sbom-bytes" + assert captured_url["url"] == "https://api/sbom" + + def test_falls_back_to_smallest_when_no_preference_matches(self): + # No asset matches the preference list — picks smallest by size. + release = { + "assets": [ + {"name": "big.bin", "size": 100_000_000, "url": "https://api/big"}, + {"name": "small.bin", "size": 1_000, "url": "https://api/small"}, + {"name": "medium.bin", "size": 10_000, "url": "https://api/medium"}, + ], + } + + captured_url = {} + + def fake_get(url, headers=None, timeout=None): + captured_url["url"] = url + resp = mock.Mock() + resp.ok = True + resp.content = b"x" + return resp + + with mock.patch( + "verify_action_build.security.requests.get", + side_effect=fake_get, + ): + name, content = _fetch_release_asset_bytes( + "org", "repo", release, (".sbom.json",), + ) + assert name == "small.bin" + assert captured_url["url"] == "https://api/small" + + def test_empty_assets_returns_none(self): + name, content = _fetch_release_asset_bytes( + "org", "repo", {"assets": []}, (".sbom.json",), + ) + assert name is None + assert content is None + + +class TestAnalyzeBinaryDownloadsTrustedDownloadEscapeHatch: + """The branch inside analyze_binary_downloads that reclassifies + unverified-download failures as warnings when the action has a + TRUSTED_DOWNLOAD_PROVENANCE entry AND the runtime provenance check + passes.""" + + _CONFIG = { + "testorg/testaction": { + "release_repo": "testorg/testtool", + "rationale": "linked upstream confirmation", + }, + } + + # Action.yml with one unverified runtime download — generates a + # failure under the normal rules; the escape hatch decides whether + # that failure stands or is reclassified. + _ACTION_YML = """\ +name: Test +runs: + using: composite + steps: + - name: Download tool + shell: bash + run: | + curl -fsSLO https://example.com/tool.tar.gz + tar xf tool.tar.gz +""" + + def test_passing_provenance_reclassifies_failures_to_warnings(self): + with mock.patch.dict( + "verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE", + self._CONFIG, clear=True, + ): + with mock.patch( + "verify_action_build.security.fetch_action_yml", + return_value=self._ACTION_YML, + ): + with mock.patch( + "verify_action_build.security.fetch_file_from_github", + return_value=None, + ): + with mock.patch( + "verify_action_build.security.verify_trusted_download_provenance", + return_value=(True, "testorg/testtool@v1 verified"), + ): + warnings, failures = analyze_binary_downloads( + "testorg", "testaction", "a" * 40, + ) + assert failures == [] + assert any("trusted via GitHub release provenance" in w for w in warnings) + + def test_failing_provenance_preserves_failures(self): + with mock.patch.dict( + "verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE", + self._CONFIG, clear=True, + ): + with mock.patch( + "verify_action_build.security.fetch_action_yml", + return_value=self._ACTION_YML, + ): + with mock.patch( + "verify_action_build.security.fetch_file_from_github", + return_value=None, + ): + with mock.patch( + "verify_action_build.security.verify_trusted_download_provenance", + return_value=(False, "release not immutable"), + ): + warnings, failures = analyze_binary_downloads( + "testorg", "testaction", "a" * 40, + ) + assert len(failures) >= 1 + assert any("tool.tar.gz" in f for f in failures) + + def test_no_config_entry_leaves_failures_alone(self): + # Action not in TRUSTED_DOWNLOAD_PROVENANCE: the escape hatch + # branch is skipped entirely and verify_trusted_download_provenance + # is never even consulted. + with mock.patch.dict( + "verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE", + {}, clear=True, + ): + with mock.patch( + "verify_action_build.security.fetch_action_yml", + return_value=self._ACTION_YML, + ): + with mock.patch( + "verify_action_build.security.fetch_file_from_github", + return_value=None, + ): + with mock.patch( + "verify_action_build.security.verify_trusted_download_provenance", + ) as verify_mock: + warnings, failures = analyze_binary_downloads( + "other", "action", "a" * 40, + ) + assert len(failures) >= 1 + verify_mock.assert_not_called() + + class _Patches: """Tiny context manager that enters/exits a sequence of mock.patch objects together — used by TestAnalyzeInTreeBinaries to keep the diff --git a/utils/verify_action_build/security.py b/utils/verify_action_build/security.py index 29a334f8a..2ae9d3a97 100644 --- a/utils/verify_action_build/security.py +++ b/utils/verify_action_build/security.py @@ -1566,6 +1566,46 @@ def analyze_binary_downloads( if not any_downloads: console.print(" [green]✓[/green] No binary downloads detected") + # Per-action trusted-download escape hatch: if every failure on this + # action can be reattributed to a download path whose trust anchor is + # GitHub-level (immutable release + GitHub-attested Sigstore bundle), + # downgrade the failures to warnings. The check is opt-in per action + # via :data:`TRUSTED_DOWNLOAD_PROVENANCE` — actions without an entry + # see no behaviour change. The provenance is verified at scan time; + # an entry alone is not enough. + if failures and f"{org}/{repo}" in TRUSTED_DOWNLOAD_PROVENANCE: + passed, reason = verify_trusted_download_provenance(org, repo) + config = TRUSTED_DOWNLOAD_PROVENANCE[f"{org}/{repo}"] + if passed: + console.print() + console.print( + f" [yellow]![/yellow] [bold]Trusted-download provenance accepted[/bold]: " + f"{reason}" + ) + console.print( + f" [dim]Rationale: {config['rationale']}[/dim]" + ) + console.print( + f" [yellow]![/yellow] {len(failures)} unverified-download " + f"finding(s) reclassified as warnings" + ) + warnings.extend( + f"trusted via GitHub release provenance ({reason}): {f}" + for f in failures + ) + failures = [] + else: + console.print() + console.print( + f" [red]✗[/red] [bold]Trusted-download provenance check FAILED[/bold]: " + f"{reason}" + ) + console.print( + f" [dim]Action {org}/{repo} is in TRUSTED_DOWNLOAD_PROVENANCE but " + f"the runtime check did not confirm the trust anchor; treating " + f"failures as unverified.[/dim]" + ) + return warnings, failures @@ -1829,6 +1869,204 @@ def _verify_via_gh_attestation( pass +# Per-action declarative trust for runtime binary downloads that the action +# does NOT verify with an in-source checksum but which we accept on the basis +# of GitHub-level release provenance (release marked ``immutable`` on GitHub +# + Sigstore attestation published by ``actions/attest-build-provenance``). +# +# Adding an entry here is a security review decision: it asserts that the +# trust anchor for binaries this action downloads at runtime lives at the +# GitHub / Sigstore layer rather than in the action's own source. At scan +# time the verify pipeline confirms both halves of that assertion before +# downgrading any unverified-download finding for the action. +# +# Schema: +# "/": { +# "release_repo": "/", # repo whose releases publish the binaries +# "rationale": "", +# } +TRUSTED_DOWNLOAD_PROVENANCE: dict[str, dict[str, str]] = { + "golangci/golangci-lint-action": { + "release_repo": "golangci/golangci-lint", + "rationale": ( + "Maintainer @ldez confirmed in " + "https://github.com/golangci/golangci-lint-action/issues/1396 " + "that golangci-lint releases since v2.12.2 are immutable on " + "GitHub and publish SLSA attestations via actions/attest, and " + "that the action itself is immutable since v9.2.1. Trust anchor " + "is GitHub release immutability + Sigstore attestation rather " + "than an in-source checksum check." + ), + }, +} + + +# Asset-name preferences for the attestation spot-check. ``gh attestation +# verify`` filters by the SLSA-provenance predicate type by default, so the +# spot-check asset must have a SLSA attestation attached — not every release +# asset does. SBOM JSON files published by ``actions/attest-build-provenance`` +# carry a SLSA attestation and are typically a few hundred KB (vs tens of +# MB for the per-platform binary tarballs), so they make the cheapest valid +# probe; binary tarballs are the durable fallback when no SBOM is present. +# A combined ``checksums.txt`` / ``SHA256SUMS`` file is intentionally NOT +# preferred — those usually carry only an in-toto release attestation, not +# SLSA provenance, and would cause ``gh attestation verify`` to 404. +_PROVENANCE_ASSET_PREFERENCES = ( + ".sbom.json", + ".intoto.jsonl", + ".tar.gz", + ".zip", +) + + +def _fetch_release_metadata( + org: str, repo: str, tag_or_latest: str = "latest", +) -> dict | None: + """Fetch the GitHub release metadata for ``tag_or_latest``. + + ``tag_or_latest="latest"`` resolves to the ``releases/latest`` endpoint; + any other value is treated as a tag name and resolved via + ``releases/tags/``. Returns the parsed JSON dict on success or + None on any error (network, 404, JSON parse failure). + """ + if tag_or_latest == "latest": + url = f"https://api.github.com/repos/{org}/{repo}/releases/latest" + else: + url = f"https://api.github.com/repos/{org}/{repo}/releases/tags/{tag_or_latest}" + headers = {"Accept": "application/vnd.github+json"} + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + try: + resp = requests.get(url, headers=headers, timeout=15) + if not resp.ok: + return None + return resp.json() + except requests.RequestException: + return None + + +def _fetch_release_asset_bytes( + org: str, repo: str, release: dict, name_preferences: tuple[str, ...] = (), +) -> tuple[str | None, bytes | None]: + """Fetch the raw bytes of one asset from a fetched release dict. + + Strategy: + * Prefer the first asset whose name contains any of + ``name_preferences`` substrings (case-insensitive), in order. + * Fall back to the smallest available asset by reported size. + * Download via the API id with ``Accept: application/octet-stream`` + so GitHub redirects to the binary content. + + Returns ``(asset_name, bytes)`` on success, ``(None, None)`` on any + failure. Bounded by a 60s timeout to keep CI predictable. + """ + assets = release.get("assets") or [] + if not assets: + return None, None + + chosen = None + for substr in name_preferences: + substr_lower = substr.lower() + for asset in assets: + if substr_lower in (asset.get("name") or "").lower(): + chosen = asset + break + if chosen: + break + if chosen is None: + chosen = min(assets, key=lambda a: a.get("size") or (1 << 30)) + + asset_url = chosen.get("url") + if not asset_url: + return None, None + + headers = { + "Accept": "application/octet-stream", + } + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + try: + resp = requests.get(asset_url, headers=headers, timeout=60) + if resp.ok: + return chosen.get("name"), resp.content + return None, None + except requests.RequestException: + return None, None + + +def verify_trusted_download_provenance( + action_org: str, action_repo: str, +) -> tuple[bool, str]: + """Verify GitHub release provenance for an action's runtime downloads. + + For actions listed in :data:`TRUSTED_DOWNLOAD_PROVENANCE`, the trust + anchor for binaries the action downloads at runtime is the GitHub / + Sigstore layer rather than an in-source checksum. This function + confirms both halves of that anchor at scan time: + + 1. The configured release repo's ``releases/latest`` is marked + ``immutable`` on GitHub — i.e. the release metadata and assets + cannot be silently modified after publication. + 2. At least one asset of that release has a valid GitHub-attested + Sigstore bundle retrievable via ``gh attestation verify``. + + Returns ``(passed, reason)``. ``passed`` is False (with an empty + reason) when the action has no entry in + :data:`TRUSTED_DOWNLOAD_PROVENANCE` — the caller should treat that as + "no opinion" and keep the existing failure. When an entry exists, + ``passed`` reflects whether both checks succeeded and ``reason`` is a + one-line summary suitable for the check-result display. + + The asset attestation spot-check downloads exactly one small text + asset (checksums.txt / SHA256SUMS / .sbom.json by preference, + smallest available otherwise) — enough to confirm the release's + publishing process emits attestations without paying the cost of + every per-platform binary. + """ + config = TRUSTED_DOWNLOAD_PROVENANCE.get(f"{action_org}/{action_repo}") + if not config: + return False, "" + + release_repo = config["release_repo"] + release_org, release_name = release_repo.split("/", 1) + + release = _fetch_release_metadata(release_org, release_name, "latest") + if release is None: + return False, ( + f"could not fetch latest release of {release_repo} " + f"(network failure or repo unavailable)" + ) + tag = release.get("tag_name") or "unknown" + if not release.get("immutable"): + return False, ( + f"latest release {release_repo}@{tag} is NOT marked immutable " + f"on GitHub (release.immutable=false)" + ) + + asset_name, asset_bytes = _fetch_release_asset_bytes( + release_org, release_name, release, _PROVENANCE_ASSET_PREFERENCES, + ) + if not asset_bytes: + return False, ( + f"{release_repo}@{tag} is immutable but no asset could be " + f"downloaded for the attestation spot-check" + ) + + if not _verify_via_gh_attestation(release_org, asset_bytes): + return False, ( + f"{release_repo}@{tag} is immutable but `gh attestation verify` " + f"failed for asset {asset_name} — no Sigstore bundle found or " + f"signature did not verify" + ) + + return True, ( + f"{release_repo}@{tag} is GitHub-immutable and asset {asset_name} " + f"has GitHub-attested Sigstore provenance" + ) + + def analyze_in_tree_binaries( org: str, repo: str, commit_hash: str, sub_path: str = "", ) -> list[str]: