Security features¶
pypiron is fail-closed by default: a half-configured credential refuses startup, secrets compare in constant time, and a private name never falls through to public PyPI. Every client behind it — uv, old pip, poetry, CI, a lockfile from two years ago — gets every protection below at once, because each one is enforced at the server, not in the client. How these defenses are verified — chaos, fuzzing, and a full-PyPI parser check — is its own page: How it's tested.
Automatic malware blocking with realtime updates¶
Known malware never installs. New releases wait long enough for a bad one to surface. Both on by default.
New releases wait. A compromised maintainer account or a typosquat is most
dangerous in its first hours, before anyone notices. A dependency cooldown
puts a window between when a release is published and when pypiron will serve
it, so resolution lands on versions old enough for a bad one to have surfaced
and been pulled. It's the same practice uv, npm, and Dependabot have
standardized on. The default is seven days, on a sliding window, re-checked on
every read. sync applies the same window to what a run mirrors.
How much does the wait buy? Measured against the full OSV malware feed, for 2024+ compromises of established packages: the advisory check alone blocks 34% on release day, the 7-day default raises that to 56%, and a 30-day cooldown catches 86%. The default catches the fast reports; a 30-day window catches most of the rest.
pypiron serve --admin-pass "$ADMIN" \
--proxy-upstream https://pypi.org \
--exclude-newer "7 days" # the default; pass "30 days" to widen
Widen the window, pin an absolute "as of" date, or disable it entirely — all
<when> formats live in
Configuration → Mirror selection.
Only admins can backdate
An ordinary upload can only claim its receipt time, so a publisher can't sneak a package in under a cutoff. Setting any other timestamp — including mirror uploads that carry PyPI's original time — requires the admin credential; with none configured, those uploads are refused.
Known malware never installs. A cooldown buys time; it can't catch what's
already confirmed bad. Some releases aren't merely risky — they're catalogued
malware, in the public advisory databases (the same source uv audit reads).
pypiron refuses to serve those files. Ask for a flagged version and the download
is refused, naming the advisory that condemned it; the on-demand proxy won't
fetch and cache one in the first place.
This closes a gap every caching mirror has. When a resolver locks a dependency through pypiron, it pins a download URL to this server. Later PyPI pulls a compromised release: it vanishes from pypi.org, but every lockfile in your org keeps asking pypiron for the copy it cached — and a plain mirror keeps handing it back, forever. pypiron checks the advisory feed at the door instead, so the file it served yesterday stops today — a fresh uv run and a two-year-old lockfile alike.
Blocking starts within minutes. Every node watches OSV for individual just-published malware advisories and starts blocking within minutes of publication — no waiting for the next daily refresh, and clients whose own audit cache still won't know for up to a day are already covered at the door. The full advisory snapshot still refreshes daily; a withdrawn advisory un-blocks on that same daily cadence.
Availability wins over freshness. If the feed source goes unreachable, clean packages keep installing and nothing slows down; blocking just stops getting fresher, and a staleness gauge climbs so you can alert before it drifts. It degrades toward stale, never toward down. A blocked download is itself worth an alert: it means some machine in your fleet is still asking for malware.
Pulled upstream, pulled here. PyPI sometimes quarantines a project
outright — a compromised account, a malware finding — freezing it so nothing new
resolves. pypiron relays that state. A quarantined project lists no files here,
and a direct download of one is refused, so a URL already pinned in a lockfile
can't route around the empty listing. When a single file is removed upstream,
sync marks it withdrawn the same way. Whatever PyPI stops standing behind,
pypiron stops serving. This refusal is a distinct guarantee from malware
blocking: turning blocking off (--malware-block=false) leaves it standing.
A private package that happens to share a malicious public name still installs. pypiron blocks only the public package the advisory names — a private name of your own is, by construction, not that package. Blocking can be turned off, or the whole advisory feature disabled, from Configuration → Server.
Dependency confusion¶
The trap: an internal package name also exists on public PyPI, and a resolver
pulling from both indexes chooses the public copy. pypiron's rule closes it:
each name is private or public, never both. The first upload — a private
push or a mirror sync — reserves the name for that world, and it stays
reserved. A private upload to a mirror-owned name is rejected, sync refuses a
name you already own privately, and collisions are hard errors, never merges.
Deleting every file of a package does not release the name.
--private-prefix reserves a whole namespace (e.g. acme-*) for private
uploads and forbids sync from touching it, so an internal package can't be
published under a name that later collides upstream. Matching is on normalized
names — acme_foo, acme.foo, and acme-foo are the same name.
The other half is client-side: point clients at this one index
(--default-index for uv; --index-url for pip, not --extra-index-url) and
let the server decide what exists. The full mental model of how private and
public names coexist is at Package sources.
Approval lists¶
Narrow the server to packages somebody chose to allow. Give sync a file of
approved names and it pre-loads exactly those; give the proxy the same include
list and on-demand fetching is constrained to it too. The cooldown and the
advisory gate still apply on top — approval is a floor, not a bypass. Writing,
updating, and enforcing a list: Approval lists.
Air-gapped deploys¶
Serve where nothing reaches the internet. The proxy talks to live PyPI on a
cache miss, so sync removes that surface: pre-load an approved package list
from a connected host, then serve from a node with no egress — the mirror is
complete on its own, no upstream needed. Malware blocking crosses the same gap:
a pypiron-to-pypiron sync ferries the advisory feed alongside the packages.
An unfed box says so in its logs until the first feed lands, then arms itself
without a restart. The full recipe — feed sources, ferry schedules, freshness —
is in Air-gapped deploys.
Vulnerability audit¶
Blocking stops malware. It says nothing about the ordinary vulnerabilities already sitting in packages you host — those are reported, not blocked, because blocking on a CVE would break your pinned builds. The audit is that report: every hosted package a known advisory affects, with its severity and the version that fixes it, ranked by how often your org installs it. The top of the list is your real exposure — the vulnerable thing everyone pulls a hundred times a day, not the one nobody touches.
No client-side tool can produce this. uv audit answers "what does this
project depend on"; pypiron already holds the whole corpus, every download
count, and the same advisory feed, so it answers "what does the org host and
install." It's a plain page at /audit, with a JSON twin at /audit.json for scripts.
Each project's own page carries the advisory list for that package.
Admin-only: a ranked list of your soft spots is exactly what an attacker would
want, so it rides the strongest credential.
Trust boundaries¶
Untrusted — every client request. Anything a client sends is hostile until
proven otherwise. Credentials compare in constant time, so a wrong guess leaks
no timing — and repeated wrong guesses earn a lockout (see
Login throttling). A half-configured credential disables
its role instead of enabling a bypassable one. Private names never fall through
to upstream, so nobody can shadow one with a public package of the same name. A
filename, once uploaded, is never replaced (PyPI's own rule): re-uploads of an
existing filename are rejected, so nobody can swap bytes under a version already
in someone's lockfile. A browser can't be turned against you: cross-site
state-changing requests are rejected, so cached Basic credentials can't be
ridden from another site to forge an upload or a yank. Every response carries
X-Content-Type-Options: nosniff. Client-set
X-Forwarded-For/X-Real-IP are ignored for the access log and the login
throttle unless you enable --trusted-proxy, so a direct caller can't forge its
logged address.
Trusted — your storage backend. The S3, GCS, Azure, or disk backend you configure is your data behind your keys, and pypiron treats its responses as trusted input. That trust has exactly one sharp edge — see Dependency advisories.
Trusted — PyPI, for public packages. For anything mirrored, pypiron is a
relay: it carries PyPI's files and provenance across unchanged and does not
re-verify them. Trusting a mirrored package is trusting PyPI. The provenance
travels as a <filename>.provenance companion (advertised by a provenance
URL in JSON and a data-provenance attribute in HTML). Consumers verify it
end-to-end and offline — Sigstore bundles check against a cached trust root with
no egress — so even an air-gapped build confirms the original publisher. pypiron
never runs Sigstore or mints provenance itself, so a direct upload carrying
first-party attestations is refused.
Trusted — the release pipeline. Releases are built by GitHub Actions with
every action pinned to a full commit SHA and every build run --locked against
a committed lockfile. Each wheel, sdist, binary, and image ships a signed
provenance attestation you can check yourself — see
Verify a release.
Login throttling¶
A client hammering login with candidate secrets is bounded, not just logged.
Five failed logins from one address and that address is locked out of logging in
for five minutes; even a correct guess during the cooldown is refused, so a hit
can't be confirmed. Successful logins are never counted and anonymous traffic is
never throttled, so the lockout can't be turned against clients that aren't
guessing. A fleet of N replicas bounds a guesser at N× one instance's rate —
each instance enforces its own budget. Tune or disable with
--login-cooldown-secs — configuration.
What pypiron does not defend¶
These are out of scope by design; know them before you lean on the rest.
- A stolen storage credential. Whoever holds your object-storage keys can rewrite an artifact and its recorded hash in one motion. Guard that credential like the root secret it is.
- The trustworthiness of upstream PyPI. For mirrored public packages, pypiron relays PyPI's provenance; it never mints or re-verifies attestations itself. A malicious upload that PyPI accepted and signed is one pypiron will carry across. Your own private uploads are a separate world you control.
- Request floods. Failed logins are throttled in-process (see above), but
volumetric floods — hammering the index, download, or metadata endpoints —
are the edge's job: put a request-rate limit on your reverse proxy or load
balancer. The access log gives an edge ban its signal: failed logins appear
as
401events and throttled attempts as429s atinfolevel (keeppypiron::access=infoif you feed them to fail2ban or a SIEM — raising it towarnkeeps only 5xx and drops them); key any ban on the real peer address, or trust the logged client field only when pypiron sits behind a proxy with--trusted-proxy, since otherwise an attacker sets thatX-Forwarded-Forthemselves.
Dependency advisories¶
Every change runs cargo audit with no ignore flags. The audit is clean.
It was not always. Through v0.0.14 pypiron carried two denial-of-service
advisories in quick-xml (RUSTSEC-2026-0194, quadratic parsing on duplicate
attributes; RUSTSEC-2026-0195, unbounded allocation on namespace declarations),
pulled in through object_store — the library pypiron uses to talk to S3, GCS,
and Azure. The vulnerable code parsed only XML from the storage endpoint you
configured and authenticated to, never anything from a package client, and the
default disk backend never invoked it at all. No released object_store
permitted the fixed quick-xml, so the advisories were documented here instead
of hidden behind audit exceptions, with the gate set to turn red the day a fix
shipped.
That day came: object_store 0.14.1 allows the fixed quick-xml 0.41, pypiron
took the bump, and releases after v0.0.14 carry no known advisories.
Verify a release¶
Every wheel, sdist, release binary, and container image is published with a signed build-provenance attestation — proof it was built by this repository's CI and hasn't been swapped since. Check one with the GitHub CLI:
# A wheel or sdist you downloaded from PyPI
gh attestation verify ./pypiron-<version>-<platform>.whl \
--repo blackthorn-interstellar/pypiron
# A release binary
curl -LO https://github.com/blackthorn-interstellar/pypiron/releases/latest/download/pypiron-x86_64-unknown-linux-musl.tar.gz
gh attestation verify pypiron-x86_64-unknown-linux-musl.tar.gz \
--repo blackthorn-interstellar/pypiron
# The container image, checked by digest without pulling it
gh attestation verify oci://ghcr.io/blackthorn-interstellar/pypiron:latest \
--repo blackthorn-interstellar/pypiron
Exit status 0 is the signal — the artifact's digest matched an attestation issued by this repo's GitHub Actions. A non-zero exit means it didn't; treat the artifact as unverified.
Three things it needs:
- GitHub CLI 2.49 or newer. Older builds (2.21, for one) have no
attestationcommand at all. - Network and a login. Verification fetches the attestation from GitHub, so it
needs egress and
gh auth login. Air-gapped? Download the attestation on a connected machine withgh attestation download, then verify offline against it with--bundle. - A public repository. Attestation is a public-repo feature; it works here because this repository is public. If it ever goes private, verification stops until it's public again.