diff --git a/.github/workflows/reusable-zizmor.md b/.github/workflows/reusable-zizmor.md index c39febaf1..8db843232 100644 --- a/.github/workflows/reusable-zizmor.md +++ b/.github/workflows/reusable-zizmor.md @@ -157,6 +157,24 @@ uses: actions/checkout@v3 # zizmor: ignore[artipacked] [zizmor-ignore-comment]: https://woodruffw.github.io/zizmor/usage/#with-comments +## Repo-local `zizmor.yml` policy gate + +When `always-use-default-config` is `false` and the calling repository uses a **repo-local** `zizmor.yml` or `.github/zizmor.yml` (so zizmor discovers that file instead of the Grafana default from `shared-workflows`), the workflow **validates that file before running zizmor**. If validation fails, the job stops and zizmor is not executed. Validation uses the composite action [`actions/validate-zizmor-config`](../../actions/validate-zizmor-config) from this repository, **hash-pinned** in `reusable-zizmor.yml` to satisfy pinning checks (bump that SHA when you change the action). **Set up Zizmor configuration** writes the path to validate as step output `repo-local-zizmor-config` when a repo-local file is used; that is separate from `zizmor-config`, which is only set when the run uses **`--config`** with the downloaded Grafana default. + +The gate is **skipped** when: + +- `always-use-default-config` is `true` (only the Grafana default from this repository is used), or +- There is no repo-local config file, or +- The run uses the fetched default via `--config` (no repo-local file in play). + +The policy rejects configs that: + +- Define any of these audit blocks under `rules` (no `disable`, `ignore`, or `config` is allowed — remove the block entirely): `insecure-commands`, `template-injection`, `impostor-commit`, `known-vulnerable-actions`, and `ref-confusion`. +- Set `rules.unpinned-uses.disable`. +- Set `rules.unpinned-uses.config.policies` with a universal [`"*": any`](https://docs.zizmor.sh/audits/#unpinned-uses) entry (all matching `uses:` clauses may stay unpinned). Scoped policies such as `actions/*: any` or `grafana/*: any` remain valid. + +Inline `# zizmor: ignore[...]` comments in workflow files are unchanged; this gate applies only to the repo-local YAML config file. + ## Configuration zizmor [can be configured][zizmor-config] with a `zizmor.yml` or diff --git a/.github/workflows/reusable-zizmor.yml b/.github/workflows/reusable-zizmor.yml index b16943c0f..3ef1a0326 100644 --- a/.github/workflows/reusable-zizmor.yml +++ b/.github/workflows/reusable-zizmor.yml @@ -361,12 +361,14 @@ jobs: if [ -f "zizmor.yml" ]; then # No action needed, zizmor will find it echo "Using zizmor.yml found in repository root." + echo "repo-local-zizmor-config=zizmor.yml" >> "${GITHUB_OUTPUT}" exit 0 fi if [ -f ".github/zizmor.yml" ]; then # No action needed, zizmor will find it echo "Using .github/zizmor.yml found in repository." + echo "repo-local-zizmor-config=.github/zizmor.yml" >> "${GITHUB_OUTPUT}" exit 0 fi @@ -388,6 +390,12 @@ jobs: cache-suffix: ${{ env.ZIZMOR_VERSION }} cache-dependency-glob: "" + - name: Validate repo-local zizmor config policy + if: ${{ inputs.always-use-default-config == false && steps.setup-config.outputs.zizmor-config == '' && steps.setup-config.outputs['repo-local-zizmor-config'] != '' }} + uses: grafana/shared-workflows/actions/validate-zizmor-config@c2b99bc7ec69e11c1c4e4cba5cbc9197f794b683 + with: + config_path: ${{ steps.setup-config.outputs['repo-local-zizmor-config'] }} + - name: Zizmor cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: diff --git a/.github/workflows/test-validate-zizmor-config.yml b/.github/workflows/test-validate-zizmor-config.yml new file mode 100644 index 000000000..408e7176a --- /dev/null +++ b/.github/workflows/test-validate-zizmor-config.yml @@ -0,0 +1,60 @@ +name: Test validate-zizmor-config action + +on: + push: + branches: + - main + paths: + - actions/validate-zizmor-config/** + - .github/workflows/test-validate-zizmor-config.yml + - .github/workflows/reusable-zizmor.yml + + pull_request: + paths: + - actions/validate-zizmor-config/** + - .github/workflows/test-validate-zizmor-config.yml + - .github/workflows/reusable-zizmor.yml + types: + - edited + - opened + - ready_for_review + - synchronize + + merge_group: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup UV + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + with: + enable-cache: true + + - name: Unit tests + env: + # renovate: datasource=pypi depName=pyyaml + PYYAML_VERSION: "6.0.2" + run: | + cd actions/validate-zizmor-config + uv run --with "pyyaml==${PYYAML_VERSION}" python3 -m unittest discover -v + + - name: Integration smoke (action on repo default zizmor) + uses: ./actions/validate-zizmor-config + with: + config_path: .github/zizmor.yml diff --git a/actions/validate-zizmor-config/README.md b/actions/validate-zizmor-config/README.md new file mode 100644 index 000000000..343e9f5eb --- /dev/null +++ b/actions/validate-zizmor-config/README.md @@ -0,0 +1,25 @@ +# validate-zizmor-config + +Composite action that enforces Grafana policy on a **repo-local** `zizmor.yml` / `.github/zizmor.yml` before running zizmor. + +Intended to be called from [`.github/workflows/reusable-zizmor.yml`](../../.github/workflows/reusable-zizmor.yml). + +## Inputs + +| Name | Required | Description | +| ------------- | -------- | ------------------------------------------------- | +| `config_path` | yes | Path to the config file relative to the workspace | + +## Requirements + +The calling job must run **`setup-uv`** (or otherwise provide `uv`) before this action, and the workspace must contain the file at `config_path`. + +## Tests + +From the repository root: + +```bash +cd actions/validate-zizmor-config && uv run --with pyyaml==6.0.2 python3 -m unittest discover -v +``` + +CI: [`.github/workflows/test-validate-zizmor-config.yml`](../../.github/workflows/test-validate-zizmor-config.yml). diff --git a/actions/validate-zizmor-config/action.yml b/actions/validate-zizmor-config/action.yml new file mode 100644 index 000000000..2c40af694 --- /dev/null +++ b/actions/validate-zizmor-config/action.yml @@ -0,0 +1,22 @@ +name: Validate repo-local zizmor config +description: >- + Enforces Grafana policy on a repo-local zizmor.yml (used from reusable-zizmor before running zizmor). +inputs: + config_path: + description: Path to the zizmor config file relative to the workspace (e.g. zizmor.yml). + required: true + +runs: + using: composite + steps: + - name: Run policy validator + shell: bash + run: | + set -euo pipefail + uv run --with "pyyaml==${PYYAML_VERSION}" python3 \ + "${GITHUB_ACTION_PATH}/validate_zizmor_config.py" \ + "${CONFIG_PATH}" + env: + CONFIG_PATH: ${{ inputs.config_path }} + # renovate: datasource=pypi depName=pyyaml + PYYAML_VERSION: "6.0.2" diff --git a/actions/validate-zizmor-config/test_validate_zizmor_config.py b/actions/validate-zizmor-config/test_validate_zizmor_config.py new file mode 100644 index 000000000..22207c33a --- /dev/null +++ b/actions/validate-zizmor-config/test_validate_zizmor_config.py @@ -0,0 +1,101 @@ +"""Unit tests for validate_zizmor_config policy logic.""" + +import unittest + +import yaml + +from validate_zizmor_config import UniqueKeyFullLoader, collect_violations + + +class CollectViolationsTests(unittest.TestCase): + def test_parsed_none_ok(self) -> None: + self.assertEqual(collect_violations(None), []) + + def test_no_rules_key_ok(self) -> None: + data = yaml.safe_load("other: true\n") + self.assertEqual(collect_violations(data), []) + + def test_empty_rules_ok(self) -> None: + data = yaml.safe_load("rules: {}\n") + self.assertEqual(collect_violations(data), []) + + def test_allows_grafana_style_unpinned(self) -> None: + text = """ +rules: + unpinned-uses: + config: + policies: + actions/*: any + grafana/*: any +""" + data = yaml.safe_load(text) + self.assertEqual(collect_violations(data), []) + + def test_rejects_insecure_commands(self) -> None: + data = yaml.safe_load( + "rules:\n insecure-commands:\n ignore: [x.yml]\n", + ) + v = collect_violations(data) + self.assertEqual(len(v), 1) + self.assertIn("insecure-commands", v[0]) + + def test_rejects_template_injection(self) -> None: + data = yaml.safe_load("rules:\n template-injection:\n disable: true\n") + v = collect_violations(data) + self.assertEqual(len(v), 1) + self.assertIn("template-injection", v[0]) + + def test_rejects_impostor_commit(self) -> None: + data = yaml.safe_load("rules:\n impostor-commit: {}\n") + self.assertTrue(any("impostor-commit" in m for m in collect_violations(data))) + + def test_rejects_known_vulnerable_actions(self) -> None: + data = yaml.safe_load("rules:\n known-vulnerable-actions:\n ignore: []\n") + v = collect_violations(data) + self.assertEqual(len(v), 1) + self.assertIn("known-vulnerable-actions", v[0]) + + def test_rejects_ref_confusion(self) -> None: + data = yaml.safe_load("rules:\n ref-confusion:\n disable: true\n") + v = collect_violations(data) + self.assertEqual(len(v), 1) + self.assertIn("ref-confusion", v[0]) + + def test_multiple_violations_in_one_config(self) -> None: + text = """ +rules: + insecure-commands: + ignore: [a.yml] + template-injection: + ignore: [b.yml] + unpinned-uses: + disable: true +""" + data = yaml.safe_load(text) + v = collect_violations(data) + self.assertGreaterEqual(len(v), 3, msg=v) + joined = " ".join(v) + self.assertIn("insecure-commands", joined) + self.assertIn("template-injection", joined) + self.assertIn("unpinned-uses.disable", joined) + + def test_rejects_unpinned_disable(self) -> None: + data = yaml.safe_load("rules:\n unpinned-uses:\n disable: true\n") + v = collect_violations(data) + self.assertTrue(any("disable" in m for m in v)) + + def test_rejects_star_any_policy(self) -> None: + data = yaml.safe_load( + 'rules:\n unpinned-uses:\n config:\n policies:\n "*": any\n', + ) + v = collect_violations(data) + self.assertTrue(any("*" in m or "any" in m for m in v)) + + def test_duplicate_mapping_keys_rejected_by_loader(self) -> None: + text = "rules:\n insecure-commands:\n x: 1\n insecure-commands:\n y: 2\n" + with self.assertRaises(yaml.YAMLError): + yaml.load(text, Loader=UniqueKeyFullLoader) + + +if __name__ == "__main__": + unittest.main() diff --git a/actions/validate-zizmor-config/validate_zizmor_config.py b/actions/validate-zizmor-config/validate_zizmor_config.py new file mode 100644 index 000000000..56a2ae12d --- /dev/null +++ b/actions/validate-zizmor-config/validate_zizmor_config.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Fail if a repo-local zizmor.yml violates Grafana shared-workflows policy.""" + +import argparse +import sys +from collections.abc import Hashable +from pathlib import Path + +import yaml +from yaml.constructor import ConstructorError +from yaml.loader import FullLoader +from yaml.nodes import MappingNode + +# Stable prefix for CI log search (e.g. `zizmor-config-validator failed`). +_FAIL_LOG_PREFIX = "zizmor-config-validator failed:" + +# Audits that must not appear under `rules` in repo-local zizmor.yml at all +# (no disable, ignore, or config — the whole block is forbidden). +_FORBIDDEN_RULE_AUDITS = ( + "insecure-commands", + "template-injection", + "impostor-commit", + "known-vulnerable-actions", + "ref-confusion", +) + + +class UniqueKeyFullLoader(FullLoader): + """Like FullLoader but reject duplicate keys in any mapping (YAML 1.2 forbids them).""" + + def construct_mapping(self, node, deep=False): + if isinstance(node, MappingNode): + self.flatten_mapping(node) + if not isinstance(node, MappingNode): + raise ConstructorError( + None, + None, + "expected a mapping node, but found %s" % node.id, + node.start_mark, + ) + mapping = {} + for key_node, value_node in node.value: + key = self.construct_object(key_node, deep=deep) + if not isinstance(key, Hashable): + raise ConstructorError( + "while constructing a mapping", + node.start_mark, + "found unhashable key", + key_node.start_mark, + ) + if key in mapping: + raise ConstructorError( + "while constructing a mapping", + node.start_mark, + "found duplicate key %r" % (key,), + key_node.start_mark, + ) + value = self.construct_object(value_node, deep=deep) + mapping[key] = value + return mapping + + +def _github_error(message: str) -> None: + escaped = message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + print(f"::error::{escaped}") + + +def collect_violations(data: object) -> list[str]: + """Return policy violation messages (empty if the parsed config is allowed).""" + violations: list[str] = [] + if data is None: + return violations + if not isinstance(data, dict): + violations.append("top-level YAML must be a mapping") + return violations + + rules = data.get("rules") + if rules is None: + return violations + if not isinstance(rules, dict): + violations.append("`rules` must be a mapping") + return violations + + for audit_id in _FORBIDDEN_RULE_AUDITS: + if audit_id in rules: + violations.append( + f"forbidden key `rules.{audit_id}` (remove this audit block from the config)", + ) + + unpinned = rules.get("unpinned-uses") + if unpinned is None: + return violations + if not isinstance(unpinned, dict): + violations.append("`rules.unpinned-uses` must be a mapping") + return violations + + if "disable" in unpinned: + violations.append("forbidden key `rules.unpinned-uses.disable`") + + cfg = unpinned.get("config") + if cfg is None: + return violations + if not isinstance(cfg, dict): + violations.append("`rules.unpinned-uses.config` must be a mapping") + return violations + + policies = cfg.get("policies") + if policies is None: + return violations + if not isinstance(policies, dict): + violations.append("`rules.unpinned-uses.config.policies` must be a mapping") + return violations + + for raw_key, raw_val in policies.items(): + key = _normalize_policy_pattern_key(raw_key) + if key == "*" and raw_val == "any": + violations.append( + 'forbidden `rules.unpinned-uses.config.policies` entry `"*": any` ' + "(universal unpinned policy); use scoped patterns instead (e.g. `actions/*: any`)", + ) + + return violations + + +def _normalize_policy_pattern_key(key: object) -> str | None: + if key is None: + return None + if isinstance(key, bool): + return None + if isinstance(key, (int, float)): + return str(key) + if isinstance(key, str): + return key.strip() + return None + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("config_path", type=Path, help="Path to zizmor.yml or .github/zizmor.yml") + args = parser.parse_args() + path: Path = args.config_path + + if not path.is_file(): + full = f"{_FAIL_LOG_PREFIX} {path}: config file does not exist or is not a file" + _github_error(full) + print(full, file=sys.stderr) + sys.exit(1) + + text = path.read_text(encoding="utf-8") + + try: + data = yaml.load(text, Loader=UniqueKeyFullLoader) + except yaml.YAMLError as exc: + full = f"{_FAIL_LOG_PREFIX} {path}: invalid YAML: {exc}" + _github_error(full) + print(full, file=sys.stderr) + sys.exit(1) + + violations = collect_violations(data) + if violations: + for msg in violations: + full = f"{_FAIL_LOG_PREFIX} {path}: {msg}" + _github_error(full) + print(full, file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()