Repository: theupdateframework/python-tuf
Pull request #2805 (2025-03-03 21:48:07)

build(deps): bump the dependencies group with 2 updates
[//]: # (dependabot-start)
⚠️  **Dependabot is rebasing this PR** ⚠️ 

Rebasing might not happen immediately, so don't worry if this takes some time.

Note: if you make any changes to this PR yourself, they will take precedence over the rebase.

---

[//]: # (dependabot-end)

Bumps the dependencies group with 2 updates: [cryptography](https://github.com/pyca/cryptography) and [zizmor](https://github.com/woodruffw/zizmor).

Updates `cryptography` from 44.0.1 to 44.0.2
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst">cryptography's changelog</a>.</em></p>
<blockquote>
<p>44.0.2 - 2025-03-01</p>
<pre><code>
* We now build wheels for PyPy 3.11.
<p>.. _v44-0-1:<br />
</code></pre></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/pyca/cryptography/commit/56cfce682c8bd2ee5101b654a429b05d0f610f0e"><code>56cfce6</code></a> 44.0.2 release (<a href="https://redirect.github.com/pyca/cryptography/issues/12537">#12537</a>)</li>
<li><a href="https://github.com/pyca/cryptography/commit/7b4cc268cb5d95180ab5cfa32d55f6045bb2dcf0"><code>7b4cc26</code></a> Backport PyPy3.11 support (<a href="https://redirect.github.com/pyca/cryptography/issues/12536">#12536</a>)</li>
<li>See full diff in <a href="https://github.com/pyca/cryptography/compare/44.0.1...44.0.2">compare view</a></li>
</ul>
</details>
<br />

Updates `zizmor` from 1.3.1 to 1.4.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/woodruffw/zizmor/releases">zizmor's releases</a>.</em></p>
<blockquote>
<h2>v1.4.1</h2>
<p>This is a small corrective release for v1.4.0.</p>
<h3>Bug Fixes 🐛<a href="https://woodruffw.github.io/zizmor/release-notes/#bug-fixes">🔗</a></h3>
<ul>
<li>Findings produced by (<a href="https://woodruffw.github.io/zizmor/audits/#unredacted-secrets">unredacted-secrets</a>) now use the correct ID and link to the correct URL in the audit documentation (<a href="https://redirect.github.com/woodruffw/zizmor/issues/566">#566</a>)</li>
</ul>
<h2>v1.4.0</h2>
<p>This release comes with one new audit (<a href="https://woodruffw.github.io/zizmor/audits/#unredacted-secrets">unredacted-secrets</a>), plus a handful of bugfixes and analysis improvements to existing audits. It also comes with improvements to SARIF presentation, ignore comments, as well as an <a href="https://ghcr.io/woodruffw/zizmor">official Docker image</a>!</p>
<h3>New Features 🌈<a href="https://woodruffw.github.io/zizmor/release-notes/#new-features">🔗</a></h3>
<ul>
<li><code>zizmor</code> now has official Docker images! You can find them on the GitHub Container Registry under <a href="https://ghcr.io/woodruffw/zizmor">ghcr.io/woodruffw/zizmor</a> (<a href="https://redirect.github.com/woodruffw/zizmor/issues/532">#532</a>)</li>
<li>New audit: <a href="https://woodruffw.github.io/zizmor/audits/#unredacted-secrets">unredacted-secrets</a> detects secret accesses that are not redacted in logs (<a href="https://redirect.github.com/woodruffw/zizmor/issues/549">#549</a>)</li>
</ul>
<h3>Improvements 🌱<a href="https://woodruffw.github.io/zizmor/release-notes/#improvements">🔗</a></h3>
<ul>
<li>SARIF outputs are now slightly more aligned with GitHub Code Scanning expectations (<a href="https://redirect.github.com/woodruffw/zizmor/issues/528">#528</a>)</li>
<li><code># zizmor: ignore[rule]</code> comments can now have trailing explanations, e.g. <code># zizmor: ignore[rule] because reasons</code> (<a href="https://redirect.github.com/woodruffw/zizmor/issues/531">#531</a>)</li>
<li>The <a href="https://woodruffw.github.io/zizmor/audits/#bot-conditions">bot-conditions</a> audit now detects <code>github.triggering_actor</code> as another spoofable actor check (<a href="https://redirect.github.com/woodruffw/zizmor/issues/559">#559</a>)</li>
</ul>
<h3>Bug Fixes 🐛<a href="https://woodruffw.github.io/zizmor/release-notes/#bug-fixes">🔗</a></h3>
<ul>
<li>Fixed a bug where <code>zizmor</code> would fail to parse workflows with <code>workflow_dispatch</code> triggers that contained non-string inputs (<a href="https://redirect.github.com/woodruffw/zizmor/issues/563">#563</a>)</li>
</ul>
<h3>Upcoming Changes 🚧<a href="https://woodruffw.github.io/zizmor/release-notes/#upcoming-changes">🔗</a></h3>
<ul>
<li>The next minor release of <code>zizmor</code> will be built with <a href="https://blog.rust-lang.org/2025/02/20/Rust-1.85.0.html">Rust 2024</a>. This should have no effect on most users, but may require users who build zizmor from source to update their Rust toolchain.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/woodruffw/zizmor/blob/main/docs/release-notes.md">zizmor's changelog</a>.</em></p>
<blockquote>
<h2>v1.4.1</h2>
<p>This is a small corrective release for v1.4.0.</p>
<h3>Bug Fixes 🐛</h3>
<ul>
<li>Findings produced by ([unredacted-secrets]) now use the correct ID and
link to the correct URL in the audit documentation (<a href="https://redirect.github.com/woodruffw/zizmor/issues/566">#566</a>)</li>
</ul>
<h2>v1.4.0</h2>
<p>This release comes with one new audit ([unredacted-secrets]), plus a handful
of bugfixes and analysis improvements to existing audits. It also comes
with improvements to SARIF presentation, ignore comments, as well as an
<a href="https://ghcr.io/woodruffw/zizmor">official Docker image</a>!</p>
<h3>New Features 🌈</h3>
<ul>
<li><code>zizmor</code> now has official Docker images! You can find them on the
GitHub Container Registry under
<a href="https://ghcr.io/woodruffw/zizmor"><code>ghcr.io/woodruffw/zizmor</code></a> (<a href="https://redirect.github.com/woodruffw/zizmor/issues/532">#532</a>)</li>
<li><strong>New audit</strong>: [unredacted-secrets] detects secret accesses that
are not redacted in logs (<a href="https://redirect.github.com/woodruffw/zizmor/issues/549">#549</a>)</li>
</ul>
<h3>Improvements 🌱</h3>
<ul>
<li>SARIF outputs are now slightly more aligned with GitHub Code Scanning
expectations (<a href="https://redirect.github.com/woodruffw/zizmor/issues/528">#528</a>)</li>
<li><code># zizmor: ignore[rule]</code> comments can now have trailing explanations,
e.g. <code># zizmor: ignore[rule] because reasons</code> (<a href="https://redirect.github.com/woodruffw/zizmor/issues/531">#531</a>)</li>
<li>The [bot-conditions] audit now detects <code>github.triggering_actor</code>
as another spoofable actor check (<a href="https://redirect.github.com/woodruffw/zizmor/issues/559">#559</a>)</li>
</ul>
<h3>Bug Fixes 🐛</h3>
<ul>
<li>Fixed a bug where <code>zizmor</code> would fail to parse workflows with
<code>workflow_dispatch</code> triggers that contained non-string inputs
(<a href="https://redirect.github.com/woodruffw/zizmor/issues/563">#563</a>)</li>
</ul>
<h3>Upcoming Changes 🚧</h3>
<ul>
<li>The next minor release of <code>zizmor</code> will be built with
<a href="https://blog.rust-lang.org/2025/02/20/Rust-1.85.0.html">Rust 2024</a>.
This should have no effect on most users, but may require users
who build <code>zizmor</code> from source to update their Rust toolchain.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/woodruffw/zizmor/commit/7c7e415df3e31eb2213787537f9cfb68a020b85d"><code>7c7e415</code></a> chore: prep 1.4.1 release (<a href="https://redirect.github.com/woodruffw/zizmor/issues/568">#568</a>)</li>
<li><a href="https://github.com/woodruffw/zizmor/commit/be5d9e835d27425e44ce234c0eae0caf1ca2fd07"><code>be5d9e8</code></a> docker: use ZIZMOR_VERSION correctly (<a href="https://redirect.github.com/woodruffw/zizmor/issues/567">#567</a>)</li>
<li><a href="https://github.com/woodruffw/zizmor/commit/75035f72b62c411130c671554bbb1077473fd1c1"><code>75035f7</code></a> bugfix: fix audit id for unredacted secrets audit (<a href="https://redirect.github.com/woodruffw/zizmor/issues/566">#566</a>)</li>
<li><a href="https://github.com/woodruffw/zizmor/commit/ff55188bf1720bcc4a6b7e2c4f97dd51db52d88d"><code>ff55188</code></a> chore: prep for 1.4.0 release (<a href="https://redirect.github.com/woodruffw/zizmor/issues/565">#565</a>)</li>
<li><a href="https://github.com/woodruffw/zizmor/commit/63634129efad737c55d83aaf2711ac2d3bcdd297"><code>6363412</code></a> bugfix: bump github-actions-models to 0.26.0 (<a href="https://redirect.github.com/woodruffw/zizmor/issues/563">#563</a>)</li>
<li><a href="https://github.com/woodruffw/zizmor/commit/69284338bf2c45d5cf9d31226a4fe7b8ebe07541"><code>6928433</code></a> chore(deps): bump the github-actions group with 5 updates (<a href="https://redirect.github.com/woodruffw/zizmor/issues/561">#561</a>)</li>
<li><a href="https://github.com/woodruffw/zizmor/commit/8543192ae4796d8b079982191f3764982a3c2f1b"><code>8543192</code></a> chore(deps): bump the cargo group with 8 updates (<a href="https://redirect.github.com/woodruffw/zizmor/issues/560">#560</a>)</li>
<li><a href="https://github.com/woodruffw/zizmor/commit/02d4bace3e1e4b02181fc6c9ee712cc7e2f2d18d"><code>02d4bac</code></a> feat: bot-conditions: check github.triggering_actor (<a href="https://redirect.github.com/woodruffw/zizmor/issues/559">#559</a>)</li>
<li><a href="https://github.com/woodruffw/zizmor/commit/976879fdac4207e6cbf6c198fe2e6daf04cfa451"><code>976879f</code></a> docs: fix release notes for Rust 2024 (<a href="https://redirect.github.com/woodruffw/zizmor/issues/557">#557</a>)</li>
<li><a href="https://github.com/woodruffw/zizmor/commit/92f27a8498f429768dc97a12a2c427c514276f95"><code>92f27a8</code></a> Revert &quot;chore: cargo: actually bump the edition (<a href="https://redirect.github.com/woodruffw/zizmor/issues/553">#553</a>)&quot; (<a href="https://redirect.github.com/woodruffw/zizmor/issues/554">#554</a>)</li>
<li>Additional commits viewable in <a href="https://github.com/woodruffw/zizmor/compare/v1.3.1...v1.4.1">compare view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will remove the ignore condition of the specified dependency and ignore conditions


</details>


Patch:
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index da36a6c7..623fae02 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -54,7 +54,7 @@ jobs:
       release_id: ${{ steps.gh-release.outputs.result }}
     steps:
       - name: Fetch build artifacts
-        uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
+        uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
         with:
           name: build-artifacts
 
@@ -96,7 +96,7 @@ jobs:
       id-token: write # to authenticate as Trusted Publisher to pypi.org
     steps:
       - name: Fetch build artifacts
-        uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
+        uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
         with:
           name: build-artifacts
 
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index a1203eb9..d3ccf453 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,5 +1,21 @@
 # Changelog
 
+## Unreleased
+
+### Changed
+
+This release is API compatible but contains a major internal change in the HTTP handling.
+
+* ngclient: urllib3 is used as the HTTP library by default instead of requests (#2762,
+  #2773, #2789)
+  * This removes dependencies on `requests`, `idna`, `charset-normalizer` and `certifi`
+  * The deprecated RequestsFetcher implementation is available but requires selecting
+    the fetcher at Updater initialization and explicitly depending on requests
+* ngclient: TLS certificate source was changed. Certificates now come from operating
+  system certificate store instead of `certifi` (#2762)
+* Test infrastucture has improved and should now be more usable externally, e.g. in
+  distro test suites (#2749)
+
 ## v5.1.0
 
 ### Changed
diff --git a/requirements/lint.txt b/requirements/lint.txt
index 80e2c0c6..98a609c2 100644
--- a/requirements/lint.txt
+++ b/requirements/lint.txt
@@ -6,9 +6,9 @@
 # Lint tools
 # (We are not so interested in the specific versions of the tools: the versions
 # are pinned to prevent unexpected linting failures when tools update)
-ruff==0.9.7
+ruff==0.9.9
 mypy==1.15.0
-zizmor==1.3.1
+zizmor==1.4.1
 
 # Required for type stubs
 freezegun==1.5.1
diff --git a/requirements/pinned.txt b/requirements/pinned.txt
index 038c8af1..d73f7fc7 100644
--- a/requirements/pinned.txt
+++ b/requirements/pinned.txt
@@ -6,7 +6,7 @@
 #
 cffi==1.17.1
     # via cryptography
-cryptography==44.0.1
+cryptography==44.0.2
     # via securesystemslib
 pycparser==2.22
     # via cffi
diff --git a/tuf/ngclient/_internal/proxy.py b/tuf/ngclient/_internal/proxy.py
new file mode 100644
index 00000000..b42ea2f4
--- /dev/null
+++ b/tuf/ngclient/_internal/proxy.py
@@ -0,0 +1,101 @@
+# Copyright New York University and the TUF contributors
+# SPDX-License-Identifier: MIT OR Apache-2.0
+
+"""Proxy environment variable handling with Urllib3"""
+
+from __future__ import annotations
+
+from typing import Any
+from urllib.request import getproxies
+
+from urllib3 import BaseHTTPResponse, PoolManager, ProxyManager
+from urllib3.util.url import parse_url
+
+
+# TODO: ProxyEnvironment could implement the whole PoolManager.RequestMethods
+# Mixin: We only need request() so nothing else is currently implemented
+class ProxyEnvironment:
+    """A PoolManager manager for automatic proxy handling based on env variables
+
+    Keeps track of PoolManagers for different proxy urls based on proxy
+    environment variables. Use `get_pool_manager()` or `request()` to access
+    the right manager for a scheme/host.
+
+    Supports '*_proxy' variables, with special handling for 'no_proxy' and
+    'all_proxy'.
+    """
+
+    def __init__(
+        self,
+        **kw_args: Any,  # noqa: ANN401
+    ) -> None:
+        self._pool_managers: dict[str | None, PoolManager] = {}
+        self._kw_args = kw_args
+
+        self._proxies = getproxies()
+        self._all_proxy = self._proxies.pop("all", None)
+        no_proxy = self._proxies.pop("no", None)
+        if no_proxy is None:
+            self._no_proxy_hosts = []
+        else:
+            # split by comma, remove leading periods
+            self._no_proxy_hosts = [
+                h.lstrip(".") for h in no_proxy.replace(" ", "").split(",") if h
+            ]
+
+    def _get_proxy(self, scheme: str | None, host: str | None) -> str | None:
+        """Get a proxy url for scheme and host based on proxy env variables"""
+
+        if host is None:
+            # urllib3 only handles http/https but we can do something reasonable
+            # even for schemes that don't require host (like file)
+            return None
+
+        # does host match any of the "no_proxy" hosts?
+        for no_proxy_host in self._no_proxy_hosts:
+            # wildcard match, exact hostname match, or parent domain match
+            if no_proxy_host in ("*", host) or host.endswith(
+                f".{no_proxy_host}"
+            ):
+                return None
+
+        if scheme in self._proxies:
+            return self._proxies[scheme]
+        if self._all_proxy is not None:
+            return self._all_proxy
+
+        return None
+
+    def get_pool_manager(
+        self, scheme: str | None, host: str | None
+    ) -> PoolManager:
+        """Get a poolmanager for scheme and host.
+
+        Returns a ProxyManager if that is correct based on current proxy env
+        variables, otherwise returns a PoolManager
+        """
+
+        proxy = self._get_proxy(scheme, host)
+        if proxy not in self._pool_managers:
+            if proxy is None:
+                self._pool_managers[proxy] = PoolManager(**self._kw_args)
+            else:
+                self._pool_managers[proxy] = ProxyManager(
+                    proxy,
+                    **self._kw_args,
+                )
+
+        return self._pool_managers[proxy]
+
+    def request(
+        self,
+        method: str,
+        url: str,
+        **request_kw: Any,  # noqa: ANN401
+    ) -> BaseHTTPResponse:
+        """Make a request using a PoolManager chosen based on url and
+        proxy environment variables.
+        """
+        u = parse_url(url)
+        manager = self.get_pool_manager(u.scheme, u.host)
+        return manager.request(method, url, **request_kw)
diff --git a/tuf/ngclient/updater.py b/tuf/ngclient/updater.py
index 5af7cfe4..a40c1fca 100644
--- a/tuf/ngclient/updater.py
+++ b/tuf/ngclient/updater.py
@@ -36,6 +36,19 @@
 A simple example of using the Updater to implement a Python TUF client that
 downloads target files is available in `examples/client
 <https://github.com/theupdateframework/python-tuf/tree/develop/examples/client>`_.
+
+Notes on how Updater uses HTTP by default:
+  * urllib3 is the HTTP library
+  * Typically all requests are retried by urllib3 three times (in cases where
+    this seems useful)
+  * Operating system certificate store is used for TLS, in other words
+    ``certifi`` is not used as the certificate source
+  * Proxy use can be configured with ``https_proxy`` and other similar
+    environment variables
+
+All of the HTTP decisions can be changed with ``fetcher`` argument:
+Custom ``FetcherInterface`` implementations are possible. The alternative
+``RequestsFetcher`` implementation is also provided (although deprecated).
 """
 
 from __future__ import annotations
diff --git a/tuf/ngclient/urllib3_fetcher.py b/tuf/ngclient/urllib3_fetcher.py
index 5d4bb1cf..88d447bd 100644
--- a/tuf/ngclient/urllib3_fetcher.py
+++ b/tuf/ngclient/urllib3_fetcher.py
@@ -15,6 +15,7 @@
 
 import tuf
 from tuf.api import exceptions
+from tuf.ngclient._internal.proxy import ProxyEnvironment
 from tuf.ngclient.fetcher import FetcherInterface
 
 if TYPE_CHECKING:
@@ -49,7 +50,7 @@ def __init__(
         if app_user_agent is not None:
             ua = f"{app_user_agent} {ua}"
 
-        self._poolManager = urllib3.PoolManager(headers={"User-Agent": ua})
+        self._proxy_env = ProxyEnvironment(headers={"User-Agent": ua})
 
     def _fetch(self, url: str) -> Iterator[bytes]:
         """Fetch the contents of HTTP/HTTPS url from a remote server.
@@ -72,7 +73,7 @@ def _fetch(self, url: str) -> Iterator[bytes]:
         #  - connect timeout (max delay before first byte is received)
         #  - read (gap) timeout (max delay between bytes received)
         try:
-            response = self._poolManager.request(
+            response = self._proxy_env.request(
                 "GET",
                 url,
                 preload_content=False,


Test patch:
diff --git a/tests/test_proxy_environment.py b/tests/test_proxy_environment.py
new file mode 100644
index 00000000..ade7b350
--- /dev/null
+++ b/tests/test_proxy_environment.py
@@ -0,0 +1,217 @@
+# Copyright 2025, the TUF contributors
+# SPDX-License-Identifier: MIT OR Apache-2.0
+
+"""Test ngclient ProxyEnvironment"""
+
+from __future__ import annotations
+
+import sys
+import unittest
+from unittest.mock import Mock, patch
+
+from urllib3 import PoolManager, ProxyManager
+
+from tests import utils
+from tuf.ngclient._internal.proxy import ProxyEnvironment
+
+
+class TestProxyEnvironment(unittest.TestCase):
+    """Test ngclient ProxyEnvironment implementation
+
+    These tests use the ProxyEnvironment.get_pool_manager() endpoint and then
+    look at the ProxyEnvironment._poolmanagers dict keys to decide if the result
+    is correct.
+
+    The test environment is changed via mocking getproxies(): this is a urllib
+    method that returns a dict with the proxy environment variable contents.
+
+    Testing ProxyEnvironment.request() would possibly be better but far more
+    difficult: the current test implementation does not require actually setting up
+    all of the different proxies.
+    """
+
+    def assert_pool_managers(
+        self, env: ProxyEnvironment, expected: list[str | None]
+    ) -> None:
+        # Pool managers have the expected proxy urls
+        self.assertEqual(list(env._pool_managers.keys()), expected)
+
+        # Pool manager types are as expected
+        for proxy_url, pool_manager in env._pool_managers.items():
+            self.assertIsInstance(pool_manager, PoolManager)
+            if proxy_url is not None:
+                self.assertIsInstance(pool_manager, ProxyManager)
+
+    @patch("tuf.ngclient._internal.proxy.getproxies")
+    def test_no_variables(self, mock_getproxies: Mock) -> None:
+        mock_getproxies.return_value = {}
+
+        env = ProxyEnvironment()
+        env.get_pool_manager("http", "example.com")
+        env.get_pool_manager("https", "example.com")
+        env.get_pool_manager("https", "example.com")
+        env.get_pool_manager("https", "subdomain.example.com")
+        env.get_pool_manager("https", "differentsite.com")
+
+        # There is a single pool manager (no proxies)
+        self.assert_pool_managers(env, [None])
+
+    @patch("tuf.ngclient._internal.proxy.getproxies")
+    def test_proxy_set(self, mock_getproxies: Mock) -> None:
+        mock_getproxies.return_value = {
+            "https": "http://localhost:8888",
+        }
+
+        env = ProxyEnvironment()
+        env.get_pool_manager("http", "example.com")
+        env.get_pool_manager("https", "example.com")
+        env.get_pool_manager("https", "example.com")
+        env.get_pool_manager("https", "differentsite.com")
+
+        # There are two pool managers: A plain poolmanager and https proxymanager
+        self.assert_pool_managers(env, [None, "http://localhost:8888"])
+
+    @patch("tuf.ngclient._internal.proxy.getproxies")
+    def test_proxies_set(self, mock_getproxies: Mock) -> None:
+        mock_getproxies.return_value = {
+            "http": "http://localhost:8888",
+            "https": "http://localhost:9999",
+        }
+
+        env = ProxyEnvironment()
+        env.get_pool_manager("http", "example.com")
+        env.get_pool_manager("https", "example.com")
+        env.get_pool_manager("https", "example.com")
+        env.get_pool_manager("https", "subdomain.example.com")
+        env.get_pool_manager("https", "differentsite.com")
+
+        # There are two pool managers: A http proxymanager and https proxymanager
+        self.assert_pool_managers(
+            env, ["http://localhost:8888", "http://localhost:9999"]
+        )
+
+    @patch("tuf.ngclient._internal.proxy.getproxies")
+    def test_no_proxy_set(self, mock_getproxies: Mock) -> None:
+        mock_getproxies.return_value = {
+            "http": "http://localhost:8888",
+            "https": "http://localhost:9999",
+            "no": "somesite.com, example.com, another.site.com",
+        }
+
+        env = ProxyEnvironment()
+        env.get_pool_manager("http", "example.com")
+        env.get_pool_manager("https", "example.com")
+        env.get_pool_manager("https", "example.com")
+
+        # There is a single pool manager (no proxies)
+        self.assert_pool_managers(env, [None])
+
+        env.get_pool_manager("http", "differentsite.com")
+        env.get_pool_manager("https", "differentsite.com")
+
+        # There are three pool managers: plain poolmanager for no_proxy domains,
+        # http proxymanager and https proxymanager
+        self.assert_pool_managers(
+            env, [None, "http://localhost:8888", "http://localhost:9999"]
+        )
+
+    @patch("tuf.ngclient._internal.proxy.getproxies")
+    def test_no_proxy_subdomain_match(self, mock_getproxies: Mock) -> None:
+        mock_getproxies.return_value = {
+            "https": "http://localhost:9999",
+            "no": "somesite.com, example.com, another.site.com",
+        }
+
+        env = ProxyEnvironment()
+
+        # this should match example.com in no_proxy
+        env.get_pool_manager("https", "subdomain.example.com")
+
+        # There is a single pool manager (no proxies)
+        self.assert_pool_managers(env, [None])
+
+        # this should not match example.com in no_proxy
+        env.get_pool_manager("https", "xexample.com")
+
+        # There are two pool managers: plain poolmanager for no_proxy domains,
+        # and a https proxymanager
+        self.assert_pool_managers(env, [None, "http://localhost:9999"])
+
+    @patch("tuf.ngclient._internal.proxy.getproxies")
+    def test_no_proxy_wildcard(self, mock_getproxies: Mock) -> None:
+        mock_getproxies.return_value = {
+            "https": "http://localhost:8888",
+            "no": "*",
+        }
+
+        env = ProxyEnvironment()
+        env.get_pool_manager("https", "example.com")
+        env.get_pool_manager("https", "differentsite.com")
+        env.get_pool_manager("https", "subdomain.example.com")
+
+        # There is a single pool manager, no proxies
+        self.assert_pool_managers(env, [None])
+
+    @patch("tuf.ngclient._internal.proxy.getproxies")
+    def test_no_proxy_leading_dot(self, mock_getproxies: Mock) -> None:
+        mock_getproxies.return_value = {
+            "https": "http://localhost:8888",
+            "no": ".example.com",
+        }
+
+        env = ProxyEnvironment()
+        env.get_pool_manager("https", "example.com")
+        env.get_pool_manager("https", "subdomain.example.com")
+
+        # There is a single pool manager, no proxies
+        self.assert_pool_managers(env, [None])
+
+    @patch("tuf.ngclient._internal.proxy.getproxies")
+    def test_all_proxy_set(self, mock_getproxies: Mock) -> None:
+        mock_getproxies.return_value = {
+            "all": "http://localhost:8888",
+        }
+
+        env = ProxyEnvironment()
+        env.get_pool_manager("http", "example.com")
+        env.get_pool_manager("https", "example.com")
+        env.get_pool_manager("https", "example.com")
+        env.get_pool_manager("https", "subdomain.example.com")
+        env.get_pool_manager("https", "differentsite.com")
+
+        # There is a single proxy manager
+        self.assert_pool_managers(env, ["http://localhost:8888"])
+
+        # This urllib3 currently only handles http and https but let's test anyway
+        env.get_pool_manager("file", None)
+
+        # proxy manager and a plain pool manager
+        self.assert_pool_managers(env, ["http://localhost:8888", None])
+
+    @patch("tuf.ngclient._internal.proxy.getproxies")
+    def test_all_proxy_and_no_proxy_set(self, mock_getproxies: Mock) -> None:
+        mock_getproxies.return_value = {
+            "all": "http://localhost:8888",
+            "no": "somesite.com, example.com, another.site.com",
+        }
+
+        env = ProxyEnvironment()
+        env.get_pool_manager("http", "example.com")
+        env.get_pool_manager("https", "example.com")
+        env.get_pool_manager("https", "example.com")
+        env.get_pool_manager("https", "subdomain.example.com")
+
+        # There is a single pool manager (no proxies)
+        self.assert_pool_managers(env, [None])
+
+        env.get_pool_manager("http", "differentsite.com")
+        env.get_pool_manager("https", "differentsite.com")
+
+        # There are two pool managers: plain poolmanager for no_proxy domains and
+        # one proxymanager
+        self.assert_pool_managers(env, [None, "http://localhost:8888"])
+
+
+if __name__ == "__main__":
+    utils.configure_test_logging(sys.argv)
+    unittest.main()
diff --git a/tests/test_updater_ng.py b/tests/test_updater_ng.py
index b37003bb..50ef5ee3 100644
--- a/tests/test_updater_ng.py
+++ b/tests/test_updater_ng.py
@@ -331,8 +331,10 @@ def test_non_existing_target_file(self) -> None:
     def test_user_agent(self) -> None:
         # test default
         self.updater.refresh()
-        session = self.updater._fetcher._poolManager
-        ua = session.headers["User-Agent"]
+        poolmgr = self.updater._fetcher._proxy_env.get_pool_manager(
+            "http", "localhost"
+        )
+        ua = poolmgr.headers["User-Agent"]
         self.assertEqual(ua[:11], "python-tuf/")
 
         # test custom UA
@@ -344,8 +346,10 @@ def test_user_agent(self) -> None:
             config=UpdaterConfig(app_user_agent="MyApp/1.2.3"),
         )
         updater.refresh()
-        session = updater._fetcher._poolManager
-        ua = session.headers["User-Agent"]
+        poolmgr = updater._fetcher._proxy_env.get_pool_manager(
+            "http", "localhost"
+        )
+        ua = poolmgr.headers["User-Agent"]
 
         self.assertEqual(ua[:23], "MyApp/1.2.3 python-tuf/")