Most serious Python security incidents now start in the packaging and build chain, not in the application's own logic: a poisoned package, a leaked PyPI token or a startup hook that runs on every interpreter launch. The language itself adds a second layer of risk through features that execute code by design, such as pickle, yaml.load, eval and shell=True. This guide covers how to install dependencies reproducibly and verifiably, how to publish packages without long-lived tokens, which coding patterns to ban and what to use instead, and how to keep secrets out of source, bytecode and images. It is written for Python developers, platform and DevSecOps engineers, and security teams who review Python services, data pipelines and AI tooling.
Key takeaways
- Install from hashes, not from names. pip's hash-checking mode (
--require-hashes) with a fully pinned lockfile stops a swapped or tampered artefact from installing, and--only-binary :all:stops source builds from running arbitrary build code. - Publish with trusted publishing. PyPI exchanges a CI identity token for an API token that expires after 15 minutes, so there is no long-lived upload token to steal; delete old tokens once it works.
- Treat deserialisation as code execution.
pickle.loadandyaml.loadon untrusted data can run arbitrary code; use JSON,yaml.safe_loador signed, schema-validated formats. - Remember the startup hooks.
importlines in.pthfiles run every time Python starts, which is why malicious packages use them; monitor site-packages for new.pthfiles. - Scan what you ship. Secrets survive in
__pycache__bytecode, wheels and container layers even when they were never committed, so scanning the repository alone is not enough.
The Python threat model in one table
Python risks group into four areas. The table maps each to the main attacker technique and the control that addresses it. ATT&CK IDs are from ATT&CK v20.
| Area | Typical failure | ATT&CK technique | Primary control |
|---|---|---|---|
| Dependencies | Malicious or hijacked package on PyPI, typosquat, dependency confusion | T1195.001 Compromise Software Dependencies and Development Tools | Private index or proxy, pinned lockfile with hashes, vulnerability and malware scanning |
| Publishing | Stolen PyPI API token used to upload a release outside the normal pipeline | T1195.002 Compromise Software Supply Chain | Trusted publishing, protected release environment, attestations |
| Runtime code paths | Untrusted input reaches pickle, yaml.load, eval, exec or a shell | T1059.006 Command and Scripting Interpreter: Python | Banned-API list enforced by Bandit, safe alternatives, input validation |
| Persistence in the interpreter | Malicious .pth or sitecustomize code runs on every start | T1546.018 Python Startup Hooks | Read-only site-packages, file integrity monitoring, clean images |
| Secrets | Tokens in code, .env, bytecode or images | T1552.001 Credentials In Files | Secret scanning of repos and artefacts, short-lived credentials |
For the cross-ecosystem picture see the Software Supply Chain Security Guide and the Malicious Package and Dependency Confusion Guide.
Dependencies: lockfiles, hashes and install-time code
By default pip does no checking against remote tampering and will run arbitrary code from a distribution while installing it, as the pip documentation states. Source distributions (sdists) run build backends at install time; wheels do not. Hardening therefore has three parts: pin everything, verify every artefact, and prefer wheels.
Pin and hash
- Hash-checking mode: pin each requirement with
==and list one or more--hash=sha256:<digest>values. Adding a hash to any line switches the whole file into hash-checking mode, and every dependency, including transitive ones, must then be pinned and hashed or pip fails. Pass--require-hasheson the command line to force the mode even when a file has no hashes, which catches a file that was generated without them. - Algorithms: pip recommends sha256 and rejects md5, sha1 and sha224 for this purpose. The
#sha256=fragment PyPI puts in download URLs only protects against corruption and does not satisfy--require-hashes. - Generating the lock:
pip-compile --generate-hashes requirements.in(pip-tools) writes a hashedrequirements.txt;pip-syncthen makes the virtual environment match it exactly. Poetry, PDM and uv produce their own lockfiles (poetry.lock,pdm.lock,uv.lock), andpylock.tomlis the standard lockfile format; all of these are read by OSV-Scanner. - Wheels only:
--only-binary :all:refuses sdists, so no package build code runs on the build host. Where a dependency has no wheel, build it once in an isolated job and publish the wheel to your internal index. - Do not bypass pip: the pip documentation advises against
python setup.py install,setup.py developandeasy_install, which can fetch unchecked dependencies. Install your own project withpython -m pip install --no-deps .after the hashed dependencies are in place.
A minimal hashed install in CI looks like this:
python -m pip install --require-hashes --only-binary :all: --no-deps -r requirements.txt
python -m pip install --no-deps .
Where packages come from
- One index: point pip at a single internal proxy or mirror rather than mixing
--index-urland--extra-index-url. With an extra index, pip chooses the best version across all indexes, which is the condition dependency confusion relies on. The PyTorch torchtriton attack worked this way: a public PyPI package registered with the same name as a dependency served from the project's own nightly index took precedence, and nightly installs pulled it in. - Reserve your names: register your internal package names on PyPI or use a namespace prefix that only your index serves.
- Quarantine new versions: many malicious releases are removed within hours. A proxy that delays brand-new versions for a few days, combined with scheduled lockfile updates rather than floating ranges, removes most of that exposure.
Broader open-source intake rules are in the Open Source Security Guide.
Publishing safely: trusted publishing and attestations
Package maintainers and any organisation that publishes internal or public Python packages hold a credential that can push code into other people's environments. That credential is the target.
Trusted publishing
PyPI trusted publishing uses OpenID Connect. The maintainer registers a specific CI configuration (for example a GitHub repository, workflow file and environment) as a trusted publisher for the project. At release time the CI job presents its OIDC identity token, PyPI checks it against the registered configuration, and if it matches, PyPI returns an API token scoped to that project and valid for 15 minutes. PyPI describes this as complementing API tokens, so an old token keeps working until someone deletes it.
- Bind to an environment: on GitHub, register the publisher with a named deployment environment and require reviewers on that environment. PyPI's own analysis of the Ultralytics attack recommends GitHub Environments with trusted publishers.
- Remove the fallback: once trusted publishing has produced a release, revoke every project and account API token. PyPI notes that a token that goes unused while trusted publishing works is a strong signal it can be revoked.
- Isolate the release job: the job that receives
id-token: writeshould not restore caches written by pull-request jobs or run third-party scanners. See the GitHub and GitHub Actions Security Guide.
Attestations (PEP 740)
PyPI accepts digital attestations on uploaded files, built on the in-toto attestation framework. Two predicates are supported: SLSA Provenance, which records where the package was built from, and PyPI Publish, which records that the file was published through a particular trusted publisher. Each file can carry at most one of each. PyPI currently accepts attestation identities from GitHub Actions, GitLab CI/CD, Google Cloud and ActiveState. Consumers and incident responders can use them to tell a release built by the real workflow from one uploaded with a stolen token, which is exactly how PyPI separated the two waves of the Ultralytics compromise. Attestations prove origin, not safety: a compromised workflow still produces a valid attestation.
| Publishing method | Credential lifetime | Stolen credential lets an attacker | Use when |
|---|---|---|---|
| Account-wide API token | Until revoked | Upload to every project the account owns | Avoid |
| Project-scoped API token | Until revoked | Upload to one project from anywhere | CI provider not supported by trusted publishing; store in a secrets manager and rotate |
| Trusted publishing | 15 minutes | Little after expiry; attacker needs to run inside the registered workflow | Default for GitHub Actions, GitLab CI/CD and other supported providers |
| Trusted publishing plus protected environment and attestations | 15 minutes | Needs workflow compromise and reviewer approval | Widely used or security-sensitive packages |
Publishing tokens are machine credentials and belong in the same inventory as other non-human identities. See the Secrets Management Guide.
Unsafe patterns and their safe alternatives
Several standard features turn data into code. They are safe with data you created and control, and dangerous anywhere an attacker can influence the input: uploaded files, message queues, caches, model files, API bodies and configuration from other teams. The general class is covered in the Insecure Deserialisation Guide.
| Pattern | Why it is risky | Bandit test | Safer alternative |
|---|---|---|---|
pickle.load, pickle.loads | The Python documentation warns that malicious pickle data can execute arbitrary code during unpickling | B301 pickle | json for untrusted data; if pickle is unavoidable, sign with hmac and verify before loading, or restrict globals with a custom Unpickler.find_class |
marshal | Not designed for untrusted input | B302 marshal | JSON or a schema-based format |
yaml.load with a full loader | PyYAML states it is as powerful as pickle.load and not safe with untrusted data | B506 yaml_load | yaml.safe_load (SafeLoader builds only standard YAML types) |
eval, exec | Run arbitrary expressions or statements | B307 eval, B102 exec_used | Explicit parsing, a lookup table of allowed operations, or json.loads; ast.literal_eval only for trusted, size-limited input |
subprocess with shell=True | Shell metacharacters in input run extra commands | B602 | Pass the command as a list of arguments; use shlex.quote only where a shell is truly required |
| String-built SQL | SQL injection | B608 | Parameterised queries in the database driver or ORM |
hashlib.md5 or sha1 for security | Broken for collision resistance | B324, B303 | SHA-256 or stronger; a password hashing function for passwords |
random for tokens | Predictable generator | B311 | secrets.token_urlsafe, secrets.token_bytes |
Short safe examples
import json, yaml, subprocess, secrets
config = yaml.safe_load(untrusted_text) # not yaml.load(...)
payload = json.loads(request_body) # not pickle.loads(...)
subprocess.run(["convert", user_path, out_path], check=True) # no shell=True
api_key = secrets.token_urlsafe(32) # not random.choice(...)
Two points are often missed. First, ast.literal_eval does not run code, but the Python documentation now says a small input can exhaust memory or the C stack and crash the process, and recommends against calling it on untrusted data. Second, many machine learning model formats are pickle underneath, so loading a model file from an untrusted source is the same decision as unpickling it. See the AI Supply Chain and AI-BOM Guide for model provenance.
Pair these rules with input validation at every trust boundary (NIST SP 800-53 SI-10) and with the language-neutral practices in the Secure Coding Guide and the OWASP Top 10 Guide.
Startup hooks, bytecode and the interpreter environment
- .pth files: the
sitemodule processes every.pthfile in site-packages at startup. Path lines are added tosys.path; lines starting withimportare passed toexec(). That code runs in every Python process on the host, whether or not the package is ever imported. Python 3.15 deprecatesimportlines in favour of.startfiles (PEP 829), with later releases planned to ignore them, but current deployments still run them. ATT&CK tracks this as T1546.018 Python Startup Hooks. - Interpreter flags:
-Sdisables thesitemodule entirely;-sorPYTHONNOUSERSITEdisables the per-user site directory. For tightly controlled jobs,-I(isolated mode) also ignoresPYTHON*environment variables and the script's directory. - Write access: any account that can write to site-packages can run code in every future Python process. Build images so that site-packages is owned by root and the application runs as a different, unprivileged user.
- Bytecode is not obfuscation:
.pycfiles are easy to decompile and keep string constants, including secrets that were in a local edit but never committed.
Secrets in Python code and artefacts
Python applications commonly need cloud keys, database passwords and LLM API keys. The failure modes are well known and repeat across incidents.
- Hardcoded values: Bandit flags likely hardcoded passwords with B105 (string) and B106 (function argument). Treat these as review prompts; dedicated secret scanners find far more key formats.
- .env files: convenient locally, but they end up in images, wheels and backups. Add
.envto.gitignoreand.dockerignore, and load production secrets from a secrets manager or workload identity at runtime. - Compiled and packaged artefacts: in the PyPI admin GitHub token leak, a token added to a local file was never committed but survived in a
__pycache__.pycfile that was built into a public Docker image, where it stayed for about 15 months. Exclude__pycache__and*.pycin.dockerignore, build from clean checkouts, and scan images and wheels before publishing. - Environment variables in CI: any step that runs third-party code can read the job's environment. Give the publishing secret only to the publishing step.
Use secrets for generating tokens and hmac.compare_digest when comparing them. See the Top 10 Secrets Management Mistakes.
Tooling: scanning, advisories and the PSRT
| Tool or source | What it does | Where to run it |
|---|---|---|
| Bandit | Static analysis of Python source for risky calls and patterns (B-coded tests above) | Pre-commit and pull request; fail on high-severity, high-confidence findings first |
| pip-audit (PyPA) | Checks installed packages or requirements files against the Python Packaging Advisory Database via PyPI, or OSV with -s osv; supports --require-hashes, --strict, --fix with --dry-run, and CycloneDX output | CI on every lockfile change and on a daily schedule |
| OSV-Scanner | Matches lockfiles (requirements.txt, poetry.lock, Pipfile.lock, pdm.lock, uv.lock, pylock.toml) against osv.dev | Monorepos and mixed-language estates |
| Secret scanner | Detects credentials in code, history, images and packages | Pre-commit, push protection and artefact pipeline |
| Python Security Response Team (PSRT) | Handles vulnerabilities in CPython and pip reported to [email protected]; advisories go to the security-announce list and the OSV-format psf/advisory-database; the PSF is a CVE Numbering Authority | Subscribe to security-announce; feed the advisory database into scanners |
Report a malicious package on PyPI to [email protected], as PyPI's incident guidance asks. Keep interpreters on supported CPython release lines so that security fixes are available. Prioritisation of the findings these tools produce is covered in the CWE Top 25 Guide.
How to implement: a phased rollout
- Inventory: list every Python service, job, notebook platform and internal package, with its interpreter version, dependency file and publishing method. List every PyPI token and who holds it.
- Observe first: run pip-audit, OSV-Scanner and Bandit in report-only mode for two to four weeks. Baseline the findings rather than blocking builds on day one.
- Lock and hash: generate hashed lockfiles for one pilot service, switch its CI to
--require-hashes, and confirm that rebuilds are reproducible. Then extend team by team. - Single index: route all installs through an internal proxy, remove
--extra-index-urlusage, and block direct egress to PyPI from build agents once the proxy is proven. - Move publishing: configure trusted publishing with a protected environment for each package, publish one release, then revoke all API tokens for that project.
- Enforce: make high-confidence Bandit findings (B301, B307, B506, B602) and known-exploited or critical vulnerabilities blocking, with a documented exception process that expires.
- Harden runtime: read-only site-packages, non-root user,
.dockerignorefor caches and.env, and integrity monitoring for new.pthfiles.
Rollback: keep the previous unhashed requirements and pipeline definition in version control for one release cycle. If hash-checking blocks a legitimate platform wheel, add its hash with pip hash rather than turning the mode off. If trusted publishing fails during an urgent release, issue a short-lived project-scoped token, publish, and revoke it the same day.
Decision guidance
- pip-tools vs Poetry, PDM or uv: pip-tools keeps plain
requirements.txtand fits existing pip workflows; the newer tools manage environments and lockfiles together. Pick one per organisation so scanning and review are consistent. - Small teams: hashed lockfiles, trusted publishing, pip-audit and Bandit in CI, plus a hosted secret scanner, cover most of the risk without a private index.
- Large organisations: add an internal proxy with new-version quarantine, a central banned-API rule set, signed internal packages with attestations, and an owner for every publishing identity.
Detection and measurement
- Monitor: new
.pthorsitecustomize.pyfiles in site-packages on servers and runners; Python processes making outbound connections to unexpected hosts; uploads to your PyPI projects that have no matching tag, workflow run or attestation; and pip installs that reach the public index directly. - Log: CI job logs for every install and publish, PyPI project security history, and secret-scanner alerts, retained long enough to investigate exposures that last months.
Metrics that show the programme is working:
- Percentage of Python builds installing with
--require-hashesfrom a lockfile. - Number of live PyPI API tokens (target: zero for projects on trusted publishing).
- Percentage of published releases carrying attestations.
- Open critical or known-exploited dependency vulnerabilities and median days to fix.
- Count of B301, B307, B506 and B602 findings in main branches, trending to zero.
- Median time from secret-scanner alert to credential revocation.
Framework mapping
| Control area | NIST SP 800-53 Rev. 5 | NIST CSF 2.0 | Other |
|---|---|---|---|
| Dependency intake and provenance | SR-3 Supply Chain Controls and Processes, SR-11 Component Authenticity | GV.SC-05 | CIS Controls v8.1 Control 16 (16.4, 16.5) |
| Artefact integrity (hashes, attestations) | SI-7 Software, Firmware, and Information Integrity | PR.PS-06 | ATT&CK T1195.001, T1195.002 |
| Secure coding and static analysis | SA-11 Developer Testing and Evaluation, SA-15 Development Process, Standards, and Tools, SI-10 Information Input Validation | PR.PS-06 | ISO/IEC 27001:2022 A.8.25, A.8.28 |
| Vulnerability scanning | RA-5 Vulnerability Monitoring and Scanning | ID.RA-01 | ISO/IEC 27001:2022 A.8.8 |
| Publishing tokens and secrets | IA-5 Authenticator Management | PR.AA-01 | ATT&CK T1552.001 |
Common failure patterns
- Trusted publishing added, old token kept: in the Ultralytics PyPI compromise, after the GitHub Actions injection was fixed, the attacker uploaded two more malicious releases with a long-lived PyPI token that had never been revoked.
- Publishing token visible to every CI step: in the LiteLLM PyPI package breach, a compromised security scanner running in CI read the
PYPI_PUBLISHtoken from the environment, and version 1.82.8 shipped alitellm_init.pthfile that ran a credential stealer in every Python process on the host. - Extra index URLs: mixing internal and public indexes lets a public package with an internal name win resolution.
- Floating ranges in production:
requests>=2in a deployed service means each build may install something nobody reviewed. - Pickle as a cache or message format: a Redis cache or queue that stores pickled objects becomes a code execution path for anyone who can write to it.
- Scanning only the repository: secrets in bytecode, wheels and image layers go unnoticed, as the PyPI admin token leak showed.
Practitioner checklist
- Every deployable Python project has a fully pinned, hashed lockfile and CI installs with
--require-hashes. - Builds use
--only-binary :all:or build sdists only in an isolated job. - All installs go through one internal index or proxy; no
--extra-index-url. - Every package you publish uses trusted publishing with a protected environment, and no PyPI API tokens remain.
- Bandit rules for pickle, marshal, yaml.load, eval, exec and shell=True block merges unless an approved exception exists.
- pip-audit or OSV-Scanner runs on every lockfile change and daily, with known-exploited issues fixed first.
- Images exclude
__pycache__,*.pycand.env, and are scanned for secrets before push. - site-packages is read-only to the application user and new
.pthfiles raise an alert. - Your team is subscribed to Python security-announce and runs supported CPython versions.
Standards and references
- PyPI: Trusted Publishers
- PyPI: Attestations
- pip documentation: Secure installs
- Python documentation: pickle, Python object serialization
- Python documentation: site, site-specific configuration hook
- Python Software Foundation: Security (Python Security Response Team)
- PyPI Blog: Supply-chain attack analysis, Ultralytics
- MITRE ATT&CK: Python Startup Hooks
Related NHI Mgmt Group resources: Secure Coding Guide · Software Supply Chain Security Guide · Secrets Management Guide · GitHub and GitHub Actions Security Guide · Node.js Security Guide