From e25cf7da0313c457ab808819319ce8dde32993be Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Thu, 8 Oct 2026 14:32:29 -0700 Subject: [PATCH] ci: add reusable GitHub Actions security scanning --- .github/workflows/workflow-tests.yml | 33 +++++++++ .github/workflows/zizmor.yml | 34 +++++++++ README.md | 95 ++++++++++++++++++++++++ tests/fixtures/annotations-caller.yml | 18 +++++ tests/fixtures/sarif-caller.yml | 18 +++++ tests/zizmor-workflow.test.mjs | 102 ++++++++++++++++++++++++++ 6 files changed, 300 insertions(+) create mode 100644 .github/workflows/workflow-tests.yml create mode 100644 .github/workflows/zizmor.yml create mode 100644 tests/fixtures/annotations-caller.yml create mode 100644 tests/fixtures/sarif-caller.yml create mode 100644 tests/zizmor-workflow.test.mjs diff --git a/.github/workflows/workflow-tests.yml b/.github/workflows/workflow-tests.yml new file mode 100644 index 0000000..b1aeaa9 --- /dev/null +++ b/.github/workflows/workflow-tests.yml @@ -0,0 +1,33 @@ +name: Shared Workflow Tests + +on: + push: + branches: [main] + pull_request: + +permissions: {} + +jobs: + test: + name: Test shared workflow contract + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "24" + + - name: Test reporting modes and permissions + run: node --test tests/zizmor-workflow.test.mjs + + - name: Validate workflows and caller fixtures + uses: docker://rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 + with: + args: .github/workflows/zizmor.yml .github/workflows/workflow-tests.yml tests/fixtures/sarif-caller.yml tests/fixtures/annotations-caller.yml diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..42df573 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,34 @@ +name: GitHub Actions Security Analysis + +on: + workflow_call: + inputs: + advanced-security: + description: Upload SARIF to code scanning. Disable for annotation-only reporting. + required: false + type: boolean + default: true + +# Intentionally inherit the caller's explicitly scoped job permissions. +# SARIF needs contents: read, actions: read, and security-events: write; +# annotation-only callers need only contents: read. This workflow cannot +# elevate the caller's token permissions. See README.md for caller examples. + +jobs: + zizmor: + name: Run zizmor 🌈 + runs-on: ubuntu-latest + env: + # Fork PRs always use annotations without attempting a SARIF upload. + ZIZMOR_ADVANCED_SECURITY: ${{ inputs.advanced-security && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run zizmor 🌈 + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + advanced-security: ${{ env.ZIZMOR_ADVANCED_SECURITY }} + annotations: ${{ env.ZIZMOR_ADVANCED_SECURITY != 'true' }} diff --git a/README.md b/README.md index cd6de4e..3210795 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,101 @@ A repository inherits the baseline by extending the shared preset, then adds its `default.json`, `renovate-config.json`, and any other preset files are validated with `renovate-config-validator --strict` by [`.github/workflows/renovate-config-validator.yml`](.github/workflows/renovate-config-validator.yml). Consumer repositories call the same reusable workflow to validate their own `renovate.json`. +## 🛡️ Shared GitHub Actions security scanning + +[`.github/workflows/zizmor.yml`](.github/workflows/zizmor.yml) is a reusable +workflow that checks the **calling repository's** workflows and actions with +zizmor. Checkout does not persist credentials, repository-local zizmor +configuration is discovered normally, and no application scripts are executed. + +### Calling the workflow + +Add this caller as `.github/workflows/zizmor.yml`. Replace the placeholder with +the **full SHA of a merged provider commit** before using it: + +```yaml +name: GitHub Actions Security Analysis + +on: + push: + branches: [main] + pull_request: + +permissions: {} + +jobs: + zizmor: + permissions: + contents: read + actions: read + security-events: write + uses: gitify-app/.github/.github/workflows/zizmor.yml@REPLACE_WITH_FULL_COMMIT_SHA +``` + +Do not filter pull requests by path: even documentation-only PRs should receive +the check. No inherited secrets or personal token is required. + +### Reporting and token permissions + +- `advanced-security` is a boolean input, defaulting to `true`. For supported + same-repository PRs and pushes, findings are uploaded to the **caller's** code + scanning interface with category `zizmor`. Upload failures remain visible. +- Fork PRs always use annotations instead of uploading SARIF. They run under + ordinary `pull_request` permissions, without privileged tokens or + `pull_request_target`. GitHub can require maintainer approval before a fork's + workflow runs. +- Set `advanced-security: false` when code-scanning uploads are unavailable. + Annotation mode retains zizmor's failure exit status for findings. SARIF mode + retains code-scanning gating: a successful scanner job alone does not mean + there are no findings. + +The reusable workflow **intentionally inherits the calling job's permissions**. +GitHub Actions cannot select permissions from an input, and a reusable workflow +cannot elevate the caller's token. Keep `permissions: {}` at the caller's +workflow level and grant only the permissions needed by its calling job. +For annotation-only reporting, that job can use just read access: + +```yaml +jobs: + zizmor: + permissions: + contents: read + uses: gitify-app/.github/.github/workflows/zizmor.yml@REPLACE_WITH_FULL_COMMIT_SHA + with: + advanced-security: false +``` + +### Validation and rollout + +Regression tests use Node.js 24+ and no package dependencies: + +```sh +node --test tests/zizmor-workflow.test.mjs +actionlint .github/workflows/zizmor.yml .github/workflows/workflow-tests.yml tests/fixtures/*-caller.yml +zizmor --no-online-audits .github/workflows/zizmor.yml tests/fixtures/*-caller.yml +``` + +The tests evaluate the workflow's boolean reporting expressions for pushes, +same-repository PRs, and fork PRs, and check the provider/caller permission +contract. They are not a substitute for live GitHub upload and fork-PR checks. + +Merge the provider first, then open separate caller migrations in website, +GNOME, Gitify, and KDE pinned to its merged SHA. Add this repository's own +SHA-pinned self-scan caller in a follow-up change. Keep these changes separate +from Renovate config work. + +Reusable jobs can change the displayed check name (for example, +`zizmor / Run zizmor 🌈`). Review each repository's required checks before +merging; no current required check in the initial rollout references the +duplicated zizmor job. Preserve category `zizmor`, and verify GitHub's analysis +key/check display after migration. Gitify and website have other required +checks, which are unaffected by this migration. + +Update provider SHA references through normal dependency PRs; Renovate's +GitHub Actions manager recognizes reusable workflow dependencies. For rollback, +restore the previous local workflow, or revert the new caller in KDE. Keep +provider commits available while consumers still reference them. + ## 📄 License Released under the [MIT License](LICENSE). diff --git a/tests/fixtures/annotations-caller.yml b/tests/fixtures/annotations-caller.yml new file mode 100644 index 0000000..8b4e46c --- /dev/null +++ b/tests/fixtures/annotations-caller.yml @@ -0,0 +1,18 @@ +name: Annotation caller fixture + +on: + push: + branches: [main] + pull_request: + +permissions: {} + +jobs: + zizmor: + permissions: + contents: read + # Keep this local lint fixture compatible with actionlint 1.7.12, which + # does not support $/ self-repository syntax. Production callers use SHAs. + uses: ./.github/workflows/zizmor.yml # zizmor: ignore[self-repository] + with: + advanced-security: false diff --git a/tests/fixtures/sarif-caller.yml b/tests/fixtures/sarif-caller.yml new file mode 100644 index 0000000..19e5dfb --- /dev/null +++ b/tests/fixtures/sarif-caller.yml @@ -0,0 +1,18 @@ +name: SARIF caller fixture + +on: + push: + branches: [main] + pull_request: + +permissions: {} + +jobs: + zizmor: + permissions: + contents: read + actions: read + security-events: write + # Keep this local lint fixture compatible with actionlint 1.7.12, which + # does not support $/ self-repository syntax. Production callers use SHAs. + uses: ./.github/workflows/zizmor.yml # zizmor: ignore[self-repository] diff --git a/tests/zizmor-workflow.test.mjs b/tests/zizmor-workflow.test.mjs new file mode 100644 index 0000000..c3c6587 --- /dev/null +++ b/tests/zizmor-workflow.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { runInNewContext } from 'node:vm'; + +const workflow = readFileSync( + new URL('../.github/workflows/zizmor.yml', import.meta.url), + 'utf8', +); + +function expressionFor(key) { + const match = workflow.match( + new RegExp(`^\\s+${key}: \\$\\{\\{ (.+) \\}\\}$`, 'm'), + ); + assert.ok(match, `Missing expression for ${key}`); + return match[1]; +} + +// These expressions use only boolean inputs, string comparisons, and &&/||, +// whose behavior matches JavaScript for the concrete values in these tests. +// Evaluate the actual workflow expression, not a separately copied condition. +function reportingMode(advancedSecurity, eventName, headRepository) { + const context = { + inputs: { 'advanced-security': advancedSecurity }, + github: { + event_name: eventName, + repository: 'gitify-app/website', + event: eventName === 'pull_request' + ? { pull_request: { head: { repo: { full_name: headRepository } } } } + : {}, + }, + }; + const upload = runInNewContext( + expressionFor('ZIZMOR_ADVANCED_SECURITY').replaceAll( + 'inputs.advanced-security', + 'inputs["advanced-security"]', + ), + context, + { timeout: 1000 }, + ); + context.env = { ZIZMOR_ADVANCED_SECURITY: String(upload) }; + return { + upload: runInNewContext(expressionFor('advanced-security'), context), + annotations: runInNewContext(expressionFor('annotations'), context), + }; +} + +for (const [name, enabled, event, head, upload] of [ + ['push with uploads enabled', true, 'push', undefined, true], + ['same-repository PR with uploads enabled', true, 'pull_request', 'gitify-app/website', true], + ['fork PR with uploads enabled', true, 'pull_request', 'contributor/website', false], + ['push with uploads disabled', false, 'push', undefined, false], + ['same-repository PR with uploads disabled', false, 'pull_request', 'gitify-app/website', false], + ['fork PR with uploads disabled', false, 'pull_request', 'contributor/website', false], + ['PR with unavailable head repository', true, 'pull_request', '', false], +]) { + test(name, () => { + assert.deepEqual(reportingMode(enabled, event, head), { + upload: String(upload), + annotations: !upload, + }); + }); +} + +test('provider exposes a boolean upload input, enabled by default', () => { + assert.match(workflow, /workflow_call:/); + assert.match(workflow, /advanced-security:\n(?:[^\n]*\n)*?\s+type: boolean\n\s+default: true/); +}); + +test('provider inherits caller-scoped permissions without requesting elevation', () => { + assert.doesNotMatch(workflow, /^\s*permissions:/m); + assert.doesNotMatch(workflow, /^\s*secrets:/m); + assert.doesNotMatch(workflow, /pull_request_target:/); +}); + +test('provider pins actions and does not persist checkout credentials', () => { + const actions = [...workflow.matchAll(/^\s+uses: (.+)$/gm)]; + assert.equal(actions.length, 2); + for (const [, action] of actions) { + assert.match(action, /^[\w./-]+@[a-f0-9]{40}(?:\s+#.*)?$/); + } + assert.match(workflow, /persist-credentials: false/); +}); + +test('provider only scans: no consumer scripts, custom config, or swallowed errors', () => { + assert.doesNotMatch(workflow, /^\s+(?:run|config|continue-on-error):/m); +}); + +test('SARIF caller fixture grants exactly the documented permissions', () => { + const fixture = readFileSync(new URL('./fixtures/sarif-caller.yml', import.meta.url), 'utf8'); + assert.match(fixture, /^permissions: \{\}$/m); + assert.match(fixture, / permissions:\n contents: read\n actions: read\n security-events: write\n/); + assert.match(fixture, /uses: \.\/\.github\/workflows\/zizmor.yml/); +}); + +test('annotation caller fixture needs only read access and disables uploads', () => { + const fixture = readFileSync(new URL('./fixtures/annotations-caller.yml', import.meta.url), 'utf8'); + assert.match(fixture, /^permissions: \{\}$/m); + assert.match(fixture, / permissions:\n contents: read\n(?: #.*\n)* uses:/); + assert.doesNotMatch(fixture, /security-events:|actions: read/); + assert.match(fixture, /advanced-security: false/); +});