postmortem
Offline static dependency scanner for Node.js, Python, Rust, Ruby, PHP, Go and JVM projects. Resolves the lockfile graph, walks vendored sources, and flags supply-chain compromise patterns.
postmortem is a static dependency scanner for Node.js, Python, Rust, Ruby, PHP, Go and JVM (Java/Kotlin) projects. It resolves the lockfile graph, walks the vendored sources, and flags the patterns that typically show up in supply-chain compromises: install hooks, obfuscation, embedded IOCs (URLs, IPs, crypto wallets), and dangerous API surface.
Offline by default. No network calls, no telemetry, no daemon. One binary, about 2.7 MB. Latest release: v1.1.0 (adds Ruby, PHP, Go and JVM support), see the GitHub releases page.
postmortem ./my-project
postmortem ./my-project --json -o report.json
postmortem ./my-project --html -o report.html
postmortem ./my-project --skip-category iocSupported ecosystems
| Ecosystem | Lockfiles parsed | Source scanned for findings |
|---|---|---|
| Node.js | package-lock.json v2/v3, npm-shrinkwrap.json, pnpm-lock.yaml, yarn.lock | node_modules/ on disk |
| Python | poetry.lock, Pipfile.lock, requirements*.txt | project root, .venv/.../site-packages/ |
| Rust | Cargo.lock | the project's own src/ |
| Ruby | Gemfile.lock (Bundler) | the project's own source (lib/, app/) |
| PHP | composer.lock (Composer) | project root, including a committed vendor/ tree |
| Go | go.mod (with go.sum for checksums) | the project's own source and a committed vendor/ tree |
| JVM | Maven pom.xml (direct deps), Gradle gradle.lockfile (full resolved set) | the project's own .java / .kt source |
Features
- Multi-ecosystem SBOM. Resolves the dependency graph from each lockfile, including parent edges (npm hoisting is fully resolved) and a direct-vs-transitive classification per package.
- Four static analyzers that run against vendored source on disk (see below).
- Four output formats: colored terminal, stable versioned JSON, a self-contained HTML report, and SARIF 2.1.0 for GitHub Code Scanning and other SARIF-aware tools.
- CI-friendly exit codes:
0clean,1findings at or above--severity,2execution error. - Suppression via CLI flags and/or a
postmortem.conffile in the scanned directory.
The four analyzers
| Analyzer | What it flags |
|---|---|
install_hook | npm pre/post-install scripts; Python setup.py invoking subprocess / os.system / exec / network primitives |
obfuscation | Shannon entropy plus language-specific signals (eval, Function, charCodeAt chains, long hex runs, base64 blobs, PHP gzinflate/str_rot13, Ruby Marshal.load, JVM defineClass). Multi-signal scoring with a minified-bundle dampener; a lone weak signal is never reported on its own |
ioc | Embedded URLs, IPv4/IPv6 addresses, bare domains, Bitcoin (Base58-validated) and Ethereum addresses. Heavily filtered to stay high-signal (see below) |
sensitive_api | child_process / net / https (Node); subprocess / socket / os.system (Python); std::process / std::net / Command::new (Rust); system / Net::HTTP / Open3 (Ruby); shell_exec / proc_open / fsockopen (PHP); exec.Command / net.Dial / plugin.Open (Go); Runtime.exec / ProcessBuilder / Class.forName (JVM) |
False-positive controls
The ioc analyzer is tuned for a high signal-to-noise ratio on real codebases:
- Scope operators are not addresses.
web::get,Foo::<T>, and other::paths in Rust, PHP, Ruby, and the JVM are never mistaken for compressed IPv6. - Comments and docstrings are skipped. A URL or IP in a
#,//,///, or/* */line is documentation, not an exfil endpoint. - Non-routable ranges are dropped. RFC1918, loopback, link-local, CGNAT, and TEST-NET IPv4, plus documentation (
2001:db8::/32), link-local, and unique-local IPv6, are treated as config/test data. - Member access is not a domain.
self.name,tc.in,this.ch, and other attribute accesses whose trailing label happens to be a TLD are filtered out, as are reverse-DNS package paths (com.google.gson,org.apache.commons). - Reference hosts are allow-listed. Registry, docs, knowledge, and module-host sites (npm, PyPI, crates.io,
golang.org,gopkg.in, Wikipedia, Stack Overflow, and more) are treated as noise, and hosts already covered by a URL match are not double-reported.
Installation
Homebrew
brew tap mlab-sh/postmortem https://github.com/mlab-sh/postmortem.git
brew install postmortemDirect tarball
Prebuilt binaries are published for four targets:
| Target triple | Platform |
|---|---|
aarch64-apple-darwin | macOS, Apple Silicon |
x86_64-apple-darwin | macOS, Intel |
x86_64-unknown-linux-gnu | Linux, x86_64 |
aarch64-unknown-linux-gnu | Linux, arm64 |
VERSION=1.1.0
TARGET=aarch64-apple-darwin # pick your target from the table above
curl -L "https://github.com/mlab-sh/postmortem/releases/download/v${VERSION}/postmortem-${VERSION}-${TARGET}.tar.gz" \
| tar xz
sudo mv "postmortem-${VERSION}-${TARGET}/postmortem" /usr/local/bin/Local build
Requires a recent stable Rust toolchain (1.80+).
git clone https://github.com/mlab-sh/postmortem.git
cd postmortem
cargo build --release
./target/release/postmortem --helpFor an editable, install-on-path build, cargo install --path . puts the binary in ~/.cargo/bin/.
Usage
postmortem [OPTIONS] <PATH>
Arguments:
<PATH> Path to the project to scan
Options:
--json Emit JSON
--html Emit a self-contained HTML report
--sarif Emit SARIF 2.1.0 (GitHub Code Scanning)
-o, --output <OUTPUT> Write output to this path. Pass `-` to force
stdout. When omitted for --json/--html/--sarif,
a file is auto-created in the cwd named
`postmortem-report-[MM.DD.YYYY::HH:MM].<ext>`
--severity <SEVERITY> Min severity that causes a non-zero exit code
[default: high] [info|low|medium|high|critical]
--min-severity <SEV> Hide findings below this severity from the report
--skip-analyze Skip every analyzer and only emit the SBOM
--enrich Attach mlab.sh deep-links to every IOC finding
--skip-category <CAT>... Hide entire finding categories. Repeatable, or
comma-separated. [ioc|obfuscation|install_hook
|sensitive_api]
--config <PATH> Path to a postmortem.conf
--no-config Disable auto-loading of postmortem.conf
--no-deps Skip the dependency table in terminal output
-h, --help Print help
-V, --version Print versionExit codes
| Code | Meaning |
|---|---|
0 | No findings at or above --severity (default: high). |
1 | At least one finding at or above the threshold. Block the build. |
2 | Execution error (no ecosystem detected, lockfile unreadable, etc.). |
postmortem.conf
Drop a postmortem.conf at the root of the project you scan to suppress noise without typing flags every time. It is auto-loaded when present; CLI flags take precedence and are unioned with the file's settings.
# Drop entire finding categories.
skip_categories = ["ioc"]
# Drop everything attributed to these dependencies. A bare name matches every
# version of that dep; "name@version" pins to a specific version.
skip_dependencies = ["lodash", "[email protected]"]
# Raise the noise floor: findings below this severity are dropped before
# rendering and never count toward the CI exit code.
min_severity = "medium"
# Fine-grained ignore rules. A finding is suppressed when ALL specified fields
# match. An empty rule (no fields) is ignored on purpose.
[[ignore]]
category = "obfuscation"
dependency = "uglify-js"
reason = "known minifier, expected high-entropy output"
[[ignore]]
path = "**/test/**"
reason = "test fixtures legitimately contain weird strings"Path matching uses globs: * matches anything except /, ** matches anything including /, ? matches one non-slash char. Use --no-config to scan without auto-loading, or --config <path> to point at a file outside the project root.
Output formats
JSON
Stable schema versioned via schema_version (currently 1), safe for CI pipelines:
{
"schema_version": 1,
"root": "/path/to/project",
"ecosystems": ["node"],
"dependencies": [ /* ... */ ],
"findings": [
{
"dependency": "flatmap-stream",
"severity": "critical",
"category": "obfuscation",
"detail": "6 obfuscation signal(s): high-entropy, eval(), …",
"location": "…/flatmap-stream/index.js"
}
]
}HTML
--html -o report.html produces a self-contained single-file report, with no external CSS, JS, fonts, or images. Safe to attach to a ticket or upload to artifact storage.
SARIF (GitHub Code Scanning)
--sarif -o report.sarif produces a SARIF 2.1.0 document: one rule per analyzer category (postmortem.ioc, .obfuscation, .install_hook, .sensitive_api), one result per finding. Severity maps to SARIF levels as critical/high to error, medium to warning, low to note, and info to none. Each result carries a stable partialFingerprints entry so re-runs do not re-open the same alert.
- name: Run postmortem
run: postmortem . --sarif -o postmortem.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: postmortem.sarifWhat is scanned
| Ecosystem | scanned | not scanned |
|---|---|---|
| Node | node_modules/ on disk, with full hoist resolution | tarballs on the registry; never downloaded |
| Python | project root (setup.py, etc.) and .venv/lib/.../site-packages/ if present | system-wide site-packages |
| Rust | the project's own src/ for sensitive APIs | ~/.cargo/registry/ (not walked by default) |
| Ruby | the project's own source (lib/, app/) | gems in the Bundler path (not walked by default) |
| PHP | project root, including a committed vendor/ tree | packages fetched by Composer when vendor/ is absent |
| Go | the project's own source and a committed vendor/ tree | modules in the Go module cache |
| JVM | the project's own .java / .kt source | dependencies in the Maven/Gradle caches |
The scanner never makes network calls. The optional --enrich flag emits clickable mlab.sh deep-links per IOC finding so a human can pivot to enrichment in one click. It does not call any network itself.
Try it on real incidents
The test corpus reproduces real public supply-chain incidents with inert payloads:
postmortem ./tests/fixtures/malicious-node # event-stream -> flatmap-stream (2018)
postmortem ./tests/fixtures/malicious-python # ctx PyPI hijack (2022)
postmortem ./tests/fixtures/malicious-rust # rustdecimal typosquat (2022)
postmortem ./tests/fixtures/malicious-ruby # rest-client hijack shape (2019)
postmortem ./tests/fixtures/malicious-php # Composer webshell shape
postmortem ./tests/fixtures/malicious-go # Go module typosquat shape
postmortem ./tests/fixtures/malicious-java # Maven artifact typosquat shapemlab CLI
The official command-line client for mlab.sh and vuln.mlab.sh - scan domains, look up IPs, analyse files, inspect SSL, check crypto addresses and search CVEs from your terminal.
assay
Offline-first, single-binary scanner for ML model artifacts - safetensors, GGUF and PyTorch pickle. Assay the weights before you trust them.