ci: harden privileged workflow trust boundaries

This commit is contained in:
edde746
2026-07-15 07:57:42 +02:00
parent 54a4b8e414
commit 0bcd00bcb6
7 changed files with 331 additions and 79 deletions
+61 -3
View File
@@ -75,7 +75,7 @@ require(
for expected in (
"if: matrix.flutter_setup == 'action'",
"if: matrix.flutter_setup == 'git'",
"git clone --depth 1 --branch 3.44.0",
"git -C $root fetch --depth 1 origin 559ffa3f75e7402d65a8def9c28389a9b2e6fe42",
"flutter pub get --enforce-lockfile --no-example",
"--dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }}",
"--split-debug-info=debug-info/windows-${{ matrix.arch }}",
@@ -161,9 +161,9 @@ for artifact in (
release = job("create-release")
require(
"needs: [build-android, build-ios, build-macos, build-windows, package-windows, build-linux]"
"needs: [validate-trusted-ref, build-android, build-ios, build-macos, build-windows, package-windows, build-linux]"
in release,
"release dependencies must include both architecture matrices and Windows packaging",
"release dependencies must include the trust gate, both architecture matrices, and Windows packaging",
)
for artifact in (
"android-apk",
@@ -203,6 +203,64 @@ require(
"untagged draft creation must not inspect or block on published releases",
)
trusted_ref = job("validate-trusted-ref")
require("permissions: {}" in trusted_ref, "trusted-ref validation must have no token permissions")
require(
'"$GITHUB_REF" != "refs/heads/main"' in trusted_ref,
"trusted-ref validation must reject non-main refs",
)
for protected_job in (
"build-android",
"build-ios",
"build-macos",
"build-windows",
"build-linux",
):
require(
"needs: validate-trusted-ref" in job(protected_job),
f"{protected_job} must depend on trusted-ref validation",
)
require(
"TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1" in text,
"build caches must use a dedicated trusted namespace",
)
require("restore-keys:" not in text, "privileged build caches must not use prefix fallback")
cache_keys = re.findall(r"(?m)^ key: (.+)$", text)
require(bool(cache_keys), "build workflow must define cache keys")
for cache_key in cache_keys:
require(
"TRUSTED_BUILD_CACHE_VERSION" in cache_key,
f"cache key is outside the trusted build namespace: {cache_key}",
)
require(
text.count("cache-key:") == text.count("cache: true"),
"every Flutter SDK cache must define its trusted cache key",
)
action_refs = re.findall(r"(?m)^\s*(?:-\s+)?uses:\s+([^\s@]+)@([^\s#]+)", text)
require(bool(action_refs), "build workflow must use pinned actions")
for action, ref in action_refs:
require(
re.fullmatch(r"[0-9a-f]{40}", ref) is not None,
f"action {action} must be pinned to a full commit SHA",
)
checkout_count = sum(action == "actions/checkout" for action, _ in action_refs)
require(
text.count("persist-credentials: false") == checkout_count,
"every build checkout must discard GitHub credentials",
)
require(
"raw.githubusercontent.com/edde746/auto_updater/9e150f71e17495b7361aedbe6df22e89ad52c254/"
in text,
"Windows signing helper must remain pinned to the locked auto_updater commit",
)
require(
'dependencies=@{cryptography="2.9.0"}' in text,
"Windows signing dependency must remain exact",
)
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Enforce trust-boundary invariants across GitHub Actions workflows."""
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[1]
WORKFLOWS = ROOT / ".github" / "workflows"
FULL_SHA = re.compile(r"[0-9a-f]{40}")
PR_TRIGGER = re.compile(r"(?m)^ pull_request:\s*$")
def _active_text(text: str) -> str:
return "\n".join(
"" if line.lstrip().startswith("#") else line for line in text.splitlines()
)
def check_workflow(path: Path, text: str) -> list[str]:
errors: list[str] = []
active = _active_text(text)
for dangerous_trigger in ("pull_request_target:", "workflow_run:"):
if dangerous_trigger in active:
errors.append(f"{path}: unaudited privileged trigger {dangerous_trigger[:-1]}")
checkout_count = 0
for line_number, line in enumerate(active.splitlines(), start=1):
match = re.match(r"^\s*(?:-\s+)?uses:\s+(.+?)\s*$", line)
if match is None:
continue
reference = match.group(1).split(" #", maxsplit=1)[0].strip()
if reference.startswith("./"):
continue
action, separator, ref = reference.rpartition("@")
if not separator or not action or FULL_SHA.fullmatch(ref) is None:
errors.append(
f"{path}:{line_number}: external action must use a full commit SHA: {reference}"
)
if action == "actions/checkout":
checkout_count += 1
if re.search(
r"https://raw\.githubusercontent\.com/[^/\s]+/[^/\s]+/(?:main|master)/",
active,
):
errors.append(f"{path}: raw GitHub downloads must use an immutable commit")
if PR_TRIGGER.search(active):
if "secrets." in active:
errors.append(f"{path}: pull-request workflow must not reference repository secrets")
if re.search(r"(?m)^\s+[a-zA-Z0-9_-]+:\s+write\s*$", active):
errors.append(f"{path}: pull-request workflow must not request write permissions")
if active.count("persist-credentials: false") != checkout_count:
errors.append(
f"{path}: every pull-request checkout must discard GitHub credentials"
)
return errors
def main() -> int:
errors: list[str] = []
for path in sorted(WORKFLOWS.glob("*.yml")):
errors.extend(check_workflow(path.relative_to(ROOT), path.read_text(encoding="utf-8")))
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
return 1
print("workflow security checks passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -83,6 +83,8 @@ fi
# 4. Workflow and script regression guards
section "workflow and script guards"
if python3 scripts/check_build_workflow.py &&
python3 scripts/check_workflow_security.py &&
python3 scripts/test_check_workflow_security.py &&
python3 scripts/check_update_packages_workflow.py &&
python3 scripts/test_pubspec_version.py &&
python3 scripts/test_clean_translations.py &&
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
from pathlib import Path
import unittest
from check_workflow_security import check_workflow
SAFE_SHA = "a" * 40
class WorkflowSecurityTests(unittest.TestCase):
def check(self, text: str) -> list[str]:
return check_workflow(Path(".github/workflows/test.yml"), text)
def test_accepts_read_only_pull_request_workflow_with_pinned_action(self) -> None:
errors = self.check(
f"""name: Test
on:
pull_request:
jobs:
test:
permissions:
contents: read
steps:
- uses: actions/checkout@{SAFE_SHA} # v7
with:
persist-credentials: false
"""
)
self.assertEqual(errors, [])
def test_rejects_mutable_action_reference(self) -> None:
errors = self.check("jobs:\n test:\n steps:\n - uses: actions/checkout@v7\n")
self.assertTrue(any("full commit SHA" in error for error in errors))
def test_rejects_secrets_in_pull_request_workflow(self) -> None:
errors = self.check(
"on:\n pull_request:\njobs:\n test:\n env:\n TOKEN: ${{ secrets.TOKEN }}\n"
)
self.assertTrue(any("must not reference repository secrets" in error for error in errors))
def test_rejects_write_permission_in_pull_request_workflow(self) -> None:
errors = self.check(
"on:\n pull_request:\njobs:\n test:\n permissions:\n contents: write\n"
)
self.assertTrue(any("must not request write permissions" in error for error in errors))
def test_rejects_privileged_untrusted_trigger(self) -> None:
errors = self.check("on:\n pull_request_target:\n")
self.assertTrue(any("unaudited privileged trigger" in error for error in errors))
def test_rejects_mutable_raw_github_download(self) -> None:
errors = self.check(
"jobs:\n test:\n steps:\n - run: curl https://raw.githubusercontent.com/o/r/main/tool.sh\n"
)
self.assertTrue(any("immutable commit" in error for error in errors))
if __name__ == "__main__":
unittest.main()