Skip to content
← Blog

Attack post-mortem

Case study: replaying ua-parser-js@0.7.29 through the Veln gate

10 min read

In October 2021, an attacker who'd phished a maintainer's npm credentials pushed malicious versions of ua-parser-js to three different majors in roughly five hours: 0.7.29, 0.8.0, and 1.0.0. The package had ~8M weekly downloads at the time. The payload was a preinstall script that downloaded a Monero miner and a credential stealer to Linux and Windows hosts.

This is a different shape from the event-stream hijack we walked through earlier. event-stream was a maintainer handoff — the package's owner intentionally granted publish rights to a stranger. ua-parser-js was an account compromise — the legitimate maintainer's npm account was taken over, and the attacker published as them.

This distinction matters for the replay. When the attacker is using the real account, the "did the publisher change?" check is silent — it's the same account publishing as before. The signals that catch this attack are about what the package does, not who shipped it.

What this case study is doing

Veln sits between your computer and the npm registry. When you run npm install some-package, Veln intercepts the request and runs more than twenty small checks on the package before letting it through. Each check either fires or it doesn't; each fired check deducts a little bit of trust from a starting score of 1.00. If the score drops below 0.30 (the default block threshold), the install fails.

A "case study" replays a known historical attack against the current agent, using a local mirror that serves the exact tarballs and registry responses from the original incident. We log what fired, what didn't, and what the final verdict was — no fixture tuning, no special configuration.

The attack, in one paragraph

On October 22, 2021, between 12:15 and 17:23 UTC, attacker-controlled malicious versions of ua-parser-js were live on the npm registry across three majors. The npm registry pulled them after ~5 hours per coord. The post-incident report (CISA AA21-295A) and GitHub Advisory GHSA-pjwm-rvh2-c87w document the exact versions, payload location (a preinstall script in package.json invoking preinstall.bat on Windows / preinstall.sh on Linux), and the timeline. We use these as ground truth.

The replay setup

We point a local agent at a fixture mirror of the malicious registry response for ua-parser-js@0.7.29 (the most-downloaded of the three coords during the window). The fixture preserves the original package.json, the original preinstall.sh shell-script payload, and the hash of the tarball as it was served from the npm CDN.

The agent runs in enforce mode with gate_protection = "medium". No fixture tuning — same defaults a fresh veln onboarding would produce.

Check-by-check walkthrough

1. Does the install script run a separate program that wasn't there before? — Yes, and it's downloading a binary.

In plain English: npm packages can include "install scripts" — small programs that run automatically when you install the package, before the rest of your code can use it. These exist for legitimate reasons (compiling native code, setting up config files), but they're also the easiest place to hide malicious activity. The agent reads every install script and looks for high-risk patterns: shell scripts that download files from random URLs, then mark them executable, then run them. That's the shape of malware delivery.

What the agent saw in ua-parser-js@0.7.29: the preinstall script in package.json invokes ./preinstall.sh (on Linux) — a shell script that uses curl to fetch a binary from a remote IP, calls chmod +x on the downloaded file, and then runs it. Three steps that, together, are almost never legitimate.

How much trust this costs: −0.60 out of 1.00. This is one of the heaviest individual deductions in our default table, because "download and execute a binary from the internet during install" is essentially never something a legitimate package does.

Signal name in logs: static.npm_dep_lifecycle_script_critical.

2. Does the install script try to read sensitive files and send them somewhere? — Yes.

In plain English: the agent also scans install scripts for exfiltration patterns specifically — code that reads from paths typically containing credentials (browser cookie stores, SSH keys, browser saved-password databases) and then sends those bytes over the network. This is the universal shape of a credential stealer.

What the agent saw in ua-parser-js@0.7.29: the payload reads files from the Edge profile path on Windows (%LOCALAPPDATA%\Microsoft\Edge\User Data\Default) and the Firefox profile on Linux (~/.mozilla/firefox), then issues an HTTP POST to an attacker-controlled endpoint. The agent flags this as exfiltration shape regardless of whether the user actually has those files — the pattern is what matters.

How much trust this costs: −0.50 out of 1.00.

Signal name in logs: static.npm_dep_lifecycle_script_exfil.

3. Does the script connect to a known-malicious endpoint? — Yes.

In plain English: the agent keeps a list of known-malicious domains and IP addresses, fed from public threat-intelligence sources (CISA advisories, abuse.ch, security-vendor feeds). The list refreshes daily. Every URL or IP the agent finds in install scripts gets checked against this list.

What the agent saw in ua-parser-js@0.7.29: the script references citationsherbe.com (the Monero-miner download) and specific IPs (the credential-exfil C2 server). Both were already in the threat-intel feeds at the time of the incident — they'd been used in other attacks first.

How much trust this costs: −0.70 out of 1.00. The heaviest deduction in our table, because a known-malicious endpoint match is high-confidence — if the endpoint has already been seen attacking others, this is almost certainly the same campaign.

Signal name in logs: static.npm_dep_lifecycle_script_ioc.

4. Did the file list inside the tarball change in suspicious ways? — Yes.

In plain English: every npm package, when you download it, is a compressed archive containing a set of files. The agent computes a fingerprint of which files exist and what each file's content hash is, and compares against the previous version. If a patch-level bump (0.7.28 → 0.7.29) suddenly adds new executable files and changes package.json to invoke them, something fundamental has changed.

What the agent saw in ua-parser-js@0.7.29: two new files (preinstall.sh, preinstall.bat) appear in the tarball that weren't in v0.7.28. package.json now has a scripts.preinstall entry that didn't exist before. This is not the shape of a normal patch release.

How much trust this costs: −0.10 out of 1.00.

Signal name in logs: artifact.drift_unverified.

5. Is the version brand-new? — Yes, less than an hour old.

In plain English: the agent enforces a "cooling window" by default. When a brand-new version of a package shows up on the registry, the agent doesn't install it immediately — it waits 24 hours. The reason: by the time a package is 24 hours old, if anything was seriously wrong, the npm community usually notices and the package gets pulled. Cooling buys time for upstream disclosure.

While cooling is in effect, the agent returns a special temporary-failure response (HTTP 503 with a "retry after 24 hours" hint) instead of allowing the install. This isn't a permanent block — the user can either wait, or pin to the previous safe version.

What the agent saw in ua-parser-js@0.7.29: the malicious version was published less than 1 hour before our replay's evaluation point. Well under the 24-hour cooling window.

Effect on trust: the cooling window doesn't deduct trust — it short-circuits the install with a 503 response and a retry hint. By the time the cooling window would have expired (24h later), npm had already removed the malicious version from the registry.

Signal name in logs: policy.cooling.min_version_age.

6. Did dependencies change in ways that propagate risk? — Yes, in a small way.

In plain English: the agent also looks at how the package's dependencies have changed between versions. New dependencies are scored against the same trust-signal pipeline — risk propagates down the dependency tree. If the package itself didn't add new dependencies but its existing dependencies suddenly contain malicious patterns, the parent inherits some of that risk.

What the agent saw in ua-parser-js@0.7.29: no new direct dependencies were added, but the install-script signals on the parent package propagate as a mild risk indicator to anything that depends on ua-parser-js.

How much trust this costs: −0.05 out of 1.00 (a small adjustment — the count of dependencies didn't change, but the inherited risk applies).

Signal name in logs: provenance.transitive_dep_delta.

Checks that didn't fire (being honest)

The contrast with the event-stream replay is the point of this case study. Four checks that did fire for event-stream are silent here:

  • Publisher-change is silent. Same npm account, same publisher fingerprint between v0.7.28 and v0.7.29. The attacker had the legitimate maintainer's credentials and published from the real account. This is the central honesty point — the check you might assume is the primary defense against "package suddenly malicious" doesn't help when the attacker is using the real account.
  • Dormant-revival is silent. ua-parser-js was actively maintained, with multiple releases per month. The gap between v0.7.28 and v0.7.29 was about three weeks — well under the dormancy threshold.
  • License-change is silent. The license stayed MIT.
  • Repository-URL-mismatch is silent. The repository URL stayed https://github.com/faisalman/ua-parser-js.

The lesson is structural: provenance checks catch maintainer-handoff attacks; install-script and source-pattern checks catch account-compromise attacks. The signal pipeline needs both classes because attackers have both options.

The score and verdict, in plain English

The cooling-window check fires first and short-circuits the install with a 503 response — before any score is computed. The install never proceeds during the live window.

If a defender opts out of cooling (with --allow-fresh), the score-based checks still produce a block. We start at 1.00 (full trust) and pull trust away for each fired check:

| Check | Trust deduction | |---|---| | Install script downloads and runs a binary | −0.60 | | Install script reads credentials and exfiltrates | −0.50 | | Install script references known-malicious endpoint | −0.70 | | File-tree fingerprint shifted | −0.10 | | Dependency-tree risk propagation | −0.05 | | Total deduction | −1.95 (clamped to a max of 1.00) |

Final trust score: 0.00 (clamped from −1.95).

The block threshold at gate_protection = "medium" is 0.30. The score is far below it.

Verdict: BLOCK. Combined with the initial cooling-window HOLD, two independent layers of defense apply. The malicious version never executes on the user's machine.

What a user would have seen, live

At 14:30 UTC on October 22, 2021 (had Veln been live then), a developer typing npm install ua-parser-js would have seen:

npm ERR! 503 Service Unavailable - GET https://gate.veln.sh/ua-parser-js/-/ua-parser-js-0.7.29.tgz
npm ERR! gate: code=hold_pending_review
npm ERR! gate: signals fired:
npm ERR!   policy.cooling.min_version_age: version age 47 minutes; below cooling window threshold of 24h
npm ERR! gate: retry after 86400 seconds (24h cooling window)
npm ERR! gate: tip: pin to last released version `^0.7.28` or wait for review

Five hours later, npm pulled the malicious versions. By the time the cooling window expired and a retry would have been allowed, the malicious tarballs were no longer on the registry. The developer never sees the payload.

Even if the developer overrides the cooling window with --allow-fresh, the block fires immediately on the install-script and source-pattern checks. Two layers of defense, both independent.

What this tells us about defense

Three observations:

  1. Cooling windows are the primary defense against account-compromise hijacks. Attackers with stolen credentials have a small window before the legitimate maintainer notices (a few hours for ua-parser-js, hours-to-days typical). A 24-hour cooling window on first-version installs puts the wait time outside the attack window. The defender doesn't need to identify the attack — they just need to be slow to trust brand-new versions.

  2. Install-script analysis catches the payload independently of who shipped it. Even with no publisher-change signal, the three install-script checks together account for 1.80 of cumulative penalty — six times what's needed to cross the block threshold. The agent's static analysis of preinstall.sh is what does the work.

  3. Known-malicious-endpoint matching is a force multiplier. For threats that have already been seen attacking other systems, the threat-intel layer catches them quickly — even if other checks are weak. For brand-new attacks with no prior intel, the install-script behavior signals carry the score on their own.

What the comparison to event-stream reveals

The event-stream replay fires five checks: publisher-change, dormant-revival, file-tree drift, transitive-dep delta, and the credential-theft pattern in a dependency. None of those except file-tree drift and the credential-theft pattern are shared with this replay.

A defender who only modeled "maintainer handoff" attacks would miss ua-parser-js entirely. A defender who only modeled "install-script malware" would miss event-stream's transitive payload (the credential-theft signal there fires on the dependency, not the parent).

That's why the gate runs more than twenty checks on every package: each one covers a different attack class. The overlap between checks is intentional — different attacks fire different subsets, but every attack class has some subset that catches it.

Reproducing this

The fixture, agent version, and CLI invocation are publishable on request. The verdict is deterministic — same inputs, same score, same checks.

What this means for you

If you're running veln safe npm install today, every npm package you pull goes through this same evaluation. Account-compromise attacks against actively-maintained packages — one of the most common npm attack classes in 2024-2025 — are caught primarily by the cooling window and the install-script checks named above, not by provenance checks.

The setup:

veln onboarding
veln wrapper on

That's the same configuration that produces the verdict above on the same fixture.

References