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]