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]