Tech 11 min read

Composer CVE-2026-45793: GITHUB_TOKEN leaks to CI logs via regex miss

IkesanContents

TL;DR

What happened Composer 2.3.0–2.9.7, 2.0.0–2.2.27, and 1.0–1.10.27 are affected. Workflows where Composer sees GITHUB_TOKEN or a GitHub App installation token via auth.json

Fix Update to Composer 2.9.8, 2.2.28 LTS, or 1.10.28. Check pinned Composer in CI images, shivammathur/setup-php with a tools: composer:<version> lock, and older build containers

What to grep Audit failed Composer runs around 2026-05-13 UTC. If hit, delete the log, revoke the token, and check for unexpected GitHub operations

Re-rollout GitHub rolled back the new token format and plans to resume on 2026-05-18 14:00 UTC (2026-05-18 23:00 JST and later)


CVE-2026-45793 in Composer is not code execution — it’s credential disclosure into logs.
The disclosure point is the GitHub Actions job log, so on public repositories or long-running CI jobs, it translates directly into repository-write capability.

Nils Adermann from Packagist asked everyone to upgrade to Composer 2.9.8 and 2.2.28 LTS on 2026-05-13.
GitHub had begun a phased rollout of a new format for Actions tokens, and the new string contains hyphens.
Composer’s validation regex assumed the 2021 token format and didn’t allow hyphens.

Failure handling was the bad part.
When Composer threw a “GitHub OAuth token contains invalid characters” exception, it embedded the rejected token value directly in the exception message.
Symfony Console wrote that exception to stderr, and CI saved stderr as the job log.
GitHub Actions secret masking is designed to redact complete contiguous strings; if line wrapping or ANSI control characters break the contiguity, redaction misses.

What Composer rejected was a valid token

The validation lived in Composer\IO\BaseIO::loadConfiguration().
According to the GHSA writeup, the old regex permitted only this character set:

^[.A-Za-z0-9_]+$

GitHub’s new Actions tokens look like ghs_<numeric-id>_<base64url-JWT>.
The JWT portion is base64url-encoded, so URL-safe characters - and _ appear in the value.
_ passes the old regex; - does not.

From Composer’s perspective, a legitimate token in a new format looks like a token “containing invalid characters.”
The error output that was meant to halt the build became the credential exfiltration path.

The fix introduced two changes.
The exception message no longer includes the rejected token value, and the validation regex now accepts hyphens.
The new regex is a single-character difference:

^[.A-Za-z0-9_-]+$

The Composer 2.9.8 changelog records this as “GitHub token validation and disclosure” with GHSA-f9f8-rm49-7jv2.
A missing character in a regex became the channel that leaked plaintext tokens into CI logs.

The leak chain looks like this end-to-end:

flowchart TD
    A["GitHub rolls out new GITHUB_TOKEN format"] --> B["Token string contains hyphens"]
    B --> C["Composer's old regex<br/>rejects as invalid"]
    C --> D["Token value embedded<br/>in exception message"]
    D --> E["Symfony Console writes to stderr"]
    E --> F["GitHub Actions saves stderr<br/>as job log"]
    F --> G["Masking misses due to<br/>line wrap and ANSI codes"]
    G --> H["Plaintext token visible<br/>in the job log"]

Why GitHub Actions secret masking misses

GitHub Actions replaces secret values and GITHUB_TOKEN with *** in job logs.
But the masking is a literal-string match: it only fires when the secret appears as a complete, contiguous substring in a single line of log output.
The matching is per-line; the masker doesn’t reassemble across line breaks.

That means contiguity breaking in any of the following ways defeats masking.

  • Line wrapping: Long exception messages wrapped by terminal width or wordwrap() insert a newline inside the token, breaking the exact match
  • ANSI control codes: Symfony Console adds color/style escape sequences to error output; codes like \e[31m inserted inside the token string break the match
  • Partial token exposure at encoding boundaries: When part of the token appears in a different output format, the visible string looks token-like but doesn’t match the registered full value
  • Tokens embedded in URLs or diff output: Some stack traces wrap the token in punctuation, which moves it off the masker’s literal-search pattern

“Tokens get masked” is more precisely “tokens get masked if they appear in the log line unmodified and contiguous.”
In the Composer case both color-decorated error output and long-message line wrapping were in play, and the literal search slid off.

GitHub documents this design publicly, but workflow authors tend to read it as “masking takes care of it.”
You need a separate layer that prevents secrets from showing up in log output in the first place — sanitizing exception messages, redirecting stderr to a file, wrapping token-handling code in try/catch — independently of masking.

You can hit this just by using setup-php

This isn’t only about workflows that manually drop a token into auth.json.
The GHSA writeup specifically points at shivammathur/setup-php, which is widely used in the PHP ecosystem.
That action can register the workflow’s GITHUB_TOKEN into Composer’s global auth.json automatically.

That means the most ordinary PHP CI flow is enough to set up the preconditions:

steps:
  - uses: actions/checkout@v4
  - uses: shivammathur/setup-php@v2
    with:
      php-version: "8.3"
  - run: composer install

setup-php has been updated to pick up the patched Composer, but workflows or build images that pin Composer to a specific version stay vulnerable.
A floating tag like composer:v2 will pick up the fix; a pinned composer:2.9.7 or a fixed binary inside a custom container needs to be replaced separately.

This is the same shape as Mini Shai-Hulud spreading to TanStack and Mistral — a GitHub Actions runner’s tokens can’t be defended purely on the assumption that “the log layer masks them.”
Mini Shai-Hulud read OIDC tokens out of runner memory.
This time Composer’s error output drops plaintext into logs.
Either way, the short-lived tokens that CI hands out cannot be treated as safe just because they’re short-lived.

The window is short, but not zero

GitHub’s advisory notes that GITHUB_TOKEN on a standard GitHub-hosted runner expires at job end, at most six hours later.
In most cases the Composer exception fails the job, and the token expires almost immediately.

Self-hosted runners are a different story.
The GHSA and Packagist writeups say GITHUB_TOKEN on a self-hosted runner can stay valid for up to 24 hours after issuance.
GitHub App installation access tokens are typically 1 hour, but they can carry broader permissions than the workflow’s permissions: block.

Sansec says they validated that a leaked token can reach the GitHub API while the originating job is still in_progress.
For a job that immediately fails on the Composer exception, the window is narrow.
But for workflows that swallow errors and keep running, long test or deploy jobs in the same job context, or self-hosted runners, take a wider view of the affected window.

What an attacker can actually do with the leaked token

What GITHUB_TOKEN can do is decided by the workflow’s permissions: block.
When it isn’t explicit, the repository’s “Workflow permissions” setting applies as the default.

Since 2023 GitHub has shipped new repositories with a default of “contents: read only,” but older repositories and orgs can still be on the permissive default.
On a permissive default, what’s reachable with a leaked token:

  • contents: write: Direct push to branches, creating tags and releases. Tainted commits can be slipped into main
  • packages: write: Publish or overwrite packages on GitHub Packages. Downstream CI consumes the tampered package
  • actions: write: Rewrite workflow files, cancel running jobs
  • id-token: write: Present an OIDC token to other clouds and bootstrap into AWS/GCP/Azure temporary credentials (depending on the cloud-side trust policy)
  • issues / pull-requests: write: Spoof PR comments and review status

GitHub App installation access tokens are broader still.
They last about 1 hour and can span multiple repositories depending on what the App is scoped for.
If the leaked token came from a Private Packagist or Composer-repository App with org-wide access, the blast radius exceeds a single repository.

On public repositories, PR-build job logs are visible externally, so an attacker can monitor without active intrusion.
Even on private repositories, once an org has many members, “everyone with access is trustworthy” is not a defensible assumption.

Lean on permissions: for blast-radius reduction

The structural defense for “what if the token leaks” is permissions: minimization.
At the org level, set “Workflow permissions” default to read-only, then explicitly elevate only the jobs that need to write.

# Workflow-wide default (least privilege)
permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    # This job stays on contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.3"
      - run: composer install
      - run: composer test

  release:
    runs-on: ubuntu-latest
    # Override permissions only for jobs that must write
    permissions:
      contents: write
      packages: write
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/release.sh

permissions: can be overridden per job, so jobs that don’t need writes stay read-only.
If a tool-side bug like the Composer issue leaks the token, and that job’s token has no write permissions, the attacker can’t push or cut a release with it.
The root cause of the log disclosure is a tool bug, but the blast radius is decided by how the workflow’s permissions are designed.

GitHub rolled back once and plans to resume on May 18

The Packagist post has a running timeline.
As of 2026-05-13 14:30 UTC, GitHub temporarily reverted the new token format rollout.
That moves it out of “stop using GitHub Actions right now” territory but only buys time to update.

The 2026-05-14 11:00 UTC update notes GitHub’s intent to resume the new format rollout from 2026-05-18 14:00 UTC.
In JST, that’s 2026-05-18 23:00 and later.
Before then, swap out CI images, GitHub Actions Composer pins, and self-hosted runner tool caches.

Affected versions and fixed releases:

LineAffected rangeFixed in
Composer 2.3+>= 2.3.0, < 2.9.82.9.8
Composer 2.2 LTS>= 2.0.0, < 2.2.282.2.28
Composer 1.x>= 1.0, < 1.10.281.10.28

Composer 1.10.28 is an exceptional legacy fix; in general, moving to 2.x is preferable.
Packagist also treats this as “upgrade to 2.x rather than staying on 1.x.”

Start audit from failed jobs

The audit target is GitHub Actions runs that executed between roughly 2026-05-13 UTC and the new-format rollback.
In particular, look at composer install, composer update, composer audit, Private Packagist integrations, and jobs that read private GitHub-hosted dependencies.

The fastest string to grep is the Composer exception message itself:

Your github oauth token for github.com contains invalid characters

Where matches exist, check whether a plaintext token remained in the log body.
If it did, delete the log, then look within the token’s validity window for unexpected GitHub API calls, pushes, tag creation, release creation, or workflow changes.
On self-hosted runners, treat the window as up to 24 hours after job completion.

To just update Composer:

composer self-update
composer --version

In CI, look beyond the local composer.phar — Docker images, the Actions tool cache, internal base images, and old setup-php pinning all need to be checked.
On the GitHub Actions side, default GITHUB_TOKEN permissions should be read-only, with jobs that need writes opting in explicitly via permissions:.
The GitHub Actions 2026 security roadmap flagged third-party Actions and transitive dependency pinning as issues, but this incident is a separate hole: Actions’ own short-lived tokens leaking across the log boundary.

For org or enterprise-wide checks, pull from the audit log API filtered by time range.
You need a token with admin:org or read:audit_log scope.

# Filter recent suspicious actions
gh api -H 'Accept: application/vnd.github+json' \
  --paginate \
  "/orgs/{ORG}/audit-log?phrase=created:>=2026-05-13T00:00:00Z+action:repo.push+OR+action:workflow_run.update+OR+action:repo.update_workflow_settings_for_repository&per_page=100" \
  | jq '.[] | {created_at, actor, action, repo}'

Roughly grouped, the actions you care about:

  • repo.push / git.push: unexpected branches, force-pushes
  • workflow_run.update: cancellation or rerun manipulation of in-flight jobs
  • repository.update: public/private flips, branch protection changes
  • org.update_default_repository_permission / repo.update_workflow_settings_for_repository: workflow-permission elevations
  • personal_access_token.create / installation_token.request: anomalous new token issuance
  • integration_installation_request.*: GitHub App installations or scope changes

If the leaked token was a GitHub App installation token, focus on the validity window (typically 1 hour) and what happened inside it.
Self-hosted runners hold state for up to 24 hours and may retain cached logs in the runner’s _work/ directory even after the job ends, so the audit needs to extend to runner-side _work/ and shell environment history alongside log deletion.

CI-tool-side token leaks aren’t a first

“A CI token that was supposed to live for a moment leaks via a tool’s processing and turns into long-running damage” is a pattern that has repeated.

  • Codecov bash uploader (2021): A tampered Codecov upload script sent CI runner environment variables (GITHUB_TOKEN, AWS keys, various API keys) to an attacker-controlled server. Multiple orgs had secrets exfiltrated over several months
  • tj-actions/changed-files (2025): A modified third-party Action was used as a path to dump environment variables including the workflow’s tokens. Over 23,000 repositories were affected
  • shai-hulud-family npm worms: postinstall hooks were observed reading CI environment variables and POSTing them to attacker-controlled webhooks across multiple incidents

Those are deliberate, externally seeded compromises where malicious code is injected into the tool or Action to read out environment variables.
The Composer case is different: the tool’s own validation logic didn’t keep up with GitHub’s token format change, and the error output leaked the token — an “internal-cause leak.”
The entry point is different, but the structure after leak — an attacker who can read it can use it — is the same.

Secrets in CI aren’t safe because they’re short-lived; they’re protected on the assumption that they’ll be used up within a short window.
Log deletion, token revocation, permissions: minimization, tool-side sanitization — none of these holds on its own, so layering is the working approach.

References