On 23 July 2026, a report described GitHub Actions abuse that turned Packagist repositories into scanners. The alarming part is not a compromised PHP package: a CI runner that can fetch attacker-chosen code and reach the internet is already a general-purpose scanning node.

That distinction changes the defensive question. Instead of asking only, “Can an attacker steal our release token?”, ask: “Which person can cause a runner to execute which code, with which credentials, and against which network destinations?” A useful workflow review answers all four questions for every trigger.

Turn the Packagist report into a reusable threat model

A GitHub Actions job has four capabilities that matter during workflow abuse: it can execute code, read repository material, obtain credentials, and make outbound connections. A job does not need contents: write access to be useful to an attacker; an unauthenticated runner with unrestricted egress can still probe hosts, enumerate services, download tooling, or make requests that appear to originate from CI infrastructure.

Model each workflow as a tuple:

trigger + checked-out ref + token/secrets + outbound network access

For example, consider a test workflow triggered by a pull request from a fork:

  • Trigger: an internet user can open a pull request.
  • Checked-out ref: the job may execute code supplied in that pull request.
  • Authority: it should receive only a read-only token and no deployment credentials.
  • Network: it may need package registries, but it should not be able to reach internal services or arbitrary sensitive destinations.

The failure mode is usually an unsafe combination, not one bad setting. A pull-request build is expected to execute untrusted test code. It becomes a serious incident path when that same job receives secrets, write permission, a trusted runner network, or a maintainer token persisted by a checkout step.

Classify triggers before changing YAML

Start with an inventory of workflow triggers. The trigger determines who can request execution; the event name alone does not prove the run is safe. List every workflow under .github/workflows/, then record whether its caller is an anonymous contributor, a repository collaborator, a bot, or a protected branch.

The events worth treating differently are:

  • pull_request: appropriate for untrusted build and test work when configured without secrets or write authority.
  • pull_request_target: runs in the context of the base repository and requires exceptional care. Do not check out or execute pull-request code in a privileged pull_request_target job.
  • workflow_dispatch: manually initiated, but often available to repository collaborators. Treat inputs as untrusted unless authorization is explicit.
  • issue_comment: useful for commands such as /retest, but the comment body and event fields are attacker-controlled input.
  • workflow_run: can create a privilege boundary between an untrusted build and a trusted follow-up, but only if artifacts and metadata are validated.
  • push: generally suitable for release or deployment work only when limited to protected branches and reviewed tags.

The practical decision rule is simple: if someone outside the group allowed to deploy can cause the run, the run must not receive deployment secrets, write permissions, or trusted-network placement. Keep an untrusted CI workflow separate from a trusted publish workflow rather than trying to make one workflow switch personalities halfway through.

A common safe pattern is two stages. The pull request workflow compiles, tests, and uploads a narrowly defined artifact. A protected-branch workflow rebuilds from the reviewed merge commit and publishes. Rebuilding costs runner minutes, but it avoids trusting an artifact produced by code an external contributor controlled.

Make the GitHub token boring and secrets unreachable

Set a default permission baseline at the workflow level, then add a permission only to the one job that needs it. Do not rely on the repository’s default GITHUB_TOKEN policy being remembered correctly six months after a settings change.

name: pull-request-tests

on:
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<FULL_40_CHARACTER_COMMIT_SHA>
        with:
          persist-credentials: false
      - run: npm ci
      - run: npm test

The persist-credentials: false setting matters when a later script runs git: it prevents checkout from leaving the token configured as an HTTPS credential for that workspace. It does not replace restrictive permissions; use both.

Keep release credentials in a separate deployment job triggered only after protected-branch review. For cloud deployments, prefer short-lived credentials obtained through OpenID Connect over a long-lived cloud key stored as a GitHub secret. Scope the cloud-side trust policy to the repository, branch or environment, and intended audience; a broad policy that accepts tokens from every branch defeats the point.

GitHub does not make fork pull requests a reason to relax this model. Treat all pull-request titles, branch names, issue bodies, artifact names, and workflow inputs as data. Never concatenate them into shell commands. Pass values through environment variables and quote them, or validate them against a narrow allowlist.

Control outbound network access where it actually matters

Repository scanning abuse is an egress problem as much as an execution problem. A runner that can issue arbitrary HTTPS requests can be used to scan public targets even when it has no repository token and no secrets. This is why “we set permissions: read-all” is not a complete answer.

First, separate jobs by network need. Unit tests that only require Node.js packages should not share a runner group with deployment jobs that can reach an internal artifact repository, Kubernetes API, or cloud metadata-adjacent network. A public pull-request runner should never be a route into private address space.

Where outbound restrictions are a requirement, run sensitive jobs on infrastructure you control and enforce policy outside the workflow YAML:

  • Route runner traffic through an egress proxy or firewall.
  • Allow required destinations such as GitHub, your package registry, and your artifact store.
  • Block private and internal address ranges from untrusted runner pools.
  • Log DNS queries and proxy requests with the workflow run ID or runner identity.
  • Use separate runner groups for public pull requests, internal CI, and production deployment.

The tradeoff nobody enjoys is package installation. Modern builds may contact GitHub, npm, PyPI, Maven Central, container registries, language mirrors, and vendor endpoints. Start by logging actual destinations for seven days, then build an allowlist from observed dependency traffic. An overly strict rule applied on day one usually becomes an emergency bypass; a measured rule becomes durable control.

Pin actions, reusable workflows, and container images

Every uses: line is third-party code execution in your CI environment. A marketplace action can be legitimate and still be a supply-chain boundary, particularly when it runs before your build or receives a token. The GitHub Marketplace includes actions maintained outside GitHub itself, including replacements for older unmaintained actions; maintenance status is not the same thing as a security review.

Pin third-party actions to a full commit SHA, not a moving tag such as @v4 or @main. Tags are convenient update channels, while a commit SHA identifies the exact code reviewed by your team.

- uses: actions/checkout@<FULL_40_CHARACTER_COMMIT_SHA>
- uses: github/codeql-action/upload-sarif@<FULL_40_CHARACTER_COMMIT_SHA>

Apply the same rule to reusable workflows referenced with owner/repository/.github/workflows/file.yml@ref. For container actions and job containers, use an immutable image digest rather than a mutable image tag. Pinning does create maintenance work, so automate pull requests for action updates with your dependency-update tooling, but keep human review for changes that alter permissions, shell commands, runner labels, or network behavior.

Also review local actions. A reference such as uses: ./.github/actions/release is code from the checked-out repository. In an untrusted pull-request job, that path can point to attacker-modified code. Local actions are not inherently safer than marketplace actions; the checked-out ref decides who controls them.

Alert on the combinations that signal abuse, then complete this week’s checklist

Alerting should identify unusual capability combinations rather than merely count failed builds. A failed test is routine. A pull-request workflow that makes thousands of outbound requests, modifies workflow files, or suddenly begins using a self-hosted runner is worth investigation.

Create alerts or scheduled reviews around these signals:

  • Changes to .github/workflows/, CODEOWNERS, runner labels, and deployment configuration.
  • New uses of pull_request_target, workflow_run, issue_comment, or workflow_dispatch.
  • Permission changes from contents: read to write-capable scopes such as contents: write, pull-requests: write, or id-token: write.
  • New third-party actions, changed action references, and unpinned action tags.
  • Unexpected runner selection, especially a change from a GitHub-hosted runner to a self-hosted runner group.
  • Unusual egress volume or destinations from untrusted workflow pools.

Use protected branches and require review for workflow-file changes. Add the security or platform team as a CODEOWNERS reviewer for .github/workflows/**, but remember that CODEOWNERS has teeth only when branch protection requires its review.

This week, do a 60-minute review: export the workflow list, label each trigger as untrusted or trusted, set top-level permissions explicitly, search for pull_request_target, and identify every action that is not SHA-pinned. Then pick one public pull-request workflow and trace its outbound destinations. That single exercise exposes whether your CI is merely running tests or is an attacker-accessible execution platform with a useful token and an open network.