A static dependency scanner for Node.js, Python, and Rust projects. 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, ~2.7 MB.
postmortem ./my-project
postmortem ./my-project --json -o report.json
postmortem ./my-project --html -o report.html
postmortem ./my-project --skip-category ioc
- Multi-ecosystem SBOM — npm
package-lock.jsonv2/v3 (with full hoisting resolution for parent edges), Pythonpoetry.lock/Pipfile.lock/requirements*.txt, RustCargo.lock. - Four static analyzers that run against vendored source on disk:
install_hook— npmpre/post-installscripts; Pythonsetup.pyinvokingsubprocess/os.system/exec/ network primitives.obfuscation— Shannon entropy +eval/Function/charCodeAtchains / long\xNNruns / base64 blobs. Multi-signal scoring; minified-bundle dampener to avoid false alarms on legit bundles.ioc— embedded URLs, IPv4 and IPv6 addresses, bare domain names (with an embedded TLD allowlist that includes commonly-abused throwaway TLDs), Bitcoin (Base58-validated) and Ethereum addresses. Hosts already covered by a URL match aren't double-reported; registry/docs hosts are allow-listed.sensitive_api—child_process/fs/net/https(Node);subprocess/socket/urllib/os.system(Python);std::process/std::net/Command::new(Rust).
- 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 ≥--severity,2execution error. - Suppression via CLI flags and / or a
postmortem.conffile in the scanned directory.
Each release is built by .github/workflows/release.yml
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 |
brew tap mlab-sh/postmortem https://github.com/mlab-sh/postmortem.git
brew install postmortemThe formula at Formula/postmortem.rb is auto-regenerated
by the release workflow with live sha256 hashes — never edit it by hand.
URL template: https://github.com/mlab-sh/postmortem/releases/download/v<VERSION>/postmortem-<VERSION>-<TARGET>.tar.gz
VERSION=1.0.1
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/The release workflow is workflow_dispatch-only — no automatic builds on push or
tag. To grab a binary from an untagged commit, trigger the workflow manually:
- Open the Release workflow.
- Click Run workflow, pick the branch or commit, run it.
- Either download the per-platform
*.tar.gzfrom the run-summary Artifacts panel (kept 90 days), or grab it from thev<version>Release the run publishes when it finishes.
git clone https://github.com/mlab-sh/postmortem.git
cd postmortem
cargo build --release
./target/release/postmortem --helpRequires a recent stable Rust toolchain (1.80+). For an editable, install-on-path
build, cargo install --path . puts the binary in ~/.cargo/bin/.
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 — only emit the SBOM
--enrich Attach mlab.sh deep-links to every IOC finding
so you can click straight through to enrichment
(WHOIS / passive DNS / abuse). Link emission
only — no HTTP is made.
--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 version
| 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.). |
Drop a postmortem.conf at the root of the project you scan to suppress noise
without typing flags every time. It's auto-loaded when present; the CLI flags
take precedence and are unioned with the file's settings.
Full schema (all fields optional):
# Drop entire finding categories.
skip_categories = ["ioc"]
# Drop everything attributed to these dependencies. Bare name matches every
# version of that dep; "name@version" pins to a specific version.
skip_dependencies = ["lodash", "left-pad@1.3.0"]
# 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 so a typo can't
# accidentally mute everything.
[[ignore]]
category = "obfuscation"
dependency = "uglify-js"
reason = "known minifier, expected high-entropy output"
[[ignore]]
path = "**/test/**"
reason = "test fixtures legitimately contain weird strings"
[[ignore]]
path = "**/*.min.js"
reason = "minified bundles"Path matching uses globs: * matches anything except /, ** matches anything
including /, ? matches one non-slash char. Matching is substring (no
implicit anchoring), so **/test/** works regardless of the absolute prefix.
Use --no-config to scan without auto-loading, or --config <path> to point
at a file outside the project root.
postmortem scan of ./tests/fixtures/malicious-node
ecosystems: node
dependencies: 2 total, 1 direct, 1 transitive
findings: 1 critical, 3 high, 1 medium, 1 low, 0 info
┌──────────────┬─────────┬────────────┬────────────────────┐
│ name ┆ version ┆ kind ┆ parents │
├──────────────┼─────────┼────────────┼────────────────────┤
│ event-stream ┆ 3.3.6 ┆ direct ┆ - │
│ flatmap-… ┆ 0.1.1 ┆ transitive ┆ event-stream@3.3.6 │
└──────────────┴─────────┴────────────┴────────────────────┘
… findings table follows …
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 -o report.html produces a self-contained single-file report — no
external CSS, JS, fonts or images. Safe to attach to a ticket or upload to
artifact storage.
--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: critical/high → error, medium → warning, low → note,
info → none. Each result carries a stable partialFingerprints entry so
re-runs don't re-open the same alert, and paths are made relative to a
SRCROOT URI base so the same SARIF file makes sense on any reviewer's
machine. When combined with --enrich, the mlab.sh deep-link is surfaced as
properties.enrichUrl on each IOC result.
Wire into GitHub Code Scanning:
- name: Run postmortem
run: postmortem . --sarif -o postmortem.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: postmortem.sarif| 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) |
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.
| IOC kind | mlab.sh link template |
|---|---|
| URL | https://mlab.sh/domain/<host> (host extracted from the URL) |
| IPv4 | https://mlab.sh/ip/<addr> |
| IPv6 | https://mlab.sh/ip/<addr> |
| Domain | https://mlab.sh/domain/<name> |
| BTC / ETH wallet | https://mlab.sh/crypto/<address> (chain auto-detected) |
The test corpus emulates real public supply-chain incidents with inert payloads (see tests/fixtures/README.md for references). Each fixture is a reproduction of patterns from the incident, not the original malicious code:
| Fixture | Models incident | Year | Trigger |
|---|---|---|---|
tests/fixtures/malicious-node/ |
event-stream@3.3.6 → flatmap-stream@0.1.1 Copay wallet stealer |
2018 | install_hook HIGH, obfuscation CRITICAL, ioc HIGH (BTC + ETH wallets), sensitive_api |
tests/fixtures/malicious-python/ |
ctx@0.2.6 PyPI hijack — AWS-key exfil via setup.py |
2022 | install_hook CRITICAL (6 primitives), ioc HIGH (wallet), sensitive_api |
tests/fixtures/malicious-rust/ |
rustdecimal typosquat of rust_decimal |
2022 | SBOM resolves typosquat, sensitive_api MEDIUM on local src/ |
tests/fixtures/clean-node/ |
benign baseline | — | no findings, exit 0 |
Run the live demo:
postmortem ./tests/fixtures/malicious-node
postmortem ./tests/fixtures/malicious-python
postmortem ./tests/fixtures/malicious-rustcargo build # debug build
cargo build --release # stripped, LTO, ~2.7 MB
cargo test # 16 unit + 16 integration testssrc/
main.rs # orchestration, exit codes
cli.rs # clap definitions
config.rs # postmortem.conf loader + filter engine
detect.rs # detect ecosystems + locate manifests/lockfiles
model.rs # Dependency, Finding, Severity, Category, Report
parsers/
node.rs # package-lock.json v2/v3 + npm hoist resolution
python.rs # poetry.lock / Pipfile.lock / requirements*.txt
rust.rs # Cargo.lock
analyze/
install_hooks.rs # npm pre/post-install + Python setup.py
obfuscation.rs # entropy + eval + hex/base64 heuristics
ioc.rs # URL / IPv4 / BTC / ETH extraction
sensitive_api.rs # dangerous primitives by language
util.rs # walkers, entropy, path → package mapping
report/
terminal.rs # colored TUI table
json.rs # schema-stable JSON
html.rs # self-contained HTML
tests/
integration.rs # end-to-end against fixtures
fixtures/ # reproductions of real public incidents
See LICENSE.
"Don't dig up the corpse to find the cause of death after the breach — do it before you ship the dependency."
