Skip to main content

Detect divergence between a package's declared source and its published artifact

Project description

phantom

Detects divergence between a package's declared source and the artifact it actually publishes.

Part of the pipebreach research project. phantom compares what a package declares (its source repository at the tag matching the published version) against what it ships (the wheel on PyPI). Any code present in the artifact that does not exist in the source is a phantom, flagged regardless of whether it "looks malicious".

Threat model

An attacker compromises a package's build/publish pipeline and injects code into the distributed artifact without touching the source repository. Anyone auditing the code on GitHub sees nothing; anyone installing the package gets the backdoor.

Real-world incidents this class of tool catches:

  • LiteLLM (March 2026): a three-stage payload (credential theft, Kubernetes lateral movement, RCE backdoor) injected into the PyPI wheel post-build. The repo was clean.
  • XZ Utils (CVE-2024-3094): backdoor present in the distribution tarball but not in git.

Malware scanners (GuardDog, Semgrep rules, etc.) look for known-bad patterns. phantom is pattern-agnostic: it doesn't care whether injected code looks bad, only that it isn't in the source. That catches clean, obfuscated, or never-before-seen injection. Reproducible-builds infrastructure (rebuilderd) solves this for OS distros; nothing equivalent exists for language ecosystems. That's the gap.

Quickstart

Requires Python 3.11+. Zero runtime dependencies (stdlib only, by design).

pip install phantom-scan
# or, without installing:
uvx phantom-scan scan requests==2.31.0

# Scan a single package version
phantom scan requests==2.31.0
phantom scan mime-db@1.52.0 --ecosystem npm

# Scan every pinned package in a lockfile
phantom audit requirements.txt
phantom audit poetry.lock

# Machine-readable output
phantom scan requests==2.31.0 --json
phantom scan requests==2.31.0 --sarif   # for GitHub code scanning

# Scan against a different index (TestPyPI, a private mirror)
phantom scan mypkg==1.0.0 --index-url https://test.pypi.org/pypi

What a scan does:

  1. Downloads the pure-Python wheel from the PyPI JSON API.
  2. Resolves the source repo from the package metadata (project_urls) on GitHub or GitLab, and finds the tag matching the version (v{ver}, {ver}, release-{ver}).
  3. Computes an AST-normalized hash of every .py file on both sides (comments/whitespace/formatting don't count) and flags every wheel file whose content exists nowhere in the source.
  4. When a diverging file does exist in the source, runs an intra-file AST diff to localize the injected or modified statements (phantom spans, reported with line numbers).
  5. Grades each phantom by its execution vectors (subprocess, network, exec/eval, os.environ access). Reading data + network egress = critical (exfiltration shape).
  6. Any .pth file inside a wheel is always flagged (it executes at interpreter startup).
  7. Compiled bytecode (.pyc) shipped without a corresponding source module is flagged as unauditable: executable content that can't be checked against source.

For npm, tarballs are compared by raw (line-ending-normalized) content and JS phantom files are graded by capability patterns (child_process, fetch, process.env, eval).

Downloads are cached on disk (~/.cache/phantom by default, --cache-dir to override), so re-scanning the same pkg==version is deterministic and offline.

Exit codes

Code Meaning
0 No findings (or only medium/low severity)
1 At least one high/critical finding
2 Execution error (bad arguments, network failure, package not found)
3 Out of scope: the release has no pure-Python wheel (binary wheels or sdist only)

GitHub Action

Package maintainers can verify their own releases right after publishing; this catches a compromised build/publish pipeline (the LiteLLM case) even though the repo looks clean:

name: verify-release
on:
  workflow_run:
    workflows: [publish]        # run after your publish workflow completes
    types: [completed]

jobs:
  phantom:
    runs-on: ubuntu-latest
    permissions:
      security-events: write    # for SARIF upload
    steps:
      - uses: pipebreach/phantom@98bfdcb6851acec6dcdf104638afd7fa3e92f4f9 # v0.3.1
        id: scan
        with:
          spec: mypkg==1.2.3    # e.g. derived from the release tag
      - uses: github/codeql-action/upload-sarif@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3.36.3
        if: always() && hashFiles('phantom-results.sarif') != ''
        with:
          sarif_file: phantom-results.sarif

The job fails when phantom finds a high/critical divergence. Notes:

  • Scan after the artifact is live on PyPI (publishing and scanning in the same workflow can race index propagation).
  • If your tags carry a v prefix, strip it for the spec: phantom probes tag conventions on the source side, but the spec version must match PyPI.
  • Releases without a pure-Python wheel emit a warning instead of failing; set fail-on-out-of-scope: "true" to make them fail.

Inputs: spec (required), ecosystem (default pypi), sarif-file (default phantom-results.sarif), fail-on-out-of-scope (default false). Outputs: exit-code, sarif-file.

Consumer projects can run phantom audit requirements.txt in CI to scan every pinned dependency; a lockfile input for the action is planned.

Finding types

Type Meaning Severity
phantom_file A file in the artifact whose content exists nowhere in the source medium to critical per execution vectors
phantom_span Code injected into (or modifying) a file that does exist in the source, with line numbers medium to critical per execution vectors
suspicious_pth A .pth file shipped inside the wheel high; critical if it has import lines
no_source_declared No source repo in the package metadata, unverifiable by construction high
source_ref_not_found Repo declared, but no tag matches the version medium

Every finding carries a confidence and a reason: phantom prefers saying "this is phantom but might be build-generated code" (e.g. _version.py from setuptools-scm gets low confidence) over false certainty.

The --json output is a versioned public contract (schema_version); breaking changes bump the version.

Library use

The CLI is a thin layer over the core (usable in CI, batch jobs, services):

from phantom.cache import DiskCache, default_cache_dir
from phantom.core import scan
from phantom.registry import build_default_registry

registry = build_default_registry(DiskCache(default_cache_dir()))
result = scan("six", "1.16.0", registry.get("pypi"))
print(result.to_dict())

Known limits

Explicitly not supported yet; the plugin architecture (Ecosystem/Fetcher/SourceResolver/Normalizer interfaces) is designed so these land without touching the core:

  • Compiled/binary wheels and sdist-only releases: reported as out of scope (exit 3). Needs build-step normalizers.
  • Deep bytecode verification: .pyc shipped without source is flagged, but .pyc shipped alongside source is trusted as its compiled form rather than decompiled and compared (marshal is unsafe on hostile data).
  • Built/minified JavaScript: npm comparison is raw content; packages with a build step (dist/ bundles, .min.js) produce low-confidence findings until minification/transpilation normalizers land.
  • Phantom spans for JS: intra-file localization currently requires a Python AST; diverging JS files are reported whole.
  • Forges other than GitHub and GitLab (Codeberg, self-hosted, etc.): not resolved yet.
  • Tag-based resolution only: packages that publish from a commit without tagging that version yield source_ref_not_found. Common tag conventions (v-prefix, CalVer date zero-padding) are probed; unusual schemes are not.

Development

pip install -e ".[dev]"
pytest                             # unit tests, fully offline
PHANTOM_NETWORK_TESTS=1 pytest -m network   # integration tests against real PyPI/GitHub

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

phantom_scan-0.4.0.tar.gz (36.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

phantom_scan-0.4.0-py3-none-any.whl (34.0 kB view details)

Uploaded Python 3

File details

Details for the file phantom_scan-0.4.0.tar.gz.

File metadata

  • Download URL: phantom_scan-0.4.0.tar.gz
  • Upload date:
  • Size: 36.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for phantom_scan-0.4.0.tar.gz
Algorithm Hash digest
SHA256 22e180055bb19d914ad29d2be625b9f539e8cebdc77d2cbe373c8ded32c7fe67
MD5 d400aa37f79414b912449279f4180afa
BLAKE2b-256 dfdfa6d7220a2b71f7f28cbc12d7d95690a04bb58e222eb2fcf7111ce911d251

See more details on using hashes here.

Provenance

The following attestation bundles were made for phantom_scan-0.4.0.tar.gz:

Publisher: publish.yml on pipebreach/phantom

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file phantom_scan-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: phantom_scan-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 34.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for phantom_scan-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 cef03164008e551c0c3878725b53dce134ed4130a91760cffec470963cf9da3b
MD5 1eccf50ea62a7399b25293faf5991f44
BLAKE2b-256 67737f35c7558866908bcbbc419feb41c53f78fd619e4243b03a8a40dc992bb0

See more details on using hashes here.

Provenance

The following attestation bundles were made for phantom_scan-0.4.0-py3-none-any.whl:

Publisher: publish.yml on pipebreach/phantom

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page