Skip to content

Engineering blog

The repository cannot enforce its own deployment policy

A deploy gate written as a job inside the repository it protects can be skipped by anyone who can edit and dispatch its workflows. Why enforcement has to live where that access does not reach, and the pattern that puts it there.

Write down your production deploy gate. If it is a job in .github/workflows/deploy-production.yml, the rule and the thing it restrains live in the same repository, under the same write permission. That has a specific failure, and the specificity is the point: not “defence in depth is nice to have”, but a two-command exploit. The example here is GitHub Actions; the shape applies to any CI system where the pipeline definition lives next to the code it ships.

The mechanic

workflow_dispatch executes the workflow file from the ref you select, not from the default branch. The file only has to exist on the default branch for the trigger to be registered; the version that actually runs is the one at the ref you name.

That becomes a deployment bypass when the modified workflow can still obtain deployment credentials without passing an approval the workflow file does not control — a repository-level secret, say, or an Environment with no protection rules. Then the approval is optional for anyone who can rewrite and run the workflow:

git push origin HEAD:fix/ci-timeout          # a branch where the approve job is gone
gh workflow run deploy-production.yml --ref fix/ci-timeout

That is the whole attack. It needs no vulnerability in GitHub Actions, no race and no stolen deploy token. It needs access that can both change workflow files and dispatch them: a maintainer’s session, or a token granted workflow-file write and Actions write. GitHub scopes those separately, so not every stolen contributor token qualifies. It defeats every check written as a job in the file it has just replaced. A verify-ref job cannot refuse a commit once verify-ref no longer exists, and an ancestry check asserting the commit is on main is removed by a push that never touches main, so branch protection sees nothing to object to.

The same goes for a human approval step. If the gate is an approval call on line 40 of a file the attacker can rewrite, the gate is a comment.

What the platform gives you

GitHub’s answer is the Environment: a deployment-branch policy withholds the deploy secret from a run whose ref falls outside the rule, and required reviewers ask a human first. It is cheap and worth having. Two things to know before relying on it. Declaring environment: production in a workflow creates no protection — GitHub creates a missing environment on first use, with no rules attached. And the rules are GitHub checking GitHub identities: a hijacked session cannot lift a branch policy, but a hijacked session of an eligible reviewer can approve the deployment, and an admin session can edit the rules, or skip them where admin bypass is enabled.

Check each rule on its own, too. Ours was once half-built: a branch policy and no required reviewers, so nothing asked a human before a deploy from main. The one-line proof our runbook offered — that the environments API did not return {"total_count":0} — had gone green while the control it stood for was absent. A check like that is worse than none, because it reads as a green light. Ask separately about reviewers, branch policy, admin bypass, and whether the environment’s secrets also exist at repository level, where any workflow can read them.

The gate has to live where push access does not reach

Every approval scheme worth the name says the same thing about verification: the side that executes must take the action it checks, and the list of who may approve it, from its own configuration — never from the request, and never from the approval itself. An approval that names its own approver verifies against itself.

Read that as a rule about data flow and you can implement it exactly and still be exposed. In CI, “its own configuration” is not a variable scope. It is a question about which repository can edit the file that supplies the value. When that is the repository under attack, every “own” resolves to the attacker. Libraries can enforce the data-flow half; the location half is a topology decision.

The pattern: a separate enforcer

Move the decision to a small repository the application repository cannot write to — ideally in a separate organization, so owning one is not owning both. The application repository keeps its tests and gains no deploy path at all; its CI should fail if a workflow there starts referencing a production secret. The enforcer is built around four rules:

  1. The enforcer holds the credential; the requester holds none.
  2. The enforcer supplies freshness. Each run adds a random request ID to what the human approves, so a previous run’s approval cannot authorize this one.
  3. The enforcer derives every value it verifies. Target, action, commit and artifact digest are its own constants and observations, never fields read from a request.
  4. The job that holds a production credential executes nothing from the application repository.

In practice a deploy becomes three jobs. A build job with no production credential checks out the requested commit, refuses it unless it is on the application’s main branch, builds the artifact and records its digest. That ancestry check was worthless inside the application repository; here it means something, because the application repository cannot edit it. An approval job then asks a human to approve exactly this:

{
  "sha": "<40-character commit>",
  "imageDigest": "<registry>/…@sha256:…",
  "environment": "production",
  "approvalRequestId": "<random, per run>"
}

It verifies the approval against a pinned list of who may approve and refuses anything automatic. Only then does the deploy job — the only one holding the deploy credential — start, and it ships exactly the approved digest. Keep the runtime configuration in the enforcer as well: if the application repository owned it, it could change the entrypoint of an image a human approved by digest.

Package releases follow the same shape. Pack while holding nothing that can publish, bind each tarball’s hash into the approval, then publish that exact file with lifecycle scripts disabled, so the credentialed job runs none of the application’s code. Prefer the registry’s OIDC trusted publishing, so there is no long-lived token to steal. Where a version tag is the release, as with Go modules, put the tag in the approval and restrict tag creation to the release identity.

Be precise about what each artifact proves. An approval over a tarball hash proves a human approved those exact bytes for that commit and version; it does not prove the tarball was built from that commit. That is what build provenance is for — and npm, for one, does not generate provenance from a private repository, so a private enforcer has to say plainly which of the two claims it makes.

This is the pattern INTYGA’s own production deploys and package releases run through, with the approval made by a passkey signature over the values above. One honest detail: INTYGA is one person today, so the quorum is one and the approver wrote the code being approved. The gate defeats someone holding our repository access without our passkey; it does not defeat us. A second approver is a policy change, not a topology change.

What this does not solve

Separation moves the problem; it does not remove it.

  • Database migrations break rule 4. Applying a migration is running the application’s SQL with production privileges. What the split still buys: dependency install scripts never meet the database credential, the runner recipe belongs to the enforcer, and the approver signs a description that names the migrations being applied.
  • Account owners. Whoever owns the source organizations and the cloud accounts can deploy around any workflow. A separate organization raises the bar; it does not help when both have the same owner.
  • The approval service is a dependency. If it is down, the gated path is down with it. Decide before an incident what the recovery path is — and make it something other than a switch that turns the gate off, because a bypass in the control is worth less than the control.
  • Approving without reading. A signature proves who authorized, never that they understood. Binding the artifact digest narrows the distance between what was approved and what runs; it does not substitute for reading the diff. The gate protects the path from commit to production and says nothing about how the commit came to exist.
  • The enforcer is now the thing to protect. What that buys is a much smaller and duller trust domain: configuration and workflows, not an application. It changes rarely, takes no dependency update unread, and a diff against it is short enough to review properly.

Four questions for your own pipeline

  1. Can write access to the application repository reach a production credential — directly, or by dispatching a workflow from a branch it controls?
  2. Does the code that verifies the approval, and the list of who may approve, come from a place the attacker can edit?
  3. Does the approval bind the exact artifact that ships — an image digest, a tarball hash — or only a source revision or a mutable tag?
  4. If the approval service itself is down, what happens — and is the answer anything other than a bypass switch?

If any answer is wrong, the fix is a location, not a library. The ChainDrop post makes the same argument for package publishing, from inside an incident. How we answer the fourth question for INTYGA itself is the subject of a follow-up.