Shai-Hulud variant coverage — vetrix SCA scanner gap analysis
This document inventories how the vetrix Software Composition Analysis (SCA) pipeline detects (or fails to detect) the family of npm self-propagating supply-chain worms publicly known as "Shai-Hulud". Every cited variant is referenced by a public identifier (GHSA / OSV / vendor-advisory URL) where one exists, or labelled "informal" where one does not.
1. Inventory — current SCA scanner inputs
1.1 Server-side scanner orchestration
The Go-side scanner registry lives in internal/scanning/. The SCA family
is registered as a composite of four backends (see
internal/scanning/sca.go NewSCACompositeWithSignatures, dispatched from
internal/scanning/registration.go scannerForType): the original two
vulnerability-feed backends, plus the two Vetrix-native Shai-Hulud
signature scanners delivered by R1 and R3 (see §4):
| Backend | Tool | Trigger | Source of vuln data / signature |
|---|---|---|---|
GovulncheckScanner |
govulncheck |
go.mod present at scan root |
Go vulnerability database (vuln.go.dev) — GO-NNNN-NNNN + CVE IDs |
TrivyFSScanner |
trivy fs |
fallback (everything non-Go, including npm) | Aqua's bundled vuln DB (NVD, GHSA, vendor advisories) |
LifecycleHookScanner |
none — in-process, structural | package.json present at scan root |
R1: flags any dependency (or the top-level package) whose installed version adds a postinstall/preinstall/prepare/install/dependencies script relative to the previously-merged lockfile baseline — zero-day, feed-independent |
BundleSHAScanner |
none — in-process, SHA-256 | npm payload files present (lifecycle-script targets plus a bounded .js/.cjs/.mjs sweep) |
R3, SCA-side half: flags a SHA-256 digest match against the embedded IOC blocklist internal/scanning/iocs/bundle_sha_blocklist.txt |
internal/scanning/scanner.go:1-17 documents the four families
(SCA / SAST / Secrets / Container) — a different axis from the four
backends inside the SCA family above. Note the package-level comment
on line 6 mentions "npm audit" — there is no npm audit
scanner implementation in the tree; the Node ecosystem is currently
handled via trivy fs plus the two signature scanners above. The
doc-string is stale (see R5, §4).
1.2 Runner-side composite action
The on-pipeline scanner orchestration is the composite action declared in
internal/actions/security-scan/action.yml. It dispatches in
parallel based on the .vetrix/tooling.yml file in the user's repo,
normalises every scanner's output to SARIF 2.1.0, and POSTs to
/security/ingest. The wider list of configurable SCA tools (the
runner can dispatch beyond what internal/scanning/registration.go wires
up server-side) is:
internal/tooling/config.go SCABlock:
trivyosv_scannergovulncheckdependency_checklifecycle_hook— the R1 npm lifecycle-hook signature scanner (LifecycleHookScanner)bundle_sha— the R3 (SCA-side half) bundle-SHA IOC scanner (BundleSHAScanner)
The last two are the two Vetrix-native classes, not external-CLI
wrappers: enabling the SCA family enables them, and their sub-block
exists only to opt out or to supply an allowlist / severity override
(see the SCABlock doc-comment in config.go).
internal/tooling/config.go SASTBlock (separate family): semgrep,
gosec, bandit, brakeman, psalm, eslint, ruff, rubocop.
1.3 SARIF ingest adapters
internal/sarif/adapters/ holds pure JSON→SARIF normalisers for tools
the runner invokes. SCA-relevant adapters present today: trivy
(testdata/trivy/), OSV-Scanner (osv.go), and DependencyCheck
(dependency_check.go). These are parsers — they consume the
external tool's output. They do not themselves add signature coverage.
1.4 SBOM pipeline
internal/sbom/ stores CycloneDX 1.5 / SPDX 2.3 blobs
content-addressed and indexes per (repo, commit, tool, format). It is
storage + retrieval + attestation-adapter only — see
internal/sbom/sbom.go:1-15. The SBOM is not post-processed against
any advisory database server-side; the package explicitly does not
shell out to syft (the runner action does that). There is no
"SBOM-diff against feed-of-known-bad-packages" pass anywhere in
internal/.
1.5 Scan policy
internal/scanpolicy/scanpolicy.go is the persistence layer for
versioned Rego source that gates merge + deploy on per-finding
severity. It is policy-as-code over the SARIF the scanners emit; it
adds no signature coverage of its own — gaps in the input scanner show
up as gaps in the gate.
1.6 First-party signature coverage (R1/R3)
There is still no internal/scanning/advisory/ or
internal/scanning/sources/ package — the two vulnerability-feed backends
(GovulncheckScanner, TrivyFSScanner) remain entirely upstream-DB-driven,
with no first-party CVE/GHSA mirror of their own.
As of R1 and the SCA-side half of R3, however, there
is first-party signature coverage layered on top of that feed. Two
Vetrix-native scanners now ship inside the same SCAComposite (see §1.1,
§4):
LifecycleHookScanner(internal/scanning/lifecycle_npm.go) is a structural signature — it carries no embedded list of known-bad package names; it flags any dependency (or the top-level package) that newly introduces an npm lifecycle script, independent of any advisory feed.BundleSHAScanner(internal/scanning/bundle_sha_npm.go) is a literal embedded list:internal/scanning/iocs/bundle_sha_blocklist.txt, a curated SHA-256 blocklist of known Shai-Hulud worm bundle hashes, embedded into the server binary viago:embed.
There is still no yara-style obfuscated-JS pattern catalog and no token-exfil network-indicator-of-compromise list — both R1 and R3 operate on the npm package tree pre-execution (file contents / lockfile diff / hash), not on the payload's runtime network behaviour.
grep -rln 'shai\|hulud\|worm\|trampoline' internal/ no longer returns
zero matches: both internal/scanning/lifecycle_npm.go and
internal/scanning/bundle_sha_npm.go name "Shai-Hulud" and "worm" directly
in their doc comments, by design (see R1/R3 status in §4).
1.7 Registry-side controls
The internal npm registry (internal/registry/npm.go) handles publish
- download but does not run any scanner against an incoming
_attachmentsblob before storing it. Publish-time interception of a worm payload is not implemented.
1.8 Secret scanning (out of band but relevant)
GitleaksScanner (internal/scanning/secrets.go) plus the built-in
BuiltInPatterns (lines 143-168) cover AWS access keys, PEM private
keys, and two generic api_key= / token= shapes. This is a
post-hoc commit/diff scanner — it would surface a token only after
it had been committed by the worm, not at network-exfil time, and only
if the matched pattern overlaps with the four built-ins (which it
typically would not for npm-token / GitHub-token shapes used by the
public Shai-Hulud variants).
2. Variant enumeration
The "Shai-Hulud" label covers a family of npm-ecosystem supply-chain worms first widely reported in September 2025. The defining behaviour is self-propagation: a compromised package, when installed in a developer environment with credentials, uses those credentials to publish trojanised versions of other packages that the same maintainer owns — turning a single account compromise into a fan-out blast across a maintainer's portfolio.
Variants below are listed chronologically. Public identifiers are included where the author could verify them at time of writing; variants without a stable public ID are labelled "informal".
V1 — Original Shai-Hulud (September 2025)
- Public references: GitHub Security Lab + StepSecurity coordinated
disclosures, npm Inc. registry takedown notices. Anchor advisories
exist on GHSA for individual compromised packages (e.g.
@ctrl/tinycolorwas an early-reported carrier); look up "shai-hulud" tag on github.com/advisories for the broad set. The author has not verified individual GHSA IDs and will not fabricate them here. - Payload shape: malicious
postinstall(orprepare/preinstall) lifecycle script inpackage.jsonthat runs a JS trampoline onnpm install. Trampoline dumps process env +~/.npmrc+ theGITHUB_TOKENand POSTs to an attacker endpoint. - Propagation: trampoline then runs
npm whoami+npm access ls-packages, enumerates other packages the local npm token can publish to, mints trojanised versions of those, andnpm publishes them — hence "worm". - Distinguishing artifacts: large obfuscated
bundle.js(or similar) shipped in the tarball; a single-linepostinstallinvoking it; a GitHub-Actions-token / npm-token regex in the obfuscated body.
V2 — Postinstall-trampoline reflavours (Sep–Oct 2025)
- Public references: Socket.dev / Snyk / Aikido / Phylum write-ups through October 2025. Specific package IDs varied per wave; no single stable CVE covers the family.
- Payload shape: same lifecycle-script trampoline mechanism, but with rotating bundlers (esbuild output, webpack output, raw obfuscator) and rotating exfil hosts. The lifecycle-script hook is the stable signal; the payload contents are not.
V3 — CI-token exfil variant (Oct–Nov 2025)
- Public references: vendor blog posts on Shai-Hulud follow-on waves (October–November 2025). Author treats this as informal: no single authoritative GHSA covers it.
- Payload shape: instead of (or in addition to) postinstall, the
trampoline lands as an
npm test/npm run buildhook that fires inside CI runners whereGITHUB_TOKEN,NPM_TOKEN,AWS_*, and OIDC credentials are environment-resident. Exfil hits an attacker endpoint disguised as a telemetry / package-registry probe. - Distinguishing artifacts: env-variable enumeration (
process.envloop), HTTPS POST to a non-npmjs host with environment payload, often gated behindif (process.env.CI)so a local install is silent.
V4 — "informal: dependency-confusion seed" variant
- Public references: anecdotal, multiple security-vendor blog posts late 2025. The author does NOT have a stable public ID and labels this informal.
- Payload shape: not Shai-Hulud sensu stricto; included here because it is easily conflated with the family. A typosquat / dependency-confusion package gets published with the same postinstall trampoline. No self-propagation — only initial fan-in.
- Distinguishing artifacts: low-download-count package, very recent publish time, name resembling a popular internal-only package.
V5 — "informal: GitHub-Actions workflow-injection variant"
- Public references: informal. Reported as a corollary of V1's
fan-out: a worm that has obtained a
GITHUB_TOKENproceeds to push.github/workflows/*.ymlpatches across every repo the token has write to. - Payload shape: not strictly an npm payload anymore — the worm pivots into the CI-config plane.
3. Coverage matrix
Legend:
- "Caught" = either (a) a vulnerability database row that names the
compromised package@version pair is present in the data source the
scanner consults, AND the scan runs against the lockfile / SBOM that
pins that package@version, OR (b) a structural / signature scanner
(
LifecycleHookScanner,BundleSHAScanner) flags the behaviour directly, independent of any upstream advisory existing yet — i.e. at zero-day. - "Partial" = the source can catch it once an advisory is published upstream, but there is no mechanism in vetrix that adds coverage ahead of the upstream feed (no zero-day detection).
- "Missed" = no signature class in any scanner the SCA scanner registry runs would surface the behaviour, even with the feed fully up to date.
| Variant | Server-side vuln-feed backends (govulncheck / trivy fs) | Server-side signature backends (LifecycleHookScanner R1 / BundleSHAScanner R3) |
Runner-side osv_scanner (when enabled) |
Runner-side dependency_check (when enabled) |
GitleaksScanner (commit-grade) |
SBOM pipeline (no analysis) | Registry publish hook (internal/registry/npm.go) |
Net coverage |
|---|---|---|---|---|---|---|---|---|
| V1 Original Shai-Hulud | Partial — trivy fs only after upstream GHSA lands | Caught — LifecycleHookScanner flags the postinstall trampoline at zero-day (no feed dependency); BundleSHAScanner adds hash-match coverage for re-introduced known bundles |
Partial — only after OSV ingests upstream GHSA | Partial — depends on dependency-check feed lag | Missed | Missed (storage only) | Missed (no scanner hook) | Caught — zero-day via R1/R3 |
| V2 Postinstall trampoline reflavours | Partial — same as V1 | Caught — the lifecycle-script hook is the stable signal R1 watches; rotating bundler / obfuscator / exfil-host payload contents don't evade it | Partial — same as V1 | Partial — same as V1 | Missed | Missed | Missed | Caught — zero-day via R1 |
| V3 CI-token exfil variant | Partial — depends on per-payload advisory | Missed — the exfil hook typically rides npm test / npm run build, not one of the five keys (postinstall/preinstall/prepare/install/dependencies) R1 watches; BundleSHAScanner only matches if the exact bundle hash is already in the IOC blocklist |
Partial — same | Partial — same | Missed (token is exfilled, not committed) | Missed | Missed | Mostly missed |
| V4 Dependency-confusion seed | Partial — only if the typosquat name is in the feed | Caught — LifecycleHookScanner also walks the top-level package.json, so a typosquat carrying a postinstall trampoline is flagged at zero-day, per R1's original "Catches: V1, V2, V4" scope |
Partial — same | Partial — same | Missed | Missed | Missed | Caught — zero-day via R1 |
| V5 Workflow-injection follow-on | Missed (not a dependency vulnerability) | Missed — not an npm dependency artifact; the worm pivots into .github/workflows/*.yml |
Missed | Missed | Partial — only if exfilled token text lands in a commit | Missed | Missed | Missed |
3.1 Cross-cutting observations
- V1/V2/V4 are now caught at zero-day via R1's structural signature
class. As of R1 (
LifecycleHookScanner, delivered — see §4), Vetrix flags any dependency (or the top-level package) that newly introduces a postinstall/preinstall/prepare/install/dependencies script, independent of any upstream advisory feed. This closes the historical "entirely feed-reactive" gap for V1/V2/V4: detection no longer waits on trivy / osv-scanner / dependency-check's upstream feed lag. R3'sBundleSHAScanner(delivered — see §4) adds a complementary hash-match pass against a curated known-bundle IOC blocklist, catching re-introductions where the lifecycle script itself was already present in the baseline. There is still no obfuscated-JS heuristic and no runtime network-IOC list — R1/R3 both operate on the package tree pre-execution (file contents / lockfile diff / hash), not on the payload's own JS content or its network behaviour at execution time. - The npm registry handler does not scan published tarballs. A
developer who compromises an internal vetrix npm registry and
publishes a worm payload to it bypasses every server-side check —
consumers will install it on
npm installwithout any signal. (This is the registry-side half of R3, and remains not yet implemented — see §5.) - Lifecycle-script presence is structural, not advisory-bound — and
vetrix now acts on it. Every Shai-Hulud variant that self-propagates
via a package install (V1/V2) or a publication-time injection (V4)
uses an npm lifecycle hook (postinstall / preinstall / prepare /
install / dependencies) to trigger. R1's
LifecycleHookScanner(delivered — see §4) flags any newly-introduced dependency carrying one of those hooks, catching V1/V2/V4 by shape rather than by signature, closing the zero-day window described above. V3's CI-token exfil trampoline typically ridesnpm test/npm run buildhooks rather than the five lifecycle-script keys R1 watches, so it remains outside R1's coverage — see the next bullet. - CI-token exfil (V3) is fundamentally undetectable at SCA time. By the time the trampoline runs in CI the credentials are already in-process. Mitigation belongs in the CI runtime (egress allowlist, short-lived OIDC tokens, ephemeral runner). This document records the gap; remediation is out of scope for the SCA scanner alone.
4. Recommendations
Ordered by ratio of risk-reduction to engineering cost. The matrix in §3 informs the priority.
R1 (highest priority) — Add an npm lifecycle-script signature class
- What: A new SCA scanner family (call it
LifecycleHookScanner) that walkspackage.json+package-lock.json/pnpm-lock.yamland flags any dependency whose installed version adds a postinstall / preinstall / prepare / install / dependencies script relative to the previously-merged lockfile. Bonus: flag the same hooks in the top-levelpackage.jsonto catch publication-time injection. - Where: New file
internal/scanning/lifecycle_npm.goregistered alongsideSCAComposite(extendscannerForTypeininternal/scanning/registration.go:95-108). The composite already selects on directory shape; the lifecycle scanner would fire wheneverpackage.jsonexists. - Severity bucket: Default Medium — there are legitimate uses of
postinstall (native module compilation). The signal is "new
lifecycle script on a newly-introduced dependency", which lifts
the precision substantially. Pair with a per-repo allowlist in
.vetrix/tooling.yml(extendSCABlock). - Catches: V1, V2, V4 at zero-day.
R1 status: delivered — npm lifecycle-script signature class
R1 ships as internal/scanning/lifecycle_npm.go (LifecycleHookScanner).
It is registered on the SCA composite via NewSCACompositeWithSignatures
in internal/scanning/registration.go (scannerForType), so every SCA
scan now also runs the lifecycle-hook pass. It walks package.json +
package-lock.json / pnpm-lock.yaml and flags any dependency whose
installed version adds a postinstall / preinstall / prepare / install /
dependencies script relative to the previously-merged lockfile baseline
(git HEAD), plus the same check on the top-level package.json to catch
the publication-time-injection variant. Default severity is Medium,
matching the recommendation above.
Per-repo false-positive suppression is via the .vetrix/tooling.yml
sca.lifecycle_hook.allowlist block (internal/tooling/config.go),
resolved at scan-dispatch time by the caller that owns the repo's
tooling config — the boot-registered scanner itself carries a nil
allowlist (report-everything-new default) since the boot path has no
repo context.
Catches V1, V2, V4 at zero-day, as originally recommended above.
R2 — Wire up OSV-Scanner as a first-class server-side backend
Today osv_scanner is a runner-side opt-in (internal/tooling/config.go:77)
and a SARIF adapter (internal/sarif/adapters/osv.go). The
server-side scannerForType defaults to SCAComposite{govulncheck, trivyfs} —
there is no OSVScannerScanner implementation in internal/scanning/.
- What: Add
OSVScannerScannertointernal/scanning/sca.goand haveSCACompositeconsult it as a third backend (or run it unconditionally alongside trivy for npm/PyPI ecosystems). OSV-Scanner resolves OSV IDs directly, which is the first feed to surface Shai-Hulud-tagged advisories — earlier than NVD and earlier than trivy's own DB pickup in practice. - Catches: V1, V2 with ~hours-faster feed lag than trivy fs.
R3 — Add a registry-side publish hook that rejects known-IOC tarballs
- What: On publish to the internal npm registry
(
internal/registry/npm.go:194NPMHandler.publish), invoke an inline scanner that: (a) extracts the tarball'spackage.jsonand flags any lifecycle script that matches the R1 pattern, AND (b) checks the tarball's blob digest against a small embedded blocklist of known Shai-Hulud bundle hashes (maintained by operations; refreshed via a curatedinternal/scanning/iocs.gofile). - Where: New function
internal/registry/npm_scan.goinvoked from the publish handler beforeGetOrCreatePackage. On match: reject with HTTP 422 and audit-log; do not persist the blob. - Catches: V1, V2 at upload time, before any downstream
npm install.
R3 status: delivered — SCA-side bundle-SHA IOC blocklist
The SCA-side half of R3 ships as internal/scanning/bundle_sha_npm.go
(BundleSHAScanner). It is registered on the SCA composite via
NewSCACompositeWithSignatures in internal/scanning/registration.go
(scannerForType), running additively alongside the R1
LifecycleHookScanner and the govulncheck / trivy-fs vuln backend -
neither signature scanner suppresses the other, and both self-noop on
non-npm targets so the pass is free on Go-only repos. No extra wiring is
required: every SCA scan now also runs the bundle-SHA pass.
What it does: computes the SHA-256 of candidate npm payload files - files
referenced by a lifecycle script in the top-level package.json, plus a
bounded breadth sweep of .js/.cjs/.mjs files under the target -
and flags any digest that matches the embedded blocklist. This closes the
gap R1 cannot: a re-introduction or variant whose lifecycle script was
already in the baseline (so R1 is silent) but whose bundle CONTENTS match
a known IOC. A match is a High-severity finding (strictly above R1
Medium) carrying the dependency name, version, matched file path, and
matched SHA.
Per-repo false-positive suppression mirrors the R1 allowlist: the
.vetrix/tooling.yml sca.bundle_sha.allowlist block
(internal/tooling/config.go BundleSHABlock / BundleSHAAllow,
validated by checkBundleSHABlock). Each row is an optional name
(dependency scope; empty = any dependency) plus a list of shas. The
in-scanner BundleSHAAllowEntry shape deliberately mirrors R1
LifecycleHookAllowEntry so the scan-dispatch bridge maps both
allowlists with one field-mapping path.
The registry-side publish hook described above (reject known-IOC tarballs
at upload, internal/registry/npm_scan.go) remains a separate
follow-up; it can reuse the same embedded blocklist.
Bundle-SHA blocklist - source and update path
The blocklist is internal/scanning/iocs/bundle_sha_blocklist.txt,
embedded into the server binary at build time via go:embed. Format:
one entry per line, sha256:<64 lowercase hex>; blank lines and lines
beginning with # are ignored; surrounding whitespace is trimmed and hex
is matched case-insensitively. Inline comments are not supported - put
commentary on its own # line.
There is intentionally no runtime or remote feed: the blocklist is a
reviewed, version-controlled artifact so every change is auditable. To
add a hash, security curation appends a new sha256:<hex> line preceded
by a # comment naming the campaign / advisory / sample provenance, opens
an MR against the SCA rc branch referencing the IOC source, and a rebuild
- redeploy ships the new hash. Do not add unverified hashes.
R4 — Add a CI-egress-allowlist policy hook
- What: Operational mitigation for V3 (CI-token exfil). The SCA
scanner cannot catch this at code-review time. Expose a runner-side
egress-allowlist enforced inside the Vetrix-Workflows runner, and gate
the deploy-side Rego policy (
internal/scanpolicy/) so a workflow that opts out is severity-flagged. - Where: Cross-cuts internal/cicd + internal/scanpolicy; out of scope for this analysis.
- Catches: V3 (mitigates, does not detect).
R5 — Replace the stale "npm audit" doc-string
internal/scanning/scanner.go:6 advertises npm-audit support. There
is no npm audit scanner anywhere in the tree. Either implement it
(low value — trivy fs and OSV-Scanner both subsume it) or fix the
doc-string. Recommend fix the doc-string. Cheap; closes a documented
expectation a reader of the scanner package today will form.
R6 — Update the runner action documentation
internal/actions/security-scan/action.yml describes the action but
makes no commitment about Shai-Hulud-class coverage. Add a section
under description: noting that npm-worm coverage is feed-reactive
today, with a pointer to this document.
5. Remaining work
R1 (lifecycle-hook signature class) and the SCA-side half of R3
(bundle-SHA IOC blocklist) are both delivered — see the status
subsections above. What remains is R2 (wiring OSV-Scanner as a
first-class server-side backend) and the registry-side half of R3
(rejecting known-IOC tarballs at publish time via
internal/registry/npm_scan.go, not yet implemented). Of the two, the
registry-side publish hook is the larger remaining coverage delta — it
is the only surface that would catch a worm payload before any
consumer's npm install runs.
6. Verification of references
All internal/... paths in §1 reference the SCA scanner source tree.
Variant references in §2 cite vendor / coordinated disclosure
write-ups; specific GHSA / CVE IDs are intentionally not listed because
they should be verified against an authoritative source before use, and
unverified identifiers must not be fabricated. Substitute verified GHSA
IDs when citing this document in a security-review write-up.