The reported abuse involving Packagist repositories is a useful reminder that a CI runner can be turned into a scanning service even when an attacker never receives a repository secret. The dangerous chain is short: trigger a workflow, control an input or checked-out file, obtain execution, then send results to an external destination.

That framing matters because “do not expose secrets to pull requests” solves only one link. A repository can still pay for runner minutes, consume package-registry quotas, probe internal network paths from a self-hosted runner, or send scan output through DNS and HTTPS. The practical objective is to make every link in that chain either unavailable or visible enough to stop quickly.

1. Turn repository-scanning abuse into a four-part threat model

The reported Packagist-related abuse can be generalized without relying on the details of one campaign. Treat every workflow as a system with four components: a trigger, attacker-controlled input, execution capability, and an outbound destination. A useful review asks what happens when all four are present in the same job.

Chain linkWhat an attacker needsControl that breaks it
TriggerA way to create runs repeatedlyLimit events, require approval, add concurrency
Controlled inputPR code, issue text, branch names, package metadata, or workflow inputsValidate inputs and avoid checking out untrusted code in privileged jobs
ExecutionA shell, package lifecycle script, action, or writable self-hosted runnerUse isolated runners, minimal tokens, and separated job tiers
Outbound destinationDNS, HTTPS, package APIs, cloud metadata endpoints, or internal servicesRestrict egress where possible and alert on new destinations

The second-order problem is cost and operational disruption. An attacker does not need to steal source code if they can make 500 runs fetch dependencies, call package APIs, and test reachable hosts. The decision rule is simple: if a workflow accepts public input and can execute code, it must be treated as an internet-facing compute endpoint.

2. Run this copyable workflow audit before changing YAML

Audit each workflow file, not just the default branch’s main CI workflow. Reusable workflows, release automation, issue bots, scheduled jobs, and deployment pipelines often have different trust boundaries. Mark each row pass or fail and attach the listed evidence to the pull request that fixes it.

CheckPass/fail questionEvidence to inspectRemediation
TriggersCan untrusted users create unlimited expensive runs?on:, branch filters, workflow historyRestrict events; add approval gates and concurrency
CheckoutDoes a privileged job check out PR-head code?actions/checkout ref and event contextCheck out the base SHA, or move PR code to an unprivileged job
PermissionsDoes every job request only named capabilities?Workflow and job-level permissions:Set a baseline and elevate only the job that needs it
SecretsCan code from a PR reach credentials?secrets.*, environments, event typeKeep secrets in trusted jobs; use protected environments for deployment
Runner typeCan public input execute on a self-hosted runner?runs-on, runner groups, labelsUse GitHub-hosted runners or require approval for privileged tiers
DependenciesCan install scripts execute before policy permits it?npm ci, Composer commands, lockfilesUse metadata-only checks or --ignore-scripts where compatible
Action pinsAre third-party actions immutable and reviewed?Every uses: linePin to a full commit SHA and record the reviewed release
EgressCan the runner contact arbitrary destinations?Firewall, proxy, DNS, VPC flow, runner logsAllow required destinations and alert on unknown ones
MonitoringWill someone notice abusive runs within one shift?Actions logs, audit logs, SIEM rules, ownershipRoute alerts to an on-call team with a documented disable step

3. Separate fork PRs, internal PRs, and writers before trusting input

Not every pull request has the same risk. GitHub normally withholds repository secrets from pull_request workflows triggered by forks, which is an important protection. It does not make fork code safe to execute on a self-hosted runner, and it does not protect against package-install side effects, runner abuse, or unrestricted outbound scanning.

  • Forked pull requests: assume source code, titles, branch names, and dependency manifests are hostile. Run only on isolated GitHub-hosted runners without credentials.
  • Same-repository branches: secrets and write-related context can be available depending on workflow design. A contributor with branch access is not automatically equivalent to a deployment maintainer.
  • Actors with write access: still require separation between ordinary CI and release or infrastructure authority. A compromised maintainer account should not immediately become cloud-admin access.

Expressions embedded directly in shell source are especially error-prone. Prefer passing data through an environment variable, but do not call that safe by itself: the receiving script must quote and validate the value, and a script checked out from the PR can itself be attacker-modified.

- name: Validate title with a base-branch script
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: |
    ./scripts/validate-pr-title "$PR_TITLE"

For this to be meaningful, ./scripts/validate-pr-title must come from the trusted base revision and must reject unexpected input. For example, a Bash script should use quoted variables and a strict allowlist such as [[ "$1" =~ ^[A-Z]+-[0-9]+:\ .{1,120}$ ]], rather than interpolating $1 into another command.

4. Make permissions and tokens deliberately boring

permissions: contents: read is a minimal common baseline for jobs that need to check out repository content. It is not “default deny.” When a workflow can operate without a token at all, use permissions: {}; this is viable for many lint, formatting, or metadata-only jobs that do not call GitHub APIs or check out private content.

When a permissions block is used, omitted named permissions are set to none. Declare the smallest set at job scope: contents: read for checkout, pull-requests: write for posting PR comments, issues: write for issue updates, checks: write for creating check runs, packages: write for publishing packages, and id-token: write only for a job that obtains cloud identity through OIDC.

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683

Keep publishing and deployment in separate jobs protected by environments and required reviewers. Do not pass a broad token through job outputs, artifacts, or files. An isolated test job with no secrets cannot be converted into a release job merely by making its test command malicious.

5. Handle pull_request_target, dependencies, pins, and egress as one design problem

pull_request_target runs in the base repository’s context, which makes it useful for trusted metadata work and dangerous for PR-head execution. The safe pattern checks out only the base revision and runs a fixed script from that revision. It can label a PR or validate a title without executing code proposed by the PR.

name: PR metadata
on: pull_request_target

permissions:
  pull-requests: write
  contents: read

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
        with:
          ref: ${{ github.event.pull_request.base.sha }}
      - run: ./scripts/label-pr "${{ github.event.pull_request.number }}"

The forbidden variant is checking out ${{ github.event.pull_request.head.sha }} in that workflow and then running npm ci, composer install, tests, or a PR-provided script. That combines trusted credentials with attacker-controlled code.

Dependency installation is execution, not preparation. Both npm lifecycle scripts and Composer plugins can run code. For public PRs, choose one of these concrete tiers:

  • Run lint or manifest validation without installing dependencies.
  • Use npm ci --ignore-scripts when the task works without lifecycle scripts.
  • Run builds and tests only on GitHub-hosted isolated runners with no credentials and unrestricted failure tolerance.
  • Require maintainer approval before PR code reaches self-hosted runners, private registries, cloud credentials, or internal network routes.

Pin actions to full commit SHAs, not tags. Obtain the SHA from the reviewed upstream release, verify it in the action repository, record the release and SHA in the dependency-update PR, and update it through a reviewed change. SHA pinning protects the action reference; it does not pin Docker-image tags used by that action or binaries downloaded at runtime. Review those download paths separately, especially in dependency-install jobs.

6. Detect the chain in progress and assign an owner this week

Preventive controls will not catch every bad workflow change. Alert on patterns that indicate one of the four chain links is being exercised at scale. Route GitHub Actions and runner alerts to the engineering security on-call or the platform team—not a mailbox with no response expectation.

  • Workflow-dispatch volume: alert when manual dispatches exceed the normal repository baseline; the responder disables the workflow or removes dispatch access while reviewing run inputs.
  • New-fork repetition: alert on repeated runs from newly created forks or repeated failures across many fork branches; the responder cancels queued runs and adds approval requirements.
  • Outbound DNS or HTTP destinations: where proxy, DNS, or VPC telemetry exists, alert on new domains and unusual destination counts; the responder blocks the destination and preserves runner and workflow logs.
  • Registry or API quota spikes: watch package-registry requests and GitHub API rate consumption per workflow; the responder disables the dependency job and rotates credentials if a credentialed job was involved.
  • Self-hosted runner anomalies: alert on unexpected network connections, unusually long jobs, and high-frequency job starts; the responder removes the runner from its group and rebuilds it from a known image.

Start with one repository this week. Export its workflow inventory, complete the audit table, change public-PR jobs to credential-free GitHub-hosted runners, and create two alerts: abnormal workflow volume and new outbound destinations. That small change breaks the most useful version of the Packagist-style chain: cheap attacker-triggered execution with invisible egress.