Research · Incident

Detecting import-time npm malware after install controls pass

Last reviewed:

The AsyncAPI compromise shows why disabling npm install scripts is not enough: the malicious loader waited for first import, then spawned a detached process with persistence and command-and-control.

In this research
  1. Contribution
  2. The pattern
  3. Why it matters to cloud defenders
  4. ATT&CK mapping
  5. Detection guidance
  6. What to do now
Research snapshot
Type
Incident
Reviewed
2026-07-27
ATT&CK
T1195.001, T1059.007

Contribution

This post adds a cross-plane detection chain for import-time package malware. The useful boundary is not npm install; it is the first import or require inside a developer host, build runner or service. The proposed alert joins a trusted release anomaly, an orphaned Node.js child process and the child's first network or persistence action, so defenders can catch the behaviour even when package provenance verifies and install scripts are disabled.

The pattern

On 14 July 2026, attackers compromised release workflows in the AsyncAPI organisation and published five malicious versions across four npm packages. Microsoft Threat Intelligence names @asyncapi/specs versions 6.11.2-alpha.1 and 6.11.2, @asyncapi/generator version 3.3.1, @asyncapi/generator-components version 0.7.1, and @asyncapi/generator-helpers version 1.1.1. The packages were distributed through normal dependency resolution and carried valid provenance from the real release workflow.

The initial compromise was a GitHub Actions "pwn request". A pull_request_target workflow checked out and ran code supplied by an untrusted pull request while retaining the base repository's secrets. Wiz Research reports that the attacker opened a noisy set of pull requests, while one concealed JavaScript beyond a large run of whitespace. The workflow ran the code without merging the pull request. It exposed a personal access token belonging to asyncapi-bot, which the attacker then used to push directly to an auto-publish branch.

Those pushes triggered legitimate release jobs. Microsoft records the publisher identity as npm-oidc-no-reply@github[.]com, while npm provenance linked the packages to the expected source and build system. This is an important distinction. npm's own provenance documentation says that provenance does not guarantee the absence of malicious code. It proves where and how a package was built. If an authorised workflow builds unauthorised source, the attestation can verify perfectly while the package is hostile.

The payload also crossed a boundary that many package controls treat as safe. It did not need a preinstall, install or postinstall hook. The injected JavaScript ran when an application or build first loaded the package with import or require. Microsoft says npm install --ignore-scripts did not neutralise the loader. A lockfile scan could identify the affected version, but a clean installation event was not proof that no code had run.

At first import, the injected function spawned a hidden, detached Node.js child. That child fetched an encrypted second stage from IPFS and wrote sync.js into a platform-specific directory made to look like normal Node.js application data. The Miasma runtime then installed user-level persistence: a miasma-monitor.service systemd unit on Linux, an HKCU Run value on Windows, or shell profile changes on macOS. It used a central command server with several decentralised fallback mechanisms.

The source reports differ in a way defenders should preserve rather than smooth over. Microsoft's analysed configuration had persistence and command-and-control active, while its collection and propagation modules were disabled. Wiz describes credential theft capabilities and remote commands for file operations and exfiltration. Unit 42's npm threat analysis links the July packages to the Miasma lineage and documents credential targets and an upload path. These may reflect configuration or sample differences. The safe incident assumption is exposure of secrets reachable from an importing host, but public evidence does not prove that every affected host lost cloud credentials.

Why it matters to cloud defenders

AsyncAPI packages can appear in developer workstations, container builds, CI jobs and long-running services. That places the same poisoned dependency in environments with very different authority. A laptop may hold browser sessions and local cloud profiles. A build runner may receive short-lived AWS, Azure or GCP credentials. A deployment service may have registry write access or permission to change production workloads.

Import-time execution makes exposure harder to derive from installation records. A dependency can be installed into a cache or image layer without executing, then run hours later when a test suite, generator or application starts. The host that downloads the package may not be the host that imports it. A package mirror can preserve the tarball after npm removes the public version. A golden runner image can carry the dependency into later jobs.

This splits the investigation into three clocks. The release clock begins with the unauthorised source push and ends with publication. The dependency clock records when a lockfile, cache, artefact or image acquired the version. The execution clock starts at first import. Searching only one clock misses hosts that resolved the package indirectly or executed it after the publication window closed.

Cloud risk begins when the execution clock overlaps credential availability. The malicious build did not need to call a cloud API to create an incident. A detached process on a runner can read environment variables, local credential files, mounted service-account tokens or workload configuration. If that runner later assumes a role or exchanges an OIDC token, cloud audit logs become the fourth plane. The useful question is not "did this package have valid provenance?" It is "which identity material was reachable when this package first ran, and what did those identities do afterwards?"

The incident also shows why release provenance and release authorisation are separate controls. Provenance answers whether npm received a package from a named workflow and source revision. Authorisation asks whether that revision passed the organisation's intended review and branch policy. A direct push by an automation account can satisfy the first while violating the second. Defender telemetry must retain the source commit, actor, authentication type, branch and triggering workflow, not only the package signature.

ATT&CK mapping

The entry path maps to T1195.001, Compromise Software Dependencies and Development Tools. The attacker compromised a development workflow, inserted code into trusted packages and distributed those packages through the expected registry. The detection point is the release relationship: an automation identity pushes an unreviewed commit to a publish branch, then an OIDC-backed job releases artefacts from that commit.

Execution maps to T1059.007, Command and Scripting Interpreter: JavaScript. The injected loader ran in Node.js at module load, launched another JavaScript process and evaluated the retrieved stage. The relevant endpoint signal is more specific than node execution, which is normal in these environments. It is a Node.js process creating a detached Node.js child that outlives the build or application step, followed by a network fetch or persistence write.

The second-stage download also fits ATT&CK T1105, Ingress Tool Transfer. The runtime's file and command channels create a possible route to T1041, Exfiltration Over C2 Channel. These are supporting behaviours rather than the incident's primary mappings. More importantly, the exfiltration capability must not be mistaken for proof that each deployment used it.

The chain ends with the data or identity material present on the importing host. If a cloud access key, browser session, Kubernetes token or GitHub token was reachable, subsequent use belongs to the appropriate credential or valid-account technique. Do not assign that final technique from package exposure alone. Add it only when endpoint evidence shows collection or the relevant service logs show use of the identity after the execution window.

Detection guidance

Start with exposure inventory, but do not stop there. Search lockfiles, software bills of materials, package mirrors, container manifests, runner caches and deployed images for the five affected versions. Record where each copy went and whether the package was imported. A lockfile hit is a lead; it is not proof of execution.

At the release plane, export Git event data from the GitHub audit log rather than relying on the web view or an API search. GitHub's token investigation documentation states that UI and REST API searches do not return Git events. Alert when a bot or service account pushes directly to a branch that triggers publication without a matching reviewed pull request. Retain actor, repository, ref, commit identifiers and authentication details. For GitHub Enterprise Cloud, the same guidance documents hashed_token, programmatic_access_type and token_scopes. These fields let responders group actions performed with the same stolen token without retaining its raw value. Token data for Git events is currently a public preview, so preserve the exported records used in the investigation.

GitHub has changed the default defence for this trigger. Its checkout security update states that pull_request_target jobs run with the base repository's token, secrets and default-branch cache access. Supported floating major versions of actions/checkout receive checks that reject common fork-head patterns. SHA-pinned, minor-pinned and patch-pinned workflows require an explicit update. Detection remains necessary because custom checkout commands and other ways of running untrusted code can preserve the same trust mistake.

At the endpoint plane, look for a process sequence rather than a package hash alone. The following EQL-style logic uses normalised field names. Adapt the image names and file paths for each operating system:

Code block EQL
sequence by host.id, process.entity_id
  [process where event.type == "start" and
    process.name in ("node", "node.exe") and
    process.parent.name in ("node", "node.exe")]
  [network where url.domain == "ipfs.io" or
    destination.ip == "85.137.53.71"]
  [file where file.name in ("sync.js", "miasma-monitor.service")]

The sequence join binds all three events to the child process instance through host.id and process.entity_id, matching Elastic's documented EQL correlation pattern. If the platform cannot carry an entity ID across event types, join on host, process ID and process start time. Bound the rule's lookback to a locally measured import-to-first-action interval rather than copying a fixed threshold. Raise severity when the parent belongs to a CI runner or when the child continues after the parent job exits. That survival condition is the stable behavioural clue: ordinary build tools may spawn Node.js helpers, but they seldom leave a hidden child that contacts IPFS and writes user-level persistence.

Tune the sequence against legitimate package managers and test workers. Allow known build helpers only when their child command line, destination and output path match an approved baseline. Do not allowlist all node descendants from npm, pnpm or yarn; that would erase the signal. A single IPFS request can be benign. A sync.js file can also be benign. The sequence matters because it links execution, retrieval and persistence on one process identity.

Retain the payload identifiers as well as the gateway hostname. Microsoft and Wiz report IPFS CIDs Qmet4fhsAaWMBUxNDfREHwgiyDeSWy4YSYs9wiKUW5jGyf for the specs package set and QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9 for the generator family. Matching the CID in proxy or full-URL telemetry survives a change of public IPFS gateway. The reported server 85.137.53.71 used port 8080 for command-and-control, 8081 for uploads and 8091 for management traffic. Treat these indicators as incident-scoping evidence, not durable behaviour-based detection.

Then join the endpoint window to cloud and SaaS activity. For each host that executed the loader, list credentials available to that process and search service logs from the execution time until rotation. Prioritise new source networks, access outside the workload's normal region, repository enumeration, secret reads, new access keys, token grants, container-registry pushes and deployment changes. Keep this as a scoped hunt. The analysed Microsoft sample had collection disabled, so cloud anomalies should determine escalation rather than an assumption that theft occurred.

What to do now

  1. Remove the affected versions from lockfiles, caches, mirrors, images and artefact stores. Rebuild from a dependency baseline that predates the malicious releases. Deleting a public npm version does not clean private mirrors or cached runner layers.

  2. Find every environment that imported the packages. Use endpoint process data, test logs, build traces and application start records. Separate systems that only downloaded a tarball from systems that loaded its JavaScript.

  3. Hunt for the detached-child sequence, IPFS content identifiers, sync.js under NodeJS-like application data, miasma-monitor.service, the Windows Run value and shell profile changes. Treat a persistence hit as execution evidence even if the original package has gone.

  4. Rotate secrets by reachability. Start with identities present on confirmed execution hosts, then cover hosts where execution telemetry is incomplete. Rotate GitHub and npm tokens, cloud credentials, registry credentials, Kubernetes tokens, SSH keys and browser sessions that the process could access. Preserve audit logs before revocation where the provider's retention or token metadata may change.

  5. Review every privileged pull_request_target and workflow_run path. Move untrusted build work into a job with no secrets and read-only permissions. Keep publication in a separate protected job that consumes reviewed source, not artefacts or caches writable by fork code. Update supported actions/checkout versions, but inspect custom git checkout, archive downloads and scripts that can fetch fork content.

  6. Make release authorisation visible. Require protected branches or rulesets on auto-publish refs, record the reviewed pull request for each release commit, and alert on direct service-account pushes. Verify provenance, then compare the attested commit with the commit your change policy approved. A valid signature is evidence. It is not an approval decision.

The AsyncAPI compromise is useful because the old install-script checkpoint stayed quiet while the real payload waited for first import. Move the detection boundary with it. Release audit, runtime process ancestry and post-execution identity use belong in one investigation, not three separate queues.

01 ATT&CK references