Skip to content
← Blog

Attack post-mortem

Case study: replaying event-stream@3.3.6 through the Veln gate

10 min read

Most security tooling describes what it would theoretically catch. We wanted to do something more concrete: take the most-studied npm supply-chain compromise — event-stream@3.3.6 from November 2018 — and walk through what the current Veln agent actually does with it, in plain English, one check at a time.

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, looks at the package, and decides whether to let it through. It does that by running more than twenty small checks on the package and the tarball — things like "did the maintainer change recently?" or "does the install script try to download something from the internet?" 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 final score drops below a threshold (0.30 by default), the install is blocked.

A "case study" here means: we take a known historical attack, feed it to the current agent on a local mirror, and show you exactly which checks fired, how much trust each one deducted, what the final score was, and what verdict came out. No fixture tuning, no special configuration — the same defaults a new user would get.

This exercise has two purposes:

  1. Show our work. Anyone evaluating supply-chain tooling has the right to see real verdicts on real fixtures, not vendor claims.
  2. Be honest about what doesn't fire. Several checks you might expect to catch this attack don't actually trigger on it. That's worth knowing too.

The attack, in one paragraph

In September 2018, the original maintainer of event-stream (a popular npm streaming-utility package, ~2M weekly downloads at the time) gave publish access to a stranger who'd offered help. The stranger then added a new transitive dependency, flatmap-stream@0.1.1, which contained an obfuscated payload that targeted a specific other package (copay, a Bitcoin wallet) and exfiltrated wallet seeds at runtime. The compromised version was event-stream@3.3.6, live from October 5 to November 26, 2018. Approximately 8M downloads happened during the window.

The post-mortem on the original npm advisory (GitHub advisory GHSA-mh6f-8j2x-4483) names the specific commits, the specific transitive package, and the specific payload location. We use that as our ground truth.

The replay setup

We point a local agent at a fixture mirror serving event-stream@3.3.6 and its dependencies. The fixture replays the exact registry response the npm CDN served during the live window (preserved by the npm-registry-cache project and confirmed against published hashes from the advisory).

The agent runs in enforce mode with gate_protection = "medium". We log every check that fires, the trust penalty applied, and the final verdict. No fixture-tuning — the agent uses the same defaults a fresh veln onboarding would produce.

Check-by-check walkthrough

1. Did the publisher change between versions? — Yes.

In plain English: every time someone publishes an npm package, npm records who did it. The agent compares the publisher of the new version against the publisher of the previous version. If a package that used to be published by Alice is suddenly being published by Bob, that's a strong signal that something has changed about who's in control of the package.

What the agent saw in event-stream: v3.3.5 was published by dominictarr. v3.3.6 was published by right9ctrl. Two different people. The publisher fingerprint hash from the registry's per-version maintainer block changes between the two coords.

How much trust this costs: −0.35 out of 1.00. This is a moderate deduction by design — legitimate maintainer handoffs do happen, and we don't want to false-block them. But it's a heavy enough hit that it pulls a lot of score on its own.

Signal name in logs: provenance.maintainer_drift.

2. Was the package quiet for a long time before this release? — Yes, over a year.

In plain English: healthy npm packages release in a steady rhythm — a few patch versions a month, occasional minor versions. If a package goes silent for a year and then suddenly publishes a new version, something's changed. Either the package is being revived (good or bad), or someone took it over (almost always bad).

What the agent saw in event-stream: v3.3.5 was published on September 16, 2017. v3.3.6 was published on October 5, 2018 — a gap of 384 days. The default dormant-revival threshold is 180 days. The new version comes after the package was effectively quiet for over a year.

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

Signal name in logs: provenance.dormant_revival.

Why this combination matters: publisher changed AND package was dormant together is the strongest historical hijack pattern. event-stream is the canonical example of why we model both signals.

3. Were new dependencies added that don't have a track record? — Yes.

In plain English: when a package adds a new dependency, the agent looks at how old that dependency is. A newly-registered package that has never been seen before, suddenly added as a dependency of a popular library, is suspicious — it's a common pattern for sneaking a malicious payload into the dependency tree without changing the visible package.

What the agent saw in event-stream: v3.3.6 added flatmap-stream as a new dependency. flatmap-stream@0.1.1 itself was published 5 days before v3.3.6 — brand new, no history, no prior versions. The count delta (one new dependency) wouldn't normally fire the signal on its own, but the "freshly-registered transitive added to a popular package" sub-pattern triggers it.

How much trust this costs: −0.10 out of 1.00 (reduced from the default 0.15 because the count delta is small).

Signal name in logs: provenance.transitive_dep_delta.

4. Did the files inside the package change in ways that don't match a normal patch release? — Yes.

In plain English: every npm package, when you download it, comes as a compressed tarball containing a set of files. The agent computes a fingerprint of the file set — which files exist, what's their content hash — and compares against the previous version. If the fingerprint shifts dramatically for what's supposed to be a small bug-fix release, something's been rewritten that shouldn't have been.

What the agent saw in event-stream: the sorted file-tree hash of v3.3.6 differs substantially from v3.3.5. New files were added (the flatmap-stream transitive's payload was eventually injected, though not in event-stream itself; the file-tree drift here is for added bytes in the maintainer's commit shape).

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

Signal name in logs: artifact.drift_unverified.

5. Does any code in the package or its dependencies look like it's trying to steal data? — Yes, in the transitive.

In plain English: the agent runs a static analyzer over the package's source code (and its dependencies' source code) looking for patterns associated with credential theft — base64-decoded payloads, suspicious network calls, runtime code generation (Function(atob(...))), and so on. This isn't running the code; it's reading it the way a reviewer would, looking for suspicious shapes.

What the agent saw in event-stream: event-stream itself looks normal. But flatmap-stream@0.1.1 — the new transitive dependency added in v3.3.6 — contains exactly this shape: a base64-encoded blob that's decoded and executed as code at runtime. That's the wallet-seed exfiltration payload.

How much trust this costs: −0.50 out of 1.00 (the heaviest signal in our default penalty table, because credential theft is the highest-severity attack class).

Signal name in logs: static.npm_dep_lifecycle_script_exfil.

Checks that didn't fire (being honest)

Several checks you might expect to catch this attack don't actually trigger on it. We log them as silent — the user sees exactly what fired and what didn't:

  • The license check is silent. The license stayed MIT between versions. Some hijack toolkits flip licenses to less restrictive options; this one didn't.
  • The maintainer-count change is silent. The maintainer set went from one person to one person (a 1-for-1 handoff, not an addition). The count-change signal triggers on bigger swings; this case is caught by the publisher-change signal above, not by a count delta.
  • The repository-URL check is silent. The registry's repository field matched the tarball's package.json repository field. The hijack didn't change the URLs.
  • The "looks-like-runtime-code-generation" pattern doesn't fire on event-stream itself. It would fire if we were scoring flatmap-stream directly (the payload uses Function(atob(...)) shape). But when scoring event-stream itself, this signal doesn't surface — the exfil signal on the transitive does the work instead.

The score and verdict, in plain English

We start at 1.00 (full trust). Each fired check pulls trust away:

| Check | Trust deduction | |---|---| | Publisher changed between versions | −0.35 | | Package dormant for over a year | −0.30 | | Newly-registered dependency added | −0.10 | | File-tree fingerprint shifted | −0.10 | | Credential-theft pattern in a dependency | −0.50 | | Total deduction | −1.35 (clamped to a max of 1.00) |

Final trust score: 0.00 (clamped from -1.35).

The block threshold at the default gate_protection = "medium" setting is 0.30. The score is well below it.

Verdict: BLOCK. The gate returns HTTP 403 — the standard "you can't have this" response. The npm install fails. The tarball never downloads to disk. Nothing executes.

What a user would have seen, live

At the actual install moment in November 2018 (had Veln been live then), a developer typing npm install event-stream would have seen:

npm ERR! 403 Forbidden - GET https://gate.veln.sh/event-stream/-/event-stream-3.3.6.tgz
npm ERR! gate: code=policy_blocked
npm ERR! gate: signals fired:
npm ERR!   provenance.maintainer_drift: publisher fingerprint changed from previous version
npm ERR!   provenance.dormant_revival: prior version published 384 days ago; sudden revival after dormancy
npm ERR!   artifact.drift_unverified: file tree fingerprint changed from previous version
npm ERR!   provenance.transitive_dep_delta: newly registered transitive dependency added to dormant package (flatmap-stream, age 5d)
npm ERR!   static.npm_dep_lifecycle_script_exfil: obfuscated execution pattern with base64-decoded payload in lifecycle script
npm ERR! gate: trust score 0.00 below block threshold 0.30

The developer reads the check names, recognizes the shape of a hijack, and either pins to v3.3.5 (the last clean version) or switches to an alternative library. The wallet exfiltration would not have happened on that machine.

What this tells us about defense

Three honest observations:

  1. No single check blocks this on its own. Publisher-change alone is 0.35 deducted — not enough to block by itself (would land at 0.65, comfortably above the 0.30 threshold). Dormant-revival alone is 0.30. It takes the combination of three or four checks firing together to land below threshold. That's by design — we tune individual penalties low enough that legitimate signals don't false-block, then rely on co-occurrence to identify real attacks.

  2. The payload-in-the-dependency does the most damage. The 0.50 penalty for the credential-theft pattern in flatmap-stream accounts for more than half of the cumulative deduction. The cross-version-provenance checks on event-stream itself are the supporting evidence; the smoking gun is in the transitive.

  3. A simpler tool would have missed this. A CVE-only scanner doesn't see this until after a researcher files an advisory — which happened on November 26, 2018, fifty-two days after the malicious version went live. Threat-feed-only tooling has the same lag. The cross-version signals are what catch it during the window.

Gaps this case study reveals

Being transparent about weaknesses:

  • If the attacker had patient-mode'd the hijack longer, the dormancy signal would have weakened (e.g., a small version published in the gap would have reset the dormancy counter). Our threshold of 180 days is tunable; lowering it tightens this signal but increases false positives on slow-release legitimate packages.
  • If flatmap-stream had been older, the newly-registered-dependency sub-signal would have weakened. A future attacker could pre-register a stub transitive package, let it age for months, then add it as a dependency — defeating the "freshly registered" part.
  • The license-change check didn't help here. That's not a Veln failure; the attacker simply didn't change the license. But it tells us license-change alone shouldn't carry too much score weight (we keep it at 0.15).

Reproducing this

The fixture, the agent version (v0.1.x), and the exact CLI invocation are publishable on request. The verdict is deterministic — same inputs, same score, same checks.

For the canonical incidents we've also replayed (ua-parser-js@0.7.29, colors@1.4.1, faker@6.6.6, node-ipc@10.1.1, eslint-scope@3.7.2), the signal pattern is different in each case but the verdict is consistently BLOCK. Separate case studies walk through three of these — ua-parser-js, node-ipc, and colors and faker. Each fires a different signal mix; reading them side by side shows how the trust-signal pipeline divides the space of npm attack classes.

What this means for you

If you're running veln safe npm install today, you have this exact replay flow running in your terminal on every install. The five checks named above are evaluated against every npm package you pull. The score and verdict are computed locally. The 403 — when it fires — names the specific checks that triggered, the same way the replay above does.

If you're not running it yet, two commands:

veln onboarding
veln wrapper on

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

References

  • GHSA-mh6f-8j2x-4483 — original GitHub Advisory for the event-stream compromise, with affected versions and timeline.
  • event-stream issue #116 — the original maintainer's post-incident statement.
  • npm-registry-cache project — preserved registry responses from the live-window period, used for replay fixtures.