Case study: replaying colors@1.4.1 and faker@6.6.6 through the Veln gate
On January 8, 2022, Marak Squires — the original author and sole npm publisher of colors (a 20M+ weekly-download terminal-coloring library) and faker (a popular fake-data generator) — pushed colors@1.4.1 and faker@6.6.6. Both releases shipped without any new dependency, without any install script, without any obfuscated payload, and without any network exfiltration. They contained a single change: an infinite while (true) {} loop in the main module's entry point, prefixed with a printout of "LIBERTY LIBERTY LIBERTY" followed by Zalgo-style glyphs.
This is the self-sabotage attack class. Same maintainer, same npm account, same source-control repository, same license. None of the checks that compare a new version to the previous version's publishing metadata can help — there's nothing to compare against, the metadata didn't change. The package was published by the same person who published every version before it.
On paper, this is the case that should defeat a "watch for new maintainers" defense. In practice, the Veln gate still blocks both packages on every replay — but for different reasons than the other attack classes we've walked through. This case study shows which checks do the work, and which are honestly silent.
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. We log what fired, what didn't, and what the final verdict was — no fixture tuning, no special configuration.
The attack, in one paragraph
colors@1.4.1 published at 18:02 UTC on January 8, 2022. Within hours, downstream packages that depended on colors (notably aws-cdk and parts of the AWS SDK at the time) began breaking in CI with infinite-loop hangs. Marak Squires confirmed the publication was intentional. The package was reverted by npm to v1.4.0 within ~24 hours under npm's policy on harmful packages. faker@6.6.6 followed similar timing and was reverted on the same day. The incident is documented in npm advisory metadata and in the GitHub issue thread on Marak/faker.
The replay setup
We point a local agent at a fixture mirror of the registry response for colors@1.4.1, with the original tarball preserved. The agent runs in enforce mode with gate_protection = "medium". No fixture tuning.
Check-by-check walkthrough
1. Is the version brand-new? — Yes, less than half 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 install is held for 24 hours instead of going through immediately. The reason: by the time a package is 24 hours old, if anything is seriously wrong, the npm community usually notices and the package gets pulled. The cooling window buys time for upstream disclosure.
This is the load-bearing defense for self-sabotage attacks. The malicious change is in the public-facing source code — no obfuscation, no hidden execution path, no environment gating. As soon as downstream users notice their builds hanging, the package gets reverted. The cooling window simply waits long enough for that to happen.
What the agent saw in colors@1.4.1: the version was about 24 minutes old at the moment of evaluation. Well under the 24-hour threshold.
Effect on trust: the cooling window doesn't deduct trust — it short-circuits the install with a 503 response and a Retry-After hint. By the time the cooling window expires and a retry is allowed, the malicious version has been reverted by npm.
Signal name in logs: policy.cooling.min_version_age.
2. Did the file list inside the tarball change in suspicious ways? — Yes, substantially.
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. Patch-level releases (v1.4.0 → v1.4.1) should normally make small, localized changes — a single bug fix, a few lines moved around. When the patch-level bump adds an entirely new file and rewrites the main entry point, that's the kind of structural change that flags.
What the agent saw in colors@1.4.1:
- A new file,
american.js, is added at the package root (containing additional Zalgo-text strings). lib/index.jsis rewritten — the previous file was ~10 lines wiring up the public API; the new version contains the infinite-loop block plus the rage-print payload.
Both changes together produce a file-tree fingerprint very different from v1.4.0.
How much trust this costs: −0.10 out of 1.00.
Signal name in logs: artifact.drift_unverified.
3. Does the source contain Unicode tricks designed to fool human reviewers? — Yes.
In plain English: the agent watches for a specific class of Unicode abuse — characters that combine and stack in ways that make text look weird (Zalgo) or that change reading direction invisibly (bidi-control characters used in the Trojan Source attack class). Real code very rarely uses these characters. Emoji and locale-specific text occasionally do, so the signal carries a moderate weight, not a heavy one.
What the agent saw in colors@1.4.1: the Zalgo strings in lib/index.js and american.js contain combining diacritical marks (Unicode ranges U+0300–U+036F) stacked far beyond normal text usage. The agent counts the density of combining marks per line of code and flags lines that exceed the default threshold of 5 combining-marks-per-100-characters. The rage-print lines blow past this threshold.
How much trust this costs: −0.10 out of 1.00.
Signal name in logs: static.source_unicode_trick.
4. Does the source look more like opaque data than readable code? — Yes, partially.
In plain English: real source code is mostly low-entropy — repetitive keywords, structured indentation, English-like identifiers. Obfuscated payloads, packed minifier output, and base64-encoded blobs look more like random noise: high entropy per byte. The agent measures the ratio of high-entropy regions to total source size and flags files where more than 40% of the source looks like opaque data.
What the agent saw in colors@1.4.1: the Zalgo-text strings are high-entropy regions of source — they look more like binary noise than readable text when measured by the entropy heuristic. The high-entropy ratio for lib/index.js lands at ~52% — above the 40% threshold.
This is a case where the signal "correctly fires for the wrong reason." The original intent of the obfuscation check is to catch base64-encoded payload blobs and packed minifier output. Here it catches Zalgo. The deduction is the same regardless of intent.
How much trust this costs: −0.15 out of 1.00.
Signal name in logs: static.source_obfuscation_score.
Checks that didn't fire (being honest)
The self-sabotage attack class is exactly the case where most checks are silent. The majority of the trust-signal pipeline is quiet on this package:
- Publisher-change is silent. Same npm account, same publisher fingerprint. Marak Squires published v1.4.1 the same way he published v1.4.0.
- Dormant-revival is silent.
colorswas actively maintained — the gap between v1.4.0 and v1.4.1 was a few weeks. - License-change is silent. License stayed
MIT. - Repository-URL-mismatch is silent. Same repository URL.
- Maintainer-count change is silent. Single maintainer, no change.
- Dependency-change is silent. No new dependencies were added.
- Install-script checks are silent. No
preinstall,postinstall, or any other lifecycle hook. The payload is in the main module's body — it runs when something elserequires the package, not at install time. - Dynamic-code-execution is silent. No
Function()oreval()— the payload is plain JavaScriptwhile (true) {}. Honest code in the bad sense. - Environment-gated-risky is silent. No conditional gating on environment. The payload runs every time, on every host.
- Known-malicious-endpoint is silent. No network calls at all in this package.
- CVE database is silent at publish time. No advisory exists yet — it's filed hours after disclosure.
This is the correct behavior. Faking signals to fire on packages where they didn't trigger would erode the trust required to leave the gate in enforce mode in production. We log only what actually fired.
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. That's what catches this attack during the live window.
To show what the score-based path produces — what would happen if a defender opted out of cooling with --allow-fresh — we start at 1.00 (full trust) and pull trust away for each fired check:
| Check | Trust deduction | |---|---| | File-tree fingerprint shifted | −0.10 | | Unicode combining-mark density too high | −0.10 | | Source looks more like opaque data than code | −0.15 | | Total deduction | −0.35 |
Final trust score: 0.65.
The block threshold at gate_protection = "medium" is 0.30. The score-only path lands at 0.65 — above the threshold.
Verdict (with cooling on, the default): HOLD then effectively BLOCK for the live attack window — the package never installs before npm reverts it upstream.
Verdict (with cooling off): WARN, not BLOCK. The user sees a warning and the install proceeds.
This is a result worth pausing on and being honest about. For self-sabotage attacks with no obfuscation, the cooling window is the load-bearing defense. Score-based checks provide supporting evidence but don't independently cross the block threshold. This is a defensible posture — tuning penalties high enough to block colors@1.4.1 on score alone would generate false positives on every legitimate package that happens to include emoji or locale-specific Unicode. The cooling window is the right primitive for "new release, unclear yet whether trustworthy."
What a user would have seen, live
At 18:05 UTC on January 8, 2022 (had Veln been live then), a developer running npm install colors@^1.4.0 or any package transitively depending on colors (aws-cdk was the high-blast-radius vector) would have seen:
npm ERR! 503 Service Unavailable - GET https://gate.veln.sh/colors/-/colors-1.4.1.tgz
npm ERR! gate: code=hold_pending_review
npm ERR! gate: signals fired:
npm ERR! policy.cooling.min_version_age: version age 3 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 `1.4.0` or wait for review
Twenty-four hours later, when the retry was permitted, npm had already reverted colors@1.4.1 and the registry was serving 1.4.0 again. The retry succeeds against the clean version. The infinite loop never enters the user's CI or local environment.
What this tells us about defense
Three observations from this replay:
-
Cooling windows are independent of the attacker's sophistication. A maintainer-rogue self-sabotage attack with zero obfuscation, zero provenance footprints, and zero classical malware signatures is still caught by "don't trust a brand-new version until it's been live for 24 hours." The cooling window doesn't try to understand the attack — it just buys time for upstream disclosure to happen.
-
The signal-mix is honest about what each check can do. The majority of checks are silent on this package. That's not a weakness of the model; it's a feature of the attack class. We don't artificially fire signals that aren't actually triggered. The user sees the same accurate list whether the package is malicious or benign — false positives erode the trust required for a gate to remain in
enforcemode in production. -
WARN is a legitimate verdict. Post-cooling, the score-based path lands at WARN, not BLOCK. That's the right answer. WARN means "install proceeds but a non-zero exit code surfaces to CI." A team that has decided "we want to know about every weird-looking patch release but not block them automatically" gets exactly that signal. A team with stricter posture sets
gate_protection = "strict"and the same checks push the score below threshold.
What the comparison to other replays reveals
The four replays — event-stream, ua-parser-js, node-ipc, and this one — exercise overlapping but distinct subsets of the trust-signal pipeline:
| Attack class | Publisher change | Dormant revival | Install script | Environment-gated | Unicode tricks | File-tree drift | Cooling window | |---|:-:|:-:|:-:|:-:|:-:|:-:|:-:| | event-stream (handoff + transitive) | ✅ | ✅ | ✅ (transitive) | ❌ | ❌ | ✅ | ❌ | | ua-parser-js (account compromise) | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | | node-ipc (rogue maintainer + IP-gate) | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | | colors / faker (self-sabotage) | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
The two universal checks across this matrix are file-tree drift (catches the fact that something changed in the tarball) and the cooling window (catches the timing). Every attack also fires at least one class-specific check that pushes the score below threshold in the strict-posture configuration.
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, the cooling window is enabled by default. Maintainer-rogue and self-sabotage attacks — increasingly common as protest mechanisms — are caught primarily through the timing primitive, not through deep code analysis. Score-based checks (Unicode tricks, obfuscation, file-tree drift) provide post-cooling backup.
The setup:
veln onboarding
veln wrapper on
That's the same configuration that produces the verdict above on the same fixture. If you prefer never to wait for cooling windows on internal packages but still want enforcement on external ones, veln config set cooling.bypass.internal_scopes "@your-org/*" (see docs/configuration).
References
- GitHub Advisory database — colors v1.4.1 — npm's advisory record on the reverted package.
- Marak/Faker.js-/issues/2810 — the original community thread documenting the sabotage and impact.
- Snyk write-up — "What you need to know about colors" — independent analysis of the payload and timeline.