Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/scripts/install-zizmor.sh
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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 }}
100 changes: 95 additions & 5 deletions .github/scripts/tests/workflow-lint.test.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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)
8 changes: 8 additions & 0 deletions .github/workflows/smoke-workflows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
Expand All @@ -29,6 +34,7 @@ jobs:
with:
workflows-ref: ${{ github.sha }}
shellcheck: true
zizmor: report
extra-files: .github/templates/publish.template.yml

tests:
Expand All @@ -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

Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/workflow-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand All @@ -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

Expand Down
Loading