Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(deps): update dependency pip-audit to ~=2.8.0 #744

Merged
merged 1 commit into from
Feb 19, 2025

Conversation

renovate[bot]
Copy link
Contributor

@renovate renovate bot commented Feb 19, 2025

This PR contains the following updates:

Package Change Age Adoption Passing Confidence
pip-audit ~=2.7.0 -> ~=2.8.0 age adoption passing confidence

Release Notes

pypa/pip-audit (pip-audit)

v2.8.0

Compare Source

Added
  • pip-audit now allows some CLI flags to be configured via environment
    variables (#​755)
Changed
  • The default cache locations on macOS and Linux now respect each platform's
    caching directory idioms (e.g. XDG)
    (#​814)

  • The minimum version of Python is now 3.9
    (#​846)

Fixed
  • Auditing a fully-pinned requirements file with --disable-pip now allows for
    duplicates, so long as the duplicates don't have conflicting specifier sets
    (#​749)
  • Fixed two sources of unnecessary resource leaks when doing file I/O
    (#​878)

Configuration

📅 Schedule: Branch creation - "* 0-12 * * 3" (UTC), Automerge - At any time (no schedule defined).

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate renovate bot requested a review from thypon as a code owner February 19, 2025 02:56
Copy link

[puLL-Merge] - pypa/pip-audit@v2.7.0..v2.8.0

Diff
diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml
new file mode 100644
index 00000000..e5bc36e9
--- /dev/null
+++ .github/ISSUE_TEMPLATE/bug-report.yml
@@ -0,0 +1,105 @@
+name: Bug report
+description: File a bug report
+title: "Bug: "
+labels:
+  - bug-candidate
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thank you for taking the time to report a potential bug in `pip-audit`!
+
+        Please read the following parts of this form carefully.
+        Invalid or incomplete submissions will be given a lower priority or
+        closed outright.
+
+  - type: checkboxes
+    attributes:
+      label: Pre-submission checks
+      description: |
+        By submitting this issue, you affirm that you've satisfied the following conditions.
+      options:
+        - label: >-
+            I am **not** filing an auditing error (false positive or negative).
+            These **must** be reported to
+            [pypa/advisory-database](https://github.com/pypa/advisory-database/issues/new) instead.
+          required: true
+        - label: >-
+            I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).
+          required: true
+        - label: >-
+            I have looked through the open issues for a duplicate report.
+          required: true
+
+  - type: textarea
+    attributes:
+      label: Expected behavior
+      description: A clear and concise description of what you expected to happen.
+      placeholder: |
+        I expected `pip-audit ...` to do X, Y, and Z.
+    validations:
+      required: true
+
+  - type: textarea
+    attributes:
+      label: Actual behavior
+      description: A clear and concise description of what actually happened.
+      placeholder: |
+        Instead of doing X, Y, and Z, `pip-audit ...` produced got the following error: ...
+    validations:
+      required: true
+
+  - type: textarea
+    attributes:
+      label: Reproduction steps
+      description: A step-by-step list of actions that we can take to reproduce the actual behavior.
+      placeholder: |
+        1. Do this
+        2. Do that
+        3. Do another thing
+    validations:
+      required: true
+
+  - type: textarea
+    attributes:
+      label: Logs
+      description: |
+        If applicable, please paste any logs or console errors here.
+
+        If you can re-run the command that produced the error, run it with
+        `--verbose` and paste the full verbose logs here.
+      render: plain text
+
+  - type: textarea
+    attributes:
+      label: Additional context
+      description: Add any other additional context about the problem here.
+
+  - type: input
+    attributes:
+      label: OS name, version, and architecture
+      placeholder: Mac OS X 10.4.11 on PowerPC
+
+  - type: input
+    attributes:
+      label: pip-audit version
+      description: |
+        `pip-audit -V`
+    validations:
+      required: true
+
+  - type: input
+    attributes:
+      label: pip version
+      description: |
+        `pip -V` or `pip3 -V`
+    validations:
+      required: true
+
+  - type: input
+    attributes:
+      label: Python version
+      description: |
+        `python -V` or `python3 -V`
+    validations:
+      required: true
diff --git .github/ISSUE_TEMPLATE/bug_report.md .github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 5e16fe4d..00000000
--- .github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,51 +0,0 @@
----
-name: Bug report
-about: Create a report to help us improve
-title: ''
-labels: bug-candidate
-assignees: ''
-
----
-
-Thank you for reporting a potential bug in `pip-audit`! Please read the next parts of this template carefully:
-
-**IMPORTANT**: Please **do not** report auditing errors (false positives or negatives) to this repository. Instead, please report them to [pypa/advisory-database](https://github.com/pypa/advisory-database/issues/new).
-
-**IMPORTANT:** Please fill out every section below. Bug reports with missing information will be
-given a lower priority or closed outright.
-
-Please comment out or remove this line and everything above it from your report.
-
-## Bug description
-
-A clear and concise description of what the bug is.
-
-## Reproduction steps
-
-A step-by-step list of actions to reproduce the behavior.
-
-## Expected behavior
-
-A clear and concise description of what you expected to happen.
-
-## Screenshots and logs
-
-If applicable, add screenshots to help explain your problem.
-
-Similarly, if applicable and possible, re-run the command with `--verbose`,
-and paste the logs in the code block below:
-
-\`\`\`
-Paste logs here, or remove me if not applicable!
-```
-
-## Platform information
-
-* OS name and version:
-* `pip-audit` version (`pip-audit -V`):
-* Python version (`python -V` or `python3 -V`):
-* `pip` version (`pip -V` or `pip3 -V`):
-
-## Additional context
-
-Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
new file mode 100644
index 00000000..10c52d6f
--- /dev/null
+++ .github/ISSUE_TEMPLATE/feature-request.yml
@@ -0,0 +1,55 @@
+name: Feature request
+description: Suggest an idea or enhancement for pip-audit
+title: "Feature: "
+labels:
+  - enhancement
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thank for for making a `pip-audit` feature request!
+
+        Please read the following parts of this form carefully.
+        Invalid or incomplete submissions will be given a lower priority or
+        closed outright.
+
+  - type: checkboxes
+    attributes:
+      label: Pre-submission checks
+      description: |
+        By submitting this issue, you affirm that you've satisfied the following conditions.
+      options:
+        - label: >-
+            I am **not** reporting a new vulnerability or requesting a new vulnerability identifier.
+            These **must** be reported or managed via upstream dependency sources or services,
+            not this repository.
+          required: true
+        - label: >-
+            I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).
+          required: true
+        - label: >-
+            I have looked through the open issues for a duplicate request.
+          required: true
+
+  - type: textarea
+    attributes:
+      label: What's the problem this feature will solve?
+      description: |
+        A clear and concise description of the problem.
+      placeholder: |
+        I'm always frustrated when ...
+    validations:
+      required: true
+
+  - type: textarea
+    attributes:
+      label: Describe the solution you'd like
+      description: A clear and concise description of what you want to happen.
+    validations:
+      required: true
+
+  - type: textarea
+    attributes:
+      label: Additional context
+      description: |
+        Any additional context, screenshots, or other material about the feature request.
diff --git .github/ISSUE_TEMPLATE/feature_request.md .github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index 1a81bc7e..00000000
--- .github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,32 +0,0 @@
----
-name: Feature request
-about: Suggest an idea for this project
-title: ''
-labels: enhancement
-assignees: ''
-
----
-
-Thank you for making a `pip-audit` feature request! Please read the next parts of this template carefully:
-
-**IMPORTANT**: Please **do not** report new vulnerabilities or request new vulnerability identifiers on this repository. This tool takes vulnerability information from upstream services and is not capable of minting new vulnerability reports.
-
-**IMPORTANT:** Please fill out every section below. Feature requests with missing information will be given a lower priority or closed outright.
-
-Please comment out or remove this line and everything above it from your request.
-
-**Is your feature request related to a problem? Please describe.**
-
-A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
-
-**Describe the solution you'd like**
-
-A clear and concise description of what you want to happen.
-
-**Describe alternatives you've considered**
-
-A clear and concise description of any alternative solutions or features you've considered.
-
-**Additional context**
-
-Add any other context or screenshots about the feature request here.
diff --git .github/workflows/ci.yml .github/workflows/ci.yml
index 16bfbb6a..053f5087 100644
--- .github/workflows/ci.yml
+++ .github/workflows/ci.yml
@@ -8,21 +8,25 @@ on:
   schedule:
     - cron: '0 12 * * *'
 
+permissions: {}
+
 jobs:
   test:
     strategy:
       matrix:
         python:
-          - "3.8"
           - "3.9"
           - "3.10"
           - "3.11"
           - "3.12"
+          - "3.13"
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4.1.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        with:
+          persist-credentials: false
 
-      - uses: actions/setup-python@v5
+      - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5
         with:
           python-version: ${{ matrix.python }}
           cache: "pip"
@@ -30,3 +34,17 @@ jobs:
 
       - name: test
         run: make test PIP_AUDIT_EXTRA=test
+
+  all-tests-pass:
+    if: always()
+
+    needs:
+    - test
+
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: check test jobs
+        uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
+        with:
+          jobs: ${{ toJSON(needs) }}
diff --git .github/workflows/docs.yml .github/workflows/docs.yml
index b498ed7f..20a579d7 100644
--- .github/workflows/docs.yml
+++ .github/workflows/docs.yml
@@ -5,13 +5,17 @@ on:
     branches:
       - main
 
+permissions: {}
+
 jobs:
   build:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4.1.1 # v3.3.0
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        with:
+          persist-credentials: false
 
-      - uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
+      - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0
         with:
           # NOTE: We use 3.10+ typing syntax via future, which pdoc only
           # understands if it's actually run with Python 3.10 or newer.
@@ -26,7 +30,7 @@ jobs:
         run: |
           make doc
       - name: upload docs artifact
-        uses: actions/upload-pages-artifact@0252fc4ba7626f0298f0cf00902a25c6afc77fa8 # v3.0.0
+        uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
         with:
           path: ./html/
 
@@ -47,4 +51,4 @@ jobs:
       url: ${{ steps.deployment.outputs.page_url }}
     steps:
       - id: deployment
-        uses: actions/deploy-pages@7a9bd943aa5e5175aeb8502edcc6c1c02d398e10 # v4.0.2
+        uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
diff --git .github/workflows/lint.yml .github/workflows/lint.yml
index 004d4531..cc1a6d5a 100644
--- .github/workflows/lint.yml
+++ .github/workflows/lint.yml
@@ -6,15 +6,19 @@ on:
       - main
   pull_request:
 
+permissions: {}
+
 jobs:
   lint:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4.1.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        with:
+          persist-credentials: false
 
-      - uses: actions/setup-python@v5
+      - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5
         with:
-          python-version: "3.8"
+          python-version: "3.9"
           cache: "pip"
           cache-dependency-path: pyproject.toml
 
@@ -24,14 +28,16 @@ jobs:
   check-readme:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4.1.1
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        with:
+          persist-credentials: false
 
-      - uses: actions/setup-python@v5
+      - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5
         # NOTE(ww): Important: use pip-audit's minimum supported Python version
         # in this check, since Python can change the `--help` rendering in
         # `argparse` between major versions.
         with:
-          python-version: "3.8"
+          python-version: "3.9"
           cache: "pip"
           cache-dependency-path: pyproject.toml
 
diff --git .github/workflows/release.yml .github/workflows/release.yml
index d08cf0c2..7be1f9ec 100644
--- .github/workflows/release.yml
+++ .github/workflows/release.yml
@@ -13,24 +13,22 @@ jobs:
 
     permissions:
       # Used to authenticate to PyPI via OIDC.
-      # Used to sign the release's artifacts with sigstore-python.
       id-token: write
 
       # Used to attach signing artifacts to the published release.
       contents: write
 
     steps:
-    - uses: actions/checkout@v4.1.1
-
-    - uses: actions/setup-python@v5
+    - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
       with:
-        python-version: ">= 3.8"
-        cache: "pip"
-        cache-dependency-path: pyproject.toml
+        persist-credentials: false
 
+    - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5
+      with:
+        python-version-file: pyproject.toml
 
     - name: deps
-      run: python -m pip install -U setuptools build wheel
+      run: python -m pip install -U build
 
     - name: build
       run: python -m build
@@ -38,8 +36,3 @@ jobs:
     - name: publish
       uses: pypa/gh-action-pypi-publish@release/v1
 
-    - name: sign
-      uses: sigstore/gh-action-sigstore-python@v2.1.1
-      with:
-        inputs: ./dist/*.tar.gz ./dist/*.whl
-        release-signing-artifacts: true
diff --git .github/workflows/scorecards.yml .github/workflows/scorecards.yml
index b3355ff7..cd3f3311 100644
--- .github/workflows/scorecards.yml
+++ .github/workflows/scorecards.yml
@@ -7,8 +7,8 @@ on:
   push:
     branches: [ "main" ]
 
-# Declare default permissions as read only.
-permissions: read-all
+# No permissions needed at top-level.
+permissions: {}
 
 jobs:
   analysis:
@@ -19,35 +19,35 @@ jobs:
       security-events: write
       # Used to receive a badge. (Upcoming feature)
       id-token: write
-    
+
     steps:
       - name: "Checkout code"
-        uses: actions/checkout@v4.1.1 # tag=v3.0.0
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
         with:
           persist-credentials: false
 
       - name: "Run analysis"
-        uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # tag=v2.3.1
+        uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0
         with:
           results_file: results.sarif
           results_format: sarif
           # Publish the results for public repositories to enable scorecard badges. For more details, see
-          # https://github.com/ossf/scorecard-action#publishing-results. 
-          # For private repositories, `publish_results` will automatically be set to `false`, regardless 
+          # https://github.com/ossf/scorecard-action#publishing-results.
+          # For private repositories, `publish_results` will automatically be set to `false`, regardless
           # of the value entered here.
           publish_results: true
 
       # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
       # format to the repository Actions tab.
       - name: "Upload artifact"
-        uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # tag=v3.1.3
+        uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
         with:
           name: SARIF file
           path: results.sarif
           retention-days: 5
-      
+
       # Upload the results to GitHub's code scanning dashboard.
       - name: "Upload to code-scanning"
-        uses: github/codeql-action/upload-sarif@cdcdbb579706841c47f7063dda365e292e5cad7a # tag=v2.13.4
+        uses: github/codeql-action/upload-sarif@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
         with:
           sarif_file: results.sarif
diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml
new file mode 100644
index 00000000..6830f8ba
--- /dev/null
+++ .github/workflows/zizmor.yml
@@ -0,0 +1,36 @@
+name: GitHub Actions Security Analysis with zizmor 🌈
+
+on:
+  push:
+    branches: ["main"]
+  pull_request:
+    branches: ["**"]
+
+jobs:
+  zizmor:
+    name: zizmor latest via PyPI
+    runs-on: ubuntu-latest
+    permissions:
+      security-events: write
+      # required for workflows in private repositories
+      contents: read
+      actions: read
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          persist-credentials: false
+
+      - name: Install the latest version of uv
+        uses: astral-sh/setup-uv@v5
+
+      - name: Run zizmor 🌈
+        run: uvx zizmor --format sarif . > results.sarif
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Upload SARIF file
+        uses: github/codeql-action/upload-sarif@v3
+        with:
+          sarif_file: results.sarif
+          category: zizmor
diff --git .pre-commit-config.yaml .pre-commit-config.yaml
index f999c0d1..d44962f5 100644
--- .pre-commit-config.yaml
+++ .pre-commit-config.yaml
@@ -27,7 +27,7 @@ repos:
     hooks:
       - id: isort
   - repo: https://github.com/pypa/pip-audit
-    rev: v2.7.0
+    rev: v2.8.0
     hooks:
       - id: pip-audit
   - repo: https://github.com/rhysd/actionlint
diff --git CHANGELOG.md CHANGELOG.md
index 598906dd..36f84ed9 100644
--- CHANGELOG.md
+++ CHANGELOG.md
@@ -8,6 +8,57 @@ All versions prior to 0.0.9 are untracked.
 
 ## [Unreleased]
 
+## [2.8.0]
+
+### Added
+
+* `pip-audit` now allows some CLI flags to be configured via environment
+  variables ([#755](https://github.com/pypa/pip-audit/pull/755))
+
+### Changed
+
+* The default cache locations on macOS and Linux now respect each platform's
+  caching directory idioms (e.g. XDG)
+  ([#814](https://github.com/pypa/pip-audit/pull/814))
+
+* The minimum version of Python is now 3.9
+  ([#846](https://github.com/pypa/pip-audit/pull/846))
+
+### Fixed
+
+* Auditing a fully-pinned requirements file with `--disable-pip` now allows for
+  duplicates, so long as the duplicates don't have conflicting specifier sets
+  ([#749](https://github.com/pypa/pip-audit/pull/749))
+* Fixed two sources of unnecessary resource leaks when doing file I/O
+  ([#878](https://github.com/pypa/pip-audit/pull/878))
+
+## [2.7.3]
+
+### Fixed
+
+* Improved handling of temporary files on Windows
+  ([#757](https://github.com/pypa/pip-audit/pull/757))
+
+* Fixed a subprocess deadlock on Windows
+  ([#756](https://github.com/pypa/pip-audit/pull/756))
+
+## [2.7.2]
+
+### Fixed
+
+* `pip-audit` now invokes `pip` with `--keyring-provider=subprocess`,
+  partially fixing a regression that was introduced with another authentication
+  fix in [2.6.2]. This allows the interior `pip` to use `keyring` to perform
+  third-party index authentication.
+
+## [2.7.1]
+
+### Fixed
+
+* Improved the error returned to users when their default temporary
+  directory lacks execute permissions
+  ([#737](https://github.com/pypa/pip-audit/pull/737))
+
 ## [2.7.0]
 
 ### Added
@@ -567,7 +618,11 @@ All versions prior to 0.0.9 are untracked.
   dependency errors ([#146](https://github.com/pypa/pip-audit/pull/146))
 
 <!-- Release URLs -->
-[Unreleased]: https://github.com/pypa/pip-audit/compare/v2.7.0...HEAD
+[Unreleased]: https://github.com/pypa/pip-audit/compare/v2.8.0...HEAD
+[2.8.0]: https://github.com/pypa/pip-audit/compare/v2.7.3...v2.8.0
+[2.7.3]: https://github.com/pypa/pip-audit/compare/v2.7.2...v2.7.3
+[2.7.2]: https://github.com/pypa/pip-audit/compare/v2.7.1...v2.7.2
+[2.7.1]: https://github.com/pypa/pip-audit/compare/v2.7.0...v2.7.1
 [2.7.0]: https://github.com/pypa/pip-audit/compare/v2.6.3...v2.7.0
 [2.6.3]: https://github.com/pypa/pip-audit/compare/v2.6.2...v2.6.3
 [2.6.2]: https://github.com/pypa/pip-audit/compare/v2.6.1...v2.6.2
diff --git CONTRIBUTING.md CONTRIBUTING.md
index 89be92bd..7ccbc291 100644
--- CONTRIBUTING.md
+++ CONTRIBUTING.md
@@ -8,7 +8,7 @@ as well as performing common development tasks.
 
 ## Requirements
 
-`pip-audit`'s only development environment requirement *should* be Python 3.8
+`pip-audit`'s only development environment requirement *should* be Python 3.9
 or newer. Development and testing is actively performed on macOS and Linux,
 but Windows and other supported platforms that are supported by Python
 should also work.
diff --git Makefile Makefile
index 9b0726fe..53bc7da9 100644
--- Makefile
+++ Makefile
@@ -57,15 +57,16 @@ $(VENV)/pyvenv.cfg: pyproject.toml
 lint: $(VENV)/pyvenv.cfg
 	. $(VENV_BIN)/activate && \
 		ruff format --check $(ALL_PY_SRCS) && \
-		ruff $(ALL_PY_SRCS) && \
+		ruff check $(ALL_PY_SRCS) && \
 		mypy $(PY_MODULE) && \
 		interrogate -c pyproject.toml .
 
 .PHONY: reformat
 reformat:
 	. $(VENV_BIN)/activate && \
-		ruff --fix $(ALL_PY_SRCS) && \
+		ruff check --fix $(ALL_PY_SRCS) && \
 		ruff format $(ALL_PY_SRCS)
+
 .PHONY: test tests
 test tests: $(VENV)/pyvenv.cfg
 	. $(VENV_BIN)/activate && \
diff --git README.md README.md
index f9dcc65c..aa860f44 100644
--- README.md
+++ README.md
@@ -25,6 +25,7 @@ with support from Google. This is not an official Google or Trail of Bits produc
   * [GitHub Actions](#github-actions)
   * [`pre-commit` support](#pre-commit-support)
 * [Usage](#usage)
+  * [Environment variables](#environment-variables)
   * [Exit codes](#exit-codes)
   * [Dry runs](#dry-runs)
 * [Examples](#examples)
@@ -50,7 +51,7 @@ with support from Google. This is not an official Google or Trail of Bits produc
 
 ## Installation
 
-`pip-audit` requires Python 3.8 or newer, and can be installed directly via `pip`:
+`pip-audit` requires Python 3.9 or newer, and can be installed directly via `pip`:
 
 ```bash
 python -m pip install pip-audit
@@ -106,7 +107,7 @@ For example, using `pip-audit` via `pre-commit` to audit a requirements file:
 
 ```yaml
   - repo: https://github.com/pypa/pip-audit
-    rev: v2.7.0
+    rev: v2.8.0
     hooks:
       -   id: pip-audit
           args: ["-r", "requirements.txt"]
@@ -218,6 +219,20 @@ optional arguments:

+### Environment variables
+
+pip-audit allows users to configure some flags via environment variables
+instead:
+
+
+| Flag | Environment equivalent | Example |
+| ------------------------- | --------------------------------- | ------------------------------------- |
+| --format | PIP_AUDIT_FORMAT | PIP_AUDIT_FORMAT=markdown |
+| --vulnerability-service | PIP_AUDIT_VULNERABILITY_SERVICE | PIP_AUDIT_VULNERABILITY_SERVICE=osv |
+| --desc | PIP_AUDIT_DESC | PIP_AUDIT_DESC=off |
+| --progress-spinner | PIP_AUDIT_PROGRESS_SPINNER | PIP_AUDIT_PROGRESS_SPINNER=off |
+| --output | PIP_AUDIT_OUTPUT | PIP_AUDIT_OUTPUT=/tmp/example |
+

Exit codes

On completion, pip-audit will exit with a code indicating its status.
@@ -448,6 +463,31 @@ $ pip-audit --no-deps -r requirements.txt
$ pip-audit --require-hashes -r requirements.txt


+### `pip-audit` can't authenticate to my third-party index!
+
+### Authenticated third-party or private indices
+
+`pip-audit` supports `--index-url` and `--extra-index-url` for configuring an alternate
+or supplemental package indices, just like `pip`.
+
+When *unauthenticated*, these indices should work as expected. However, when a third-party
+index requires authentication, `pip-audit` has a few additional restrictions on top of
+ordinary `pip`:
+
+* Interactive authentication is **not** supported. In other words: `pip-audit` will **not**
+  prompt you for a username/password for the index.
+* [`pip`'s `keyring` authentication](https://pip.pypa.io/en/stable/topics/authentication/#keyring-support)
+  **is** supported, but in a limited fashion: `pip-audit` uses the `subprocess` keyring provider,
+  since audits happen in isolated virtual environments. The `subprocess` provider in turn
+  is subject to additional restrictions (such as a required username);
+  [`pip`'s documentation](https://pip.pypa.io/en/stable/topics/authentication/#using-keyring-as-a-command-line-application)
+  explains these in depth.
+
+In addition to the above, some third-party indices have required, hard-coded usernames.
+For example, for Google Artifact registry, the hard-coded username is `oauth2accesstoken`.
+See [#742](https://github.com/pypa/pip-audit/issues/742) and
+[pip#11971](https://github.com/pypa/pip/issues/11971) for additional context.
+
## Tips and Tricks

### Running against a `pipenv` project
@@ -491,6 +531,7 @@ exitcode="${?}"
See [Exit codes](#exit-codes) for a list of potential codes that need handling.

### Reporting only fixable vulnerabilities
+
In development workflows, you may want to ignore the vulnerabilities that haven't been remediated yet and only investigate them in your release process. `pip-audit` does not support ignoring unfixed vulnerabilities. However, you can export its output in JSON format and externally process it. For example, if you want to exit with a non-zero code only when the detected vulnerabilities have known fix versions, you can process the output using [jq](https://github.com/jqlang/jq) as:

```shell
diff --git pip_audit/__init__.py pip_audit/__init__.py
index 40c2d467..1e94264a 100644
--- pip_audit/__init__.py
+++ pip_audit/__init__.py
@@ -2,4 +2,4 @@
The `pip_audit` APIs.
"""

-__version__ = "2.7.0"
+__version__ = "2.8.0"
diff --git pip_audit/_audit.py pip_audit/_audit.py
index 79df57da..b13cf91c 100644
--- pip_audit/_audit.py
+++ pip_audit/_audit.py
@@ -1,11 +1,12 @@
"""
Core auditing APIs.
"""
+
from __future__ import annotations

import logging
+from collections.abc import Iterator
from dataclasses import dataclass
-from typing import Iterator

from pip_audit._dependency_source import DependencySource
from pip_audit._service import Dependency, VulnerabilityResult, VulnerabilityService
@@ -62,7 +63,7 @@ def audit(
        if self._options.dry_run:
            # Drain the iterator in dry-run mode.
            logger.info(f"Dry run: would have audited {len(list(specs))} packages")
-            return {}
+            yield from ()
        else:
            for dep, vulns in self._service.query_all(specs):
                unique_vulns: list[VulnerabilityResult] = []
diff --git pip_audit/_cache.py pip_audit/_cache.py
index 41d6e462..cef93e65 100644
--- pip_audit/_cache.py
+++ pip_audit/_cache.py
@@ -6,6 +6,7 @@

import logging
import os
+import shutil
import subprocess
import sys
from pathlib import Path
@@ -17,6 +18,7 @@
from cachecontrol import CacheControl
from cachecontrol.caches import FileCache
from packaging.version import Version
+from platformdirs import user_cache_path

from pip_audit._service.interface import ServiceError

@@ -28,7 +30,7 @@

_PIP_VERSION = Version(str(pip_api.PIP_VERSION))

-_PIP_AUDIT_INTERNAL_CACHE = Path.home() / ".pip-audit-cache"
+_PIP_AUDIT_LEGACY_INTERNAL_CACHE = Path.home() / ".pip-audit-cache"


def _get_pip_cache() -> Path:
@@ -60,6 +62,16 @@ def _get_cache_dir(custom_cache_dir: Path | None, *, use_pip: bool = True) -> Pa
    if custom_cache_dir is not None:
        return custom_cache_dir

+    # Retrieve pip-audit's default internal cache using `platformdirs`.
+    pip_audit_cache_dir = user_cache_path("pip-audit", appauthor=False, ensure_exists=True)
+
+    # If the retrieved cache isn't the legacy one, try to delete the old cache if it exists.
+    if (
+        _PIP_AUDIT_LEGACY_INTERNAL_CACHE.exists()
+        and pip_audit_cache_dir != _PIP_AUDIT_LEGACY_INTERNAL_CACHE
+    ):
+        shutil.rmtree(_PIP_AUDIT_LEGACY_INTERNAL_CACHE)
+
    # Respect pip's PIP_NO_CACHE_DIR environment setting.
    if use_pip and not os.getenv("PIP_NO_CACHE_DIR"):
        pip_cache_dir = _get_pip_cache() if _PIP_VERSION >= _MINIMUM_PIP_VERSION else None
@@ -68,11 +80,11 @@ def _get_cache_dir(custom_cache_dir: Path | None, *, use_pip: bool = True) -> Pa
        else:
            logger.warning(
                f"pip {_PIP_VERSION} doesn't support the `cache dir` subcommand, "
-                f"using {_PIP_AUDIT_INTERNAL_CACHE} instead"
+                f"using {pip_audit_cache_dir} instead"
            )
-            return _PIP_AUDIT_INTERNAL_CACHE
+            return pip_audit_cache_dir
    else:
-        return _PIP_AUDIT_INTERNAL_CACHE
+        return pip_audit_cache_dir


class _SafeFileCache(FileCache):
diff --git pip_audit/_cli.py pip_audit/_cli.py
index 39a6b500..0c6f9f11 100644
--- pip_audit/_cli.py
+++ pip_audit/_cli.py
@@ -9,9 +9,10 @@
import logging
import os
import sys
+from collections.abc import Iterator
from contextlib import ExitStack, contextmanager
from pathlib import Path
-from typing import IO, Iterator, NoReturn, cast
+from typing import IO, NoReturn, cast

from pip_audit import __version__
from pip_audit._audit import AuditOptions, Auditor
@@ -208,7 +209,7 @@ def _parser() -> argparse.ArgumentParser:  # pragma: no cover
    dep_source_args.add_argument(
        "-r",
        "--requirement",
-        type=argparse.FileType("r"),
+        type=Path,
        metavar="REQUIREMENT",
        action="append",
        dest="requirements",
@@ -225,7 +226,7 @@ def _parser() -> argparse.ArgumentParser:  # pragma: no cover
        "--format",
        type=OutputFormatChoice,
        choices=OutputFormatChoice,
-        default=OutputFormatChoice.Columns,
+        default=os.environ.get("PIP_AUDIT_FORMAT", OutputFormatChoice.Columns),
        metavar="FORMAT",
        help=_enum_help("the format to emit audit results in", OutputFormatChoice),
    )
@@ -234,7 +235,7 @@ def _parser() -> argparse.ArgumentParser:  # pragma: no cover
        "--vulnerability-service",
        type=VulnerabilityServiceChoice,
        choices=VulnerabilityServiceChoice,
-        default=VulnerabilityServiceChoice.Pypi,
+        default=os.environ.get("PIP_AUDIT_VULNERABILITY_SERVICE", VulnerabilityServiceChoice.Pypi),
        metavar="SERVICE",
        help=_enum_help(
            "the vulnerability service to audit dependencies against",
@@ -260,7 +261,7 @@ def _parser() -> argparse.ArgumentParser:  # pragma: no cover
        choices=VulnerabilityDescriptionChoice,
        nargs="?",
        const=VulnerabilityDescriptionChoice.On,
-        default=VulnerabilityDescriptionChoice.Auto,
+        default=os.environ.get("PIP_AUDIT_DESC", VulnerabilityDescriptionChoice.Auto),
        help="include a description for each vulnerability; "
        "`auto` defaults to `on` for the `json` format. This flag has no "
        "effect on the `cyclonedx-json` or `cyclonedx-xml` formats.",
@@ -285,7 +286,7 @@ def _parser() -> argparse.ArgumentParser:  # pragma: no cover
        "--progress-spinner",
        type=ProgressSpinnerChoice,
        choices=ProgressSpinnerChoice,
-        default=ProgressSpinnerChoice.On,
+        default=os.environ.get("PIP_AUDIT_PROGRESS_SPINNER", ProgressSpinnerChoice.On),
        help="display a progress spinner",
    )
    parser.add_argument(
@@ -355,7 +356,7 @@ def _parser() -> argparse.ArgumentParser:  # pragma: no cover
        type=Path,
        metavar="FILE",
        help="output results to the given file",
-        default="stdout",
+        default=os.environ.get("PIP_AUDIT_OUTPUT", "stdout"),
    )
    parser.add_argument(
        "--ignore-vuln",
@@ -464,9 +465,12 @@ def audit() -> None:  # pragma: no cover

        source: DependencySource
        if args.requirements is not None:
-            req_files: list[Path] = [Path(req.name) for req in args.requirements]
+            for req in args.requirements:
+                if not req.exists():
+                    _fatal(f"invalid requirements input: {req}")
+
            source = RequirementSource(
-                req_files,
+                args.requirements,
                require_hashes=args.require_hashes,
                no_deps=args.no_deps,
                disable_pip=args.disable_pip,
@@ -553,9 +557,7 @@ def audit() -> None:  # pragma: no cover
                        )
                    else:
                        fix = cast(ResolvedFixVersion, fix)
-                        logger.info(
-                            f"Dry run: would have upgraded {fix.dep.name} to " f"{fix.version}"
-                        )
+                        logger.info(f"Dry run: would have upgraded {fix.dep.name} to {fix.version}")
                    continue

                if not fix.is_skipped():
@@ -571,11 +573,15 @@ def audit() -> None:  # pragma: no cover
                fixes.append(fix)

    if vuln_count > 0:
+        if vuln_ignore_count:
+            ignored = f", ignored {vuln_ignore_count}"
+        else:
+            ignored = ""
+
        summary_msg = (
            f"Found {vuln_count} known "
            f"{'vulnerability' if vuln_count == 1 else 'vulnerabilities'}"
-            f"{(vuln_ignore_count and ', ignored %d ' % vuln_ignore_count) or ' '}"
-            f"in {pkg_count} {'package' if pkg_count == 1 else 'packages'}"
+            f"{ignored} in {pkg_count} {'package' if pkg_count == 1 else 'packages'}"
        )
        if args.fix:
            summary_msg += (
diff --git pip_audit/_dependency_source/interface.py pip_audit/_dependency_source/interface.py
index 0c888726..9f07739a 100644
--- pip_audit/_dependency_source/interface.py
+++ pip_audit/_dependency_source/interface.py
@@ -2,10 +2,11 @@
Interfaces for interacting with "dependency sources", i.e. sources
of fully resolved Python dependency trees.
"""
+
from __future__ import annotations

from abc import ABC, abstractmethod
-from typing import Iterator
+from collections.abc import Iterator

from pip_audit._fix import ResolvedFixVersion
from pip_audit._service import Dependency
diff --git pip_audit/_dependency_source/pip.py pip_audit/_dependency_source/pip.py
index 52667547..4404732f 100644
--- pip_audit/_dependency_source/pip.py
+++ pip_audit/_dependency_source/pip.py
@@ -7,8 +7,8 @@
import os
import subprocess
import sys
+from collections.abc import Iterator, Sequence
from pathlib import Path
-from typing import Iterator, Sequence

import pip_api
from packaging.version import InvalidVersion, Version
diff --git pip_audit/_dependency_source/pyproject.py pip_audit/_dependency_source/pyproject.py
index 03fdb6d3..a3e7313f 100644
--- pip_audit/_dependency_source/pyproject.py
+++ pip_audit/_dependency_source/pyproject.py
@@ -1,13 +1,14 @@
"""
Collect dependencies from `pyproject.toml` files.
"""
+
from __future__ import annotations

import logging
import os
+from collections.abc import Iterator
from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
-from typing import Iterator

import toml
from packaging.requirements import Requirement
@@ -80,9 +81,10 @@ def collect(self) -> Iterator[Dependency]:
            # dependency resolution now, we can think about doing `pip install <local-project-dir>`
            # regardless of whether the project has a `pyproject.toml` or not. And if it doesn't
            # have a `pyproject.toml`, we can raise an error if the user provides `--fix`.
-            with TemporaryDirectory() as ve_dir, NamedTemporaryFile(
-                dir=ve_dir, delete=False
-            ) as req_file:
+            with (
+                TemporaryDirectory() as ve_dir,
+                NamedTemporaryFile(dir=ve_dir, delete=False) as req_file,
+            ):
                # We use delete=False in creating the tempfile to allow it to be
                # closed and opened multiple times within the context scope on
                # windows, see GitHub issue #646.
@@ -141,8 +143,8 @@ def fix(self, fix_version: ResolvedFixVersion) -> None:
            # Now dump the new edited TOML to the temporary file.
            toml.dump(pyproject_data, tmp)

-            # And replace the original `pyproject.toml` file.
-            os.replace(tmp.name, self.filename)
+        # And replace the original `pyproject.toml` file.
+        os.replace(tmp.name, self.filename)


class PyProjectSourceError(DependencySourceError):
diff --git pip_audit/_dependency_source/requirement.py pip_audit/_dependency_source/requirement.py
index 902a530c..96d3f4b5 100644
--- pip_audit/_dependency_source/requirement.py
+++ pip_audit/_dependency_source/requirement.py
@@ -5,13 +5,13 @@
from __future__ import annotations

import logging
-import os
import re
import shutil
+from collections.abc import Iterator
from contextlib import ExitStack
from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
-from typing import IO, Iterator
+from typing import IO

from packaging.specifiers import SpecifierSet
from packaging.utils import canonicalize_name
@@ -193,7 +193,7 @@ def fix(self, fix_version: ResolvedFixVersion) -> None:
            # Make temporary copies of the existing requirements files. If anything goes wrong, we
            # want to copy them back into place and undo any partial application of the fix.
            tmp_files: list[IO[str]] = [
-                stack.enter_context(NamedTemporaryFile(mode="w")) for _ in self._filenames
+                stack.enter_context(NamedTemporaryFile(mode="r+")) for _ in self._filenames
            ]
            for filename, tmp_file in zip(self._filenames, tmp_files):
                with filename.open("r") as f:
@@ -220,23 +220,28 @@ def _fix_file(self, filename: Path, fix_version: ResolvedFixVersion) -> None:
        #
        # This time we're using the `RequirementsFile.parse` API instead of `Requirements.from_file`
        # since we want to access each line sequentially in order to rewrite the file.
-        reqs = list(RequirementsFile.parse(filename=str(filename)))
+        reqs = list(RequirementsFile.parse(filename=filename.as_posix()))

        # Check ahead of time for anything invalid in the requirements file since we don't want to
        # encounter this while writing out the file. Check for duplicate requirements and lines that
        # failed to parse.
-        req_names: set[str] = set()
+        req_specifiers: dict[str, SpecifierSet] = dict()
+
        for req in reqs:
            if (
                isinstance(req, InstallRequirement)
                and (req.marker is None or req.marker.evaluate())
                and req.req is not None
            ):
-                if req.name in req_names:
+                duplicate_req_specifier = req_specifiers.get(req.name)
+
+                if not duplicate_req_specifier:
+                    req_specifiers[req.name] = req.specifier
+
+                elif duplicate_req_specifier != req.specifier:
                    raise RequirementFixError(
                        f"package {req.name} has duplicate requirements: {str(req)}"
                    )
-                req_names.add(req.name)
            elif isinstance(req, InvalidRequirementLine):
                raise RequirementFixError(
                    f"requirement file {filename} has invalid requirement: {str(req)}"
@@ -278,10 +283,9 @@ def _fix_file(self, filename: Path, fix_version: ResolvedFixVersion) -> None:
    def _recover_files(self, tmp_files: list[IO[str]]) -> None:
        for filename, tmp_file in zip(self._filenames, tmp_files):
            try:
-                os.replace(tmp_file.name, filename)
-                # We need to tinker with the internals to prevent the file wrapper from attempting
-                # to remove the temporary file like in the regular case.
-                tmp_file._closer.delete = False  # type: ignore[attr-defined]
+                tmp_file.seek(0)
+                with filename.open("w") as f:
+                    shutil.copyfileobj(tmp_file, f)
            except Exception as e:
                # Not much we can do at this point since we're already handling an exception. Just
                # log the error and try to recover the rest of the files.
@@ -294,7 +298,7 @@ def _collect_preresolved_deps(
        """
        Collect pre-resolved (pinned) dependencies.
        """
-        req_names: set[str] = set()
+        req_specifiers: dict[str, SpecifierSet] = dict()
        for req in reqs:
            if not req.hash_options and require_hashes:
                raise RequirementSourceError(f"requirement {req.dumps()} does not contain a hash")
@@ -313,14 +317,25 @@ def _collect_preresolved_deps(
            if self._skip_editable and req.is_editable:
                yield SkippedDependency(name=req.name, skip_reason="requirement marked as editable")
            if req.marker is not None and not req.marker.evaluate():
+                # TODO(ww): Remove this `no cover` pragma once we're 3.10+.
+                # See: https://github.com/nedbat/coveragepy/issues/198
                continue  # pragma: no cover

-            # This means we have a duplicate requirement for the same package
-            if req.name in req_names:
+            duplicate_req_specifier = req_specifiers.get(req.name)
+
+            if not duplicate_req_specifier:
+                req_specifiers[req.name] = req.specifier
+
+            # We have a duplicate requirement for the same package
+            # but different specifiers, meaning a badly resolved requirements.txt
+            elif duplicate_req_specifier != req.specifier:
                raise RequirementSourceError(
                    f"package {req.name} has duplicate requirements: {str(req)}"
                )
-            req_names.add(req.name)
+            else:
+                # We have a duplicate requirement for the same package and the specifier matches
+                # As they would return the same result from the audit, there no need to yield it a second time.
+                continue  # pragma: no cover

            # NOTE: URL dependencies cannot be pinned, so skipping them
            # makes sense (under the same principle of skipping dependencies
diff --git pip_audit/_fix.py pip_audit/_fix.py
index a7f568fc..3ae19ed5 100644
--- pip_audit/_fix.py
+++ pip_audit/_fix.py
@@ -1,11 +1,13 @@
"""
Functionality for resolving fixed versions of dependencies.
"""
+
from __future__ import annotations

import logging
+from collections.abc import Iterator
from dataclasses import dataclass
-from typing import Any, Iterator, cast
+from typing import Any, cast

from packaging.version import Version

diff --git pip_audit/_format/columns.py pip_audit/_format/columns.py
index 988fc35d..262a2f08 100644
--- pip_audit/_format/columns.py
+++ pip_audit/_format/columns.py
@@ -4,8 +4,9 @@

from __future__ import annotations

+from collections.abc import Iterable
from itertools import zip_longest
-from typing import Any, Iterable, cast
+from typing import Any, cast

from packaging.version import Version

diff --git pip_audit/_format/cyclonedx.py pip_audit/_format/cyclonedx.py
index c60fcaf5..3e67ff9b 100644
--- pip_audit/_format/cyclonedx.py
+++ pip_audit/_format/cyclonedx.py
@@ -1,6 +1,7 @@
"""
Functionality for formatting vulnerability results using the CycloneDX SBOM format.
"""
+
from __future__ import annotations

import enum
diff --git pip_audit/_format/interface.py pip_audit/_format/interface.py
index 85e11df2..baea436c 100644
--- pip_audit/_format/interface.py
+++ pip_audit/_format/interface.py
@@ -1,6 +1,7 @@
"""
Interfaces for formatting vulnerability results into a string representation.
"""
+
from __future__ import annotations

from abc import ABC, abstractmethod
diff --git pip_audit/_format/json.py pip_audit/_format/json.py
index 86d3586f..3c07f85e 100644
--- pip_audit/_format/json.py
+++ pip_audit/_format/json.py
@@ -1,6 +1,7 @@
"""
Functionality for formatting vulnerability results as an array of JSON objects.
"""
+
from __future__ import annotations

import json
diff --git pip_audit/_service/interface.py pip_audit/_service/interface.py
index 6aefcd07..b02942ce 100644
--- pip_audit/_service/interface.py
+++ pip_audit/_service/interface.py
@@ -6,9 +6,10 @@
from __future__ import annotations

from abc import ABC, abstractmethod
+from collections.abc import Iterator
from dataclasses import dataclass, replace
from datetime import datetime
-from typing import Any, Iterator, NewType
+from typing import Any, NewType

from packaging.utils import canonicalize_name
from packaging.version import Version
diff --git pip_audit/_service/osv.py pip_audit/_service/osv.py
index f59e6d61..8b0fca28 100644
--- pip_audit/_service/osv.py
+++ pip_audit/_service/osv.py
@@ -1,6 +1,7 @@
"""
Functionality for using the [OSV](https://osv.dev/) API as a `VulnerabilityService`.
"""
+
from __future__ import annotations

import json
diff --git pip_audit/_service/pypi.py pip_audit/_service/pypi.py
index 87b25d37..ff1617aa 100644
--- pip_audit/_service/pypi.py
+++ pip_audit/_service/pypi.py
@@ -2,6 +2,7 @@
Functionality for using the [PyPI](https://warehouse.pypa.io/api-reference/json.html)
API as a `VulnerabilityService`.
"""
+
from __future__ import annotations

import logging
@@ -103,7 +104,7 @@ def query(self, spec: Dependency) -> tuple[Dependency, list[VulnerabilityResult]
            try:
                fix_versions = [Version(fixed_in) for fixed_in in v["fixed_in"]]
            except InvalidVersion as iv:
-                raise ServiceError(f'Received malformed version from PyPI: {v["fixed_in"]}') from iv
+                raise ServiceError(f"Received malformed version from PyPI: {v['fixed_in']}") from iv

            # The ranges aren't guaranteed to come in chronological order
            fix_versions.sort()
diff --git pip_audit/_state.py pip_audit/_state.py
index 46caf707..abd5ae81 100644
--- pip_audit/_state.py
+++ pip_audit/_state.py
@@ -2,12 +2,14 @@
Interfaces for for propagating feedback from the API to provide responsive progress indicators as
well as a progress spinner implementation for use with CLI applications.
"""
+
from __future__ import annotations

import logging
from abc import ABC, abstractmethod
+from collections.abc import Sequence
from logging.handlers import MemoryHandler
-from typing import Any, Sequence
+from typing import Any

from rich.align import StyleType
from rich.console import Console, Group, RenderableType
diff --git pip_audit/_subprocess.py pip_audit/_subprocess.py
index 72bd20de..504f6d48 100644
--- pip_audit/_subprocess.py
+++ pip_audit/_subprocess.py
@@ -5,8 +5,8 @@

import os.path
import subprocess
+from collections.abc import Sequence
from subprocess import Popen
-from typing import Sequence

from ._state import AuditState

@@ -34,10 +34,6 @@ def run(args: Sequence[str], *, log_stdout: bool = False, state: AuditState = Au
    the process's `stdout` stream as a string.
    """

-    # Run the process with unbuffered I/O, to make the poll-and-read loop below
-    # more responsive.
-    process = Popen(args, bufsize=0, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-
    # NOTE(ww): We frequently run commands inside of ephemeral virtual environments,
    # which have long absolute paths on some platforms. These make for confusing
    # state updates, so we trim the first argument down to its basename.
@@ -47,23 +43,25 @@ def run(args: Sequence[str], *, log_stdout: bool = False, state: AuditState = Au
    stdout = b""
    stderr = b""

-    # NOTE: We use `poll()` to control this loop instead of the `read()` call
-    # to prevent deadlocks. Similarly, `read(size)` will return an empty bytes
-    # once `stdout` hits EOF, so we don't have to worry about that blocking.
-    while not terminated:
-        terminated = process.poll() is not None
-        # NOTE(ww): Buffer size chosen arbitrarily here and below.
-        stdout += process.stdout.read(4096)  # type: ignore
-        stderr += process.stderr.read(4096)  # type: ignore
-        state.update_state(
-            f"Running {pretty_args}",
-            stdout.decode(errors="replace") if log_stdout else None,
-        )
+    # Run the process with unbuffered I/O, to make the poll-and-read loop below
+    # more responsive.
+    with Popen(args, bufsize=0, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as process:
+        # NOTE: We use `poll()` to control this loop instead of the `read()` call
+        # to prevent deadlocks. Similarly, `read(size)` will return an empty bytes
+        # once `stdout` hits EOF, so we don't have to worry about that blocking.
+        while not terminated:
+            terminated = process.poll() is not None
+            stdout += process.stdout.read()  # type: ignore
+            stderr += process.stderr.read()  # type: ignore
+            state.update_state(
+                f"Running {pretty_args}",
+                stdout.decode(errors="replace") if log_stdout else None,
+            )

-    if process.returncode != 0:
-        raise CalledProcessError(
-            f"{pretty_args} exited with {process.returncode}",
-            stderr=stderr.decode(errors="replace"),
-        )
+        if process.returncode != 0:
+            raise CalledProcessError(
+                f"{pretty_args} exited with {process.returncode}",
+                stderr=stderr.decode(errors="replace"),
+            )

    return stdout.decode("utf-8", errors="replace")
diff --git pip_audit/_virtual_env.py pip_audit/_virtual_env.py
index acee66ad..c59ae530 100644
--- pip_audit/_virtual_env.py
+++ pip_audit/_virtual_env.py
@@ -7,9 +7,10 @@
import json
import logging
import venv
-from tempfile import NamedTemporaryFile, TemporaryDirectory
+from collections.abc import Iterator
+from os import PathLike
+from tempfile import NamedTemporaryFile, TemporaryDirectory, gettempdir
from types import SimpleNamespace
-from typing import Iterator

from packaging.version import Version

@@ -70,6 +71,32 @@ def __init__(
        self._packages: list[tuple[str, Version]] | None = None
        self._state = state

+    def create(self, env_dir: str | bytes | PathLike[str] | PathLike[bytes]) -> None:
+        """
+        Creates the virtual environment.
+        """
+
+        try:
+            return super().create(env_dir)
+        except PermissionError:
+            # `venv` uses a subprocess internally to bootstrap pip, but
+            # some Linux distributions choose to mark the system temporary
+            # directory as `noexec`. Apart from having only nominal security
+            # benefits, this completely breaks our ability to execute from
+            # within the temporary virtualenv.
+            #
+            # We may be able to hack around this in the future, but doing so
+            # isn't straightforward or reliable. So we bail for now.
+            #
+            # See: https://github.com/pypa/pip-audit/issues/732
+            base_tmpdir = gettempdir()
+            raise VirtualEnvError(
+                f"Couldn't execute in a temporary directory under {base_tmpdir}. "
+                "This is sometimes caused by a noexec mount flag or other setting. "
+                "Consider changing this setting or explicitly specifying a different "
+                "temporary directory via the TMPDIR environment variable."
+            )
+
    def post_setup(self, context: SimpleNamespace) -> None:
        """
        Install the custom package and populate the list of installed packages.
@@ -117,13 +144,18 @@ def post_setup(self, context: SimpleNamespace) -> None:

            # Install our packages
            # NOTE(ww): We pass `--no-input` to prevent `pip` from indefinitely
-            # blocking on user input for repository credentials.
+            # blocking on user input for repository credentials, and
+            # `--keyring-provider=subprocess` to allow `pip` to access the `keyring`
+            # program on the `$PATH` for index credentials, if necessary. The latter flag
+            # is required beginning with pip 23.1, since `--no-input` disables the default
+            # keyring behavior.
            package_install_cmd = [
                context.env_exe,
                "-m",
                "pip",
                "install",
                "--no-input",
+                "--keyring-provider=subprocess",
                *self._index_url_args,
                "--dry-run",
                "--report",
diff --git pyproject.toml pyproject.toml
index 8cc44ed3..3d62a943 100644
--- pyproject.toml
+++ pyproject.toml
@@ -19,25 +19,25 @@ classifiers = [
    "License :: OSI Approved :: Apache Software License",
    "Programming Language :: Python :: 3 :: Only",
    "Programming Language :: Python :: 3",
-    "Programming Language :: Python :: 3.8",
    "Programming Language :: Python :: 3.9",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
+    "Programming Language :: Python :: 3.13",
    "Topic :: Security",
]
dependencies = [
    "CacheControl[filecache] >= 0.13.0",
-    "cyclonedx-python-lib >= 5,< 7",
-    "html5lib>=1.1",
+    "cyclonedx-python-lib >= 5,< 9",
    "packaging>=23.0.0",                 # https://github.com/pypa/pip-audit/issues/464
    "pip-api>=0.0.28",
    "pip-requirements-parser>=32.0.0",
    "requests >= 2.31.0",
    "rich>=12.4",
    "toml>=0.10",
+    "platformdirs>=4.2.0",
]
-requires-python = ">=3.8"
+requires-python = ">=3.9"

[project.optional-dependencies]
test = [
@@ -47,17 +47,14 @@ test = [
    "pytest-cov",
]
lint = [
-    # NOTE(ww): ruff is under active development, so we pin conservatively here
-    # and let Dependabot periodically perform this update.
-    "ruff < 0.1.12",
-    "interrogate",
+    "ruff ~= 0.9",
+    "interrogate ~= 1.6",
    "mypy",
-    "types-html5lib",
    "types-requests",
    "types-toml",
]
doc = ["pdoc"]
-dev = ["build", "bump>=1.3.2", "pip-audit[doc,test,lint]"]
+dev = ["build", "pip-audit[doc,test,lint]"]

[project.scripts]
pip-audit = "pip_audit._cli:audit"
@@ -96,7 +93,9 @@ input = "pip_audit/__init__.py"
reset = true

[tool.ruff]
+line-length = 100
+
+[tool.ruff.lint]
# Never enforce `E501` (line length violations).
ignore = ["E501"]
select = ["E", "F", "I", "W", "UP"]
-line-length = 100
diff --git test/conftest.py test/conftest.py
index 2e416b16..9e5c97fd 100644
--- test/conftest.py
+++ test/conftest.py
@@ -76,7 +76,7 @@ def fix(self, _) -> None:
@pytest.fixture(scope="session")
def cache_dir():
    cache = tempfile.TemporaryDirectory()
-    yield cache.name
+    yield Path(cache.name)
    cache.cleanup()


diff --git test/dependency_source/test_requirement.py test/dependency_source/test_requirement.py
index ad8d8ef5..d1a84640 100644
--- test/dependency_source/test_requirement.py
+++ test/dependency_source/test_requirement.py
@@ -1,6 +1,7 @@
from __future__ import annotations

import os
+import sys
from email.message import EmailMessage
from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
@@ -121,13 +122,13 @@ def test_requirement_source_git(req_file):
        [
            (
                req_file(),
-                "git+https://github.com/unbit/uwsgi.git@1bb9ad77c6d2d310c2d6d1d9ad62de61f725b824",
+                "git+https://github.com/pypa/sampleproject.git@5d277956b5a571dac16b28db74e5f2b780d9af5f",
            )
        ]
    )

    specs = list(source.collect())
-    assert ResolvedDependency(name="uWSGI", version=Version("2.0.20")) in specs
+    assert ResolvedDependency(name="sampleproject", version=Version("3.0.0")) in specs


@pytest.mark.online
@@ -375,10 +376,12 @@ def test_requirement_source_fix_rollback_failure(monkeypatch, req_file):
            f.write(input_req)

    # Simulate an error being raised during file recovery
-    def mock_replace(*_args, **_kwargs):
+    def mock_seek(*_args, **_kwargs):
        raise OSError

-    monkeypatch.setattr(os, "replace", mock_replace)
+    from tempfile import _TemporaryFileWrapper
+
+    monkeypatch.setattr(_TemporaryFileWrapper, "seek", mock_seek, raising=False)

    source = requirement.RequirementSource(req_paths)
    with pytest.raises(DependencyFixError):
@@ -523,6 +526,30 @@ def test_requirement_source_disable_pip_duplicate_dependencies(req_file):
        [(req_file(), "flask==1.0\nflask==1.0")], disable_pip=True, no_deps=True
    )

+    specs = list(source.collect())
+
+    # If the dependency list has duplicates, then converting to a set will reduce the length of the
+    # collection
+    assert len(specs) == len(set(specs))
+
+
+def test_requirement_source_disable_pip_duplicate_dependencies_with_extras(req_file):
+    source = _init_requirement(
+        [(req_file(), "aiohttp==3.9\naiohttp[speedups]==3.9")], disable_pip=True, no_deps=True
+    )
+
+    specs = list(source.collect())
+
+    # If the dependency list has duplicates, then converting to a set will reduce the length of the
+    # collection
+    assert len(specs) == len(set(specs))
+
+
+def test_requirement_source_disable_pip_duplicate_dependencies_diff_specifier(req_file):
+    source = _init_requirement(
+        [(req_file(), "flask==1.0\nflask==2.0")], disable_pip=True, no_deps=True
+    )
+
    with pytest.raises(DependencySourceError):
        list(source.collect())

@@ -559,6 +586,7 @@ def virtual_env(*args, **kwargs):
    assert ResolvedDependency("Flask", Version("2.0.1")) in specs


+@pytest.mark.skipif(sys.platform == "win32", reason="os.mkfifo does not exists on windows")
def test_requirement_source_fifo():
    with TemporaryDirectory() as tmp_dir:
        fifo_path = Path(os.path.join(tmp_dir, "fifo"))
diff --git test/test_cache.py test/test_cache.py
index d6c80190..de2c52bf 100644
--- test/test_cache.py
+++ test/test_cache.py
@@ -1,23 +1,40 @@
+import importlib
+import sys
from pathlib import Path

+import platformdirs
import pretend  # type: ignore
+import pytest
from packaging.version import Version
+from pytest import MonkeyPatch

import pip_audit._cache as cache
from pip_audit._cache import _get_cache_dir, _get_pip_cache


+def _patch_platformdirs(monkeypatch: MonkeyPatch, sys_platform: str) -> None:
+    """Utility function to patch `platformdirs` in order to test cross-platforms."""
+    # Mocking OS host
+    monkeypatch.setattr(sys, "platform", sys_platform)
+    # We are forced to reload `platformdirs` to get the correct cache directory
+    # as cache definition is stored in the top level `__init__.py` file of the
+    # `platformdirs` package
+    importlib.reload(platformdirs)
+    if sys_platform == "win32":
+        monkeypatch.setenv("LOCALAPPDATA", "/tmp/AppData/Local")
+
+
def test_get_cache_dir(monkeypatch):
    # When we supply a cache directory, always use that
    cache_dir = _get_cache_dir(Path("/tmp/foo/cache_dir"))
-    assert str(cache_dir) == "/tmp/foo/cache_dir"
+    assert cache_dir.as_posix() == "/tmp/foo/cache_dir"

-    get_pip_cache = pretend.call_recorder(lambda: "/fake/pip/cache/dir")
+    get_pip_cache = pretend.call_recorder(lambda: Path("/fake/pip/cache/dir"))
    monkeypatch.setattr(cache, "_get_pip_cache", get_pip_cache)

    # When `pip cache dir` works, we use it. In this case, it's mocked.
    cache_dir = _get_cache_dir(None, use_pip=True)
-    assert str(cache_dir) == "/fake/pip/cache/dir"
+    assert cache_dir.as_posix() == "/fake/pip/cache/dir"


def test_get_pip_cache():
@@ -26,31 +43,97 @@ def test_get_pip_cache():
    assert cache_dir.stem == "http"


-def test_get_cache_dir_do_not_use_pip():
+@pytest.mark.parametrize(
+    "sys_platform,expected",
+    [
+        pytest.param(
+            "linux",
+            Path.home() / ".cache" / "pip-audit",
+            id="on Linux",
+        ),
+        pytest.param(
+            "win32",
+            Path("/tmp") / "AppData" / "Local" / "pip-audit" / "Cache",
+            id="on Windows",
+        ),
+        pytest.param(
+            "darwin",
+            Path.home() / "Library" / "Caches" / "pip-audit",
+            id="on MacOS",
+        ),
+    ],
+)
+def test_get_cache_dir_do_not_use_pip(monkeypatch, sys_platform, expected):
+    # Check cross-platforms
+    _patch_platformdirs(monkeypatch, sys_platform)
    # Even with None, we never use the pip cache if we're told not to.
    cache_dir = _get_cache_dir(None, use_pip=False)
-    assert cache_dir == Path.home() / ".pip-audit-cache"
-
-
-def test_get_cache_dir_pip_disabled_in_environment(monkeypatch):
+    assert cache_dir == expected
+
+
+@pytest.mark.parametrize(
+    "sys_platform,expected",
+    [
+        pytest.param(
+            "linux",
+            Path.home() / ".cache" / "pip-audit",
+            id="on Linux",
+        ),
+        pytest.param(
+            "win32",
+            Path("/tmp") / "AppData" / "Local" / "pip-audit" / "Cache",
+            id="on Windows",
+        ),
+        pytest.param(
+            "darwin",
+            Path.home() / "Library" / "Caches" / "pip-audit",
+            id="on MacOS",
+        ),
+    ],
+)
+def test_get_cache_dir_pip_disabled_in_environment(monkeypatch, sys_platform, expected):
    monkeypatch.setenv("PIP_NO_CACHE_DIR", "1")
+    # Check cross-platforms
+    _patch_platformdirs(monkeypatch, sys_platform)

    # Even with use_pip=True, we avoid pip's cache if the environment tells us to.
-    assert _get_cache_dir(None, use_pip=True) == Path.home() / ".pip-audit-cache"
-
-
-def test_get_cache_dir_old_pip(monkeypatch):
+    assert _get_cache_dir(None, use_pip=True) == expected
+
+
+@pytest.mark.parametrize(
+    "sys_platform,expected",
+    [
+        pytest.param(
+            "linux",
+            Path.home() / ".cache" / "pip-audit",
+            id="on Linux",
+        ),
+        pytest.param(
+            "win32",
+            Path("/tmp") / "AppData" / "Local" / "pip-audit" / "Cache",
+            id="on Windows",
+        ),
+        pytest.param(
+            "darwin",
+            Path.home() / "Library" / "Caches" / "pip-audit",
+            id="on MacOS",
+        ),
+    ],
+)
+def test_get_cache_dir_old_pip(monkeypatch, sys_platform, expected):
    # Check the case where we have an old `pip`
    monkeypatch.setattr(cache, "_PIP_VERSION", Version("1.0.0"))
+    # Check cross-platforms
+    _patch_platformdirs(monkeypatch, sys_platform)

    # When we supply a cache directory, always use that
    cache_dir = _get_cache_dir(Path("/tmp/foo/cache_dir"))
-    assert str(cache_dir) == "/tmp/foo/cache_dir"
+    assert cache_dir.as_posix() == "/tmp/foo/cache_dir"

    # In this case, we can't query `pip` to figure out where its HTTP cache is
    # Instead, we use `~/.pip-audit-cache`
    cache_dir = _get_cache_dir(None)
-    assert cache_dir == Path.home() / ".pip-audit-cache"
+    assert cache_dir == expected


def test_cache_warns_about_old_pip(monkeypatch, cache_dir):
@@ -67,3 +150,13 @@ def test_cache_warns_about_old_pip(monkeypatch, cache_dir):
    # have an old `pip`, then we should expect a warning to be logged
    _get_cache_dir(None)
    assert len(logger.warning.calls) == 1
+
+
+def test_delete_legacy_cache_dir(monkeypatch, tmp_path):
+    legacy = tmp_path / "pip-audit-cache"
+    legacy.mkdir()
+    assert legacy.exists()
+    monkeypatch.setattr(cache, "_PIP_AUDIT_LEGACY_INTERNAL_CACHE", legacy)
+
+    _get_cache_dir(None, use_pip=False)
+    assert not legacy.exists()
diff --git test/test_cli.py test/test_cli.py
index b6ca5a78..6943412f 100644
--- test/test_cli.py
+++ test/test_cli.py
@@ -1,3 +1,5 @@
+from pathlib import Path
+
import pretend  # type: ignore
import pytest

@@ -25,9 +27,9 @@ def test_str(self):


class TestVulnerabilityServiceChoice:
-    def test_to_service_is_exhaustive(self):
+    def test_to_service_is_exhaustive(self, cache_dir):
        for choice in VulnerabilityServiceChoice:
-            assert choice.to_service(0, pretend.stub()) is not None
+            assert choice.to_service(0, cache_dir) is not None

    def test_str(self):
        for choice in VulnerabilityServiceChoice:
@@ -98,7 +100,7 @@ def test_plurals(capsys, monkeypatch, args, vuln_count, pkg_count, expected):
    monkeypatch.setattr(pip_audit._cli, "PipSource", lambda *a, **kw: dummysource)

    parser = pip_audit._cli._parser()
-    monkeypatch.setattr(pip_audit._cli, "_parse_args", lambda x: parser.parse_args(args))
+    monkeypatch.setattr(pip_audit._cli, "_parse_args", lambda *a: parser.parse_args(args))

    result = [
        (
@@ -165,7 +167,7 @@ def test_print_format(monkeypatch, vuln_count, pkg_count, skip_count, print_form
    monkeypatch.setattr(pip_audit._cli, "ColumnsFormat", lambda *a, **kw: dummyformat)

    parser = pip_audit._cli._parser()
-    monkeypatch.setattr(pip_audit._cli, "_parse_args", lam,bda x: parser.parse_args([]))
+    monkeypatch.setattr(pip_audit._cli, "_parse_args", lambda *a: parser.parse_args([]))

    result = [
        (
@@ -215,3 +217,22 @@ def test_print_format(monkeypatch, vuln_count, pkg_count, skip_count, print_form
        pass

    assert bool(dummyformat.format.calls) == print_format
+
+
+def test_environment_variable(monkeypatch):
+    """Environment variables set before execution change CLI option default."""
+    monkeypatch.setenv("PIP_AUDIT_DESC", "off")
+    monkeypatch.setenv("PIP_AUDIT_FORMAT", "markdown")
+    monkeypatch.setenv("PIP_AUDIT_OUTPUT", "/tmp/fake")
+    monkeypatch.setenv("PIP_AUDIT_PROGRESS_SPINNER", "off")
+    monkeypatch.setenv("PIP_AUDIT_VULNERABILITY_SERVICE", "osv")
+
+    parser = pip_audit._cli._parser()
+    monkeypatch.setattr(pip_audit._cli, "_parse_args", lambda *a: parser.parse_args([]))
+    args = pip_audit._cli._parse_args(parser, [])
+
+    assert args.desc == VulnerabilityDescriptionChoice.Off
+    assert args.format == OutputFormatChoice.Markdown
+    assert args.output == Path("/tmp/fake")
+    assert not args.progress_spinner
+    assert args.vulnerability_service == VulnerabilityServiceChoice.Osv
diff --git test/test_virtual_env.py test/test_virtual_env.py
index efc14b56..507e30f9 100644
--- test/test_virtual_env.py
+++ test/test_virtual_env.py
@@ -1,5 +1,7 @@
+import subprocess
from tempfile import TemporaryDirectory

+import pretend
import pytest
from packaging.version import Version

@@ -58,3 +60,17 @@ def run_mock(args, **kwargs):
        ve = VirtualEnv(["flask==2.0.1"])
        with pytest.raises(VirtualEnvError):
            ve.create(ve_dir)
+
+
+def test_virtual_env_failed_permission_error(monkeypatch):
+    """
+    This is a mocked test for GH#732, which is really caused by a user's
+    default `$TMPDIR` having the `noexec` flag set. We have no easy way
+    to unit test this, so we hopefully replicate its effect with a monkeypatch.
+    """
+
+    monkeypatch.setattr(subprocess, "run", pretend.raiser(PermissionError))
+    with TemporaryDirectory() as ve_dir:
+        ve = VirtualEnv(["flask==2.0.1"])
+        with pytest.raises(VirtualEnvError, match=r"^Couldn't execute in a temporary directory .+"):
+            ve.create(ve_dir)


Here's my review of the PR:

Description

This PR includes several significant changes to pip-audit:

  1. Upgrades minimum Python version requirement from 3.8 to 3.9
  2. Adds environment variable support for configuring CLI flags
  3. Improves cache directory handling to follow platform conventions using platformdirs
  4. Fixes file handling and requirement resolution issues
  5. Updates GitHub Actions workflows with pinned versions and better security practices
  6. Updates dependencies and pre-commit hooks

Possible Issues

  • The removal of Python 3.8 support could impact users who are still using that version
  • Legacy cache directory migration may need additional testing for edge cases
  • Platform-specific cache directory changes could cause issues if not properly tested across all platforms

Security Hotspots

  • The updated cache directory handling could potentially expose sensitive data if permissions are not properly set (though this risk is mitigated by following platform conventions)
Changes

Changes

By filename:

  1. .github/workflows/:

    • Updates all action versions to pinned commits
    • Adds minimal permissions model
    • Adds new zizmor workflow for security analysis
    • Removes sigstore signing from release workflow
  2. .github/ISSUE_TEMPLATE/:

    • Converts templates from markdown to YAML format
    • Adds more structured issue submission process
  3. pip_audit/:

    • Updates cache handling to use platformdirs
    • Improves file handling and subprocess management
    • Adds environment variable support for CLI options
    • Fixes requirement resolution bugs
  4. tests/:

    • Adds cross-platform testing for cache directories
    • Improves test coverage for new features
    • Fixes Windows-specific test issues
  5. Project Configuration:

    • Updates Python version requirements
    • Updates dependencies
    • Improves ruff configuration
sequenceDiagram
    participant User
    participant CLI
    participant EnvVars
    participant Cache
    participant VirtualEnv
    participant AuditService

    User->>CLI: Run pip-audit
    CLI->>EnvVars: Check for environment variables
    CLI->>Cache: Initialize cache directory
    Cache->>Cache: Migrate legacy cache (if exists)
    CLI->>VirtualEnv: Create isolated environment
    VirtualEnv->>VirtualEnv: Install dependencies
    VirtualEnv->>AuditService: Query vulnerabilities
    AuditService-->>CLI: Return results
    CLI-->>User: Display formatted output
Loading

@thypon thypon merged commit c63b0cc into main Feb 19, 2025
8 checks passed
@thypon thypon deleted the renovate/pip-audit-2.x branch February 19, 2025 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant