fix(supply-chain): verify CI and production inputs
Pin external actions, images, toolchains, native archives, and tvOS engine artifacts; enforce fail-closed CI checks and keep website privacy disclosures aligned with shipped behavior.
This commit is contained in:
@@ -7,48 +7,87 @@ from check_workflow_security import check_workflow
|
||||
|
||||
|
||||
SAFE_SHA = "a" * 40
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CI_PATH = Path(".github/workflows/ci.yml")
|
||||
|
||||
|
||||
class WorkflowSecurityTests(unittest.TestCase):
|
||||
def check(self, text: str) -> list[str]:
|
||||
return check_workflow(Path(".github/workflows/test.yml"), text)
|
||||
def check(self, text: str, path: Path | None = None) -> list[str]:
|
||||
return check_workflow(path or 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:
|
||||
pull_request: {{}}
|
||||
jobs:
|
||||
test:
|
||||
permissions:
|
||||
contents: read
|
||||
permissions: {{contents: read}}
|
||||
steps:
|
||||
- uses: actions/checkout@{SAFE_SHA} # v7
|
||||
- name: Checkout
|
||||
uses: "actions/checkout@{SAFE_SHA}" # reviewed pin
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: "false"
|
||||
"""
|
||||
)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_accepts_benign_ci_names_runners_matrices_and_commands(self) -> None:
|
||||
workflow = (ROOT / CI_PATH).read_text(encoding="utf-8")
|
||||
changed = (
|
||||
workflow.replace("name: CI - Sanity Checks", "name: Continuous integration")
|
||||
.replace(" analyze:\n", " static-analysis:\n", 1)
|
||||
.replace("name: Code Analysis", "name: Repository checks", 1)
|
||||
.replace("runs-on: ubuntu-latest", "runs-on: internal-linux", 1)
|
||||
.replace("- sanitizer: address", "- sanitizer: memory", 1)
|
||||
.replace("dart run scripts/check_analyzer.dart", "dart run tool/check.dart", 1)
|
||||
)
|
||||
|
||||
self.assertEqual(self.check(changed, CI_PATH), [])
|
||||
|
||||
def test_accepts_a_different_immutable_action_pin(self) -> None:
|
||||
workflow = f"jobs:\n test:\n steps:\n - uses: actions/setup-go@{'b' * 40}\n"
|
||||
self.assertEqual(self.check(workflow), [])
|
||||
|
||||
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_missing_checkout_credential_guard_on_pull_requests(self) -> None:
|
||||
errors = self.check(
|
||||
f"""on: [pull_request]
|
||||
jobs:
|
||||
test:
|
||||
steps:
|
||||
- uses: actions/checkout@{SAFE_SHA}
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- run: echo 'persist-credentials: false elsewhere is not enough'
|
||||
"""
|
||||
)
|
||||
self.assertTrue(any("discard GitHub credentials" 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(
|
||||
def test_rejects_block_or_flow_write_permission_on_pull_requests(self) -> None:
|
||||
block_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))
|
||||
flow_errors = self.check(
|
||||
"on: {pull_request: {}}\npermissions: {contents: write}\n"
|
||||
)
|
||||
self.assertTrue(any("must not request write permissions" in error for error in block_errors))
|
||||
self.assertTrue(any("must not request write permissions" in error for error in flow_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_privileged_untrusted_triggers(self) -> None:
|
||||
target_errors = self.check("on:\n pull_request_target:\n")
|
||||
run_errors = self.check("on: [push, workflow_run]\n")
|
||||
self.assertTrue(any("pull_request_target" in error for error in target_errors))
|
||||
self.assertTrue(any("workflow_run" in error for error in run_errors))
|
||||
|
||||
def test_rejects_mutable_raw_github_download(self) -> None:
|
||||
errors = self.check(
|
||||
@@ -56,6 +95,37 @@ jobs:
|
||||
)
|
||||
self.assertTrue(any("immutable commit" in error for error in errors))
|
||||
|
||||
def test_rejects_ci_fail_open_constructs(self) -> None:
|
||||
continued = self.check(
|
||||
"jobs:\n test:\n steps:\n - continue-on-error: ${{ github.event_name == 'push' }}\n run: ./check\n",
|
||||
CI_PATH,
|
||||
)
|
||||
suppressed = self.check(
|
||||
"jobs:\n test:\n steps:\n - run: ./check || true\n",
|
||||
CI_PATH,
|
||||
)
|
||||
explicit_false = self.check(
|
||||
"jobs:\n test:\n steps:\n - continue-on-error: false\n run: ./check\n",
|
||||
CI_PATH,
|
||||
)
|
||||
self.assertTrue(any("continue-on-error" in error for error in continued))
|
||||
self.assertTrue(any("suppress failure" in error for error in suppressed))
|
||||
self.assertEqual(explicit_false, [])
|
||||
|
||||
def test_comments_do_not_create_security_findings(self) -> None:
|
||||
errors = self.check(
|
||||
"""on:
|
||||
push:
|
||||
# pull_request_target:
|
||||
jobs:
|
||||
test:
|
||||
steps:
|
||||
# uses: actions/checkout@main
|
||||
- run: echo safe
|
||||
"""
|
||||
)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user