Vetrix Docs

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:

  • trivy
  • osv_scanner
  • govulncheck
  • dependency_check
  • lifecycle_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 via go: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 _attachments blob 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/tinycolor was 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 (or prepare/preinstall) lifecycle script in package.json that runs a JS trampoline on npm install. Trampoline dumps process env + ~/.npmrc + the GITHUB_TOKEN and 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, and npm publishes them — hence "worm".
  • Distinguishing artifacts: large obfuscated bundle.js (or similar) shipped in the tarball; a single-line postinstall invoking 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 build hook that fires inside CI runners where GITHUB_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.env loop), HTTPS POST to a non-npmjs host with environment payload, often gated behind if (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_TOKEN proceeds to push .github/workflows/*.yml patches 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 CaughtLifecycleHookScanner 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 CaughtLifecycleHookScanner 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's BundleSHAScanner (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 install without 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 rides npm test / npm run build hooks 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 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. Bonus: flag the same hooks in the top-level package.json to catch publication-time injection.
  • Where: New file internal/scanning/lifecycle_npm.go registered alongside SCAComposite (extend scannerForType in internal/scanning/registration.go:95-108). The composite already selects on directory shape; the lifecycle scanner would fire whenever package.json exists.
  • 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 (extend SCABlock).
  • 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 OSVScannerScanner to internal/scanning/sca.go and have SCAComposite consult 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:194 NPMHandler.publish), invoke an inline scanner that: (a) extracts the tarball's package.json and 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 curated internal/scanning/iocs.go file).
  • Where: New function internal/registry/npm_scan.go invoked from the publish handler before GetOrCreatePackage. 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.