ci: harden privileged workflow trust boundaries
This commit is contained in:
@@ -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)
|
||||
|
||||
Executable
+79
@@ -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())
|
||||
@@ -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 &&
|
||||
|
||||
Executable
+61
@@ -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()
|
||||
Reference in New Issue
Block a user