Skip to content
Open
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
18 changes: 18 additions & 0 deletions .github/workflows/reusable-zizmor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment thread
kleimkuhler marked this conversation as resolved.
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
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/reusable-zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
config_path: ${{ steps.setup-config.outputs['repo-local-zizmor-config'] }}

- name: Zizmor cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
Expand Down
60 changes: 60 additions & 0 deletions .github/workflows/test-validate-zizmor-config.yml
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions actions/validate-zizmor-config/README.md
Original file line number Diff line number Diff line change
@@ -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).
22 changes: 22 additions & 0 deletions actions/validate-zizmor-config/action.yml
Original file line number Diff line number Diff line change
@@ -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"
101 changes: 101 additions & 0 deletions actions/validate-zizmor-config/test_validate_zizmor_config.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading