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..ef9e98579 --- /dev/null +++ b/features/steps/security_pyyaml_dependency_steps.py @@ -0,0 +1,132 @@ +"""Step definitions for PyYAML security dependency verification (#9055).""" + +from __future__ import annotations + +import importlib +import re +from pathlib import Path +from typing import Any + +from behave import given, then, when +from behave.runner import Context + + +@given('the pyproject.toml file exists at "{path}"') +def step_pyproject_exists(context: Context, path: str) -> None: + """Verify pyproject.toml exists at the given path.""" + pyproject_path = Path(path) + assert pyproject_path.exists(), f"pyproject.toml not found at {path}" + context.pyproject_path = pyproject_path + + +@when("I read the project dependencies from pyproject.toml") +def step_read_project_dependencies(context: Context) -> None: + """Read the [project.dependencies] list from pyproject.toml.""" + content = context.pyproject_path.read_bytes() + data: dict[str, Any] = _load_toml(content) + context.project_dependencies = data.get("project", {}).get("dependencies", []) + + +@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 min_version == expected, ( + f"PyYAML minimum version {min_version} does not match expected {expected}. " + f"Dependency spec: {context.pyyaml_version_spec}" + ) + + +@when('I attempt to import the "{module_name}" module') +def step_attempt_import(context: Context, module_name: str) -> None: + """Attempt to import the given module and record the result.""" + try: + context.imported_module = importlib.import_module(module_name) + context.import_error = None + except ImportError as exc: + context.imported_module = None + context.import_error = exc + + +@then("the import should succeed without errors") +def step_import_succeeded(context: Context) -> None: + """Assert that the previous import attempt succeeded.""" + assert context.import_error is None, f"Import failed with: {context.import_error}" + assert context.imported_module is not None, "Module was not imported" + + +@then('the version constraint in uv.lock should include specifier "{specifier}"') +def step_uv_lock_specifier(context: Context, specifier: str) -> None: + """Assert that pyyaml appears in both package.dependencies and requires-dist in uv.lock.""" + lock_path = context.pyproject_path.parent / "uv.lock" + content = lock_path.read_bytes().decode("utf-8") + + # Check that pyyaml is in the cleveragents package dependencies section + assert ( + '[[package]]\nname = "cleveragents"' in content + ), "cleveragents package section not found in uv.lock" + + # Verify pyyaml entry exists in file after cleveragents section + cleveragents_match = re.search( + r'\[\[package\]\]\s*name = "cleveragents"(.*?)^\[\[package\]\]', + content, + re.DOTALL | re.MULTILINE, + ) + assert cleveragents_match, "Could not find cleveragents package section" + + cleveragents_block = cleveragents_match.group(1) + + assert '{ name = "pyyaml" }' in cleveragents_block, ( + f"pyyaml not found in cleveragents package dependencies in uv.lock" + ) + + +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: + # Fallback for older Python versions without tomllib + import tomlkit + + return tomlkit.parse(content.decode("utf-8")).unwrap() # type: ignore[no-any-return] 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" },