From 37e42808e73fefda2c239efe26f428176a44776b Mon Sep 17 00:00:00 2001 From: "Yorkstone Supplies (sycamoregroupltd)" <58149681+sycamoregroupltd@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:16:48 +0100 Subject: [PATCH] fix(security): pin httplib2==0.32.0 in setup.py REQUIRED_PACKAGES (GHSA-j5g9-f88f-gfj3) The previous fix (904ade32b) pinned httplib2==0.32.0 in pyproject.toml's google extra and tools/lazy_deps.py's skill.google_workspace, but missed a third install path: skills/productivity/google-workspace/scripts/setup.py REQUIRED_PACKAGES. A user following the --install-deps path could still resolve httplib2 via unpinned ranges. This commit: 1. Exact-pins all four Google packages in REQUIRED_PACKAGES to match pyproject.toml and lazy_deps.py contracts exactly. 2. Adds a focused regression test that parses setup.py's REQUIRED_PACKAGES via AST and asserts every pin matches the other two install paths. Changelog: fix(security), test(security) --- .../google-workspace/scripts/setup.py | 12 +- .../test_google_workspace_setup_deps.py | 201 ++++++++++++++++++ 2 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 tests/skills/test_google_workspace_setup_deps.py diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index c2393f92db..803d3d34fe 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -54,7 +54,17 @@ SCOPES = [ "https://www.googleapis.com/auth/documents", ] -REQUIRED_PACKAGES = ["google-api-python-client", "google-auth-oauthlib", "google-auth-httplib2"] +# Exact pins: keep in sync with pyproject.toml [project.optional-dependencies].google +# and tools/lazy_deps.py LAZY_DEPS['skill.google_workspace']. +# Pinning all three protects against version drift and ensures the httplib2 +# GHSA-j5g9-f88f-gfj3 security fix is honoured regardless of install path. +REQUIRED_PACKAGES = [ + "google-api-python-client==2.194.0", + "google-auth-oauthlib==1.3.1", + "google-auth-httplib2==0.3.1", + # GHSA-j5g9-f88f-gfj3 — Decompression Bomb DoS via unbounded gzip/deflate + "httplib2==0.32.0", +] # OAuth redirect for "out of band" manual code copy flow. # Google deprecated OOB, so we use a localhost redirect and tell the user to diff --git a/tests/skills/test_google_workspace_setup_deps.py b/tests/skills/test_google_workspace_setup_deps.py new file mode 100644 index 0000000000..f31ff0c786 --- /dev/null +++ b/tests/skills/test_google_workspace_setup_deps.py @@ -0,0 +1,201 @@ +"""Regression test: google-workspace setup.py REQUIRED_PACKAGES must pin httplib2. + +GHSA-j5g9-f88f-gfj3 (HIGH) — Decompression Bomb DoS via unbounded gzip/deflate +response handling. Fixed in httplib2 0.32.0. + +There are three install paths for google-workspace dependencies: + 1. pyproject.toml [project.optional-dependencies].google + 2. tools/lazy_deps.py LAZY_DEPS['skill.google_workspace'] + 3. skills/productivity/google-workspace/scripts/setup.py REQUIRED_PACKAGES + +This test ensures path 3 stays pinned and consistent with the other two. +""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + +SETUP_PY = REPO_ROOT / "skills/productivity/google-workspace/scripts/setup.py" +PYPROJECT_TOML = REPO_ROOT / "pyproject.toml" +LAZY_DEPS_PY = REPO_ROOT / "tools/lazy_deps.py" + +# --------------------------------------------------------------------------- +# Static parsers +# --------------------------------------------------------------------------- + +_GOOGLE_EXTRA_KEY = "google" +_LAZY_DEPS_KEY = "skill.google_workspace" + + +def _parse_setup_py_required_packages() -> list[str]: + """Parse setup.py and return the REQUIRED_PACKAGES list.""" + tree = ast.parse(SETUP_PY.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "REQUIRED_PACKAGES": + if isinstance(node.value, ast.List): + return [elt.value for elt in node.value.elts if isinstance(elt, ast.Constant)] + raise AssertionError("REQUIRED_PACKAGES not found in setup.py") + + +def _parse_pyproject_google_extra() -> list[str]: + """Parse pyproject.toml and return the google extra dependency list.""" + try: + import tomllib + except ImportError: + import tomli as tomllib # type: ignore[no-redef] + data = tomllib.loads(PYPROJECT_TOML.read_text(encoding="utf-8")) + optional_deps = data["project"]["optional-dependencies"] + return list(optional_deps[_GOOGLE_EXTRA_KEY]) + + +def _parse_lazy_deps_google_workspace() -> list[str]: + """Parse tools/lazy_deps.py and return the LAZY_DEPS for skill.google_workspace.""" + tree = ast.parse(LAZY_DEPS_PY.read_text(encoding="utf-8")) + for node in ast.walk(tree): + # LAZY_DEPS is declared as AnnAssign: `LAZY_DEPS: dict[str, tuple[str, ...]] = {...}` + target_name = None + if isinstance(node, ast.AnnAssign): + if isinstance(node.target, ast.Name): + target_name = node.target.id + elif isinstance(node, ast.Assign): + for t in node.targets: + if isinstance(t, ast.Name): + target_name = t.id + break + if target_name != "LAZY_DEPS": + continue + if isinstance(node, ast.AnnAssign) and isinstance(node.value, ast.Dict): + dict_val = node.value + elif isinstance(node, ast.Assign) and isinstance(node.value, ast.Dict): + dict_val = node.value + else: + continue + for k, v in zip(dict_val.keys, dict_val.values): + if isinstance(k, ast.Constant) and k.value == _LAZY_DEPS_KEY: + if isinstance(v, (ast.Tuple, ast.List)): + return [elt.value for elt in v.elts if isinstance(elt, ast.Constant)] # pyright: ignore[reportReturnType] + raise AssertionError(f"LAZY_DEPS[{_LAZY_DEPS_KEY!r}] not found") + + +def _extract_pins(packages: list[str]) -> dict[str, str]: + """Extract pinned versions: {package_name: version} for entries with == pin.""" + pins: dict[str, str] = {} + for pkg in packages: + if "==" in pkg: + name, version = pkg.split("==", 1) + pins[name.strip()] = version.strip() + return pins + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestGoogleWorkspaceSetupDepsPins: + """Security pin consistency across all three google-workspace install paths.""" + + def test_setup_py_pins_httplib2(self): + """setup.py REQUIRED_PACKAGES must include httplib2==0.32.0.""" + packages = _parse_setup_py_required_packages() + pins = _extract_pins(packages) + assert "httplib2" in pins, ( + f"httplib2 not found in setup.py REQUIRED_PACKAGES.\n" + f" Current entries: {packages}" + ) + assert pins["httplib2"] == "0.32.0", ( + f"httplib2 pin mismatch in setup.py: expected 0.32.0, got {pins['httplib2']}.\n" + f" Full REQUIRED_PACKAGES: {packages}" + ) + + def test_setup_py_pins_match_pyproject_toml(self): + """httplib2 pin in setup.py must match pyproject.toml google extra.""" + required_packages = _parse_setup_py_required_packages() + pyproject_packages = _parse_pyproject_google_extra() + + required_pins = _extract_pins(required_packages) + pyproject_pins = _extract_pins(pyproject_packages) + + for pkg in ("httplib2", "google-api-python-client", "google-auth-oauthlib", "google-auth-httplib2"): + setup_ver = required_pins.get(pkg) + toml_ver = pyproject_pins.get(pkg) + if setup_ver is None and toml_ver is None: + continue # neither path pins it, skip + assert toml_ver is not None, ( + f"{pkg} is pinned in setup.py ({setup_ver}) but NOT in pyproject.toml google extra.\n" + f" setup.py: {required_pins}\n" + f" pyproject.toml google: {pyproject_pins}" + ) + assert setup_ver is not None, ( + f"{pkg} is pinned in pyproject.toml ({toml_ver}) but NOT in setup.py.\n" + f" pyproject.toml google: {pyproject_pins}\n" + f" setup.py: {required_pins}" + ) + assert setup_ver == toml_ver, ( + f"{pkg} pin mismatch: setup.py has {setup_ver}, pyproject.toml has {toml_ver}.\n" + f" setup.py: {required_pins}\n" + f" pyproject.toml google: {pyproject_pins}" + ) + + def test_setup_py_pins_match_lazy_deps(self): + """httplib2 pin in setup.py must match tools/lazy_deps.py skill.google_workspace.""" + required_packages = _parse_setup_py_required_packages() + lazy_packages = _parse_lazy_deps_google_workspace() + + required_pins = _extract_pins(required_packages) + lazy_pins = _extract_pins(lazy_packages) + + for pkg in ("httplib2", "google-api-python-client", "google-auth-oauthlib", "google-auth-httplib2"): + setup_ver = required_pins.get(pkg) + lazy_ver = lazy_pins.get(pkg) + if setup_ver is None and lazy_ver is None: + continue + assert lazy_ver is not None, ( + f"{pkg} is pinned in setup.py ({setup_ver}) but NOT in lazy_deps.py.\n" + f" setup.py: {required_pins}\n" + f" lazy_deps.py: {lazy_pins}" + ) + assert setup_ver is not None, ( + f"{pkg} is pinned in lazy_deps.py ({lazy_ver}) but NOT in setup.py.\n" + f" lazy_deps.py: {lazy_pins}\n" + f" setup.py: {required_pins}" + ) + assert setup_ver == lazy_ver, ( + f"{pkg} pin mismatch: setup.py has {setup_ver}, lazy_deps.py has {lazy_ver}.\n" + f" setup.py: {required_pins}\n" + f" lazy_deps.py: {lazy_pins}" + ) + + def test_all_google_packages_are_pinned_in_all_paths(self): + """Every google workspace package that is version-pinned in any path must appear in all three.""" + pyproject_packages = _parse_pyproject_google_extra() + lazy_packages = _parse_lazy_deps_google_workspace() + setup_packages = _parse_setup_py_required_packages() + + all_pins: dict[str, set[str]] = {} + for label, pkgs in [ + ("pyproject.toml", pyproject_packages), + ("lazy_deps.py", lazy_packages), + ("setup.py", setup_packages), + ]: + for pkg in pkgs: + if "==" in pkg: + name, ver = pkg.split("==", 1) + all_pins.setdefault(name.strip(), set()).add(f"{label}={ver.strip()}") + + for pkg, entries in sorted(all_pins.items()): + versions = {e.split("=", 1)[1] for e in entries} + assert len(versions) == 1, ( + f"{pkg} has inconsistent pins across install paths:\n" + + "\n".join(f" {e}" for e in sorted(entries)) + ) + assert len(entries) == 3, ( + f"{pkg} is not pinned in all three install paths. Found {len(entries)}/3:\n" + + "\n".join(f" {e}" for e in sorted(entries)) + )