Skip to content

Commit 2e713f7

Browse files
committed
ci(security): pin every GitHub Action to an immutable commit SHA [@Raydikalx]
A major tag such as `v4` is mutable: the action's owner can repoint it, so the code that executes in CI can change while not a single byte of this repository does. This workflow runs with `permissions: contents: write` and the repository token, so that substituted code would be allowed to write to `main`. All five `uses:` now name a 40-hex commit SHA, with the human-readable version kept in a trailing comment — the form Dependabot understands, so upgrades are still proposed as ordinary PRs. actions/checkout 11d5960a326750d5838078e36cf38b85af677262 v4.4.0 actions/setup-python a26af69be951a213d495a4c3e4e4022e16d87065 v5.6.0 actions/cache (x2) 0057852bfaa89a56745cba8c7296529d2fc39830 v4.3.0 actions/upload-artifact ea165f8d65b6e75b540449e92b4886f43607fa02 v4.6.2 Each SHA was resolved from the upstream repository and checked to be the commit its version tag actually points at. The pins deliberately stay on the current majors: this commit is about integrity, not about upgrading. test_pipeline.py — `test_workflow_downloads_and_caches_the_geoip_database` asserted the literal string `actions/cache@v4`, so it failed the moment the workflow became *safer*. It now parses `uses:` out of the YAML and asserts the cache step exists regardless of how its version is written. A new gate, `test_every_workflow_action_is_pinned_to_an_immutable_commit_sha`, stops the pins being undone in a drive-by change; it carries a self-test of its own SHA matcher, because a check that cannot fail is not a check. Suite: 289/289. SECURITY.md — the supply-chain row for Actions now reads "yes, a SHA is immutable" where it read "no", and the three citations of aggregate.yml line numbers shift +6 in lockstep with the comment inserted above the checkout step. CONTRIBUTING.md — the sample runner output said 288/288; there are now 289.
1 parent 9ed059c commit 2e713f7

4 files changed

Lines changed: 82 additions & 14 deletions

File tree

.github/workflows/aggregate.yml

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,13 @@ jobs:
112112
timeout-minutes: 15
113113
steps:
114114
- name: ⬇️ Checkout
115-
uses: actions/checkout@v4
115+
# ★ SHA-pinned, not a moving tag ★
116+
# A major tag such as `v4` is mutable: the owner can repoint it, so the
117+
# bytes that execute in CI can change while this file does not. Pinning
118+
# the commit SHA makes the executed code immutable. The trailing comment
119+
# keeps the human-readable version visible and is the form Dependabot
120+
# understands, so version bumps are still proposed as normal PRs.
121+
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
116122

117123
# ── دروازهٔ زمانی: اگر به‌تازگی آپدیت شده، زود خارج شو ──────────────
118124
- name: ⏱️ Freshness gate
@@ -202,7 +208,7 @@ jobs:
202208
# ══════════════════════════════════════════════════════════════════════
203209
- name: 🐍 Setup Python
204210
if: steps.gate.outputs.should_run == 'true'
205-
uses: actions/setup-python@v5
211+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
206212
with:
207213
python-version: "3.12"
208214
cache: pip
@@ -239,7 +245,7 @@ jobs:
239245
- name: 🌍 Restore GeoIP database cache
240246
if: steps.gate.outputs.should_run == 'true'
241247
id: geoip_cache
242-
uses: actions/cache@v4
248+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
243249
with:
244250
path: .cache/dbip-country-lite.mmdb
245251
key: dbip-country-lite-${{ steps.geoip_key.outputs.ym }}
@@ -381,7 +387,7 @@ jobs:
381387
# می‌کند و هیچ گامِ دستی‌ای فراموش نمی‌شود.
382388
- name: 📦 Restore xray-knife cache
383389
if: steps.gate.outputs.should_run == 'true'
384-
uses: actions/cache@v4
390+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
385391
with:
386392
path: .cache/xray-knife
387393
# نسخه و checksum هر دو در کلید هستند: اگر یکی عوض شود، cacheِ
@@ -498,7 +504,7 @@ jobs:
498504

499505
- name: 📎 Upload validation report on failure
500506
if: failure() && steps.gate.outputs.should_run == 'true'
501-
uses: actions/upload-artifact@v4
507+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
502508
with:
503509
name: validation-report
504510
path: |

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ It prints one line per test and a summary, and exits non-zero if anything failed
5959
```
6060
✅ test_state_history_growth_is_bounded
6161
62-
288/288 passed
62+
289/289 passed
6363
```
6464

6565
Notes that matter:
@@ -227,7 +227,7 @@ please check that file before claiming a source works.
227227
step uses it to find the last human commit, and a human commit carrying that
228228
marker would be skipped as if it were bot output.
229229
- In the PR description, state **what you measured**, not just what you changed.
230-
"Ran the suite: 288/288" or "source returned 412 configs, 38 unique after
230+
"Ran the suite: 289/289" or "source returned 412 configs, 38 unique after
231231
dedup" is the expected style.
232232

233233
### What gets a PR rejected

SECURITY.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,21 +109,21 @@ These are verifiable claims, not assurances:
109109
| `xray-knife` 10.1.1 | version **and** SHA-256 of both the release archive and the extracted binary, cross-checked against upstream's own `.dgst` file in MD5/SHA-256/SHA-512 | ✅ yes |
110110
| `sing-box` 1.13.14 | version only | ❌ **no — see below** |
111111
| `mihomo` v1.19.29 | version only | ❌ **no — see below** |
112-
| GitHub Actions | major tags (`actions/checkout@v4`, `actions/setup-python@v5`, `actions/cache@v4`, `actions/upload-artifact@v4`) | ❌ not SHA-pinned |
112+
| GitHub Actions | commit SHAs, with the version in a trailing comment: `checkout` v4.4.0, `setup-python` v5.6.0, `cache` v4.3.0, `upload-artifact` v4.6.2 | ✅ yes — a SHA is immutable |
113113
| Dependency updates | [`.github/dependabot.yml`](.github/dependabot.yml) — `pip` + `github-actions`, weekly | — |
114114

115115
### Known gap, stated rather than hidden
116116

117117
`sing-box` and `mihomo` are downloaded from GitHub Releases with the version
118118
pinned, but **their checksums are not verified**. The workflow itself names this
119-
asymmetry, in the comment block at lines 370–373, and explains why
119+
asymmetry, in the comment block at lines 376–379, and explains why
120120
version-pinning alone is not integrity. That comment is written in Persian; in
121121
English it says:
122122

123-
<!-- Paraphrase, not a quotation. The comment at aggregate.yml:370-373 reads
123+
<!-- Paraphrase, not a quotation. The comment at aggregate.yml:376-379 reads
124124
"فقط pin کردنِ *نسخه* تضمینی نمی‌دهد، چون یک انتشارِ گیت‌هاب قابلِ جای‌گزینی
125125
است (asset را می‌توان با همان نام دوباره بارگذاری کرد)." Presenting an English
126-
rendering of that in blockquote form would invite a reader to go to line 371
126+
rendering of that in blockquote form would invite a reader to go to line 377
127127
expecting these exact words and find something else — a small gap between
128128
what is advertised and what is delivered, which is the failure mode this
129129
whole document is written against. -->
@@ -133,7 +133,13 @@ the same asset name can be re-uploaded with different bytes. Pinning only the
133133

134134
`xray-knife` was hardened this way; the two validator binaries were not. This is
135135
a real, open hardening item, and it is listed here instead of being left for
136-
someone else to discover. The same applies to SHA-pinning the GitHub Actions.
136+
someone else to discover.
137+
138+
The GitHub Actions used to carry the same weakness — they were referenced by
139+
mutable major tags — and that has since been closed: every `uses:` in the
140+
workflow now names an immutable commit SHA, with the human version kept in a
141+
trailing comment so Dependabot can still propose upgrades. That is why the row
142+
above reads ✅ where it previously read ❌.
137143

138144
**Threat model for that gap:** it requires an attacker who can replace an asset
139145
on the `SagerNet/sing-box` or `MetaCubeX/mihomo` release pages — i.e. a

scripts/test_pipeline.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -813,7 +813,7 @@ def test_every_workflow_fetch_is_bounded_and_time_capped():
813813
چرا این تست وجود دارد — با اندازه‌گیریِ واقعی روی همین مخزنِ ۳.۵۵ گیگابایتی،
814814
نه حدس:
815815
816-
کلونِ CI (‏actions/checkout@v4) عمق ۱ دارد. مرحلهٔ انتشار خودش force-push
816+
کلونِ CI (‏actions/checkout) عمق ۱ دارد. مرحلهٔ انتشار خودش force-push
817817
است، پس کامیتی که checkout روی آن نشسته، به‌محضِ انتشارِ یک اجرای دیگر
818818
**از دسترس خارج** می‌شود. در آن لحظه تنها «have»ِ کلونِ shallow دیگر جزوِ
819819
تاریخِ نوکِ جدید نیست، سرور مبنایی برای بستهٔ کوچک ندارد و کلِ تاریخ را
@@ -1969,14 +1969,70 @@ def _workflow_text() -> str:
19691969
return f.read()
19701970

19711971

1972+
def _workflow_uses() -> list[str]:
1973+
"""هر مقدارِ `uses:`ِ ورک‌فلو — از YAMLِ پارس‌شده، نه regex روی متنِ خام.
1974+
1975+
دلیلِ پارس‌کردن: مرجعِ هر اکشن یک کامنتِ دنباله‌دار دارد
1976+
(`…@<sha> # v4.4.0`). regex روی متنِ خام آن کامنت را جزوِ مرجع می‌شمارد و
1977+
آزمون را الکی سرخ می‌کند؛ YAML کامنت را طبعاً حذف می‌کند.
1978+
"""
1979+
doc = yaml.safe_load(_workflow_text())
1980+
out: list[str] = []
1981+
for job in (doc.get("jobs") or {}).values():
1982+
for step in (job.get("steps") or []):
1983+
u = step.get("uses")
1984+
if isinstance(u, str):
1985+
out.append(u.strip())
1986+
return out
1987+
1988+
1989+
def _is_sha_pin(ref: str) -> bool:
1990+
"""آیا `ref` یک SHAِ کاملِ ۴۰ رقمیِ hex است (و نه تگِ متحرکی مثل `v4`)؟"""
1991+
return len(ref) == 40 and all(c in "0123456789abcdef" for c in ref.lower())
1992+
1993+
19721994
def test_workflow_downloads_and_caches_the_geoip_database():
19731995
"""بدونِ این مرحله، خط‌لوله در CI بی‌صدا به برچسب‌گذاریِ ضعیف برمی‌گردد."""
19741996
wf = _workflow_text()
19751997
assert "download.db-ip.com" in wf, "the workflow must fetch the DB-IP database"
1976-
assert "actions/cache@v4" in wf, "the database must be cached, not re-downloaded 96×/day"
1998+
# ⚠️ این ادعا عمداً به رشتهٔ `actions/cache@v4` گره نمی‌خورد. نسخهٔ قبلی همین
1999+
# کار را می‌کرد و درست در لحظه‌ای شکست که ورک‌فلو *امن‌تر* شد: پین‌شدنِ
2000+
# اکشن‌ها به SHA، رشتهٔ `@v4` را حذف کرد و این تست سرخ شد در حالی که
2001+
# مرحلهٔ cache هنوز سرِ جایش بود. یک آزمون باید به «رفتار» گره بخورد
2002+
# (اینکه cache وجود دارد) نه به «نگارشِ نسخه».
2003+
caches = [u for u in _workflow_uses() if u.split("@", 1)[0] == "actions/cache"]
2004+
assert caches, "the database must be cached, not re-downloaded 96×/day"
19772005
assert "dbip-country-lite.mmdb" in wf
19782006

19792007

2008+
def test_every_workflow_action_is_pinned_to_an_immutable_commit_sha():
2009+
"""تگِ متحرک قابلِ جابه‌جایی است؛ SHA نیست.
2010+
2011+
مالکِ یک اکشن می‌تواند تگِ `v4` را به کامیتِ دیگری repoint کند. آن‌وقت کدی
2012+
که در CI **اجرا** می‌شود عوض می‌شود بدون آن‌که حتی یک بایت از این مخزن
2013+
تغییر کند — و این ورک‌فلو با `permissions: contents: write` و توکنِ مخزن
2014+
اجرا می‌شود، پس آن کدِ عوض‌شده اجازهٔ نوشتن روی `main` را دارد. پین‌کردنِ
2015+
SHA این مسیر را می‌بندد، و این آزمون نمی‌گذارد کسی در یک PRِ گذری آن را
2016+
باز کند.
2017+
"""
2018+
# ① گاردِ خودِ ابزار: سنجه‌ای که نتواند سرخ شود، سنجه نیست. اگر `_is_sha_pin`
2019+
# روزی همه‌چیز را «پین‌شده» بخواند، ادعای پایین بی‌معنا می‌شود.
2020+
assert _is_sha_pin("11d5960a326750d5838078e36cf38b85af677262") is True
2021+
for bogus in ("v4", "v4.4.0", "main", "", "11d5960", "z" * 40,
2022+
"11d5960a326750d5838078e36cf38b85af6772620"):
2023+
assert _is_sha_pin(bogus) is False, f"matcher wrongly accepted {bogus!r}"
2024+
2025+
uses = _workflow_uses()
2026+
# ② گاردِ پارسر: لیستِ خالی هم «هیچ اکشنِ پین‌نشده‌ای نیست» را راست می‌کند.
2027+
assert len(uses) >= 4, f"parsed only {len(uses)} `uses:` — parser looks broken"
2028+
2029+
# اکشن‌های محلی (`./…`) و `docker://` مرجعِ گیت ندارند و پین نمی‌شوند.
2030+
remote = [u for u in uses if not u.startswith("./") and not u.startswith("docker://")]
2031+
unpinned = [u for u in remote
2032+
if "@" not in u or not _is_sha_pin(u.split("@", 1)[1])]
2033+
assert not unpinned, f"these actions are not pinned to a SHA: {unpinned}"
2034+
2035+
19802036
def _workflow_run_text() -> str:
19812037
"""فقط بدنهٔ `run:`های ورک‌فلو — یعنی چیزی که *اجرا* می‌شود.
19822038

0 commit comments

Comments
 (0)