Hunt Npm Postinstall Grandchild Network Payload


npm supply-chain worms where the payload runs two process generations down — 'sins of the grandfather' shape. Traces npm → sh -c → curl.

KQL Library  /  Hunting

 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.

 Download .kql
// 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