diff --git a/.github/scripts/install-zizmor.sh b/.github/scripts/install-zizmor.sh new file mode 100644 index 0000000..19fba6e --- /dev/null +++ b/.github/scripts/install-zizmor.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +set -euo pipefail + +version=1.31.1 +case "$(uname -m)" in + x86_64|amd64) + arch=x86_64 + sha256=d185f726bdaf92df78273217dfdd24ebcd6b0ef48cf3ce776ff5762b6e138c99 + ;; + aarch64|arm64) + arch=aarch64 + sha256=37eebbd5762a44f262d94875431b14a6d2402991e90a685113ea97b8b41be833 + ;; + *) + echo "Unsupported zizmor architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +archive="$RUNNER_TEMP/zizmor.tar.gz" +curl -fsSL "https://github.com/zizmorcore/zizmor/releases/download/v${version}/zizmor-${arch}-unknown-linux-gnu.tar.gz" -o "$archive" +echo "$sha256 $archive" | sha256sum --check --strict +mkdir -p "$RUNNER_TEMP/bin" +tar -xzf "$archive" -C "$RUNNER_TEMP/bin" zizmor +echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" diff --git a/.github/scripts/tests/fixtures/workflow-lint/security/.github/workflows/unsafe.yml b/.github/scripts/tests/fixtures/workflow-lint/security/.github/workflows/unsafe.yml new file mode 100644 index 0000000..3feadd2 --- /dev/null +++ b/.github/scripts/tests/fixtures/workflow-lint/security/.github/workflows/unsafe.yml @@ -0,0 +1,13 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +name: Unsafe security audit control +on: pull_request +permissions: + contents: read +jobs: + unsafe: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: docker/login-action@v3 + - run: echo ${{ github.event.pull_request.title }} diff --git a/.github/scripts/tests/workflow-lint.test.py b/.github/scripts/tests/workflow-lint.test.py index 18bcc52..a017b0e 100644 --- a/.github/scripts/tests/workflow-lint.test.py +++ b/.github/scripts/tests/workflow-lint.test.py @@ -1,6 +1,6 @@ # Copyright (c) Cratis. All rights reserved. # Licensed under the MIT license. See LICENSE file in the project root for full license information. -"""Offline actionlint red controls and reusable-workflow smoke coverage.""" +"""Offline actionlint/zizmor red controls and reusable-workflow smoke coverage.""" import os from pathlib import Path import re @@ -46,11 +46,23 @@ def lint(case, config=True): capture_output=True, text=True, timeout=30) +def workflow_step(name): + workflow = document(WORKFLOWS / "workflow-lint.yml") + return next(step for step in workflow["jobs"]["lint"]["steps"] if step.get("name") == name) + + +def audit(root, mode): + return subprocess.run(["bash", "-c", workflow_step("Audit workflow security")["run"]], + cwd=root, env=dict(os.environ, ZIZMOR_MODE=mode), + capture_output=True, text=True, timeout=30) + + class WorkflowLintTests(unittest.TestCase): @classmethod def setUpClass(cls): - if not shutil.which("actionlint"): - raise RuntimeError("actionlint is required; run .github/scripts/install-actionlint.sh in CI") + for tool in ["actionlint", "zizmor"]: + if not shutil.which(tool): + raise RuntimeError(f"{tool} is required; run .github/scripts/install-{tool}.sh in CI") def test_broken_fixture_fails_with_expected_rule_ids(self): result = lint("broken") @@ -88,8 +100,7 @@ def test_every_reusable_is_smoked_or_has_an_explicit_reason(self): self.assertNotIn("secrets", job) def test_lint_step_uses_repo_config_or_shared_default_and_keeps_workflows_with_extras(self): - workflow = document(WORKFLOWS / "workflow-lint.yml") - script = workflow["jobs"]["lint"]["steps"][-1]["run"] + script = workflow_step("Lint workflows and additional files")["run"] for config_name in ["actionlint.yaml", "actionlint.yml", None]: with self.subTest(config=config_name), tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -114,6 +125,85 @@ def test_lint_step_uses_repo_config_or_shared_default_and_keeps_workflows_with_e self.assertEqual(result.returncode, 1, result.stdout + result.stderr) self.assertIn("[job-needs]", result.stdout) + def test_zizmor_blocking_fails_and_report_prints_the_same_findings(self): + for mode in ["blocking", "report"]: + with self.subTest(mode=mode): + result = audit(FIXTURES / "security", mode) + if mode == "blocking": + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + for rule in ["unpinned-uses", "template-injection"]: + self.assertIn(f"[{rule}]", result.stdout + result.stderr) + + def test_zizmor_off_skips_install_and_audit_and_invalid_modes_fail(self): + workflow = document(WORKFLOWS / "workflow-lint.yml") + inputs = workflow.get("on", workflow.get(True))["workflow_call"]["inputs"] + self.assertEqual(inputs["zizmor"]["default"], "report") + self.assertEqual(workflow["permissions"], {"contents": "read"}) + self.assertEqual(workflow_step("Install zizmor")["if"], "inputs.zizmor != 'off'") + with tempfile.TemporaryDirectory() as directory: + # No workflows here: an attempted audit would fail. + result = audit(directory, "off") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = audit(directory, "typo") + self.assertNotEqual(result.returncode, 0) + self.assertIn("Invalid zizmor mode", result.stderr) + + def test_zizmor_discovers_caller_config_and_report_still_fails_on_tool_errors(self): + for config_name in [".github/zizmor.yml", "zizmor.yml"]: + with self.subTest(config=config_name), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + shutil.copytree(FIXTURES / "security/.github", root / ".github") + config = root / config_name + config.write_text("rules:\n template-injection:\n ignore: [unsafe.yml]\n") + result = audit(root, "blocking") + output = result.stdout + result.stderr + self.assertIn("[unpinned-uses]", output) + self.assertNotIn("[template-injection]", output) + config.write_text("rules: [invalid config]\n") + result = audit(root, "report") + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + + +class InstallZizmorTests(unittest.TestCase): + """The pinned archive checksum must reject a substituted download before extraction.""" + + def run_installer(self, machine): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + stubs, runner, github_path = root / "stubs", root / "runner", root / "github_path" + stubs.mkdir() + runner.mkdir() + github_path.write_text("") + # Offline: curl writes a substituted archive; uname selects the architecture branch. + (stubs / "curl").write_text('#!/bin/sh\nwhile [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\n' + 'printf substituted > "$out"\n') + (stubs / "uname").write_text(f"#!/bin/sh\necho {machine}\n") + # Fails if the installer reaches extraction. + (stubs / "tar").write_text(f'#!/bin/sh\necho "$@" > "{root}/tar-called"\nexit 0\n') + probe = subprocess.run(["sha256sum", "--check", "--strict"], input="", capture_output=True, text=True) + if "usage" in probe.stderr.lower(): + # macOS portability shim; CI (Linux) uses the real sha256sum. + (stubs / "sha256sum").write_text('#!/bin/sh\nexec shasum -a 256 -c -\n') + for stub in stubs.iterdir(): + stub.chmod(0o755) + env = dict(os.environ, PATH=f"{stubs}:{os.environ['PATH']}", RUNNER_TEMP=str(runner), + GITHUB_PATH=str(github_path)) + result = subprocess.run(["bash", str(ROOT / ".github/scripts/install-zizmor.sh")], env=env, + capture_output=True, text=True, timeout=30) + return (result, (root / "tar-called").exists(), (runner / "bin" / "zizmor").exists(), + github_path.read_text()) + + def test_substituted_archive_is_rejected_on_both_architectures(self): + for machine in ["x86_64", "aarch64"]: + with self.subTest(machine=machine): + result, extracted, installed, github_path = self.run_installer(machine) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse(extracted) + self.assertFalse(installed) + self.assertEqual(github_path, "") + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/.github/workflows/smoke-workflows.yml b/.github/workflows/smoke-workflows.yml index 2649c1e..f66e95f 100644 --- a/.github/workflows/smoke-workflows.yml +++ b/.github/workflows/smoke-workflows.yml @@ -10,6 +10,11 @@ on: - .github/templates/** - .github/actionlint.yaml - .github/scripts/install-actionlint.sh + - .github/scripts/install-zizmor.sh + - .github/zizmor.yml + - .github/zizmor.yaml + - zizmor.yml + - zizmor.yaml - .github/scripts/tests/workflow-lint.test.py - .github/scripts/tests/fixtures/workflow-lint/** - .github/scripts/tests/fixtures/discoverability/** @@ -29,6 +34,7 @@ jobs: with: workflows-ref: ${{ github.sha }} shellcheck: true + zizmor: report extra-files: .github/templates/publish.template.yml tests: @@ -40,6 +46,8 @@ jobs: persist-credentials: false - name: Install actionlint run: bash .github/scripts/install-actionlint.sh + - name: Install zizmor + run: bash .github/scripts/install-zizmor.sh - name: Exercise lint failures and smoke coverage run: python3 -B .github/scripts/tests/workflow-lint.test.py diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index 4d3550e..fb107b7 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -17,6 +17,10 @@ on: description: Also check inline shell scripts with shellcheck. type: boolean default: false + zizmor: + description: Security audit mode (report, blocking, or off); offline, no token required. + type: string + default: report extra-files: description: Space-separated additional workflow files, such as templates, to lint. type: string @@ -65,3 +69,22 @@ jobs: files=(.github/workflows/*.yml .github/workflows/*.yaml) read -r -a extra_files <<< "$EXTRA_FILES" actionlint "${args[@]}" "${files[@]}" "${extra_files[@]}" + + - name: Install zizmor + if: inputs.zizmor != 'off' + run: bash .cratis-workflows/.github/scripts/install-zizmor.sh + + - name: Audit workflow security + shell: bash + env: + ZIZMOR_MODE: ${{ inputs.zizmor }} + run: | + set -euo pipefail + args=(--offline) + case "$ZIZMOR_MODE" in + report) args+=(--no-exit-codes) ;; + blocking) ;; + off) exit 0 ;; + *) echo "Invalid zizmor mode: $ZIZMOR_MODE (expected report, blocking, or off)" >&2; exit 1 ;; + esac + zizmor "${args[@]}" .github/workflows diff --git a/README.md b/README.md index 4275985..e209fba 100644 --- a/README.md +++ b/README.md @@ -904,7 +904,7 @@ The check's program is inline in the reusable workflow, so the rules that run ar ## Verifying workflow changes `smoke-workflows.yml` runs on `pull_request` changes to workflows, actions, -templates, the actionlint configuration/installer, and its tests and fixtures. +templates, the actionlint/zizmor configurations and installers, and their tests and fixtures. It calls reusable workflows through `./.github/workflows/` so GitHub uses the PR merge commit, not the version on `main`. The lint and public-runner calls also pass `${{ github.sha }}` as `workflows-ref` for their supporting files. Permissions @@ -913,7 +913,7 @@ The path filter is intentional: this workflow is **not a required check**. | Coverage | Reusable workflow | Limit | | --- | --- | --- | -| Smoke call | `workflow-lint.yml` | All workflows plus the publish template; shellcheck enabled | +| Smoke call | `workflow-lint.yml` | All workflows plus the publish template; shellcheck enabled; zizmor in report mode | | Smoke call | `docker-cleanup-gate.yml` | Read-only scan; its checker checkout still defaults to `main` | | Smoke call | `check-public-runners.yml` | Read-only scan using the PR merge commit's checker | | Smoke call | `verify-no-work-records.yml` | Uses its existing immutable script pin | @@ -933,22 +933,45 @@ in `NOT_SMOKEABLE`; adding a reusable without either fails the test. The shared installer pins actionlint 1.7.12 and verifies the Linux amd64/arm64 archive checksum. `workflow-lint.yml` accepts `runs-on` (default `ubuntu-latest`), -`workflows-ref` (default `main`), `shellcheck` (default `false`), and `extra-files` -(a space-separated list of additional paths). It always lints all caller workflows, +`workflows-ref` (default `main`), `shellcheck` (default `false`), `zizmor` (default +`report`), and `extra-files` (a space-separated list of additional paths). It always lints all caller workflows, including when extra files are supplied. It prefers the caller's `.github/actionlint.yaml` or `.github/actionlint.yml`, otherwise uses the shared configuration. The shared config recognizes Cratis runner labels and ignores only actionlint's unsupported `concurrency.queue` key under workflows/templates; remove that exception when actionlint supports it. Pyflakes is disabled. +The zizmor installer pins **1.31.1** and verifies the Linux amd64/arm64 release +archive SHA-256 before extraction. The security audit runs with `--offline`: +no GitHub token or secrets are needed, and online-only audits are not run. +The `zizmor` input selects one of three modes: + +- `report` (default): print findings without failing on them; tool errors still fail. +- `blocking`: findings fail the job using zizmor's normal exit codes. +- `off`: skip installing and running zizmor (actionlint still runs). + +Zizmor uses its native caller configuration discovery: `.github/zizmor.yml`, +`.github/zizmor.yaml`, `zizmor.yml`, then `zizmor.yaml`. Without a caller config, +it uses zizmor's built-in defaults; no shared suppressions are applied. It audits +all caller workflows; `extra-files` applies only to actionlint. Start with report +mode, fix or narrowly justify findings in the caller's config, then set +`zizmor: blocking` on the reusable workflow call once the offline audit is clean. +Workflows' own smoke call remains in report mode while existing findings are triaged. + +For local checks, install actionlint, zizmor, shellcheck, and Python's PyYAML: + ```bash actionlint actionlint .github/templates/publish.template.yml +zizmor --offline .github/workflows python3 -B .github/scripts/tests/workflow-lint.test.py ``` The test proves a broken workflow fails with `expression`, `job-needs` and `syntax-check` rule IDs, and that `queue: max` passes with the shared config. +It also proves zizmor detects `unpinned-uses` and `template-injection`, blocking +mode fails, report mode prints the findings without failing, and caller config +is honored. ### Manual fallback for changes smoke cannot exercise