-
Notifications
You must be signed in to change notification settings - Fork 0
Ghost
Every analyzer reads code that exists somewhere. The supply-chain attacks that
hurt most put their payload in exactly one place: the published tarball.
event-stream's flatmap-stream, ua-parser-js, the xz backdoor's release
tarball. The repository everyone reviewed was clean. The artifact everyone
installed was not.
ghost compares the two.
postmortem ghost # direct npm dependencies
postmortem ghost . --all # transitive too
postmortem ghost . --package left-pad # just this one
postmortem ghost . --all --json -o ghost.jsonghost . · published tarball vs source
? @types/node@22.20.4 published without a gitHead, and DefinitelyTyped/DefinitelyTyped has no tag for 22.20.4
≈ esbuild@0.28.2 4 file(s) differ, nothing unexplained github.com/evanw/esbuild@609683d89297
≈ prettier@3.8.4 53 file(s) differ, nothing unexplained github.com/prettier/prettier@3.8.4
! published from commit fbf300f9d898, which prettier/prettier does not have (unpushed or force-pushed away)
✓ dotenv@16.6.1 11 file(s) identical github.com/motdotla/dotenv@076ba3b6a225
0 ghost · 1 unverifiable · 2 rebuilt · 1 identical
For each npm dependency:
- Read the version manifest from the registry: tarball URL, repository,
repository.directory, andgitHead, the commit it was published from. - Download the tarball (capped at 50 MiB) and unpack it with the system
tar. - Check out the repository at
gitHead, shallow. Without one, use the release tag:v1.2.3,1.2.3,name@1.2.3,short@1.2.3,short-v1.2.3orshort-1.2.3. - Find the package in the checkout:
repository.directory, the root, or the firstpackage.jsoncarrying its name (monorepos). - Compare every published file with its source. Line endings are normalized.
package.jsonis compared by its install hooks only. - Run the scan analyzers over only the files that differ, and drop any finding whose evidence the source also contains.
| Verdict | Meaning | Exit |
|---|---|---|
ghost ☠ |
Code or an install hook in the tarball that the source does not explain. | 1 |
unverifiable ? |
No repository on a known host, a private or deleted one, or no commit/tag for the version. Never read as clean. | 0 |
rebuilt ≈ |
Files differ because a build ran, and nothing in the difference is unexplained. | 0 |
identical ✓ |
Every published file matches the source byte for byte. | 0 |
An install hook (preinstall/install/postinstall) in the published
package.json that the source does not declare is always ghost. The one hook
npm writes itself, install: node-gyp rebuild for a package with a
binding.gyp, is exempt.
Bundles inline third-party code the repo never contains, and minifiers produce escape tables that look like obfuscation. So the bar depends on where the file comes from:
| Where | What makes it a ghost |
|---|---|
Hand-written (no build step, not a dist-like path) |
any finding of medium severity or higher |
Build output: a build script, a Makefile, a bundler or tsconfig config (package or repo root), or a dist/, build/, esm/, cjs/, umd/, *.min.* path |
high or higher, and obfuscation only when it executes a decoded blob (eval/Function plus base64 or an escape run), the event-stream shape |
Vendored (compiled/, vendor/, third_party/, node_modules/) |
critical only |
This is the known limit: a medium-severity payload dropped into a built package's
dist/ does not trip it. Closing that gap takes rebuilding the package locally
and diffing against the result.
-
gitHeadmissing from the repo. The manifest names a commit the repository does not have. That usually means a release published from an unpushed working tree, or history rewritten afterwards. It is shown as a note, and the comparison falls back to the tag. -
Unverifiable is common. DefinitelyTyped never tags
@types/*releases, and many monorepos publish without agitHeador a per-package tag. The reason is always printed.
- Needs
gitandtar. Both ship with macOS, Linux and Windows 10+. - The tarball is attacker-controlled. It is size-capped and unpacked by
tar, which refuses..and absolute member names. Nothing from it is executed. -
gitruns withGIT_TERMINAL_PROMPT=0, so a private repo fails instead of prompting, and withGIT_LFS_SKIP_SMUDGE=1. - Work happens under
~/.postmortem/ghost/<pid>/. Each package's directory is deleted as soon as it has been compared, and the whole workspace at exit.
--json, --webhook and -o/--output behave as in every other command.
The JSON carries per package: verdict, repo, git_ref, reason, notes,
identical_files, modified, only_in_tarball, scripts, has_build_step
and the unexplained findings.
Commands
- scan
- tree
- audit
- fix
- licenses
- why
- diff
- sbom
- system
- scripts
- hook
- watch
- timeline
- ghost
- hunt
- allowlist
- cache
Targets
Ecosystems (overview)
System managers
Windows (overview)
- WinGet
- MSIX / AppX
- Chocolatey
- Scoop
- Add/Remove Programs
- Auto-start (ASEP)
- Scheduled tasks
- Services & drivers
- Jobs & file-based
- Privilege & trust posture
- Network posture
Concepts