Skip to content

Commit ccd779c

Browse files
committed
docs+tests: document and cover TRUSTED_DOWNLOAD_PROVENANCE escape hatch
Companion to the previous commit that introduced TRUSTED_DOWNLOAD_PROVENANCE for actions whose runtime downloads are anchored at the GitHub release / Sigstore layer rather than via an in-source checksum. README: add a paragraph after the "Pre-compiled native binaries shipped in-tree" bullet explaining the escape hatch — what an entry asserts, what the runtime check confirms (immutable release + valid Sigstore attestation on an attested asset), and that the config alone is not enough. Tests: * TestVerifyTrustedDownloadProvenance — happy path, missing config (returns no-opinion), network failure on release metadata, non- immutable release, no asset downloadable, `gh attestation verify` failure. * TestFetchReleaseAssetBytes — asset-preference ordering picks the cheapest valid probe (.sbom.json over .tar.gz); smallest-asset fallback when no preference matches; empty assets returns (None, None). * TestAnalyzeBinaryDownloadsTrustedDownloadEscapeHatch — the call- site branch: passing provenance reclassifies failures as warnings; failing provenance preserves failures; no config entry skips the branch entirely (verify_trusted_download_provenance never called). 12 new tests; full test_security.py (144 tests) passes. Generated-by: Claude Code (Claude Opus 4.7)
1 parent 42006ff commit ccd779c

2 files changed

Lines changed: 319 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ When reviewing an action (new or updated), watch for these potential issues in t
233233
- **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.
234234
- **Compiled JS mismatch**: any unexplained diff between the published `dist/` and a clean rebuild — this is the primary check the verification script performs.
235235
- **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 <org>` (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.
236+
- **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.
236237

237238
For the full approval policy and requirements, see the [ASF GitHub Actions Policy](https://infra.apache.org/github-actions-policy.html).
238239

utils/tests/verify_action_build/test_security.py

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@
2929
analyze_repo_metadata,
3030
)
3131
from verify_action_build.security import (
32+
_fetch_release_asset_bytes,
3233
_file_is_pure_data_fetch,
3334
_find_binary_downloads_js,
3435
_looks_like_in_tree_binary,
3536
_parse_sha256sums,
37+
verify_trusted_download_provenance,
3638
)
3739

3840

@@ -1668,6 +1670,322 @@ def test_many_binaries_truncates_in_message(self):
16681670
assert "17 more" in msg
16691671

16701672

1673+
class TestVerifyTrustedDownloadProvenance:
1674+
"""Runtime check that backs the TRUSTED_DOWNLOAD_PROVENANCE escape
1675+
hatch. The config alone is not enough — at scan time we must confirm
1676+
the release repo publishes immutable releases AND has a valid
1677+
Sigstore attestation on at least one asset.
1678+
"""
1679+
1680+
_CONFIG = {
1681+
"testorg/testaction": {
1682+
"release_repo": "testorg/testtool",
1683+
"rationale": "test rationale",
1684+
},
1685+
}
1686+
1687+
def test_no_config_returns_no_opinion(self):
1688+
# Action without an entry: returns (False, "") so the caller
1689+
# leaves the existing failure as-is.
1690+
passed, reason = verify_trusted_download_provenance("unknown", "action")
1691+
assert passed is False
1692+
assert reason == ""
1693+
1694+
def test_release_fetch_failure(self):
1695+
with mock.patch.dict(
1696+
"verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE",
1697+
self._CONFIG, clear=True,
1698+
):
1699+
with mock.patch(
1700+
"verify_action_build.security._fetch_release_metadata",
1701+
return_value=None,
1702+
):
1703+
passed, reason = verify_trusted_download_provenance(
1704+
"testorg", "testaction",
1705+
)
1706+
assert passed is False
1707+
assert "could not fetch latest release" in reason
1708+
assert "testorg/testtool" in reason
1709+
1710+
def test_release_not_immutable_fails(self):
1711+
release = {
1712+
"tag_name": "v1.2.3",
1713+
"immutable": False,
1714+
"assets": [{"name": "tool.sbom.json", "size": 1024, "url": "u"}],
1715+
}
1716+
with mock.patch.dict(
1717+
"verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE",
1718+
self._CONFIG, clear=True,
1719+
):
1720+
with mock.patch(
1721+
"verify_action_build.security._fetch_release_metadata",
1722+
return_value=release,
1723+
):
1724+
passed, reason = verify_trusted_download_provenance(
1725+
"testorg", "testaction",
1726+
)
1727+
assert passed is False
1728+
assert "NOT marked immutable" in reason
1729+
assert "v1.2.3" in reason
1730+
1731+
def test_no_asset_downloadable_fails(self):
1732+
# Release is immutable but every asset download attempt fails —
1733+
# we can't run the attestation spot-check, so we can't accept
1734+
# the trust anchor.
1735+
release = {"tag_name": "v1.2.3", "immutable": True, "assets": []}
1736+
with mock.patch.dict(
1737+
"verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE",
1738+
self._CONFIG, clear=True,
1739+
):
1740+
with mock.patch(
1741+
"verify_action_build.security._fetch_release_metadata",
1742+
return_value=release,
1743+
):
1744+
with mock.patch(
1745+
"verify_action_build.security._fetch_release_asset_bytes",
1746+
return_value=(None, None),
1747+
):
1748+
passed, reason = verify_trusted_download_provenance(
1749+
"testorg", "testaction",
1750+
)
1751+
assert passed is False
1752+
assert "no asset could be downloaded" in reason
1753+
1754+
def test_attestation_verify_failure(self):
1755+
release = {
1756+
"tag_name": "v2.12.2",
1757+
"immutable": True,
1758+
"assets": [{"name": "tool.sbom.json", "size": 340_000, "url": "u"}],
1759+
}
1760+
with mock.patch.dict(
1761+
"verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE",
1762+
self._CONFIG, clear=True,
1763+
):
1764+
with mock.patch(
1765+
"verify_action_build.security._fetch_release_metadata",
1766+
return_value=release,
1767+
):
1768+
with mock.patch(
1769+
"verify_action_build.security._fetch_release_asset_bytes",
1770+
return_value=("tool.sbom.json", b"sbom-bytes"),
1771+
):
1772+
with mock.patch(
1773+
"verify_action_build.security._verify_via_gh_attestation",
1774+
return_value=False,
1775+
):
1776+
passed, reason = verify_trusted_download_provenance(
1777+
"testorg", "testaction",
1778+
)
1779+
assert passed is False
1780+
assert "gh attestation verify" in reason
1781+
assert "tool.sbom.json" in reason
1782+
1783+
def test_happy_path(self):
1784+
# Immutable release + asset downloads + gh attestation verifies
1785+
# → escape hatch accepts the trust anchor.
1786+
release = {
1787+
"tag_name": "v2.12.2",
1788+
"immutable": True,
1789+
"assets": [{"name": "tool.sbom.json", "size": 340_000, "url": "u"}],
1790+
}
1791+
with mock.patch.dict(
1792+
"verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE",
1793+
self._CONFIG, clear=True,
1794+
):
1795+
with mock.patch(
1796+
"verify_action_build.security._fetch_release_metadata",
1797+
return_value=release,
1798+
):
1799+
with mock.patch(
1800+
"verify_action_build.security._fetch_release_asset_bytes",
1801+
return_value=("tool.sbom.json", b"sbom-bytes"),
1802+
):
1803+
with mock.patch(
1804+
"verify_action_build.security._verify_via_gh_attestation",
1805+
return_value=True,
1806+
):
1807+
passed, reason = verify_trusted_download_provenance(
1808+
"testorg", "testaction",
1809+
)
1810+
assert passed is True
1811+
assert "GitHub-immutable" in reason
1812+
assert "Sigstore provenance" in reason
1813+
assert "tool.sbom.json" in reason
1814+
1815+
1816+
class TestFetchReleaseAssetBytes:
1817+
"""Asset selection for the attestation spot-check: name-preference
1818+
ordering picks the cheapest valid probe (sbom > intoto > tarball >
1819+
zip); smallest-asset fallback only fires when no preference matches.
1820+
"""
1821+
1822+
def test_prefers_sbom_over_tarball(self):
1823+
release = {
1824+
"assets": [
1825+
{"name": "tool-linux-amd64.tar.gz", "size": 50_000_000,
1826+
"url": "https://api/tar"},
1827+
{"name": "tool.sbom.json", "size": 340_000,
1828+
"url": "https://api/sbom"},
1829+
],
1830+
}
1831+
1832+
captured_url = {}
1833+
1834+
def fake_get(url, headers=None, timeout=None):
1835+
captured_url["url"] = url
1836+
resp = mock.Mock()
1837+
resp.ok = True
1838+
resp.content = b"sbom-bytes"
1839+
return resp
1840+
1841+
with mock.patch(
1842+
"verify_action_build.security.requests.get",
1843+
side_effect=fake_get,
1844+
):
1845+
name, content = _fetch_release_asset_bytes(
1846+
"org", "repo", release, (".sbom.json", ".tar.gz"),
1847+
)
1848+
assert name == "tool.sbom.json"
1849+
assert content == b"sbom-bytes"
1850+
assert captured_url["url"] == "https://api/sbom"
1851+
1852+
def test_falls_back_to_smallest_when_no_preference_matches(self):
1853+
# No asset matches the preference list — picks smallest by size.
1854+
release = {
1855+
"assets": [
1856+
{"name": "big.bin", "size": 100_000_000, "url": "https://api/big"},
1857+
{"name": "small.bin", "size": 1_000, "url": "https://api/small"},
1858+
{"name": "medium.bin", "size": 10_000, "url": "https://api/medium"},
1859+
],
1860+
}
1861+
1862+
captured_url = {}
1863+
1864+
def fake_get(url, headers=None, timeout=None):
1865+
captured_url["url"] = url
1866+
resp = mock.Mock()
1867+
resp.ok = True
1868+
resp.content = b"x"
1869+
return resp
1870+
1871+
with mock.patch(
1872+
"verify_action_build.security.requests.get",
1873+
side_effect=fake_get,
1874+
):
1875+
name, content = _fetch_release_asset_bytes(
1876+
"org", "repo", release, (".sbom.json",),
1877+
)
1878+
assert name == "small.bin"
1879+
assert captured_url["url"] == "https://api/small"
1880+
1881+
def test_empty_assets_returns_none(self):
1882+
name, content = _fetch_release_asset_bytes(
1883+
"org", "repo", {"assets": []}, (".sbom.json",),
1884+
)
1885+
assert name is None
1886+
assert content is None
1887+
1888+
1889+
class TestAnalyzeBinaryDownloadsTrustedDownloadEscapeHatch:
1890+
"""The branch inside analyze_binary_downloads that reclassifies
1891+
unverified-download failures as warnings when the action has a
1892+
TRUSTED_DOWNLOAD_PROVENANCE entry AND the runtime provenance check
1893+
passes."""
1894+
1895+
_CONFIG = {
1896+
"testorg/testaction": {
1897+
"release_repo": "testorg/testtool",
1898+
"rationale": "linked upstream confirmation",
1899+
},
1900+
}
1901+
1902+
# Action.yml with one unverified runtime download — generates a
1903+
# failure under the normal rules; the escape hatch decides whether
1904+
# that failure stands or is reclassified.
1905+
_ACTION_YML = """\
1906+
name: Test
1907+
runs:
1908+
using: composite
1909+
steps:
1910+
- name: Download tool
1911+
shell: bash
1912+
run: |
1913+
curl -fsSLO https://example.com/tool.tar.gz
1914+
tar xf tool.tar.gz
1915+
"""
1916+
1917+
def test_passing_provenance_reclassifies_failures_to_warnings(self):
1918+
with mock.patch.dict(
1919+
"verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE",
1920+
self._CONFIG, clear=True,
1921+
):
1922+
with mock.patch(
1923+
"verify_action_build.security.fetch_action_yml",
1924+
return_value=self._ACTION_YML,
1925+
):
1926+
with mock.patch(
1927+
"verify_action_build.security.fetch_file_from_github",
1928+
return_value=None,
1929+
):
1930+
with mock.patch(
1931+
"verify_action_build.security.verify_trusted_download_provenance",
1932+
return_value=(True, "testorg/testtool@v1 verified"),
1933+
):
1934+
warnings, failures = analyze_binary_downloads(
1935+
"testorg", "testaction", "a" * 40,
1936+
)
1937+
assert failures == []
1938+
assert any("trusted via GitHub release provenance" in w for w in warnings)
1939+
1940+
def test_failing_provenance_preserves_failures(self):
1941+
with mock.patch.dict(
1942+
"verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE",
1943+
self._CONFIG, clear=True,
1944+
):
1945+
with mock.patch(
1946+
"verify_action_build.security.fetch_action_yml",
1947+
return_value=self._ACTION_YML,
1948+
):
1949+
with mock.patch(
1950+
"verify_action_build.security.fetch_file_from_github",
1951+
return_value=None,
1952+
):
1953+
with mock.patch(
1954+
"verify_action_build.security.verify_trusted_download_provenance",
1955+
return_value=(False, "release not immutable"),
1956+
):
1957+
warnings, failures = analyze_binary_downloads(
1958+
"testorg", "testaction", "a" * 40,
1959+
)
1960+
assert len(failures) >= 1
1961+
assert any("tool.tar.gz" in f for f in failures)
1962+
1963+
def test_no_config_entry_leaves_failures_alone(self):
1964+
# Action not in TRUSTED_DOWNLOAD_PROVENANCE: the escape hatch
1965+
# branch is skipped entirely and verify_trusted_download_provenance
1966+
# is never even consulted.
1967+
with mock.patch.dict(
1968+
"verify_action_build.security.TRUSTED_DOWNLOAD_PROVENANCE",
1969+
{}, clear=True,
1970+
):
1971+
with mock.patch(
1972+
"verify_action_build.security.fetch_action_yml",
1973+
return_value=self._ACTION_YML,
1974+
):
1975+
with mock.patch(
1976+
"verify_action_build.security.fetch_file_from_github",
1977+
return_value=None,
1978+
):
1979+
with mock.patch(
1980+
"verify_action_build.security.verify_trusted_download_provenance",
1981+
) as verify_mock:
1982+
warnings, failures = analyze_binary_downloads(
1983+
"other", "action", "a" * 40,
1984+
)
1985+
assert len(failures) >= 1
1986+
verify_mock.assert_not_called()
1987+
1988+
16711989
class _Patches:
16721990
"""Tiny context manager that enters/exits a sequence of mock.patch
16731991
objects together — used by TestAnalyzeInTreeBinaries to keep the

0 commit comments

Comments
 (0)