mlab docs

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 ioc

Supported ecosystems

EcosystemLockfiles parsedSource scanned for findings
Node.jspackage-lock.json v2/v3, npm-shrinkwrap.json, pnpm-lock.yaml, yarn.locknode_modules/ on disk
Pythonpoetry.lock, Pipfile.lock, requirements*.txtproject root, .venv/.../site-packages/
RustCargo.lockthe project's own src/
RubyGemfile.lock (Bundler)the project's own source (lib/, app/)
PHPcomposer.lock (Composer)project root, including a committed vendor/ tree
Gogo.mod (with go.sum for checksums)the project's own source and a committed vendor/ tree
JVMMaven 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: 0 clean, 1 findings at or above --severity, 2 execution error.
  • Suppression via CLI flags and/or a postmortem.conf file in the scanned directory.

The four analyzers

AnalyzerWhat it flags
install_hooknpm pre/post-install scripts; Python setup.py invoking subprocess / os.system / exec / network primitives
obfuscationShannon 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
iocEmbedded URLs, IPv4/IPv6 addresses, bare domains, Bitcoin (Base58-validated) and Ethereum addresses. Heavily filtered to stay high-signal (see below)
sensitive_apichild_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 postmortem

Direct tarball

Prebuilt binaries are published for four targets:

Target triplePlatform
aarch64-apple-darwinmacOS, Apple Silicon
x86_64-apple-darwinmacOS, Intel
x86_64-unknown-linux-gnuLinux, x86_64
aarch64-unknown-linux-gnuLinux, 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 --help

For 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 version

Exit codes

CodeMeaning
0No findings at or above --severity (default: high).
1At least one finding at or above the threshold. Block the build.
2Execution 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.sarif

What is scanned

Ecosystemscannednot scanned
Nodenode_modules/ on disk, with full hoist resolutiontarballs on the registry; never downloaded
Pythonproject root (setup.py, etc.) and .venv/lib/.../site-packages/ if presentsystem-wide site-packages
Rustthe project's own src/ for sensitive APIs~/.cargo/registry/ (not walked by default)
Rubythe project's own source (lib/, app/)gems in the Bundler path (not walked by default)
PHPproject root, including a committed vendor/ treepackages fetched by Composer when vendor/ is absent
Gothe project's own source and a committed vendor/ treemodules in the Go module cache
JVMthe project's own .java / .kt sourcedependencies 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 shape

On this page