How My Homemade NPM Hunter Caught a Mini Shai-Hulud Package
A small detection pipeline for independent research
Monitoring For Weird NPM Packages
A few nights ago my npm-hunter pipeline flagged @antv/f2@5.16.0. Not long after, Socket disclosed the Mini Shai-Hulud wave — 639 compromised package versions across 323 packages, including 558 versions across 279 @antv packages.
I built this pipeline because, as an independent researcher, I got tired of waiting around on the sidelines. I wanted a way to watch newly published npm packages, surface weird behavior fast, and get leads in front of me while the attack was still relevant.
After the catch, a few people asked how it works. So I figured I'd write about it.
This isn’t a polished commercial system. It’s a working shell with rough edges, false positives, and a list of things I want to improve. But it’s enough to hunt with.
The Catch
The pipeline didn’t “find malware.” It surfaced something worth opening fast enough for me to triage it while the wave was still unfolding. That’s the whole point of my npm-hunter, get eyes on something sooner so I can start poking at it.
Detection Strategy
The idea is simple: catch suspicious packages early.
With all the recent supply chain attacks, I wanted to focus on newly published or newly updated packages. So I started pulling npm package data into a small detector and looking for behavior I personally care about.
For this hit, the detection was looking for a combination of:
install-time execution
.envor secret readsoutbound network behavior
Individually, none of those mean malware. A package can have a preinstall script for a normal reason. A package can read env vars for a normal reason. A package can make network requests for a normal reason.
Together though, especially at install time, that combination is worth opening.
The pipeline is really asking one question:
Is this weird enough that I should look at it?
Pulling Recent Packages
The first piece is just getting fresh packages into the pipeline. Nothing crazy going on, just gives the detector something new to inspect.
const res = await fetch(
"https://registry.npmjs.org/-/v1/search?text=created:>2026-01-01&size=250"
);
const data = await res.json();
const packages = data.objects.map((item) => ({
name: item.package.name,
version: item.package.version,
date: item.package.date,
links: item.package.links
}));
console.log(`Found ${packages.length} recent packages`);
In practice I tune this depending on what I’m hunting. Sometimes I care about brand new packages, sometimes recent updates, sometimes specific name patterns, install scripts, or publisher behavior that looks thin. Different workflows for different detections. The point is to keep the input fresh.
Scripts and Logic
From there I built the detection scripts.
Most of the logic is hard coded. There’s no AI decision-making in the pipeline yet, it is currently a set of rules looking for specific combinations:
install-time execution
.envor secret readsoutbound network calls
obfuscation
suspicious URLs or raw IPs
dynamic execution patterns
The pipeline is only as good as the rules I give it. I’ll probably add smarter triage later, but for now I like that it’s simple and explainable. It’s mostly just saying:
This matched a pattern I care about. You should probably open it.
Here’s a simplified version of what that logic looks like:
function detectSuspiciousBehavior(pkg) {
const reasons = [];
if (pkg.scripts?.preinstall) {
reasons.push("runs preinstall script");
}
if (pkg.filesText?.match(/dotenv|process\.env|\.env/i)) {
reasons.push("appears to read environment secrets");
}
if (pkg.filesText?.match(/fetch\(|axios|http\.request|https\.request/i)) {
reasons.push("contains outbound network behavior");
}
const hasInstallTimeExecution = reasons.includes("runs preinstall script");
const hasSecretReads = reasons.includes("appears to read environment secrets");
const hasNetworkBehavior = reasons.includes("contains outbound network behavior");
if (hasInstallTimeExecution && hasSecretReads && hasNetworkBehavior) {
return {
worthOpening: true,
severity: "critical",
reasons
};
}
return {
worthOpening: false,
severity: "low",
reasons
};
}
Install-time execution plus secret reads plus network behavior doesn’t automatically mean malware, but it’s absolutely worth reviewing. I just wanted a tool that can give me good leads.
Sending Alerts to Discord
Once something matches, I want eyes on it immediately. For me, that’s a Discord channel dedicated to my npm-hunter alerts.
async function sendDiscordAlert(result) {
if (!result.worthOpening) return;
await fetch(process.env.DISCORD_WEBHOOK, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
content: [
`🚨 **${result.package}@${result.version}**`,
`Severity: ${result.severity}`,
`Why: ${result.reasons.join("; ")}`,
`npm: https://www.npmjs.com/package/${result.package}`
].join("\n")
})
});
}
The Discord message could be prettier, but it mainly needs to answer four things: what package matched, what severity I assigned, why it matched, and where to open it. From there I triage.
Pipeline Workflow
At first I was running the scripts manually and passing in time windows myself. That got old fast. So I moved it into a GitHub Actions workflow that runs on a schedule.
Every few hours, it pulls the latest batch of npm packages, runs the detection logic, and sends anything interesting to Discord.
name: npm-hunter
on:
schedule:
- cron: "0 */3 * * *"
workflow_dispatch:
jobs:
hunt:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Run npm hunter
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
run: node scan.js
That’s what turned it from a manual script into a pipeline. I don’t have to remember to run it. I don’t have to manually check npm all day. The workflow runs, sends me leads, and I decide what deserves attention.
What I Want To Improve
The biggest one is smarter triage.
Right now the logic is mostly hard coded. Useful, but it means I have to manually think through every new detection idea and encode it into rules.
Eventually I’d like to experiment with AI-assisted triage, but I don’t want it to become a black box. The version I’d actually want is something that can summarize why the package matched, highlight the suspicious files, explain the install-time behavior, compare the tarball to the repo, group similar alerts, and help me prioritize what to open first. Something that surfaces behavior and evidence rather than just slapping a verdict on it.
I also want better state tracking to avoid duplicate alerts, better scoring so medium-confidence leads don’t drown out the good stuff, and better enrichment around maintainers, publish history, and workflow custody.
But the current version is enough to be useful, and that’s the point.
Final Thoughts
npm-hunter is still rough, but it works.
The goal is to have a fast, reviewable lead generator for supply chain research — a way to watch the npm ecosystem, catch weird behavior early, and spend more time investigating instead of digging through noise.
A few scripts. A few rules. A scheduled workflow. A Discord webhook. That’s enough to start hunting.
Happy hunting!

