This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new a59a47d  Fix problems with the code and tests for creating secure 
sessions
a59a47d is described below

commit a59a47d7e3ebef095e10d1899929f9c0fa60a4c8
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Jan 28 14:48:46 2026 +0000

    Fix problems with the code and tests for creating secure sessions
---
 atr/admin/__init__.py                  |  2 +-
 atr/datasources/apache.py              | 12 +++----
 atr/jwtoken.py                         |  2 +-
 atr/post/keys.py                       |  2 +-
 atr/sbom/osv.py                        |  2 +-
 atr/sbom/utilities.py                  |  2 +-
 atr/storage/writers/distributions.py   |  6 ++--
 atr/tasks/gha.py                       |  7 ++--
 atr/util.py                            | 62 +++++++++++++++-------------------
 tests/{ => unit}/test_util_security.py | 16 ++++-----
 typestubs/asfquart/auth.pyi            | 23 +++++++------
 typestubs/asfquart/session.pyi         |  4 ++-
 typestubs/asfquart/utils.pyi           |  1 +
 13 files changed, 68 insertions(+), 73 deletions(-)

diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index bfa2754..dd1728c 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -833,7 +833,7 @@ async def test(session: web.Committer) -> web.QuartResponse:
     """Test the storage layer."""
     import atr.storage as storage
 
-    async with await util.create_secure_session() as aiohttp_client_session:
+    async with util.create_secure_session() as aiohttp_client_session:
         url = "https://downloads.apache.org/zeppelin/KEYS";
         async with aiohttp_client_session.get(url) as response:
             keys_file_text = await response.text()
diff --git a/atr/datasources/apache.py b/atr/datasources/apache.py
index 358bd7d..f1dfcd3 100644
--- a/atr/datasources/apache.py
+++ b/atr/datasources/apache.py
@@ -224,7 +224,7 @@ class ProjectsData(helpers.DictRoot[ProjectStatus]):
 async def get_active_committee_data() -> CommitteeData:
     """Returns the list of currently active committees."""
 
-    async with await util.create_secure_session() as session:
+    async with util.create_secure_session() as session:
         async with session.get(_WHIMSY_COMMITTEE_INFO_URL) as response:
             response.raise_for_status()
             data = await response.json()
@@ -235,7 +235,7 @@ async def get_active_committee_data() -> CommitteeData:
 async def get_current_podlings_data() -> PodlingsData:
     """Returns the list of current podlings."""
 
-    async with await util.create_secure_session() as session:
+    async with util.create_secure_session() as session:
         async with session.get(_PROJECTS_PODLINGS_URL) as response:
             response.raise_for_status()
             data = await response.json()
@@ -245,7 +245,7 @@ async def get_current_podlings_data() -> PodlingsData:
 async def get_groups_data() -> GroupsData:
     """Returns LDAP Groups with their members."""
 
-    async with await util.create_secure_session() as session:
+    async with util.create_secure_session() as session:
         async with session.get(_PROJECTS_GROUPS_URL) as response:
             response.raise_for_status()
             data = await response.json()
@@ -253,7 +253,7 @@ async def get_groups_data() -> GroupsData:
 
 
 async def get_ldap_projects_data() -> LDAPProjectsData:
-    async with await util.create_secure_session() as session:
+    async with util.create_secure_session() as session:
         async with session.get(_WHIMSY_PROJECTS_URL) as response:
             response.raise_for_status()
             data = await response.json()
@@ -264,7 +264,7 @@ async def get_ldap_projects_data() -> LDAPProjectsData:
 async def get_projects_data() -> ProjectsData:
     """Returns the list of projects."""
 
-    async with await util.create_secure_session() as session:
+    async with util.create_secure_session() as session:
         async with session.get(_PROJECTS_PROJECTS_URL) as response:
             response.raise_for_status()
             data = await response.json()
@@ -274,7 +274,7 @@ async def get_projects_data() -> ProjectsData:
 async def get_retired_committee_data() -> RetiredCommitteeData:
     """Returns the list of retired committees."""
 
-    async with await util.create_secure_session() as session:
+    async with util.create_secure_session() as session:
         async with session.get(_WHIMSY_COMMITTEE_RETIRED_URL) as response:
             response.raise_for_status()
             data = await response.json()
diff --git a/atr/jwtoken.py b/atr/jwtoken.py
index 1fa14fe..b88b20b 100644
--- a/atr/jwtoken.py
+++ b/atr/jwtoken.py
@@ -104,7 +104,7 @@ def verify(token: str) -> dict[str, Any]:
 
 async def verify_github_oidc(token: str) -> dict[str, Any]:
     try:
-        async with await util.create_secure_session() as session:
+        async with util.create_secure_session() as session:
             r = await session.get(
                 f"{_GITHUB_OIDC_ISSUER}/.well-known/openid-configuration",
                 timeout=aiohttp.ClientTimeout(total=10),
diff --git a/atr/post/keys.py b/atr/post/keys.py
index 02e4bb6..82b6aff 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -231,7 +231,7 @@ async def _fetch_keys_from_url(keys_url: str) -> str:
     """Fetch KEYS file from ASF downloads."""
     try:
         timeout = aiohttp.ClientTimeout(total=30)
-        async with await util.create_secure_session(timeout=timeout) as 
session:
+        async with util.create_secure_session(timeout=timeout) as session:
             async with session.get(keys_url, allow_redirects=True) as response:
                 response.raise_for_status()
                 return await response.text()
diff --git a/atr/sbom/osv.py b/atr/sbom/osv.py
index ff83815..028e757 100644
--- a/atr/sbom/osv.py
+++ b/atr/sbom/osv.py
@@ -89,7 +89,7 @@ async def scan_bundle(bundle: models.bundle.Bundle) -> 
tuple[list[models.osv.Com
         ignored_count = len(ignored)
         if ignored_count > 0:
             print(f"[DEBUG] {ignored_count} components ignored (missing purl 
or version)")
-    async with await util.create_secure_session() as session:
+    async with util.create_secure_session() as session:
         component_vulns_map = await 
_scan_bundle_fetch_vulnerabilities(session, queries, 1000)
         if _DEBUG:
             print(f"[DEBUG] Total components with vulnerabilities: 
{len(component_vulns_map)}")
diff --git a/atr/sbom/utilities.py b/atr/sbom/utilities.py
index f369f92..027ebc0 100644
--- a/atr/sbom/utilities.py
+++ b/atr/sbom/utilities.py
@@ -75,7 +75,7 @@ async def bundle_to_ntia_patch(bundle_value: 
models.bundle.Bundle) -> models.pat
     from .conformance import ntia_2021_issues, ntia_2021_patch
 
     _warnings, errors = ntia_2021_issues(bundle_value.bom)
-    async with await util.create_secure_session() as session:
+    async with util.create_secure_session() as session:
         patch_ops = await ntia_2021_patch(session, bundle_value.doc, errors)
     return patch_ops
 
diff --git a/atr/storage/writers/distributions.py 
b/atr/storage/writers/distributions.py
index 5832d21..74fe074 100644
--- a/atr/storage/writers/distributions.py
+++ b/atr/storage/writers/distributions.py
@@ -333,7 +333,7 @@ class CommitteeMember(CommitteeParticipant):
         self, api_url: str, platform: sql.DistributionPlatform, version: str
     ) -> outcome.Outcome[basic.JSON]:
         try:
-            async with await util.create_secure_session() as session:
+            async with util.create_secure_session() as session:
                 async with session.get(api_url) as response:
                     response.raise_for_status()
                     response_json = await response.json()
@@ -353,7 +353,7 @@ class CommitteeMember(CommitteeParticipant):
         import datetime
 
         try:
-            async with await util.create_secure_session() as session:
+            async with util.create_secure_session() as session:
                 async with session.get(api_url) as response:
                     response.raise_for_status()
 
@@ -384,7 +384,7 @@ class CommitteeMember(CommitteeParticipant):
         import xml.etree.ElementTree as ET
 
         try:
-            async with await util.create_secure_session() as session:
+            async with util.create_secure_session() as session:
                 async with session.get(api_url) as response:
                     response.raise_for_status()
                     xml_text = await response.text()
diff --git a/atr/tasks/gha.py b/atr/tasks/gha.py
index 5e3a628..0830dfd 100644
--- a/atr/tasks/gha.py
+++ b/atr/tasks/gha.py
@@ -30,11 +30,10 @@ import atr.log as log
 import atr.models.results as results
 import atr.models.schema as schema
 import atr.models.sql as sql
-
-# import atr.shared as shared
 import atr.storage as storage
 import atr.tasks as tasks
 import atr.tasks.checks as checks
+import atr.util as util
 from atr.models.results import DistributionWorkflowStatus
 
 _BASE_URL: Final[str] = "https://api.github.com/repos";
@@ -95,7 +94,7 @@ async def trigger_workflow(args: DistributionWorkflow, *, 
task_id: int | None =
             json.dumps(args.arguments, indent=2)
         }"
     )
-    async with aiohttp.ClientSession() as session:
+    async with util.create_secure_session() as session:
         try:
             async with session.post(
                 
f"{_BASE_URL}/apache/tooling-actions/actions/workflows/{workflow}/dispatches",
@@ -130,7 +129,7 @@ async def status_check(args: WorkflowStatusCheck) -> 
DistributionWorkflowStatus:
     log.info("Updating Github workflow statuses from apache/tooling-actions")
     runs = []
     try:
-        async with aiohttp.ClientSession() as session:
+        async with util.create_secure_session() as session:
             try:
                 async with session.get(
                     
f"{_BASE_URL}/apache/tooling-actions/actions/runs?event=workflow_dispatch", 
headers=headers
diff --git a/atr/util.py b/atr/util.py
index ca5f8eb..ab9b223 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -90,38 +90,6 @@ class FetchError(RuntimeError):
         self.url = url
 
 
-def create_secure_ssl_context() -> ssl.SSLContext:
-    """Create a secure SSL context compliant with ASVS 9.1.1 and 9.1.2.
-
-    Explicitly configures:
-    - check_hostname = True: Verifies hostname matches the certificate
-    - verify_mode = ssl.CERT_REQUIRED: Requires a valid certificate
-    - minimum_version = ssl.TLSVersion.TLSv1_2: Enforces TLS 1.2 or higher
-    """
-    ctx = ssl.create_default_context()
-    ctx.check_hostname = True
-    ctx.verify_mode = ssl.CERT_REQUIRED
-    ctx.minimum_version = ssl.TLSVersion.TLSv1_2
-    return ctx
-
-
-async def create_secure_session(
-    timeout: aiohttp.ClientTimeout | None = None,  # noqa: ASYNC109
-) -> aiohttp.ClientSession:
-    """Create a secure aiohttp.ClientSession with hardened SSL/TLS 
configuration.
-
-    Returns a ClientSession with TCPConnector using the secure SSL context.
-    This ensures all HTTP connections made through this session use secure TLS 
settings.
-
-    Args:
-        timeout: Optional ClientTimeout object for request timeouts.
-                 Provided for backward compatibility with existing call sites 
(ASVS 9.1.1, 9.1.2).
-    """
-    connector = aiohttp.TCPConnector(ssl=create_secure_ssl_context())
-    # We pass the timeout to the ClientSession constructor
-    return aiohttp.ClientSession(connector=connector, timeout=timeout)
-
-
 async def archive_listing(file_path: pathlib.Path) -> list[str] | None:
     """Attempt to list contents of supported archive files."""
     if not await aiofiles.os.path.isfile(file_path):
@@ -340,6 +308,30 @@ def create_path_matcher(lines: Iterable[str], full_path: 
pathlib.Path, base_dir:
     return lambda file_path: gitignore_parser.handle_negation(file_path, rules)
 
 
+def create_secure_session(
+    timeout: aiohttp.ClientTimeout | None = None,
+) -> aiohttp.ClientSession:
+    """Create a secure aiohttp.ClientSession with hardened SSL/TLS 
configuration."""
+    connector = aiohttp.TCPConnector(ssl=create_secure_ssl_context())
+    # We pass the timeout to the ClientSession constructor
+    return aiohttp.ClientSession(connector=connector, timeout=timeout)
+
+
+def create_secure_ssl_context() -> ssl.SSLContext:
+    """Create a secure SSL context compliant with ASVS 9.1.1 and 9.1.2."""
+    # These are the default values in Python 3.13.3:
+    # >>> import ssl
+    # >>> ctx = ssl.create_default_context()
+    # >>> (ctx.check_hostname, ctx.verify_mode, ctx.minimum_version)
+    # (True, <VerifyMode.CERT_REQUIRED: 2>, <TLSVersion.TLSv1_2: 771>)
+    # But we set them explicitly to pin and document them
+    ctx = ssl.create_default_context()
+    ctx.check_hostname = True
+    ctx.verify_mode = ssl.CERT_REQUIRED
+    ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+    return ctx
+
+
 def email_from_uid(uid: str) -> str | None:
     if m := re.search(r"<([^>]+)>", uid):
         return m.group(1).lower()
@@ -542,7 +534,7 @@ def get_upload_staging_dir(session_token: str) -> 
pathlib.Path:
 
 async def get_urls_as_completed(urls: Sequence[str]) -> 
AsyncGenerator[tuple[str, int | str | None, bytes]]:
     """GET a list of URLs in parallel and yield (url, status, content_bytes) 
as they become available."""
-    async with aiohttp.ClientSession() as session:
+    async with create_secure_session() as session:
 
         async def _fetch(one_url: str) -> tuple[str, int | str | None, bytes]:
             try:
@@ -902,7 +894,7 @@ async def task_archive_url(task_mid: str, recipient: str | 
None = None) -> str |
     lid = recipient_address.replace("@", ".")
     url = 
f"https://lists.apache.org/api/email.json?id=%3C{task_mid}%3E&listid=%3C{lid}%3E";
     try:
-        async with aiohttp.ClientSession() as session:
+        async with create_secure_session() as session:
             async with session.get(url) as response:
                 response.raise_for_status()
                 # TODO: Check whether this blocks from network
@@ -924,7 +916,7 @@ async def thread_messages(
     thread_url = f"https://lists.apache.org/api/thread.json?id={thread_id}";
 
     try:
-        async with aiohttp.ClientSession() as session:
+        async with create_secure_session() as session:
             async with session.get(thread_url) as resp:
                 resp.raise_for_status()
                 thread_data: Any = await resp.json(content_type=None)
diff --git a/tests/test_util_security.py b/tests/unit/test_util_security.py
similarity index 92%
rename from tests/test_util_security.py
rename to tests/unit/test_util_security.py
index 05c1e31..dfb2c7b 100644
--- a/tests/test_util_security.py
+++ b/tests/unit/test_util_security.py
@@ -73,7 +73,7 @@ class 
TestCreateSecureSession(unittest.IsolatedAsyncioTestCase):
 
     async def test_creates_client_session(self) -> None:
         """Test that create_secure_session returns an aiohttp.ClientSession."""
-        session = await util.create_secure_session()
+        session = util.create_secure_session()
         try:
             self.assertIsInstance(session, aiohttp.ClientSession)
         finally:
@@ -81,7 +81,7 @@ class 
TestCreateSecureSession(unittest.IsolatedAsyncioTestCase):
 
     async def test_session_has_tcp_connector(self) -> None:
         """Test that session is initialized with a TCPConnector."""
-        session = await util.create_secure_session()
+        session = util.create_secure_session()
         try:
             self.assertIsNotNone(session.connector)
             self.assertIsInstance(session.connector, aiohttp.TCPConnector)
@@ -90,7 +90,7 @@ class 
TestCreateSecureSession(unittest.IsolatedAsyncioTestCase):
 
     async def test_connector_has_secure_ssl_context(self) -> None:
         """Test that TCPConnector uses the secure SSL context."""
-        session = await util.create_secure_session()
+        session = util.create_secure_session()
         try:
             connector = session.connector
             self.assertIsNotNone(connector)
@@ -99,7 +99,7 @@ class 
TestCreateSecureSession(unittest.IsolatedAsyncioTestCase):
             # Verify the connector was initialized with SSL context
             # The ssl attribute on TCPConnector will be the ssl.SSLContext
             if hasattr(connector, "_ssl"):
-                ssl_context = connector._ssl
+                ssl_context = getattr(connector, "_ssl")
                 self.assertIsNotNone(ssl_context)
                 if isinstance(ssl_context, ssl.SSLContext):
                     self.assertTrue(ssl_context.check_hostname)
@@ -111,7 +111,7 @@ class 
TestCreateSecureSession(unittest.IsolatedAsyncioTestCase):
     async def test_session_accepts_optional_timeout(self) -> None:
         """Test that create_secure_session accepts optional timeout 
parameter."""
         timeout = aiohttp.ClientTimeout(total=30)
-        session = await util.create_secure_session(timeout=timeout)
+        session = util.create_secure_session(timeout=timeout)
         try:
             self.assertIsNotNone(session.timeout)
             self.assertEqual(session.timeout.total, 30)
@@ -120,7 +120,7 @@ class 
TestCreateSecureSession(unittest.IsolatedAsyncioTestCase):
 
     async def test_session_without_timeout(self) -> None:
         """Test that create_secure_session works without explicit timeout."""
-        session = await util.create_secure_session()
+        session = util.create_secure_session()
         try:
             self.assertIsNotNone(session)
             self.assertIsInstance(session, aiohttp.ClientSession)
@@ -129,8 +129,8 @@ class 
TestCreateSecureSession(unittest.IsolatedAsyncioTestCase):
 
     async def test_multiple_sessions_have_independent_contexts(self) -> None:
         """Test that multiple sessions each have their own SSL context."""
-        session1 = await util.create_secure_session()
-        session2 = await util.create_secure_session()
+        session1 = util.create_secure_session()
+        session2 = util.create_secure_session()
         try:
             # Both sessions should be valid and independent
             self.assertIsInstance(session1, aiohttp.ClientSession)
diff --git a/typestubs/asfquart/auth.pyi b/typestubs/asfquart/auth.pyi
index f3fad3b..156cb6c 100644
--- a/typestubs/asfquart/auth.pyi
+++ b/typestubs/asfquart/auth.pyi
@@ -2,15 +2,14 @@
 This type stub file was generated by pyright.
 """
 
-from collections.abc import Callable, Coroutine, Iterable
-from typing import Any, TypeVar, overload
-
+import typing
+from typing import Any, Callable, Coroutine, Iterable, Optional, TypeVar, 
Union, overload
 from . import base, session
 
 """ASFQuart - Authentication methods and decorators"""
 
-T = TypeVar("T")
-P = TypeVar("P", bound=Callable[..., Coroutine[Any, Any, Any]])
+T = TypeVar('T')
+P = TypeVar('P', bound=Callable[..., Coroutine[Any, Any, Any]])
 ReqFunc = Callable[[session.ClientSession], tuple[bool, str]]
 
 class Requirements:
@@ -68,16 +67,18 @@ def requirements_to_iter(args: Any) -> Iterable[Any]:
 
 @overload
 def require(func: P) -> P: ...
+
 @overload
 def require(
-    func: ReqFunc | None = None,
-    all_of: ReqFunc | Iterable[ReqFunc] | None = None,
-    any_of: ReqFunc | Iterable[ReqFunc] | None = None,
+    func: Optional[ReqFunc] = None,
+    all_of: Optional[Union[ReqFunc, Iterable[ReqFunc]]] = None,
+    any_of: Optional[Union[ReqFunc, Iterable[ReqFunc]]] = None,
 ) -> Callable[[P], P]: ...
+
 @overload
 def require(
-    func: Callable[..., tuple[bool, str]] | Iterable[Callable[..., tuple[bool, 
str]]] = None,
+    func: Union[Callable[..., tuple[bool, str]], Iterable[Callable[..., 
tuple[bool, str]]]] = None,
     *,
-    all_of: Callable[..., tuple[bool, str]] | Iterable[Callable[..., 
tuple[bool, str]]] | None = None,
-    any_of: Callable[..., tuple[bool, str]] | Iterable[Callable[..., 
tuple[bool, str]]] | None = None,
+    all_of: Optional[Union[Callable[..., tuple[bool, str]], 
Iterable[Callable[..., tuple[bool, str]]]]] = None,
+    any_of: Optional[Union[Callable[..., tuple[bool, str]], 
Iterable[Callable[..., tuple[bool, str]]]]] = None,
 ) -> Callable[[P], P]: ...
diff --git a/typestubs/asfquart/session.pyi b/typestubs/asfquart/session.pyi
index 0cfa52a..dc71a6d 100644
--- a/typestubs/asfquart/session.pyi
+++ b/typestubs/asfquart/session.pyi
@@ -2,6 +2,8 @@
 This type stub file was generated by pyright.
 """
 
+import typing
+
 """ASFQuart - User session methods and decorators"""
 
 class ClientSession(dict):
@@ -23,7 +25,7 @@ class ClientSession(dict):
         we can send it to quart in a format it can render."""
         ...
 
-async def read(expiry_time=..., app=...) -> ClientSession | None:
+async def read(expiry_time=..., app=...) -> typing.Optional[ClientSession]:
     """Fetches a cookie-based session if found (and valid), and updates the 
last access timestamp
     for the session."""
     ...
diff --git a/typestubs/asfquart/utils.pyi b/typestubs/asfquart/utils.pyi
index 7f60ee2..28e53e7 100644
--- a/typestubs/asfquart/utils.pyi
+++ b/typestubs/asfquart/utils.pyi
@@ -25,6 +25,7 @@ def use_template(
     template,
 ):  # -> Callable[..., _Wrapped[Callable[..., Any], Any, Callable[..., Any], 
Coroutine[Any, Any, str]]]:
     ...
+
 def render(t, data):  # -> str:
     "Simple function to render a template into a string."
     ...


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to