Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

depwal

A dependency firewall for pip, poetry, and uv. It scores every package in your dependency graph against supply-chain heuristics and refuses the install if something looks wrong.

Local CLI, no service, no account, no YAML. Configuration lives in pyproject.toml, environment variables, and flags.

pandas3 is a real package on PyPI today. It is one character from pandas, ships no wheel, and has one release from 2018.

$ depwal install pandas3
depwal: requested packages
package   version   risk   verdict   reason
pandas3   0.0.1      100   BLOCK     risk 100 >= threshold 70

pandas3 signals:
  + 45 typosquat        one edit away from popular package 'pandas'
  + 20 sdist_only       no wheel; setup.py executes on install
  + 15 single_release   only one release ever published
  + 15 abandoned        no release in 2514d
  + 15 no_repo          no source repository declared

1 package(s) blocked
blocked before resolution; nothing was downloaded

That last line is the point. pip never ran.

Why

pip install runs arbitrary code from the internet as your user. That is well known.

Less well known: so does pip install --dry-run. For any package that ships an sdist and no wheel, pip downloads it and executes its setup.py to read the metadata. Resolution is not a read-only operation. A tool that "checks a package before installing it" by resolving it first has already lost.

depwal is built around that fact:

  • check, audit, and explain read PyPI and GitHub metadata over HTTPS. They never download a distribution and never execute package code.
  • install and add screen the packages you named from metadata alone, before any resolver touches them. Then they resolve the transitive closure wheels-only (--only-binary :all:), so no setup.py runs during resolution. Then they gate the closure. Only then does the real install happen.
  • Path, URL, and VCS specs are refused by default, because pip always builds those.

If some package in your graph genuinely ships no wheel, depwal stops and tells you that resolving it means running its code, and you can opt in with --allow-sdist.

Install

pipx install depwal    # or: pip install depwal

Python 3.11+. Dependencies: typer, rich, certifi.

Use

depwal check                          # gate poetry.lock / uv.lock / requirements.txt, exit 1 on block
depwal check --req requirements.txt
depwal install fastapi                # gate the closure, then pip install
depwal add fastapi                    # gate the closure, then poetry add
depwal audit                          # score what is already installed
depwal explain pandas3                # full signal breakdown for one package
depwal explain requests 2.31.0        # ...at a specific version
depwal explain psf/requests           # score a GitHub repository directly
depwal cleanup                        # delete the metadata cache

--json writes a machine-readable report to stdout; the human table goes to stderr, so you can pipe one without losing the other.

explain also takes a GitHub owner/repo or URL and scores the repository signals alone (archived, stale, fork, stars) -- useful for a dependency that is not on PyPI yet.

Alias mode

Make the firewall the default instead of something you have to remember to invoke:

alias pip='depwal alias-pip'
alias poetry='depwal alias-poetry'
alias uv='depwal alias-uv'

pip install, poetry add, and uv add are then gated exactly like depwal install / depwal add. Every other subcommand -- pip list, poetry lock, uv run, all of it -- passes through untouched with the original exit code. To bypass the gate once, prefix the command with a backslash (\pip install ...), which skips the shell alias.

Editable installs (pip install -e) and path/URL/VCS specs are refused in alias mode, because pip always builds those before depwal could judge them.

uv

depwal check already understands uv.lock. For installs, uv add normally locks and installs in one step, so alias-uv splits it: it locks with uv add --no-sync --no-build (no package code can run), gates the closure that landed in uv.lock, and only then runs uv sync. On a block, pyproject.toml and uv.lock are restored to what they were.

How it decides

Signals are collected from the PyPI JSON API and the GitHub REST API, cached for 24h in ~/.cache/depwal (depwal cleanup wipes it), and fetched in parallel. Each rule adds points; the total is clamped to 100.

Every heuristic lives in one module, depwal/score.py, about a hundred lines with no I/O in it. Read it before you trust it; that is the whole rulebook.

rule points signal
pkg_missing 100 not on PyPI
version_missing 100 that version was never published
version_yanked 100 this exact version was yanked
all_yanked 60 every release is yanked
typosquat 45 one edit from a top-5000 package name
owner_changed 30 maintainer email differs from the previous release
pkg_new 30 first published under 30 days ago
repo_archived 30 GitHub repo archived or disabled
dormant_release 25 released after over a year of silence
repo_moved 25 repository owner changed since the previous release
repo_missing 25 declared repository is gone or private
sdist_only 20 no wheel, so setup.py executes on install
license_changed 20 license differs from the previous release
pkg_very_new 15 under a week old, stacks with pkg_new
abandoned 15 no release in two years
single_release 15 only one release ever
no_repo 15 no source repository declared
repo_stale 15 no push in a year
repo_low_stars 10 under 10 stars
repo_fork 10 repository is a fork

The first three are hard rules: they score 100 and suppress every other finding.

Account takeover has a shape. A package goes quiet for a year, then publishes a release with a new maintainer address and a rewritten license. That is dormant_release + owner_changed + license_changed = 75, over the default threshold, on its own.

Metadata changes are diffed against the previous release on PyPI, not against a stored baseline, so there is no state file to commit and nothing to bootstrap.

Policy order

  1. allow glob matches, the package is allowed, score ignored.
  2. deny glob matches, the package is blocked.
  3. risk >= risk_threshold, the package is blocked (mode = "block") or flagged (warn, audit).

Names are normalized per PEP 503 before matching, so Zope.Interface and zope-interface are the same package.

Configure

# pyproject.toml
[tool.depwal]
risk_threshold = 70
mode = "block"        # block | warn | audit

[tool.depwal.allow]
packages = ["requests", "fastapi", "pydantic"]

[tool.depwal.deny]
packages = ["*"]      # default-deny: nothing but the allowlist gets in

Precedence is flags > environment > pyproject.toml > defaults. Every setting has a DEPWAL_ variable (DEPWAL_RISK_THRESHOLD, DEPWAL_MODE, DEPWAL_ALLOW, DEPWAL_DENY, DEPWAL_LLM_COMMAND, DEPWAL_OFFLINE, ...). --allow and --deny append to the configured lists rather than replacing them.

Set GITHUB_TOKEN to lift the 60 requests/hour anonymous GitHub limit. Without it, depwal simply scores the PyPI signals alone; a rate-limited API is never read as a missing repository.

--offline skips the network entirely and enforces only the allow and deny lists.

LLM review

Point depwal at any command that reads a prompt on stdin and writes text on stdout.

depwal --llm "claude -p" check
export DEPWAL_LLM_COMMAND="ollama run qwen2.5-coder"

It runs only on packages in the gray band: those that passed the heuristic but scored at or above --llm-floor (default 40). Below that, a call is not worth the latency. Above the threshold, the package is already blocked.

Two properties matter:

  • The LLM can only tighten a verdict, never loosen one. It can turn an allow into a block. It can never un-block something the heuristic rejected. A malicious package's own metadata is part of that prompt, so it must never be able to argue its way past the gate. The prompt says as much, and escalate() enforces it regardless of what the model returns.
  • It fails open. A timeout, a nonzero exit, or unparseable output leaves the heuristic verdict untouched. Your build does not break because a model was slow.

Only computed signals are sent: name, version, score, and the rules that fired. Package descriptions and READMEs are never forwarded, because that is the obvious prompt-injection channel.

CI

- run: pipx install depwal
- run: depwal check
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Exit codes: 0 allowed, 1 blocked, 2 error. mode = "warn" and mode = "audit" always exit 0, so you can roll it out in report-only mode before turning it on.

As a pre-commit hook:

- repo: local
  hooks:
    - id: depwal
      name: depwal
      entry: depwal check
      language: system
      pass_filenames: false
      files: ^(poetry\.lock|uv\.lock|requirements\.txt)$

Harness

harness/showhn.py is the field test and the regression tracker for the heuristics. It works in three steps:

python harness/showhn.py collect            # freeze this week's corpus -> harness/data/29.2026.csv
python harness/showhn.py score              # score it -> harness/data/29.2026.scores.0.1.0.csv
python harness/showhn.py diff 29.2026 0.1.0 0.2.0

collect pulls the newest Show HN posts, keeps the ones linking a GitHub repo that ships a Python manifest (uv.lock, poetry.lock, requirements.txt, or pyproject.toml), and freezes their dependencies as a CSV named after the ISO week (collect 20 5 limits it to 20 projects from 5 HN pages; default 100 from 10). Nothing is downloaded or installed; it is the same metadata-only scoring check uses.

score runs the installed depwal over a frozen corpus and files one row per package -- risk, verdict, rules fired -- under the current depwal version. To see what a heuristic change does, score with one version, check out or install the other, and score again: diff then prints every package whose risk, verdict, or fired rules moved between the two files.

Score both versions within the cache TTL (24h) and the second run reads the same cached PyPI/GitHub metadata as the first, so the diff isolates the rule change from real-world drift. Set GITHUB_TOKEN before the first run; a hundred projects will exhaust the anonymous GitHub rate limit quickly (depwal then degrades to PyPI signals alone). Expect false positives -- fresh Show HN projects are, by definition, new packages by unknown authors, which is exactly what the heuristics distrust. That makes it a good calibration corpus.

What this does not do

Being honest about the boundary, because a security tool that oversells itself is worse than none.

  • It does not inspect package contents. No sdist unpacking, no AST analysis, no scanning for eval or exfiltration. That would mean downloading the artifact, which is the thing depwal exists to avoid doing before a verdict.
  • It does not detect a compromised release from a legitimate maintainer who keeps the same email, license, and repository. Metadata heuristics cannot see that. Nothing here replaces pinned hashes.
  • It is not a CVE scanner. Use pip-audit or osv-scanner; they answer a different question. depwal asks "does this package look like it was taken over," not "does this version have a known bug."
  • audit is detection, not prevention. By the time a package is installed, its setup.py has already run.
  • Heuristics have false positives. A quiet, single-release, sdist-only package by a solo author with no GitHub stars is indistinguishable from a fresh typosquat on metadata alone. Start in mode = "warn", look at what fires, then allowlist and tighten.
  • The top-5000 list is bundled and goes stale. It refreshes on release, not on a schedule.
  • Metadata diffing compares against the immediately preceding release, including prereleases.

Prior art

pip-audit and osv-scanner for known vulnerabilities. guarddog for content-based malware heuristics, which does download and analyze the package. Socket.dev for the commercial, hosted version of roughly this idea. depwal is the small local one that runs before the download.

License

MIT

About

pip/poetry dependency firewall

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages