Your GitHub Actions Workflow Has More Power Than Most Developers Realize
DEV Community

Your GitHub Actions Workflow Has More Power Than Most Developers Realize

Most developers think of GitHub Actions as automation. Build the app. Run tests. Deploy. Publish a package. Maybe send a notification. But a workflow is not just a script runner. Depending on how you configure it, a GitHub Actions job may be able to: - read your repository - modify code - create releases - publish packages - access secrets - talk to cloud providers - deploy production - comment on pull requests - change repository state That means your CI pipeline is not just automation. It is part of your security boundary. And many workflows have more authority than the developers maintaining them realize. Start With GITHUB_TOKEN Every GitHub Actions job receives a GITHUB_TOKEN . GitHub creates it automatically for the job, and its permissions determine what the workflow can do inside the repository. That makes this small section of YAML extremely important: permissions: contents: read Without thinking about permissions explicitly, developers can easily give a workflow more authority than it actually needs. The safer idea is simple: Give each workflow the minimum permissions required to complete its job. If a job only needs to check out code and run tests, it probably does not need write access. A Test Job Should Not Be Able to Publish Anything Consider a basic CI workflow: name: CI on: pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - run: npm ci - run: npm test Ask yourself: What does this job actually need? Probably: permissions: contents: read That is it. It does not need permission to: - create releases - modify issues - publish packages - write repository contents A useful mental model is: Permissions belong to the job, not to your general trust in GitHub Actions. The Most Dangerous Workflow Trigger May Surprise You One trigger developers should understand very carefully is: pull_request_target It looks similar to: pull_request But the security model is very different. A normal pull_request from a fork gets strong restrictions: GitHub withholds repository secrets and gives the workflow a read-only token. pull_request_target , on the other hand, runs with the trust of the base repository and can receive repository secrets and a read/write GITHUB_TOKEN . That is useful for things like: - labeling pull requests - triage - authenticated checks But it becomes dangerous if you combine it with untrusted code. The “Pwn Request” Pattern This is the dangerous shape: on: pull_request_target: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha }} - run: npm install - run: npm test At first glance, this looks reasonable. You want to test the contributor's code. But now you have: Untrusted pull request code running inside a privileged workflow. That code could modify: package.json - install scripts - test scripts - build scripts - Makefiles - configuration files And GitHub explicitly warns against executing fork code in a privileged pull_request_target workflow. The problem is not checkout itself. The problem starts when you execute what you checked out. npm install Is Code Execution This is another thing developers sometimes forget. When you run: npm install you are not necessarily just downloading files. A dependency may have install scripts. Your own repository may have lifecycle scripts. Build tools may execute configuration. So if untrusted code can modify dependency or build configuration, running: npm install inside a privileged job may effectively mean: Execute code controlled by the pull request. The same idea applies beyond npm: make pip install ./gradlew build cargo build docker build CI commands are execution boundaries. Treat them that way. Secrets Change the Risk Completely Imagine your workflow contains: env: API_KEY: ${{ secrets.API_KEY }} Now anything executing in that job may potentially interact with that secret. GitHub recommends using least-privilege credentials because jobs and actions can access secrets available to the workflow. A useful question is: If this step were compromised, what could it steal or modify? That question should influence how you structure jobs. Separate Untrusted Code From Privileged Operations Suppose your pipeline needs to: - test a pull request - publish something afterward Do not automatically put everything in one giant privileged job. Think in trust boundaries. For example: Pull request code ↓ Build + test No secrets Read-only token ↓ Artifact ↓ Trusted workflow ↓ Publish / deploy The first job handles untrusted code. The later job handles privileged operations. That separation makes the system much easier to reason about. Third-Party Actions Are Dependencies Too Developers carefully review npm packages. Then write: uses: some-user/some-action@v2 without thinking twice. But third-party actions execute inside your workflow. A compromised action could potentially access repository secrets or use the workflow's GITHUB_TOKEN . GitHub recommends pinning actions to a full commit SHA when you need an immutable reference. Instead of relying only on: uses: vendor/action@v2 a more controlled setup can pin the exact commit: uses: vendor/a*****@3c2f...full-commit-sha Tags are convenient. But tags can move. A full commit SHA is immutable. Your Workflow Has a Dependency Tree Most developers think their dependency tree is: application ├── react ├── express └── postgres client But there is another one: CI pipeline ├── actions/checkout ├── setup-node ├── deployment action ├── security scanner └── custom third-party actions Those dependencies deserve review too. Because they may run with more privilege than your application code. Long-Lived Cloud Credentials Are Often Unnecessary A common deployment setup looks like this: env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} Now you have long-lived credentials stored in GitHub. A better model, where supported, is short-lived authentication. GitHub Actions supports OpenID Connect so workflows can request short-lived identity tokens and exchange them with cloud providers instead of storing permanent cloud credentials. Conceptually: GitHub workflow ↓ OIDC identity ↓ Cloud provider verifies workflow ↓ Temporary credentials ↓ Deploy Now there may be no permanent cloud secret sitting in the repository configuration. That is a major improvement. Self-Hosted Runners Change the Threat Model GitHub-hosted runners are usually temporary. Self-hosted runners may not be. If your runner also has access to: - internal networks - Docker sockets - production databases - cloud metadata - SSH keys - mounted files then executing untrusted code there can become much more dangerous. GitHub specifically warns that compromised runners can expose secrets and other resources available to the runner environment. Ask: What can this machine reach that GitHub itself cannot? That includes network access, not just stored credentials. A Green Build Does Not Mean a Safe Workflow Developers often see: ✓ Build passed ✓ Tests passed ✓ Deployment passed and assume everything is fine. But workflow security is not about whether the commands succeeded. It is about: What authority did those commands have while they were running? A perfectly successful workflow can still be badly designed. Audit These 8 Things in Your GitHub Actions Today 1. Token Permissions Search your workflows for: permissions: If it is missing, ask whether you should define it explicitly. Prefer the minimum required permission. 2. pull_request_target Search: pull_request_target If you find it, understand exactly why it exists. Be especially careful if that workflow checks out and executes pull request code. GitHub is actively tightening policy around this event in public repositories because of its security implications. 3. Secrets Search for: secrets. For every secret, ask: Does this job really need it? Do not make secrets available simply because a later step might use them. 4. Third-Party Actions Review every: uses: Ask: - who maintains this? - do we still need it? - is it pinned? - what permissions does the job have? 5. Publishing Search for: npm publish docker push gh release terraform apply kubectl aws gcloud az Anything that changes an external system deserves extra scrutiny. 6. Untrusted Input Be careful when inserting values from issues, pull requests, branch names, commit messages, or other external sources into shell commands. The workflow file is code. Untrusted strings should be treated like untrusted input anywhere else. 7. Self-Hosted Runners Ask what the runner can access. A runner with internal network access has a very different blast radius from an isolated temporary runner. 8. Build and Deploy Separation If possible, separate: build from: deploy Your build job should not automatically inherit production authority just because deployment happens later. A Safer Mental Model Do not think: GitHub Actions runs my CI. Think: GitHub Actions runs code with an identity, permissions, credentials, network access, and external capabilities. That changes how you review a workflow. A workflow becomes much closer to a small production service than a simple YAML file. Final Thought Most developers would never give every application endpoint administrator access. But we sometimes give CI pipelines broad permissions because: “It is just our build workflow.” It is not. Your workflow may have the ability to modify repositories, access secrets, publish packages, deploy infrastructure, and authenticate to production systems. So the next time you open: .github/workflows/ do not only ask: Does this pipeline work? Ask: What could this pipeline do if one step became untrusted? That question may be far more important. Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.