Run npm i on a package today and, by default, npm will execute that package's postinstall script the moment the tarball lands on disk — no prompt, no diff, no sandboxing. That single design decision, unchanged since npm's early lifecycle-script model, is what Snyk's research team used in a June 28, 2023 writeup to demonstrate a quiet way to pull sensitive data off a macOS developer machine. The target wasn't the literal macOS Keychain — it was Text Replacement, the System Settings feature that expands a short trigger like @card into whatever text a user configured, stored in a user-defaults database that any unprivileged process can read with a single defaults command. Snyk cited a real example of a developer at a well-known company who had set up a text replacement to expand into their own credit card number, treating it as a convenience macro rather than a secret store. A malicious postinstall script needs no sudo, triggers no macOS permission dialog, and produces no signal beyond ordinary npm install noise. This post walks through the exact mechanism, why it evaded the usual defenses, and what changes to your local npm and CI configuration actually close the gap.
What exactly does a malicious postinstall script do here?
The chain has four steps, and none of them require anything unusual. First, an attacker publishes a package to npm with a postinstall entry in its scripts block. Second, a victim runs npm install <package>, either directly or as a transitive dependency pulled in by something else in their package.json. Third, npm resolves and unpacks the tarball, then automatically executes the postinstall script as the current user — this is standard npm lifecycle behavior, not a bug or misconfiguration on the victim's part. Fourth, the script runs something as simple as execSync('defaults read -g NSUserDictionaryReplacementItems') via Node's child_process module and ships the output to an attacker-controlled endpoint. Because postinstall output is normally just build noise (compiling native bindings, printing a changelog, and so on), a few extra lines of network activity or a background defaults call is easy to miss in a terminal scrollback that most developers never read closely.
Why does defaults read work without triggering a macOS permission prompt?
macOS gates sensitive resources — the Camera, Contacts, Full Disk Access, the actual Keychain via Keychain Services APIs — behind Transparency, Consent, and Control (TCC), which pops a system dialog the first time an app requests access. Text Replacement entries stored under NSUserDictionaryReplacementItems sit in the same user-defaults system as thousands of other application preferences, and TCC does not classify that key as protected. That means defaults read -g NSUserDictionaryReplacementItems, run from a plain Node.js child process with the current user's own privileges, returns the full replacement list — shortcut and expansion text together — with no dialog, no sudo, and no elevated entitlement. It is a legitimate command doing exactly what it is designed to do; the vulnerability is entirely in what users choose to store there and in npm executing arbitrary code to reach it, not in a macOS access-control bypass.
Is this a macOS Keychain exploit?
No, and this distinction matters for anyone tuning detection or writing an incident report. The Keychain proper — where Safari saved passwords, Wi-Fi credentials, and TLS identities live — is accessed through Keychain Services APIs and is TCC-protected; a script calling security find-generic-password against an unlocked, unauthorized item still has to contend with the macOS security framework's own access controls. Text Replacement is a convenience feature for typing, not a credential store, and its data lives in ordinary defaults preference files with no equivalent protection. Snyk's research is best described as "keychain-adjacent": it targets data users treat like a keychain — because they've repurposed a text-expansion shortcut to hold a credit card number or a password fragment — sitting in a location with none of the Keychain's actual safeguards. Overstating it as a Keychain compromise misrepresents both the attack surface and the fix.
What does --ignore-scripts actually change?
Passing --ignore-scripts to a single install (npm i <package> --ignore-scripts) or setting it globally with npm config set ignore-scripts true tells npm to skip preinstall, install, postinstall, and other lifecycle hooks entirely for that operation. This is the single most direct mitigation for the class of attack Snyk described, because it removes the execution step the whole chain depends on — no postinstall script runs, so there is nothing to call defaults read from in the first place. The tradeoff is real: some packages legitimately need postinstall to compile native addons (node-gyp based packages) or download platform binaries, so a blanket global setting can break builds until you identify and explicitly allow the packages that need it. Many teams land on a middle ground — ignore-scripts on by default in CI and on fresh installs, with a documented allowlist process for packages that have a verified, legitimate reason to run install-time code.
How does this fit into the broader npm lifecycle-script problem?
This is one demonstration of a pattern that has recurred across the npm ecosystem for years: lifecycle scripts give any published package the ability to run arbitrary code on install, and npm's registry has historically had far weaker gatekeeping on new and updated packages than it does on runtime API surface. It's the same mechanism behind credential-harvesting packages that read ~/.aws/credentials, ~/.ssh, or ~/.npmrc during install rather than at import time, since a postinstall hook runs before any application code that might otherwise be reviewed even loosely. The specific target — macOS text replacement — is notable mainly because it's a location developers don't think to treat as sensitive, which is exactly why Snyk picked it as an illustration: the attack surface isn't a novel macOS flaw, it's the gap between what npm will silently execute and what a reviewer actually reads before merging a dependency bump.
How Safeguard helps
Safeguard's Eagle malware classifier scores every npm publish against seven indicator classes, and install-script behavior — post-install hooks that contact unusual hosts, install additional packages, or write outside the package's own directory — is one of them, alongside a dedicated credential-harvesting indicator for reads from paths like ~/.aws, ~/.ssh, and ~/.npmrc. That scoring runs on every npm publish and can also be applied retroactively across the last decade of packages, so a dependency that looked benign at install time gets re-flagged the moment Eagle's model improves. On enforcement, a .safeguard/malware-policy.yaml block lets teams block installs of anything Eagle scores above a chosen threshold before the postinstall step ever runs, and the same classification gates what's admitted into Safeguard's Gold Registry. None of that replaces disciplined local hygiene — ignore-scripts in CI, and never storing plaintext secrets in a text-expansion shortcut in the first place — but it closes the gap between "a malicious package got published" and "someone on your team runs npm install before anyone notices."