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 63d8ea6  feat(security): centralize secure HTTP sessions and enforce 
TLS 1.2+ (#548)
63d8ea6 is described below

commit 63d8ea69ddc02fcb5dc9e909127a866ca36b44db
Author: Abhishek Mishra <[email protected]>
AuthorDate: Tue Jan 27 18:14:42 2026 +0000

    feat(security): centralize secure HTTP sessions and enforce TLS 1.2+ (#548)
---
 atr/admin/__init__.py                |   3 +-
 atr/datasources/apache.py            |  13 ++--
 atr/jwtoken.py                       |   3 +-
 atr/post/keys.py                     |   2 +-
 atr/sbom/osv.py                      |   5 +-
 atr/sbom/utilities.py                |   5 +-
 atr/storage/writers/distributions.py |   6 +-
 atr/util.py                          |  33 ++++++++
 tests/test_util_security.py          | 145 +++++++++++++++++++++++++++++++++++
 typestubs/asfquart/auth.pyi          |  23 +++---
 typestubs/asfquart/session.pyi       |   4 +-
 typestubs/asfquart/utils.pyi         |   1 -
 12 files changed, 209 insertions(+), 34 deletions(-)

diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 71537a2..bfa2754 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -27,7 +27,6 @@ from collections.abc import Callable, Mapping
 from typing import Any, Final, Literal, NamedTuple
 
 import aiofiles.os
-import aiohttp
 import asfquart
 import asfquart.base as base
 import asfquart.session
@@ -834,7 +833,7 @@ async def test(session: web.Committer) -> web.QuartResponse:
     """Test the storage layer."""
     import atr.storage as storage
 
-    async with aiohttp.ClientSession() as aiohttp_client_session:
+    async with await 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 72bffe9..358bd7d 100644
--- a/atr/datasources/apache.py
+++ b/atr/datasources/apache.py
@@ -25,7 +25,6 @@ from typing import TYPE_CHECKING, Annotated, Any, Final
 if TYPE_CHECKING:
     from collections.abc import Mapping
 
-import aiohttp
 import sqlmodel
 
 import atr.db as db
@@ -225,7 +224,7 @@ class ProjectsData(helpers.DictRoot[ProjectStatus]):
 async def get_active_committee_data() -> CommitteeData:
     """Returns the list of currently active committees."""
 
-    async with aiohttp.ClientSession() as session:
+    async with await util.create_secure_session() as session:
         async with session.get(_WHIMSY_COMMITTEE_INFO_URL) as response:
             response.raise_for_status()
             data = await response.json()
@@ -236,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 aiohttp.ClientSession() as session:
+    async with await util.create_secure_session() as session:
         async with session.get(_PROJECTS_PODLINGS_URL) as response:
             response.raise_for_status()
             data = await response.json()
@@ -246,7 +245,7 @@ async def get_current_podlings_data() -> PodlingsData:
 async def get_groups_data() -> GroupsData:
     """Returns LDAP Groups with their members."""
 
-    async with aiohttp.ClientSession() as session:
+    async with await util.create_secure_session() as session:
         async with session.get(_PROJECTS_GROUPS_URL) as response:
             response.raise_for_status()
             data = await response.json()
@@ -254,7 +253,7 @@ async def get_groups_data() -> GroupsData:
 
 
 async def get_ldap_projects_data() -> LDAPProjectsData:
-    async with aiohttp.ClientSession() as session:
+    async with await util.create_secure_session() as session:
         async with session.get(_WHIMSY_PROJECTS_URL) as response:
             response.raise_for_status()
             data = await response.json()
@@ -265,7 +264,7 @@ async def get_ldap_projects_data() -> LDAPProjectsData:
 async def get_projects_data() -> ProjectsData:
     """Returns the list of projects."""
 
-    async with aiohttp.ClientSession() as session:
+    async with await util.create_secure_session() as session:
         async with session.get(_PROJECTS_PROJECTS_URL) as response:
             response.raise_for_status()
             data = await response.json()
@@ -275,7 +274,7 @@ async def get_projects_data() -> ProjectsData:
 async def get_retired_committee_data() -> RetiredCommitteeData:
     """Returns the list of retired committees."""
 
-    async with aiohttp.ClientSession() as session:
+    async with await 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 8f3b7ee..1fa14fe 100644
--- a/atr/jwtoken.py
+++ b/atr/jwtoken.py
@@ -29,6 +29,7 @@ import quart
 
 import atr.config as config
 import atr.log as log
+import atr.util as util
 
 _ALGORITHM: Final[str] = "HS256"
 _ATR_JWT_AUDIENCE: Final[str] = "atr-api-pat-test-v1"
@@ -103,7 +104,7 @@ def verify(token: str) -> dict[str, Any]:
 
 async def verify_github_oidc(token: str) -> dict[str, Any]:
     try:
-        async with aiohttp.ClientSession() as session:
+        async with await 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 9e5eb6f..02e4bb6 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 aiohttp.ClientSession(timeout=timeout) as session:
+        async with await 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 4cba5c6..ff83815 100644
--- a/atr/sbom/osv.py
+++ b/atr/sbom/osv.py
@@ -20,12 +20,13 @@ from __future__ import annotations
 import os
 from typing import TYPE_CHECKING, Any
 
-import aiohttp
+import atr.util as util
 
 from . import models
 from .utilities import get_pointer, osv_severity_to_cdx
 
 if TYPE_CHECKING:
+    import aiohttp
     import yyjson
 
 _DEBUG: bool = os.environ.get("DEBUG_SBOM_TOOL") == "1"
@@ -88,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 aiohttp.ClientSession() as session:
+    async with await 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 900bfb5..f369f92 100644
--- a/atr/sbom/utilities.py
+++ b/atr/sbom/utilities.py
@@ -27,9 +27,10 @@ if TYPE_CHECKING:
 
     import atr.sbom.models.osv as osv
 
-import aiohttp
 import yyjson
 
+import atr.util as util
+
 from . import constants, models
 
 
@@ -74,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 aiohttp.ClientSession() as session:
+    async with await 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 542012e..5832d21 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 aiohttp.ClientSession() as session:
+            async with await 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 aiohttp.ClientSession() as session:
+            async with await 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 aiohttp.ClientSession() as session:
+            async with await 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/util.py b/atr/util.py
index 4b83f96..ca5f8eb 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -26,6 +26,7 @@ import json
 import os
 import pathlib
 import re
+import ssl
 import tarfile
 import tempfile
 import uuid
@@ -89,6 +90,38 @@ 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):
diff --git a/tests/test_util_security.py b/tests/test_util_security.py
new file mode 100644
index 0000000..05c1e31
--- /dev/null
+++ b/tests/test_util_security.py
@@ -0,0 +1,145 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for secure HTTP client configuration in atr.util module."""
+
+import ssl
+import unittest
+
+import aiohttp
+
+import atr.util as util
+
+
+class TestCreateSecureSSLContext(unittest.TestCase):
+    """Tests for create_secure_ssl_context function."""
+
+    def test_creates_ssl_context(self) -> None:
+        """Test that create_secure_ssl_context returns an ssl.SSLContext."""
+        ctx = util.create_secure_ssl_context()
+        self.assertIsInstance(ctx, ssl.SSLContext)
+
+    def test_check_hostname_enabled(self) -> None:
+        """Test that hostname checking is explicitly enabled."""
+        ctx = util.create_secure_ssl_context()
+        self.assertTrue(ctx.check_hostname)
+
+    def test_verify_mode_cert_required(self) -> None:
+        """Test that verify_mode is set to CERT_REQUIRED.
+
+        CERT_REQUIRED value is 2 (or ssl.CERT_REQUIRED).
+        """
+        ctx = util.create_secure_ssl_context()
+        self.assertEqual(ctx.verify_mode, ssl.CERT_REQUIRED)
+        self.assertEqual(ctx.verify_mode, 2)
+
+    def test_minimum_version_tls_1_2(self) -> None:
+        """Test that minimum_version is set to TLSv1_2.
+
+        TLSv1_2 value is 771 (or ssl.TLSVersion.TLSv1_2).
+        """
+        ctx = util.create_secure_ssl_context()
+        self.assertEqual(ctx.minimum_version, ssl.TLSVersion.TLSv1_2)
+        self.assertEqual(ctx.minimum_version, 771)
+
+    def test_all_security_settings_together(self) -> None:
+        """Comprehensive test verifying all ASVS 9.1.1/9.1.2 compliance 
settings."""
+        ctx = util.create_secure_ssl_context()
+
+        # Verify all three critical settings are enforced
+        self.assertTrue(ctx.check_hostname, "ASVS 9.1.1: check_hostname must 
be True for certificate validation")
+        self.assertEqual(ctx.verify_mode, ssl.CERT_REQUIRED, "ASVS 9.1.2: 
verify_mode must be CERT_REQUIRED")
+        self.assertEqual(
+            ctx.minimum_version, ssl.TLSVersion.TLSv1_2, "ASVS 9.1.2: 
minimum_version must be TLSv1_2 or higher"
+        )
+
+
+class TestCreateSecureSession(unittest.IsolatedAsyncioTestCase):
+    """Tests for create_secure_session function."""
+
+    async def test_creates_client_session(self) -> None:
+        """Test that create_secure_session returns an aiohttp.ClientSession."""
+        session = await util.create_secure_session()
+        try:
+            self.assertIsInstance(session, aiohttp.ClientSession)
+        finally:
+            await session.close()
+
+    async def test_session_has_tcp_connector(self) -> None:
+        """Test that session is initialized with a TCPConnector."""
+        session = await util.create_secure_session()
+        try:
+            self.assertIsNotNone(session.connector)
+            self.assertIsInstance(session.connector, aiohttp.TCPConnector)
+        finally:
+            await session.close()
+
+    async def test_connector_has_secure_ssl_context(self) -> None:
+        """Test that TCPConnector uses the secure SSL context."""
+        session = await util.create_secure_session()
+        try:
+            connector = session.connector
+            self.assertIsNotNone(connector)
+            self.assertIsInstance(connector, aiohttp.TCPConnector)
+
+            # 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
+                self.assertIsNotNone(ssl_context)
+                if isinstance(ssl_context, ssl.SSLContext):
+                    self.assertTrue(ssl_context.check_hostname)
+                    self.assertEqual(ssl_context.verify_mode, 
ssl.CERT_REQUIRED)
+                    self.assertEqual(ssl_context.minimum_version, 
ssl.TLSVersion.TLSv1_2)
+        finally:
+            await session.close()
+
+    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)
+        try:
+            self.assertIsNotNone(session.timeout)
+            self.assertEqual(session.timeout.total, 30)
+        finally:
+            await session.close()
+
+    async def test_session_without_timeout(self) -> None:
+        """Test that create_secure_session works without explicit timeout."""
+        session = await util.create_secure_session()
+        try:
+            self.assertIsNotNone(session)
+            self.assertIsInstance(session, aiohttp.ClientSession)
+        finally:
+            await session.close()
+
+    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()
+        try:
+            # Both sessions should be valid and independent
+            self.assertIsInstance(session1, aiohttp.ClientSession)
+            self.assertIsInstance(session2, aiohttp.ClientSession)
+            self.assertNotEqual(id(session1), id(session2))
+        finally:
+            await session1.close()
+            await session2.close()
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/typestubs/asfquart/auth.pyi b/typestubs/asfquart/auth.pyi
index 156cb6c..f3fad3b 100644
--- a/typestubs/asfquart/auth.pyi
+++ b/typestubs/asfquart/auth.pyi
@@ -2,14 +2,15 @@
 This type stub file was generated by pyright.
 """
 
-import typing
-from typing import Any, Callable, Coroutine, Iterable, Optional, TypeVar, 
Union, overload
+from collections.abc import Callable, Coroutine, Iterable
+from typing import Any, TypeVar, 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:
@@ -67,18 +68,16 @@ def requirements_to_iter(args: Any) -> Iterable[Any]:
 
 @overload
 def require(func: P) -> P: ...
-
 @overload
 def require(
-    func: Optional[ReqFunc] = None,
-    all_of: Optional[Union[ReqFunc, Iterable[ReqFunc]]] = None,
-    any_of: Optional[Union[ReqFunc, Iterable[ReqFunc]]] = None,
+    func: ReqFunc | None = None,
+    all_of: ReqFunc | Iterable[ReqFunc] | None = None,
+    any_of: ReqFunc | Iterable[ReqFunc] | None = None,
 ) -> Callable[[P], P]: ...
-
 @overload
 def require(
-    func: Union[Callable[..., tuple[bool, str]], Iterable[Callable[..., 
tuple[bool, str]]]] = None,
+    func: Callable[..., tuple[bool, str]] | Iterable[Callable[..., tuple[bool, 
str]]] = 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,
+    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,
 ) -> Callable[[P], P]: ...
diff --git a/typestubs/asfquart/session.pyi b/typestubs/asfquart/session.pyi
index dc71a6d..0cfa52a 100644
--- a/typestubs/asfquart/session.pyi
+++ b/typestubs/asfquart/session.pyi
@@ -2,8 +2,6 @@
 This type stub file was generated by pyright.
 """
 
-import typing
-
 """ASFQuart - User session methods and decorators"""
 
 class ClientSession(dict):
@@ -25,7 +23,7 @@ class ClientSession(dict):
         we can send it to quart in a format it can render."""
         ...
 
-async def read(expiry_time=..., app=...) -> typing.Optional[ClientSession]:
+async def read(expiry_time=..., app=...) -> ClientSession | None:
     """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 28e53e7..7f60ee2 100644
--- a/typestubs/asfquart/utils.pyi
+++ b/typestubs/asfquart/utils.pyi
@@ -25,7 +25,6 @@ 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