Hunting
hunt-npm-postinstall-grandchild-network-payload.kql
npm supply-chain worms where the payload runs two process generations down — 'sins of the grandfather' shape. Traces npm → sh -c → curl.
// Author: Ian D. Hanley (DevSecOpsDad) | linkedin.com/in/ianhanley | devsecopsdad.com | devsecopsdadattack.com
// Detects npm supply-chain worms where the payload runs two process generations down from the
// initial npm install — the 'sins of the grandfather' shape. Traces process ancestry through npm
// -> sh -c -> curl (or equivalent) rather than looking at the direct child.
// Source: KQL Detection of the Week: Sins of the Grandfather (2026-08-12) — https://devsecopsdadattack.com/2026-08-12-KQL-Detection-of-the-Week-Sins-of-the-Grandfather/
let lookback = 7d;
// Image names arrive inconsistently. FileName is usually bare, but the schema documents
// InitiatingProcessParentFileName as "name OR FULL PATH", and Windows adds an extension.
// Normalize once so node, /usr/bin/node, NODE.EXE and npm.cmd all compare as one token.
let Bare = (s:string) {
trim_end(@"\.(exe|cmd|bat|com|ps1)", tolower(extract(@"([^\\/]+)$", 1, tostring(s))))
};
// Package-manager runtimes that execute lifecycle scripts. Note "npm" is here for
// WINDOWS (npm.cmd). On Linux, /usr/bin/npm is a shebang script and the exec'd image is
// `node` -- so `InitiatingProcessFileName in~ ("npm")` returns almost nothing on a Linux
// build host, which is where your builds actually run.
let PkgRuntimes = dynamic(["node","npm","npx","yarn","pnpm","bun","corepack"]);
// npm runs EVERY lifecycle script through a shell. This layer is the whole point of the
// query: it is why a one-level parent test sees the scaffolding and not the payload.
let ScriptShells = dynamic(["sh","bash","dash","zsh","ash","busybox","cmd","powershell","pwsh"]);
// DERIVED, not hand-kept beside the lists. A prefilter that is not a superset of the
// authoritative test is a silent narrowing, and a parallel copy drifts -- mine did, twice.
let AncestryTerms = array_concat(PkgRuntimes, ScriptShells);
let NetworkTools = dynamic(["curl","wget","nc","ncat","netcat","socat","ssh","scp","sftp","rsync"]);
let Interpreters = dynamic(["python","python3","perl","ruby","php","osascript","node"]);
let Stagers = dynamic(["base64","xxd","openssl","tar","zip","gzip","bzip2","7z","chmod"]);
// SUBSTRING fragments, not terms. Every entry below contains a delimiter, which is
// precisely why has_any() is the wrong operator for them. See the bonus.
let SecretFragments = dynamic([
".npmrc","npm_token","npm-token",".git-credentials",".gitconfig",".netrc",
".aws/credentials",".aws/config",".config/gh/hosts.yml",".kube/config",
".docker/config.json",".ssh/id_","gcloud/credentials","github_token",
"actions_runtime_token","actions_id_token","runner/work/_temp","/proc/self/environ",
"printenv","process.env"
]);
DeviceProcessEvents
| where Timestamp > ago(lookback)
// Indexed prefilter on the RAW columns. This is the ONE place in this article where
// has_any is the correct operator: every needle is a single alphanumeric term, so the
// term index can serve it, and "node" as a term also matches node.exe and /usr/bin/node.
// ("sh" is two characters and falls below the index threshold -- still correct, just
// scanned rather than looked up.)
| where InitiatingProcessFileName has_any (AncestryTerms)
or InitiatingProcessParentFileName has_any (AncestryTerms)
| extend Self = Bare(FileName),
Parent = Bare(InitiatingProcessFileName),
Grand = Bare(InitiatingProcessParentFileName)
// THE authoritative test, and the entire correction: TWO generations, both already on
// this row. Generation 1 is npm -> X. Generation 2 is npm -> shell -> X, which is what
// a lifecycle script actually looks like and what a PID self-join structurally cannot
// return. Recorded as a NUMBER rather than a boolean, because which generation the match
// landed in is evidence, not bookkeeping.
| extend RuntimeGen = case(
Parent in (PkgRuntimes), 1,
Grand in (PkgRuntimes), 2,
0)
| where RuntimeGen > 0
// Classify the payload; do NOT filter on it. A shell spawned by node is scaffolding and
// a curl spawned by that shell is a payload, but both belong in the output -- the second
// one is only interpretable in the presence of the first.
| extend PayloadClass = case(
Self in (NetworkTools), "NetworkTool",
Self in (Stagers), "Stager",
Self in (Interpreters), "Interpreter", // node is in BOTH lists; order decides
Self in (ScriptShells), "Shell",
Self in (PkgRuntimes), "Runtime",
"Other")
| extend CmdLower = tolower(tostring(ProcessCommandLine))
// On npm >= 8.17 this is the package.json script body itself, inline. On a gen-2 row it
// is the malicious postinstall source, verbatim, in a column.
| extend ParentCmd = tolower(tostring(InitiatingProcessCommandLine))
// Substring-any over the authoritative fragment list. There is no contains_any() in KQL,
// and has_any() is a TERM test that would answer a different question for every one of
// these needles. mv-apply is the idiom -- ONE list, consumed mechanically, rather than a
// regex alternation maintained in parallel with it.
| mv-apply Frag = SecretFragments to typeof(string) on (
summarize SecretHits = make_set_if(Frag, CmdLower contains Frag
or ParentCmd contains Frag, 12)
)
| extend SecretRef = strcat_array(SecretHits, " ")
// Lifecycle context, recovered from the data rather than assumed. Ranked, not gated:
// "None" still appears in the output, because a shell under node with no node_modules
// path is either a toolchain quirk worth learning or a payload worth reading.
| extend LifecycleEvidence = case(
ParentCmd contains "node_modules/", "NodeModulesPath",
ParentCmd contains "npm-cli.js", "NpmCli",
ParentCmd matches regex @"\b(pre|post)?install\b", "InstallPhase",
CmdLower contains "node_modules/", "NodeModulesPathChild",
"None")
// Scoped package names have a slash in them, so the (?:@[^/\s]+/)? group is not optional
// decoration -- without it every @scope/pkg reports as "@scope".
| extend PkgFromParent = extract(@"node_modules/((?:@[^/\s]+/)?[^/\s]+)/", 1, ParentCmd)
| extend PkgFromSelf = extract(@"node_modules/((?:@[^/\s]+/)?[^/\s]+)/", 1, CmdLower)
// NOT coalesce(). extract() returns an EMPTY STRING on no match, not null, and coalesce()
// takes the first NON-NULL value -- so a miss on the first argument would win over a hit
// on the second. Same shape as last week's zero-versus-null byte counter, different
// function, and it was in my first draft here too.
| extend PkgName = iff(isempty(PkgFromParent), PkgFromSelf, PkgFromParent)
// Entity = the DEVICE. A build host is the unit of compromise here: the token lives on
// it, the worm republishes from it, and the package that started it is an attribute.
| summarize
Events = count(),
// The generation split is the headline number. Gen2 > 0 means a lifecycle script ran
// something, which is the finding; Gen1-only means the toolchain spawned a shell and
// nothing came of it.
Gen2Events = countif(RuntimeGen == 2),
NetworkRuns = countif(PayloadClass == "NetworkTool"),
StagerRuns = countif(PayloadClass == "Stager"),
InterpRuns = countif(PayloadClass == "Interpreter" and RuntimeGen == 2),
SecretRuns = countif(array_length(SecretHits) > 0),
SecretsSeen = make_set_if(SecretRef, isnotempty(SecretRef), 20),
Packages = make_set_if(PkgName, isnotempty(PkgName), 30),
Chains = make_set(strcat(iff(isempty(Grand), "?", Grand), " > ", Parent,
" > ", Self), 15),
Payloads = make_set(Self, 20),
LifecycleTags = make_set(LifecycleEvidence, 5),
// take_anyif, not take_any(iff(...)): take_any picks an arbitrary row, and an
// arbitrary row is very often the one where the iff returned "".
SampleScript = take_anyif(InitiatingProcessCommandLine, RuntimeGen == 2),
SampleCmd = take_anyif(ProcessCommandLine, PayloadClass != "Shell"),
Accounts = make_set(AccountName, 10),
// DeviceName is a label, not an identity -- it changes on rename and collides across
// a fleet. DeviceId is the key; the names ride along so triage can find the box.
DeviceNames = make_set(DeviceName, 5),
ActiveDays = dcount(bin(Timestamp, 1d)),
FirstSeen = min(Timestamp),
LastSeen = max(Timestamp)
by DeviceId
| extend
DistinctPackages = array_length(Packages),
DistinctPayloads = array_length(Payloads),
ReachedGen2 = Gen2Events > 0,
RenamedInWindow = array_length(DeviceNames) > 1
// Ranking, in order of what changes your afternoon:
// 1. a lifecycle process touched a credential path
// 2. it reached the network
// 3. it got to generation 2 at all
// 4. it staged or encoded something
// 5. breadth of packages executing scripts
// Raw event count is last on purpose. One postinstall is the whole incident; forty
// thousand node-gyp invocations are Tuesday.
| order by SecretRuns desc, NetworkRuns desc, Gen2Events desc, StagerRuns desc,
DistinctPackages desc, Events desc