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.
postmortem ./my-project
postmortem ./my-project --json -o report.json
postmortem ./my-project --html -o report.html
postmortem ./my-project --skip-category ioc
| 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 |
- Multi-ecosystem SBOM. Resolves the full 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:
install_hook: npmpre/post-installscripts, and Pythonsetup.pyinvokingsubprocess,os.system,exec, or network primitives.obfuscation: Shannon entropy plus language-specific signals (eval,Function,charCodeAtchains, long\xNNruns, base64 blobs, PHPgzinflate/str_rot13, RubyMarshal.load). Multi-signal scoring, with a minified-bundle dampener so legit bundles do not raise alarms. A lone weak signal (a bareeval(orcompile() is never reported on its own.ioc: embedded URLs, IPv4 and IPv6 addresses, bare domain names, and Bitcoin (Base58-validated) and Ethereum addresses. Heavily filtered to stay high-signal (see False-positive controls).sensitive_api: dangerous primitives per language, such aschild_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), andRuntime.exec/ProcessBuilder/Class.forName(JVM).
- 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 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, and Ruby 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,logging.info,tc.in,this.ch, and other attribute accesses whose trailing label happens to be a TLD (including short ccTLDs like.inor.ch) 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.
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, with no automatic builds on
push or tag. To grab a binary from an untagged commit, trigger it 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 and 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 is auto-loaded when present; 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. A 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 cannot
# 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, with 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 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, 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.sarifThe 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 reproduces patterns from the incident, not the original malicious code:
| Fixture | Models incident | Year | Triggers |
|---|---|---|---|
malicious-node/ |
event-stream@3.3.6 to flatmap-stream@0.1.1 Copay wallet stealer |
2018 | install_hook HIGH, obfuscation CRITICAL, ioc HIGH (BTC + ETH wallets), sensitive_api |
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 |
malicious-rust/ |
rustdecimal typosquat of rust_decimal |
2022 | SBOM resolves typosquat, sensitive_api MEDIUM on local src/ |
malicious-ruby/ |
rest-client 1.6.13 / strong_password hijack shape |
2019 | SBOM resolves typosquat, obfuscation (eval + base64), sensitive_api, ioc |
malicious-php/ |
Composer package-hijack webshell shape | n/a | SBOM resolves typosquat, obfuscation HIGH (eval + gzinflate + base64), sensitive_api, ioc |
malicious-go/ |
Go module-typosquat payload shape | n/a | SBOM resolves typosquat and // indirect split, obfuscation (base64 blob), sensitive_api, ioc |
malicious-java/ |
Maven artifact-typosquat payload shape | n/a | SBOM reads pom direct deps and skips the BOM, obfuscation (base64 blob), sensitive_api, ioc |
clean-node/ |
benign baseline | n/a | no findings, exit 0 |
Run the live demo:
postmortem ./tests/fixtures/malicious-node
postmortem ./tests/fixtures/malicious-python
postmortem ./tests/fixtures/malicious-rust
postmortem ./tests/fixtures/malicious-ruby
postmortem ./tests/fixtures/malicious-php
postmortem ./tests/fixtures/malicious-go
postmortem ./tests/fixtures/malicious-javacargo build # debug build
cargo build --release # stripped, LTO, about 2.7 MB
cargo test # 61 unit + 36 integration testsscripts/fp-harness.sh clones a set of well-known,
legitimate repositories (algorithm collections and popular libraries) across all
supported ecosystems, runs postmortem on each, and summarizes the findings.
Because the repos are trusted, essentially every finding is a candidate false
positive, so the breakdown doubles as a regression harness for the IOC and
obfuscation heuristics. It also runs a few per-repo sanity checks (determinism,
SARIF validity, --skip-category, and the CI gate).
scripts/fp-harness.sh # all ecosystems
scripts/fp-harness.sh rust ruby # a subsetsrc/
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
ruby.rs # Gemfile.lock (Bundler)
php.rs # composer.lock (Composer)
go.rs # go.mod / go.sum (Go modules)
java.rs # Maven pom.xml / Gradle gradle.lockfile
analyze/
install_hooks.rs # npm pre/post-install + Python setup.py
obfuscation.rs # entropy + eval + hex/base64 heuristics
ioc.rs # URL / IPv4 / IPv6 / domain / BTC / ETH extraction
sensitive_api.rs # dangerous primitives by language
util.rs # walkers, entropy, path to package mapping
report/
terminal.rs # colored TUI table
json.rs # schema-stable JSON
html.rs # self-contained HTML
sarif.rs # SARIF 2.1.0
scripts/
fp-harness.sh # false-positive harness over real repos
fp_summarize.py # JSON report summarizer used by the harness
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."
