Releases: binuka200/package-doctor
Release list
package-doctor 1.0.2
Upgrading
If you copied the Claude Code hook configuration from an earlier README, add
Bash to the PostToolUse matcher so the lockfile check runs after shell
commands:
"PostToolUse": [
{ "matcher": "Bash|Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "command": "package-doctor hook claude-code", "timeout": 60 }] }
]Without it, only edits to dependency files are checked after the fact.
Fixed
- The install hook checks what an install pulls in, not just what it
names.uv add,poetry addandpipenv installnow trigger the
lockfile check that previously ran only after resolves such asuv sync, so
a vulnerable or abandoned package two levels down the tree reaches the model
as context. Names the command typed are left out, since they were checked
before it ran. - The documented hook configuration ran the lockfile check for nobody. Its
PostToolUsematcher listed onlyEdit|Write|MultiEdit, so the check after
shell commands never fired. It is nowBash|Edit|Write|MultiEdit, in the
README and the agent guardrail page. The page also stopped claiming coverage
after pip, pip-tools, PDM and Rye commands, whose lockfiles are not read. checkand the hook honour accepted risks. Onlyscanread
package-doctor.toml, although the hook's own block message told the agent
to ask for an acceptance and rerun. The same rules apply as inscan: an
accepted package is allowed with its reason passed to the model, an expired
acceptance blocks again and says so, a version-tied one covers only that
version, and provenance checks are never overridden.checkgains
--config, and its JSON anacceptedfield.
package-doctor 1.0.1
Documentation and metadata. Nothing covered by the 1.0 compatibility promise
changes.
Changed
- The README is a landing page. It went from 1,006 lines to 169: what the
tool does, the report sections, install, quick start, CI and Claude Code
setup, and the headline accuracy numbers. The detail moved unchanged into
docs/usage.md,docs/agent-guardrail.md,docs/how-it-works.md,
docs/exposure-map.mdanddocs/accuracy.md, linked by absolute URL so
PyPI renders them. - The summary says what the tool is for: which Python dependencies to fix
first, the ones being exploited and the ones nobody is left to patch. The
same wording is in the PyPI summary, the GitHub Action description and
package-doctor --help. - The accuracy numbers were re-measured on 1.0.0, on fresh clones: 6,897 of
6,898 pinned pairs agree with OSV, pip-audit finds nothing package-doctor
misses, and the exposure map's comparison now excludes entries decided from
advisories, which would otherwise build the outcome into the test. - PyPI keywords drop
sbom, which the tool does not do, and add
vulnerability,osv,cisa-kev,epss,sarif,pip-auditand
claude-code.
Fixed
--stale-release-daysand--stale-push-dayshave help text, with their
defaults, inscan,explain,checkandhook.- The pre-commit hook runs when
setup.pyorsetup.cfgchanges, both of
which the scanner reads. - Stale documentation, checked against the code: the Action and pre-commit
examples pinned v0.3.0; acheckexample showed a warning the tool no longer
gives; thescanexample listed its sections out of order; joblib was named
as a model-loading package; the map size and test time were out of date. - Undocumented behaviour is documented:
research/audit_map.py, building
the datasetsuggest_map.pyreads, GitHub as a data source, the--json,
--output,--fail-on,--alland--depthoptions, and every install and
resolve command the agent guardrail reads.
package-doctor 1.0.0
No behaviour changes from 0.9.1. This release marks the interfaces other
tools build on as stable.
Stable from 1.0
Covered by semantic versioning, so a change that breaks any of these waits
for 2.0:
- the
scan,explain,check,hookandcachecommands and their options; - the
--jsonoutput atschema_version3 - fields may be added, none
removed or renamed; - the verdict names exploited, replace, mitigate, upgrade, quiet,
unchecked and ok, and the--fail-onlevels; - exit codes:
0nothing that fails the build,1something does,2a
usage error; - the Claude Code hook's input and output.
Not frozen: the data. The exposure map, the advisories, the CISA KEV list
and EPSS scores change continuously, and map updates ship in minor and patch
releases. Upgrading package-doctor can therefore change the verdict on a
package you have not touched - that is the tool getting more accurate, not an
interface change. Pin package-doctor itself when a build must be reproducible.
Changed
- The package is marked
Development Status :: 5 - Production/Stable,
replacing Alpha. - The README shows real output. Screenshots of
package-doctor scanand
package-doctor explain pillowon a demo project replace two text blocks,
one of which still showed the pre-0.9 EXPLOITED header. They are served
from GitHub, so PyPI renders them too.
Fixed
- The sdist no longer carries the README screenshots.
docs/is excluded,
keeping the sdist at around 317 KB rather than 1.9 MB.
package-doctor 0.9.1
Added
- The exposure map is audited against its own advisories.
research/audit_map.pyreads each mapped package's OSV advisories against
the answer the map gives and reports three disagreements: a package cleared
as not at a boundary whose advisories cite a trust-boundary weakness, an
exposed package whose advisories point to a worse consequence than any of
its categories, and awhyciting an advisory id OSV has never heard of.
Naming the advisory in awhysettles a finding either way, so silence is
the only thing reported. It runs weekly in the live contract workflow with
--strict; an OSV outage is listed, not failed. A wrong entry is worse than
a missing one - a missing entry says "boundary not reviewed", a wrong one
looks exactly like a reviewed answer - and this is the check for it.
Changed
- 66 map entries the first audit disagreed with were decided from the
advisory text. 28 packages gained the category their advisories reached:
code execution for langchain, llama-index, smolagents, semantic-kernel,
pillow, chromadb, pdfminer-six, apache-airflow, litellm and open-webui, and
auth for signature verifiers (ecdsa, rsa, signxml, starkbank-ecdsa) and for
mcp, fastmcp and mitmproxy. 26 kept their call with the reason recorded -
mislabelled CWEs such as uvicorn's log injection, opt-in features, trusted or
local preconditions, and langchain-core, whose serialization flaw stops
short of code execution. Nine cleared packages now cite the advisories they
were cleared despite. - 47 unreviewed packages were decided, found in a 110-repository random
sample, most because their own advisories cite a trust-boundary weakness.
31 are now mapped - among them fugue's pickling RPC server,
nemo-toolkit and torchgeo's model loaders, lightrag-hku's JWT handling,
gdown's archive extraction, homeassistant, zeroconf, rpyc and django-ses -
and 16 are reviewed as not exposed, including vyper, whose advisories are
miscompiled contracts, and pyngrok, which never sees tunnel traffic. - HTML and XML classifiers no longer drive inference. Of the unreviewed
packages they guessed as parsing markup, about one in four did; the rest
generate it (dominate, htmlmin, pytablewriter) or are documentation tooling.
The five that parse outside markup - inscriptis, mf2py, sickle, onvif-python
and feedgen - have curated entries instead.
Fixed
- Map citations that named no real advisory. babel cited CVE-2021-20095
and diskcache GHSA-r8gq-9x9w-jcpg; OSV has neither. They now cite
CVE-2021-42771 and GHSA-w8v5-vhqr-4h9v. open-webui's reason said it was
listed under auth, and it never was.
package-doctor 0.9.0
Changed
-
The report is grouped by trust boundary, and the verdicts are named for
what to do. Sections used to be a diagnosis to decode ("exposed + no one
home"), and the boundary was baked into the verdict, so the same work had two
names depending on whether a human had curated the package yet. Now the group
carries the priority and the verdict carries the action:FIX TODAY known exploited, and your version is affected AT A TRUST BOUNDARY replace and upgrade fail the build NOT AT A TRUST BOUNDARY reviewed: worth knowing, not blocking BOUNDARY NOT REVIEWED nobody has judged these yetThe verdicts are exploited, replace, mitigate, upgrade, quiet,
unchecked and ok, replacing act/watch/low/unknown. The two lower groups
print as a count;--alllists them. -
"Quiet for years" no longer asks for a replacement. Two weak signals are a
forecast about who would answer, not evidence that anything is wrong, so they
now produce quiet, which never fails a build. Across sixty repositories 19
of 26 "replace" verdicts rested on age alone with no advisory against the
version in use. Replace now needs proof - an archived repository, the
maintainer's Inactive classifier, or an unfixable advisory in a project that
has also gone quiet. -
An advisory nobody fixed, in a project that is still shipping, is
mitigate. chromadb, mariadb and nltk were being told to plan a
replacement while committing daily. Upgrading cannot clear these, so the call
is a human one and it never fails a build. -
The trust boundary decides what blocks, not what a finding is called.
Exploitation blocks wherever it is found; replace and upgrade block at a
reviewed boundary and inform away from one. An inferred exposure is
unreviewed, so it can never fail a build. -
--fail-ontakesexploited,boundary(default),vulnerable,allor
never.act,watchand the short-livedreplace/upgrade/review/bump
names still work. The action'sfail-oninput follows. -
JSON
schema_versionis 3: findings carryboundary("at", "clear",
"unreviewed") andblocks, andcountsuses the new verdict names. SARIF
rules arepackage-doctor/exploited,/replace,/upgrade,/mitigateand
/quiet, and a finding that does not fail the build is downgraded from
errortowarningso a dashboard shows what a pipeline would. -
checkand the Claude Code hook map one-to-one onto the report. They
block exactly what a scan fails on - fix today anywhere, replace or
upgrade at a reviewed trust boundary - and warn on mitigate, on the same
facts away from a boundary, and on a boundary package that has gone quiet. -
The hook reads the install shapes it was missing.
uv run --with,
uvx,uv tool run,uv tool install,pipx runandrye addwere all
invisible to it; the first three fetch a package and execute it in one step,
which is the case the guardrail exists for.uvx ruff check .namesruff
and notcheck, and an option value is never read as a package. -
The hook is aware of where an install would fetch from. A non-PyPI
--index-urlor--extra-index-urlis stated, and one over plain HTTP - or
with TLS waived for its host by--trusted-host- says so. With a private
index configured, not on PyPI is a warning rather than a block: a package
missing from PyPI is what an internal package looks like. -
A block names a requirement that would pass -
-> retry with pillow==12.3.0- so a refusal is a corrected command rather than a reason
to try the same one again. -
A warning is said once per session, keyed on the event's
session_id.
Blocks still repeat every time the command is tried. -
A
PostToolUsehook checks what a resolve locked.uv sync,
poetry lockandpip install -rtype no package name; this diffs the
lockfile against the last commit and checks what was added, up to twenty
packages, counting the rest. -
A healthy package at a trust boundary no longer warns. Inherited from the
old watch tier, it fired on seven of the ten packages an agent most often
adds -httpx,fastapi,jinja2andrequestsamong them, each with a
spotless advisory record - and said nothing anyone could act on.
Fixed
- Claude Code hook silently allowed non-
git/http(s)VCS installs. The
PreToolUsehook's URL/VCS block only recognized four prefixes
(http://,https://,git+,ssh://).pip installalso accepts
hg+,bzr+,svn+, and baregit://references, including the
bzr+lp:nameLaunchpad shorthand; none of those matched the allowlist,
and none parse as a valid PyPI requirement string either, so the hook
produced no block and no warning at all.is_remote_install_target()
now classifies every scheme pip fetches over the network, and the hook's
filter uses it instead of the narrower inline tuple.
v0.8.4
Fixed
- Hash-pinned requirements files were read as empty.
pip-compile --generate-hashesanduv exportwrite every requirement as a
backslash-continued block with one--hashper line. The requirements
parser read the file physically, so no line was a requirement and a
459-package file scanned as "No dependencies found". Continued lines are
now joined the way pip does - a trailing backslash continues the logical
line, a comment ends it - and per-requirement options such as--hash=...
are stripped before the line reaches the requirement parser.-rincludes
split across a continuation line are followed as before.
Measured
The sixty-repository run (research/eval-repos.txt) was repeated on 0.8.3,
and the README's Accuracy section now carries these figures:
- 13,042 packages, 6,879 distinct pinned pairs; advisory matching agrees
with OSV on 6,878 of them, the one disagreement stilllangsmith 0.3.45. - Against
pip-audit --no-deps --disable-pip -s osvon the same pins:
331 packages flagged by each, 1,661 vulnerabilities found by both, zero
found only by pip-audit, one only by this tool. - 77,148 import sites, down from 108,193, because 0.8.1 stopped counting
files under overlapping source roots twice; 77,119 verify, the other 29
are pytest's_pytestandpymodules. - 2,257 advisories affecting pinned versions, 27 on CISA KEV or above 10%
EPSS (13 distinct CVEs). - Curated coverage 75%, up from 45%, after the 249 map entries in 0.8.3.
- 552 act verdicts, 132 of them on maintenance signals across 42 packages,
up from 97 on 28; the increase is the larger map, and the README names the
entrants that sit closest to the release and commit thresholds. letta-ai/lettano longer has a dependency file in its repository, so 59
of 60 repositories are read, as before.
v0.8.3
Added
- Families as data.
[reviewed] familiesholds the name patterns the
map's header used to describe in a comment -google-cloud-*,types-*,
pytest-*,opentelemetry-*and twelve others - each with a reason.
is_reviewedhonours them,explainshows the family's reason, and the
suggestion tooling stops proposing their members. An explicit entry beats
the pattern, which is howapache-airflow-providers-fab(auth manager),
google-cloud-aiplatform(stored XSS) andopentelemetry-instrumentation
(request-attribute cardinality DoS) stay exposed inside not-exposed
families. Exposure mapschema_versionis 6. - A reason on every entry. All 997 category entries, the
reviewed-and-cleared list and the stable list record what convinced the
curator;tests/exposure_unexplained.txtis empty and stays that way. - 249 new category entries, worked from the CWE-backed candidate list
and the rank 101-1,000 gap: every one of the top 100 and 998 of the top
1,000 packages by downloads now have a curated call (1,774 of 3,000). New
shelves with a family test each: inbound webhook verifiers (stripe,
twilio,slack-sdk,svix,sendgrid,django-anymail), rate
limiters (flask-limiter,slowapi,django-ratelimit,limits),
broker consumers (confluent-kafka,kafka-python,aiokafka,
nats-py,faststream,arq,taskiq), expression evaluators
(simpleeval,asteval,restrictedpython,numexpr,sympy),
hostile-by-design parsers (pefile,oletools,yara-python,
pyelftools,lief,capstone), HDF5, netCDF and GDAL readers, notebook
and app servers (jupyterlab,notebook,marimo,voila,panel,
bokeh,nicegui), the packaging toolchain (uv,poetry,pipenv,
installer), and email parsing.
Changed
- Placements. A package may now carry several categories and the report
takes the worst consequence:mlflow,vllm,sglang,bentomland
rayadd the deserialization, model-loading, framework or remote-access
categories their advisories describe instead of only llm/agent;
pandasadds deserialization forread_pickle;reportlabadds
templating for CVE-2023-33733;litellmandgradioadd web framework.
Moved:django-redisto deserialization (pickle by default),
flask-sessionto auth,dateparser,lark,pyparsingandwebcolors
out of markup into data parsing,pathvalidateto archive extraction,
lmdbto file parsing (its advisories are crafted database files),
faiss-cputo model loading,docxtplto templating,babelto
deserialization (CVE-2021-20095 loaded pickled locale data by
Accept-Language).svgwrite,xlsxwriterandxlwtwrite and parse
nothing and move to reviewed-not-exposed.
Fixed
- A package locked at more than one version is scanned at the newest.
uv.lock forks a package by Python version or extra, and the first entry
used to win - which uv lists oldest. GitGuardian/ggshield was scanned as its
Python 3.9 environment, with act verdicts oncryptography,marshmallow,
requestsandurllib3that a 3.10+ install does not have. The older
versions are now named in a scan note rather than dropped. - UTF-16 and BOM-prefixed dependency files are read.
pip freeze > requirements.txtin PowerShell writes UTF-16; decoded as UTF-8 nothing
parsed, and microsoft/Table-Pretraining's 31 pins reported as "No
dependencies found". - setup.py is read, and never run.
install_requiresand
extras_requirewritten as literals - or as a list bound to a name first -
are taken from the syntax tree. Three of forty randomly sampled
repositories declared their dependencies nowhere else and scanned as "No
dependencies found". A list computed in Python is invisible to a parser, so
the scan names the file as not fully read instead of coming back clean. research/evaluate_repos.pyno longer crashes on cache rows with no
response body, which OSV lookups for unknown packages leave behind.- A lockfile's version beats a different pin in another file. The
newest-wins rule above applied across files too, so a requirements.txt
newer than the lock would have been scanned instead of what installs.
the-paperless-project/paperless ships a Pipfile.lock and a requirements.txt
that disagree on 49 pins, and installs from the lock. - A root whose files declare nothing no longer stops discovery. Mailu's
root pyproject.toml holds only towncrier settings, so the nested search
never ran and its pins in core/base/requirements-prod.txt went unscanned:
"No dependencies found". The same fallback now runs, and says so.
v0.8.2
Added
- Nested discovery. When the project root has no dependency files,
scanlooks up to two directories down - never into tests, docs,
examples, fixtures, vendored code or hidden directories - and says which
files it used. A root with files sees no change.scan .now works for
a project whose requirements live inconfigs/. setup.cfg.install_requiresandextras_requireare read.
setup.pyis deliberately not: a computedinstall_requiresis
invisible to a parser, and a partial answer that looks complete is the
failure this tool exists to avoid.explain --src. The same repeatable optionscanhas, through the
same root pruning and merge, so both commands report identical sites.- Map entries, each with a reason:
flower(auth; GHSA-q4qm-xhf9-4p8f is
an OAuth bypass) andflask-jwt(auth);uwsgi(http);flask-passlib
(crypto);flask-restplusandflask-restx(web framework). Three new
families: Flask API frameworks, WSGI and ASGI servers, Celery and its
tooling.
Full changelog: v0.8.1...v0.8.2
v0.8.1
Fixed
- Overlapping source roots double-counted import sites. Auto-detection
returns each package directory and the project root when it holds a
script -coreand.for a layout withcore/__init__.pyand
app.py- and a file under both was indexed and reported twice, so
explainlisted every site twice and one package claimed 65 sites
where there were 33. Overlapping--srcvalues did the same. Only the
outermost roots are walked now, and sites from several roots are merged
by resolved path and line rather than by their rendered string.
Full changelog: v0.8.0...v0.8.1
v0.8.0
Added
- Map entries from a second outside review, each with a reason:
flask-httpauth(CVE-2026-34531 is an authentication bypass) and
flask-ipfilter(auth);netaddr(url: before 1.0 it accepted
inet_aton forms, so010.0.0.1parsed as 8.0.0.1 - no CVE, 1.0 simply
stopped);flask-expects-json(data parsing);flask-mysql(query);
legacycrypt(crypto);gearman3andpython3-gearman(http - the
wire protocol is the boundary; nothing is unpickled); and
configparser,json-log-formatter,log-with-context,rollbar,
blinkeras reviewed and not exposed. Two new families guard the
Flask auth extensions and the wire-protocol job queues.
Changed
- The commit date is the default branch's, not the repository's push
time. GitHub'spushed_atadvances on a push to any branch or tag and
read fourteen months newer than the code forflask-restful. The last
commit on the default branch is now read from GitHub's commit feed
whenever the push time is inside the stale window, and is what the
staleness signal uses; the push time is kept as the fallback and shown
inexplainlabelled as such. - The watch tier reads as what it is. Its hint is now nothing to do
today: someone is home, and a clean advisory record is worded
healthy record: N of M fixed at or before disclosure rather than
presented as the reason for a warning.
Fixed
--src FILEwith a file outside the scanned project rendered import
sites as.:LINE. The path is now given relative to the project, the
working directory, or in full.- A
name @ https://…tar.gzrequirement was classified by its suffix as a
local path named by the whole line. Lines the requirement parser accepts
are now its to handle; only lines it rejects are classified as git, URL
or path. - A bare wheel or sdist URL was named by normalising the whole filename
(foo-1-0-py3-none-any-whl). It is now named by its distribution. - A renamed repository read as "repository metadata unavailable". GitHub's
redirect is followed to the new address. - A failed repository lookup showed only in
explain. The report table
and Markdown now carry missing signal: … on the row, so a row is not
mistaken for complete.
Full changelog: v0.7.0...v0.8.0