A few months ago our CI egress firewall blocked an outbound HTTPS connection during npm ci on a routine dependency bump. The build still passed, because the request failed quietly and the install moved on. The connection came from a postinstall script in a transitive dependency four levels deep, a small utility none of us had heard of. Its new patch release added a single line that ran node ./lib/setup.js. That file contained a 9 KB Base64 string, decoded at runtime, which tried to read environment variables and post them to a host we had never seen. Without the firewall, nothing in our pipeline would have noticed.
I wrote this with help from AI tools and checked every command, script and manifest field myself. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET, and I build PixelValut, a set of browser-side developer tools. This is Part 5 of the series Client Trust & Agent Hijack Defense. Part 1 covered prompt injection in fetched docs, Part 2 covered tool-calling attack surfaces, Part 3 covered SRI and CDN pinning and Part 4 covered CSP and Trusted Types reports. Those parts defended the page. This one goes upstream, to the code that runs on developer machines and inside browsers before your policies ever apply.
๐ Series Roadmap โ Client Trust & Agent Hijack Defense
- Part 1: Prompt Injection via Docs Your Tools Fetch
- Part 2: WebMCP & Tool-Calling Attack Surfaces
- Part 3: Subresource Integrity & CDN Script Pinning
- Part 4: CSP, Trusted Types & Residual XSS
- Part 5 (This Post): Extension & npm Postinstall Forensics: Homoglyphs, Base64 & Unexpected Network
- Part 6: Browser Secrets That Leak: Tokens, Storage Quotas & Exfil Paths
Related earlier posts: Deobfuscating Malicious npm & Browser Payloads ยท ReDoS in JS & PHP: Spotting Catastrophic Backtracking ยท Webhook Security in Production
How do you investigate a suspicious npm postinstall script or browser extension?
Direct answer: Install with lifecycle scripts disabled, list every package that declares install scripts, and diff those scripts against the previous version. Scan the code for long Base64 or hex blobs, invisible or mixed-script Unicode, and dynamic evaluation. Then run the install or extension in a sandbox with network logging and treat any unexpected outbound connection as an incident.
Why install-time code is the soft spot
npm runs preinstall, install and postinstall scripts from dependencies by default, with your user's permissions, your environment variables and your network. That is how native modules compile, and it is also how several well-known supply-chain incidents delivered payloads, including the hijacked ua-parser-js releases in 2021, which used an install script to fetch and run malware. Browser extensions have a similar shape: once installed, a content script with broad host permissions can read and modify every page you open, including your admin panels.
Both are trusted by default, both update silently, and both run before your CSP, SRI or code review gets a say.
Step 1: stop scripts from running while you look
Make "no scripts" the default and opt packages in:
# .npmrc (project or CI)
ignore-scripts=true
npm ci --ignore-scripts
npm rebuild esbuild sharp # explicitly run scripts only for packages you trust
pnpm (v10 and later) does not run dependency build scripts unless the package is allowlisted, and Bun similarly requires trustedDependencies. Check your package manager version's documentation, because these defaults have changed over time.
Step 2: find every package that wants to run code
This script walks node_modules and prints each package with lifecycle scripts:
// list-install-scripts.mjs
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
const HOOKS = ['preinstall', 'install', 'postinstall', 'prepare'];
function* pkgs(dir) {
if (!existsSync(dir)) return;
for (const name of readdirSync(dir)) {
if (name.startsWith('.')) continue;
const p = join(dir, name);
if (name.startsWith('@')) { yield* pkgs(p); continue; }
const pj = join(p, 'package.json');
if (existsSync(pj)) yield [p, JSON.parse(readFileSync(pj, 'utf8'))];
yield* pkgs(join(p, 'node_modules'));
}
}
for (const [path, pj] of pkgs('node_modules')) {
const hits = HOOKS.filter((h) => pj.scripts?.[h]);
if (hits.length) console.log(`${pj.name}@${pj.version}`, hits.map((h) => `${h}: ${pj.scripts[h]}`).join(' | '));
}
Run it after every lockfile change and diff the output in the PR. A package that gains an install script in a patch release deserves a human look. Also note that a binding.gyp file without an explicit install script still makes npm run node-gyp rebuild.
To compare two published versions without installing either:
npm pack some-util@1.4.2 && npm pack some-util@1.4.3
mkdir a b && tar -xzf some-util-1.4.2.tgz -C a && tar -xzf some-util-1.4.3.tgz -C b
diff -ru a/package b/package | less
Step 3: hunt for obfuscation signals
Attackers hide payloads from reviewers rather than from machines. These are the signals I scan for:
| Signal | What it looks like | Why it matters | How to check |
|---|---|---|---|
| Long Base64 | 200+ chars of [A-Za-z0-9+/=] in a string |
Hides URLs, commands or a second-stage script | Decode and read; strings with no business in a utility are red flags |
Hex / \x escapes |
"\x68\x74\x74\x70" chains |
Hides URLs and keywords from grep | Decode, then grep again |
| Dynamic eval |
eval, new Function, vm.runInNewContext on decoded data |
Executes the hidden payload | Trace the argument back to its source |
| Child processes |
child_process.exec, spawn with curl, sh, powershell |
Runs system commands at install | Check what command and why |
| Env harvesting | Loops over process.env, reads .npmrc, SSH or cloud credential paths |
Credential theft | Almost never legitimate in install scripts |
| Invisible Unicode | Zero-width chars, bidi controls like U+202E | Makes code read differently than it runs | Inspect code points, not glyphs |
| Homoglyphs | Cyrillic ะฐ (U+0430) in an identifier or hostname |
Looks like Latin a, resolves somewhere else |
Mixed-script detection per token |
A quick scanner for the first and last rows:
# long Base64-looking strings
grep -rEno '[A-Za-z0-9+/]{200,}={0,2}' node_modules/some-util | cut -c1-120
# non-ASCII characters in JS files, with code points
grep -rnP '[^\x00-\x7F]' --include='*.js' node_modules/some-util \
| head -20
When something turns up, decode it outside the process. I paste suspicious blobs into the PixelValut Base64 decoder, which runs in the browser and never executes the result, and decode repeatedly until it stops changing, because layered encoding is common. For lines that look normal but behave strangely, the Unicode inspector shows every code point and script, so a Cyrillic letter inside a Latin hostname or a hidden bidi override becomes obvious. This is the same class of trick as the Trojan Source research on bidirectional control characters.
Step 4: watch the network, not just the code
Static review misses payloads fetched at runtime. Run the install where unexpected traffic either fails loudly or is recorded:
# 1) No network at all: anything that needs it will fail visibly
docker run --rm --network none -v "$PWD":/app -v "$HOME/.npm":/root/.npm -w /app node:22 \
sh -c "npm ci --offline --ignore-scripts=false"
# 2) Record outbound connections during a normal install (Linux)
strace -f -e trace=connect -o install.trace npm ci
grep -E 'sin_port=htons\((80|443)\)' install.trace | sort -u
For the first option, run one normal npm ci --ignore-scripts on the host first so the mounted cache has every tarball and --offline can install from it. In CI, an egress allowlist that permits only your registry or proxy is the cheapest long-term control, and it is the one that caught our incident.
Browser extensions: the same review, different files
Extensions are ZIP archives with a manifest. On Chrome, installed copies live under the profile's Extensions/<id>/<version>/ folder, so you can inspect exactly what is running. Start with the manifest:
EXT=~/.config/google-chrome/Default/Extensions/<id>/<version>
jq '{name, version, permissions, host_permissions,
cs: [.content_scripts[]? | {matches, js, all_frames}],
externally_connectable}' "$EXT/manifest.json"
What to flag:
-
<all_urls>or*://*/*inhost_permissionsor content scriptmatchesfor an extension that only needs one site. -
scripting,webRequest,cookies,debugger,nativeMessagingwithout a clear reason. - Permission growth between versions. Diff manifests across updates the same way you diff npm tarballs.
- Remote config that changes behaviour. Manifest V3 forbids remotely hosted code, but an extension can still fetch configuration that tells its existing code which pages to target.
- Homoglyph names in the store listing that imitate a popular extension.
Then load it in a throwaway profile, open the extension's service worker in DevTools, and watch the Network panel while you browse a test page.
Diagnostic checklist
- [ ]
ignore-scriptsis the default in CI and on developer machines, with an explicit allowlist. - [ ] Packages declaring install scripts are listed and diffed on every lockfile change.
- [ ] New install scripts in patch releases trigger human review.
- [ ] Version-to-version tarball diffs are done for flagged packages.
- [ ] Code is scanned for long Base64, hex escapes, eval and child processes.
- [ ] Non-ASCII, zero-width and bidi characters are checked by code point.
- [ ] Decoding happens in a tool that never executes the output.
- [ ] Installs run with network denied or recorded; CI has an egress allowlist.
- [ ] Extension manifests are reviewed for broad hosts and risky permissions.
- [ ] Extension permission changes across versions are diffed.
- [ ] Any unexpected outbound connection is treated as an incident: rotate exposed tokens.
FAQ
Is disabling install scripts going to break my build?
Some packages with native binaries need their scripts. Allowlist those explicitly and rebuild them; most pure JavaScript packages do not need scripts at all.
Does npm audit catch malicious postinstall scripts?
It reports known advisories. A freshly published malicious version may not have an advisory yet, which is why behaviour-based checks matter.
Can npm package names contain homoglyphs?
New package names must be URL-safe lowercase, so attackers usually rely on typosquatting instead. Homoglyphs show up more in code, hostnames, extension listings and identifiers.
Is a lockfile enough protection?
A lockfile pins versions and integrity hashes, which stops silent swaps. It does not judge whether the pinned version was malicious when you locked it.
What should I do if a script already ran?
Assume anything in the environment was read. Rotate npm, cloud, Git, and CI tokens, check for new SSH keys or startup entries, and review recent publishes from your accounts.
Are Manifest V3 extensions safe by design?
They are more constrained, mainly because remotely hosted code is not allowed. Broad host permissions still let a content script read and change pages.
Next in this series, Part 6 covers browser secrets that leak: tokens, storage quotas and exfiltration paths.
Follow & Connect
- Website: PixelValut Tools
- YouTube: PixelValut (@pixel-valut)
- TikTok: PixelValut (@pixelvalut007)
- GitHub: Muhammad Ali Mughal (@ali111887)
- LinkedIn: Muhammad Ali Mughal

Top comments (0)