diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b40ede8..3dda41d07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Security +- **PyYAML upgraded to >=6.0.3 to address known security vulnerability** (#9055): + Added an explicit `pyyaml>=6.0.3` dependency constraint to `pyproject.toml` to + prevent installation of vulnerable older versions with known YAML parsing security + issues. + - **aiohttp upgraded to >=3.13.4 to remediate CVE-2026-34513 and CVE-2026-34515** (#1549, #1544): Added an explicit `aiohttp>=3.13.4` dependency constraint to `pyproject.toml` to remediate two high-severity open redirect vulnerabilities. Both CVEs affect the CleverAgents platform's diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index b42a9f1a5..595cb3ac5 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -40,3 +40,4 @@ Below are some of the specific details of various contributions. * HAL 9000 has contributed the ACMS context path matching fix (PR #10975 / issue #10972): corrects `_path_matches()` and `_matches_pattern()` to properly match absolute fragment paths against relative glob patterns by auto-prefixing with `**/` before calling `PurePath.full_match()`, preventing silent inefficacy of include/exclude filters for absolute paths in fragment metadata. * HAL 9000 has contributed database resource types (PostgreSQL, SQLite) with transaction-based sandbox strategy: implemented ``DatabaseResourceHandler`` providing full CRUD operations (`read`, `write`, `delete`, `list_children`) and connection validation with automatic credential masking for PostgreSQL and SQLite backends. Includes ``TransactionSandbox`` infrastructure wired into ``SandboxFactory``, BDD test coverage in ``features/database_resources.feature``, and Robot Framework integration tests in ``robot/database_resources.robot`` (PR #10591 / issue #8608, Epic #8568). * HAL 9000 has contributed the agents plan rollback command (PR #8674 / issue #8557): implemented checkpoint-based plan state restoration with the `agents plan rollback []` CLI command as part of Epic #8493, enabling plans to be restored to previous checkpoints, discarding post-checkpoint decisions, and resuming execution from the rolled-back state. Supported by `--yes/-y`, `--to-checkpoint`, and `--format/-f` flags. Includes comprehensive BDD test coverage (>= 97%) for rollback, decision discarding, and plan resume functionality. +* HAL 9000 has contributed the PyYAML security upgrade (PR #11012 / issue #9055): added `pyyaml>=6.0.3` dependency constraint to address known YAML parsing vulnerabilities. diff --git a/features/security_pyyaml_dependency.feature b/features/security_pyyaml_dependency.feature new file mode 100644 index 000000000..985f286b3 --- /dev/null +++ b/features/security_pyyaml_dependency.feature @@ -0,0 +1,23 @@ +@tdd_issue @tdd_issue_9055 +Feature: PyYAML is a declared project dependency with minimum secure version + As a CleverAgents developer + I want the PyYAML dependency to be listed in pyproject.toml with a minimum secure version + So that vulnerable older versions of PyYAML cannot be installed + + Background: + Given the pyproject.toml file exists at "pyproject.toml" + + @tdd_issue @tdd_issue_9055 + Scenario: PyYAML is listed in project dependencies with minimum version constraint + When I read the project dependencies from pyproject.toml + Then the dependency list should include a package matching "pyyaml>=" + + @tdd_issue @tdd_issue_9055 + Scenario: PyYAML minimum version is >=6.0.3 + When I read the PyYAML dependency specification from pyproject.toml + Then the minimum version should be at least "6.0.3" + + @tdd_issue @tdd_issue_9055 + Scenario: yaml module is importable as a project dependency + When I attempt to import the "yaml" module + Then the import should succeed without errors diff --git a/features/steps/security_pyyaml_dependency_steps.py b/features/steps/security_pyyaml_dependency_steps.py new file mode 100644 index 000000000..9150433a4 --- /dev/null +++ b/features/steps/security_pyyaml_dependency_steps.py @@ -0,0 +1,108 @@ +"""Step definitions for PyYAML security dependency verification (#9055). + +Note: the shared steps for reading pyproject.toml and verifying importability +are defined in ``tdd_a2a_sdk_dependency_steps.py`` to avoid AmbiguousStep +conflicts. This file contains only the PyYAML-specific step definitions. +""" + +from __future__ import annotations + +import re +from typing import Any + +from behave import then, when +from behave.runner import Context +from packaging.version import parse as parse_version + + +@then('the dependency list should include a package matching "{pattern}"') +def step_dependency_matches_pattern(context: Context, pattern: str) -> None: + """Assert that a dependency in the project dependencies matches the given regex pattern.""" + deps: list[str] = context.project_dependencies + found = any(re.search(pattern, dep) for dep in deps) + assert found, ( + f"No dependency matching '{pattern}' found in " + f"[project.dependencies]. Current deps: {deps}" + ) + + +@when("I read the PyYAML dependency specification from pyproject.toml") +def step_read_pyyaml_dependency(context: Context) -> None: + """Read and parse the PyYAML dependency from pyproject.toml.""" + content = context.pyproject_path.read_bytes() + data: dict[str, Any] = _load_toml(content) + deps: list[str] = data.get("project", {}).get("dependencies", []) + + # Find the pyyaml dependency entry + pyyaml_dep = None + for dep in deps: + if dep.strip().startswith("pyyaml"): + pyyaml_dep = dep.strip() + break + + assert pyyaml_dep is not None, ( + f"PyYAML dependency not found in [project.dependencies]. Current deps: {deps}" + ) + + # Parse the minimum version from the constraint + # Examples: "pyyaml>=6.0.3", "pyyaml>=6.0", "pyyaml==6.0.2" + match = re.search(r"(?:>=|==|~>)([\d]+(?:\.[\d]+)*(?:\.\w+)*)", pyyaml_dep) + assert match is not None, ( + f"Could not parse version from PyYAML dependency: {pyyaml_dep}" + ) + + context.pyyaml_version_spec = pyyaml_dep + context.pyyaml_min_version = match.group(1) + + +@then('the minimum version should be at least "{expected}"') +def step_pyyaml_minimum_version(context: Context, expected: str) -> None: + """Assert that the PyYAML minimum version is >= expected version.""" + min_version = context.pyyaml_min_version + assert parse_version(min_version) >= parse_version(expected), ( + f"PyYAML minimum version {min_version} is less than required {expected}. " + f"Dependency spec: {context.pyyaml_version_spec}" + ) + + +def _flatten_toml_dict(d: dict[Any, Any]) -> dict[str, Any]: + """Recursively convert a tomlkit dict to a plain Python dict. + + tomlkit wraps every value in a proxy object that behaves like the + underlying Python type but is not identical to it. This helper + strips those wrappers so the result is a plain ``dict[str, Any]`` + compatible with ``tomllib`` output. + """ + result: dict[str, Any] = {} + for key, value in d.items(): + if isinstance(value, dict): + result[key] = _flatten_toml_dict(value) + else: + result[key] = value + return result + + +def _toml_to_python(data: object) -> dict[str, Any]: + """Convert a tomlkit document to a plain Python dict. + + Delegates recursive flattening to the module-level + ``_flatten_toml_dict`` helper so the logic stays testable in + isolation and ruff does not flag nested function definitions. + """ + if isinstance(data, dict): + return _flatten_toml_dict(data) + raise TypeError(f"Expected dict-like TOML table, got {type(data).__name__}") + + +def _load_toml(content: bytes) -> dict[str, Any]: + """Load a TOML file from bytes. Uses tomllib if available, else fallback.""" + try: + import tomllib + + return tomllib.loads(content.decode("utf-8")) + except ImportError: # pragma: no cover — Python <3.11 + # Fallback for older Python versions without tomllib + import tomlkit + + parsed_doc = tomlkit.parse(content.decode("utf-8")) + return _toml_to_python(parsed_doc) diff --git a/pyproject.toml b/pyproject.toml index c1fdf4a71..f711440c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "tomlkit>=0.13.0", # TOML writing with comment preservation for config CLI "tenacity>=8.2.0", # Retry framework for service layer resilience "aiohttp>=3.13.4", # CVE-2026-34515 mitigation: open redirect vulnerability + "pyyaml>=6.0.3", # Security: address known YAML parsing vulnerabilities "a2a-sdk>=0.3.0,<1.0.0", # A2A Python SDK — required transport for local (stdio) and server (HTTP) modes (ADR-047); pinned <1.0.0 (removed legacy A2AClient) ] diff --git a/uv.lock b/uv.lock index 31d88e524..c0ed72e3f 100644 --- a/uv.lock +++ b/uv.lock @@ -446,6 +446,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-ulid" }, + { name = "pyyaml" }, { name = "restrictedpython" }, { name = "rx" }, { name = "structlog" }, @@ -528,6 +529,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "python-ulid", specifier = ">=2.7.0" }, { name = "radon", marker = "extra == 'dev'", specifier = ">=6.0.1" }, { name = "restrictedpython", specifier = ">=7.0" },