A CI workflow runs code from many places: your repository, third-party actions, and pull requests from people you don't know. Each one is a way in. This post covers six controls that close the common gaps. The examples come from rzv-ci-plb, the Runzivo CI playbook.

Two columns joined by a merge arrow. Pull request checks, untrusted: pull_request trigger, read-only token, no secrets for forks, no persisted credentials, event text through env, avoid pull_request_target. Deploy, trusted: push to main, environment 04-prd-01, OIDC only if needed, concurrency group deploy-04-prd-01, SHA pins, actionlint and zizmor.

1. Start every workflow with read-only permissions

Mistake: no permissions: key. The GITHUB_TOKEN then gets the default set for the enterprise, organization or repository, which may allow writes.

Do this: set read-only at the top, as every workflow in the playbook does. From ci.yml:

yaml
permissions:
  contents: read

Raise it only on the one job that needs more. A simplified example:

yaml
jobs:
  label:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      pull-requests: write
    steps:
      - run: echo "This job can label pull requests."

When you set any permission, every permission you leave out becomes none. The workflow syntax reference documents this. See also Permissions in docs/security.md.

2. Pin third-party actions to a full commit SHA

Mistake: uses: some-org/some-action@v3. A tag can be moved to new code at any time.

Do this: pin to the full SHA and keep the version in a comment.

yaml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Check the SHA before you use it. From docs/security.md:

bash
gh api repos/actions/checkout/git/ref/tags/v7.0.1 --jq '.object.type + " " + .object.sha'
# If the type is "tag", dereference it:
gh api repos/OWNER/REPO/git/tags/TAG_OBJECT_SHA --jq .object.sha

Make sure the SHA belongs to the action's own repository, not a fork. Dependabot can keep pinned SHAs up to date. GitHub also documents workflow dependency lockfiles, currently in public preview, as a stronger option than SHA pinning.

3. Never paste event text into shell code

Don't do this. This step is shortened from GitHub's own script injection guide:

yaml
- name: Check PR title
  run: |
    title="${{ github.event.pull_request.title }}"
    if [[ $title =~ ^octocat ]]; then
      echo "PR title starts with 'octocat'"
    fi

GitHub replaces ${{ }} before the shell starts. A pull request titled a"; ls $GITHUB_WORKSPACE" becomes a command.

Do this: pass the value through an environment variable and quote it. The shell then treats it as data. Adapted from Untrusted input in docs/security.md:

yaml
- name: Show title
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: |
    printf 'Title: %s\n' "$PR_TITLE"

actionlint and zizmor both flag the unsafe pattern. I ran both against the example above to confirm.

4. Treat pull requests from forks as untrusted

GitHub already limits fork pull requests. Per the events reference, secrets other than GITHUB_TOKEN are not passed to the runner, and the token is read-only.

The risk comes back when you use pull_request_target or workflow_run. Those run with the base repository's privileges. GitHub's secure use guide says to avoid them unless you need them, and never to check out and run untrusted pull request code inside them.

Also stop actions/checkout from keeping the token for later steps:

yaml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  with:
    persist-credentials: false

Self-hosted runners need one more rule: don't run fork pull requests on them. The playbook's runzivo.yml runs only on push to main and manual dispatch. See Self-hosted runners in docs/security.md.

5. Deploy from a separate, protected job

Mistake: the pull request workflow also holds deploy credentials.

Do this: deploy from main only, after checks pass, through a GitHub environment. Add required reviewers where your plan supports them, allow only the main branch, and store deploy secrets in the environment.

I name environments <NN>-<env>-<NN>: 01-dev-01, 02-qat-01, 03-stg-01 and 04-prd-01, with the codes dev, qat, stg and prd. Lists then sort from development to production. This deploy job, from the deploy gate in docs/environments.md, targets the first production environment:

yaml
deploy:
  needs: [test, build]
  if: github.ref == 'refs/heads/main'
  runs-on: ubuntu-latest
  timeout-minutes: 15
  environment:
    name: 04-prd-01
  permissions:
    contents: read
  concurrency:
    group: deploy-04-prd-01
    cancel-in-progress: false
  steps:
    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      with:
        persist-credentials: false
    - run: bash "${GITHUB_WORKSPACE}/scripts/ci/03-deploy.sh"

03-deploy.sh stands for your own deploy script, numbered after build and test. Pull request jobs never name an environment, so they never see its secrets.

For cloud access, prefer OpenID Connect over a long-lived key: add id-token: write to this job only, and GitHub issues a short-lived token per run. See Credentials in docs/security.md.

6. Keep secrets out of logs

  • Don't print secrets, even partly.
  • Mask derived values with ::add-mask::, as the secrets guide describes.
  • Don't store JSON or YAML blobs as one secret. Redaction matches exact values, so structured secrets can leak.
  • If a secret reaches a log, delete the log and rotate the secret.

Check it automatically

The playbook runs zizmor on every pull request, every push to main and once a week, through security.yml. Locally:

bash
bash scripts/ci/01-validate.sh security

zizmor has audits for template injection, excessive permissions, unpinned actions and dangerous triggers. The repository's zizmor.yml requires a SHA pin for every action.

Running on Runzivo

Runzivo is in Early Access. It assigns each eligible job a fresh, single-use microVM, which limits what one job can leave behind for the next. It doesn't replace any control above. Permissions, pinning and injection safety live in your workflow. Its GitHub App permissions are listed in the Runzivo docs.

In the repo

To run these workflows on managed GitHub Actions runners hosted in Canada, see pricing for Runzivo runners and sandboxes.

Every example in this series is tested in rzv-ci-plb, the Runzivo CI playbook, pinned to tag v0.1.0.