From 1b78fe8c745f38c138f75afe3c96e5c5934984e7 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 24 Sep 2026 00:06:02 +0200 Subject: [PATCH] fix(transport): sign the uncompressed body; release 3.0.2 The payload signature was computed over the gzip-compressed wire body, but the server verifies the HMAC after decompressing, so every request large enough to trigger compression was rejected as signature-invalid. The signature now always covers the uncompressed body, matching the server contract and all four agent ports. Also fixes the _version.py drift left by 3.0.1 (agent_version reported 3.0.0 on 3.0.1 installs) with a consistency test, and widens the deprecated fastapi-guard-agent shim pin to guard-agent 3.x (shim 1.3.0). --- .mike.yml | 3 +- CHANGELOG.md | 17 +++++++ docs/release-notes.md | 17 +++++++ docs/versions/versions.json | 3 +- guard_agent/_transport_dispatch.py | 32 +++++++------ guard_agent/_version.py | 2 +- pyproject.toml | 2 +- shim/README.md | 2 +- shim/pyproject.toml | 4 +- tests/test_transport.py | 74 +++++++++++++++++++++++++++--- tests/test_version_consistency.py | 28 +++++++++++ 11 files changed, 157 insertions(+), 27 deletions(-) create mode 100644 tests/test_version_consistency.py diff --git a/.mike.yml b/.mike.yml index fec3a37..40f4d91 100644 --- a/.mike.yml +++ b/.mike.yml @@ -2,6 +2,7 @@ version_selector: true title_switch: true versions_file: docs/versions/versions.json versions: + - 3.0.2 - 3.0.0 - 2.10.0 - 2.9.1 @@ -27,4 +28,4 @@ versions: - 0.0.1 - latest aliases: - latest: 3.0.0 + latest: 3.0.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 84738ee..13bb978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ Release Notes ___ +v3.0.2 (2026-09-23) +------------------- + +Payload signatures now cover the uncompressed body (v3.0.2) +----------------------------------------------------------- + +### Fixed + +- **The payload signature was computed over the compressed wire body, so every compressed request failed server-side verification.** When gzip compression kicked in (payload at or above `compression_threshold` with `compression_enabled`), `X-Payload-Signature` was an HMAC over the gzipped bytes, while the server verifies after decompressing; compressed batches were therefore rejected as signature-invalid. The signature now always covers the uncompressed body, matching the server contract and all four agent ports (Go, PHP, Rust, TypeScript). Regression tests verify the signature equals the HMAC over the decompressed wire body on both the encrypted and unencrypted POST paths. +- **The package version and the reported agent version could drift apart.** The 3.0.1 release updated `pyproject.toml` but left `guard_agent/_version.py` (the "single source of truth" used for the User-Agent and `agent_version` on every batch) at 3.0.0. Both are now 3.0.2, the bump script remains the canonical way to change the version, and a new consistency test fails if the two files ever diverge again. + +### Changed + +- **The deprecated `fastapi-guard-agent` alias now resolves to guard-agent 3.x.** Its dependency pin was still `guard-agent>=2.0.0,<3.0.0` from the 2.0.0 rename, so installs through the alias kept resolving to 2.x after 3.0.0 shipped. The pin is now `>=3.0.0,<4.0.0` and the shim version is 1.3.0; the shim itself publishes via the manual `release-shim.yml` workflow after the main release. + +___ + v3.0.1 (2026-09-23) ------------------- diff --git a/docs/release-notes.md b/docs/release-notes.md index 7cd539e..a6507c9 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -3,6 +3,23 @@ Release Notes ___ +v3.0.2 (2026-09-23) +------------------- + +TITLE (v3.0.2) +------------ + +### Fixed + +- **The payload signature was computed over the compressed wire body, so every compressed request failed server-side verification.** When gzip compression kicked in (payload at or above `compression_threshold` with `compression_enabled`), `X-Payload-Signature` was an HMAC over the gzipped bytes, while the server verifies after decompressing; compressed batches were therefore rejected as signature-invalid. The signature now always covers the uncompressed body, matching the server contract and all four agent ports (Go, PHP, Rust, TypeScript). Regression tests verify the signature equals the HMAC over the decompressed wire body on both the encrypted and unencrypted POST paths. +- **The package version and the reported agent version could drift apart.** The 3.0.1 release updated `pyproject.toml` but left `guard_agent/_version.py` (the "single source of truth" used for the User-Agent and `agent_version` on every batch) at 3.0.0. Both are now 3.0.2, the bump script remains the canonical way to change the version, and a new consistency test fails if the two files ever diverge again. + +### Changed + +- **The deprecated `fastapi-guard-agent` alias now resolves to guard-agent 3.x.** Its dependency pin was still `guard-agent>=2.0.0,<3.0.0` from the 2.0.0 rename, so installs through the alias kept resolving to 2.x after 3.0.0 shipped. The pin is now `>=3.0.0,<4.0.0` and the shim version is 1.3.0; the shim itself publishes via the manual `release-shim.yml` workflow after the main release. + +___ + v3.0.0 (2026-09-04) ------------------- diff --git a/docs/versions/versions.json b/docs/versions/versions.json index 4ea6592..0727322 100644 --- a/docs/versions/versions.json +++ b/docs/versions/versions.json @@ -1,4 +1,5 @@ { + "3.0.2": "3.0.2", "3.0.0": "3.0.0", "2.10.0": "2.10.0", "2.9.1": "2.9.1", @@ -22,5 +23,5 @@ "0.1.1": "0.1.1", "0.1.0": "0.1.0", "0.0.1": "0.0.1", - "latest": "3.0.0" + "latest": "3.0.2" } diff --git a/guard_agent/_transport_dispatch.py b/guard_agent/_transport_dispatch.py index 1b2b3f4..416f4bd 100644 --- a/guard_agent/_transport_dispatch.py +++ b/guard_agent/_transport_dispatch.py @@ -105,15 +105,25 @@ async def _handle_response(self, response: httpx.Response) -> dict[str, Any] | b class TransportDispatchMixin(TransportResponseMixin, TransportLifecycleMixin): _ENCRYPTED_ENDPOINTS = ("/api/v1/events", "/api/v1/metrics") - def _maybe_compress(self, json_text: str) -> tuple[bytes, dict[str, str]]: - """Return body bytes plus Content-Encoding header when compression applies.""" + def _build_post_body(self, json_text: str) -> tuple[bytes, dict[str, str]]: + """Return wire body bytes plus headers (signature, compression) for a POST. + + The signature always covers the uncompressed body: the server + decompresses the wire body before verifying, so a signature taken + over gzipped bytes never verifies. + """ raw = json_text.encode("utf-8") + headers: dict[str, str] = {} + signature = sign_payload(raw, secret=self.config.payload_signing_secret) + if signature is not None: + headers["X-Payload-Signature"] = signature if ( - not self.config.compression_enabled - or len(raw) < self.config.compression_threshold + self.config.compression_enabled + and len(raw) >= self.config.compression_threshold ): - return raw, {} - return gzip.compress(raw), {"Content-Encoding": "gzip"} + headers["Content-Encoding"] = "gzip" + return gzip.compress(raw), headers + return raw, headers def _redact_sensitive_headers(self, data: dict[str, Any]) -> dict[str, Any]: for event in data.get("events", []): @@ -220,10 +230,7 @@ async def _post_encrypted(self, data: dict[str, Any]) -> dict[str, Any] | bool: ) self._fire_error_hook("encryption", e, {"endpoint": encrypted_url}) return False - body, headers = self._maybe_compress(json_data) - signature = sign_payload(body, secret=self.config.payload_signing_secret) - if signature is not None: - headers["X-Payload-Signature"] = signature + body, headers = self._build_post_body(json_data) self.bytes_sent += len(body) response = await self._client.post(encrypted_url, content=body, headers=headers) return await self._handle_response(response) @@ -242,10 +249,7 @@ async def _post_unencrypted( ) self._fire_error_hook("transport_send", e, {"endpoint": url}) return False - body, headers = self._maybe_compress(json_data) - signature = sign_payload(body, secret=self.config.payload_signing_secret) - if signature is not None: - headers["X-Payload-Signature"] = signature + body, headers = self._build_post_body(json_data) self.bytes_sent += len(body) response = await self._client.post(url, content=body, headers=headers) return await self._handle_response(response) diff --git a/guard_agent/_version.py b/guard_agent/_version.py index 5574aea..df14905 100644 --- a/guard_agent/_version.py +++ b/guard_agent/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the package version, importable without side-effects.""" -__version__ = "3.0.0" +__version__ = "3.0.2" diff --git a/pyproject.toml b/pyproject.toml index 872ca6b..788cd42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "guard-agent" -version = "3.0.1" +version = "3.0.2" description = "Framework-agnostic telemetry and monitoring agent for the Guard security ecosystem (fastapi-guard, flaskapi-guard, djangoapi-guard, tornadoapi-guard)" authors = [ {name = "Renzo Franceschini", email = "rennf93@users.noreply.github.com"} diff --git a/shim/README.md b/shim/README.md index 79fba3a..fad844c 100644 --- a/shim/README.md +++ b/shim/README.md @@ -4,7 +4,7 @@ As of `guard-agent` 2.0.0, the telemetry agent previously distributed under the name `fastapi-guard-agent` has been repositioned as a framework-agnostic agent serving `fastapi-guard`, `flaskapi-guard`, `djangoapi-guard`, and `tornadoapi-guard`. -This PyPI entry (`fastapi-guard-agent==1.2.0`) is a meta-package: installing it transitively installs `guard-agent>=2.0.0,<3.0.0`. It exists so that existing install commands referencing `fastapi-guard-agent` in scripts, Dockerfiles, and lockfiles continue to resolve to the renamed distribution. +This PyPI entry (`fastapi-guard-agent==1.3.0`) is a meta-package: installing it transitively installs `guard-agent>=3.0.0,<4.0.0`. It exists so that existing install commands referencing `fastapi-guard-agent` in scripts, Dockerfiles, and lockfiles continue to resolve to the renamed distribution. ## Migration diff --git a/shim/pyproject.toml b/shim/pyproject.toml index 5dc2bf3..de7f37f 100644 --- a/shim/pyproject.toml +++ b/shim/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fastapi_guard_agent" -version = "1.2.0" +version = "1.3.0" description = "Deprecated alias — the package has been renamed to guard-agent. Installing fastapi-guard-agent will transitively install guard-agent." authors = [ {name = "Renzo Franceschini", email = "rennf93@users.noreply.github.com"} @@ -23,7 +23,7 @@ classifiers = [ "Topic :: System :: Monitoring", ] dependencies = [ - "guard-agent>=2.0.0,<3.0.0", + "guard-agent>=3.0.0,<4.0.0", ] [project.urls] diff --git a/tests/test_transport.py b/tests/test_transport.py index c24793b..8b74bd5 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1665,27 +1665,27 @@ async def test_get_with_retry_records_failure_after_retry_after_attempts_exhaust class TestHTTPTransportCompression: """Tests for the gzip compression of outgoing request bodies.""" - def test_maybe_compress_skips_when_below_threshold( + def test_build_post_body_skips_when_below_threshold( self, agent_config: AgentConfig ) -> None: agent_config.compression_enabled = True agent_config.compression_threshold = 10_000 transport = HTTPTransport(agent_config) - body, headers = transport._maybe_compress("hi") + body, headers = transport._build_post_body("hi") assert body == b"hi" assert headers == {} - def test_maybe_compress_skips_when_disabled( + def test_build_post_body_skips_when_disabled( self, agent_config: AgentConfig ) -> None: agent_config.compression_enabled = False agent_config.compression_threshold = 1 transport = HTTPTransport(agent_config) - body, headers = transport._maybe_compress("x" * 100) + body, headers = transport._build_post_body("x" * 100) assert body == b"x" * 100 assert headers == {} - def test_maybe_compress_gzips_above_threshold( + def test_build_post_body_gzips_above_threshold( self, agent_config: AgentConfig ) -> None: import gzip @@ -1693,11 +1693,30 @@ def test_maybe_compress_gzips_above_threshold( agent_config.compression_enabled = True agent_config.compression_threshold = 10 transport = HTTPTransport(agent_config) - body, headers = transport._maybe_compress("y" * 200) + body, headers = transport._build_post_body("y" * 200) assert headers == {"Content-Encoding": "gzip"} assert gzip.decompress(body) == b"y" * 200 assert len(body) < 200 + def test_build_post_body_signs_uncompressed_body_when_gzipped( + self, agent_config: AgentConfig + ) -> None: + import gzip + import hashlib + import hmac + + agent_config.compression_enabled = True + agent_config.compression_threshold = 10 + agent_config.payload_signing_secret = "my-hmac-secret" + transport = HTTPTransport(agent_config) + body, headers = transport._build_post_body("z" * 200) + expected = ( + "v1=" + hmac.new(b"my-hmac-secret", b"z" * 200, hashlib.sha256).hexdigest() + ) + assert headers["X-Payload-Signature"] == expected + assert headers["Content-Encoding"] == "gzip" + assert gzip.decompress(body) == b"z" * 200 + @pytest.mark.asyncio async def test_send_events_uses_gzip_when_payload_is_large( self, @@ -1731,6 +1750,49 @@ async def test_send_events_uses_gzip_when_payload_is_large( decoded = gzip.decompress(sent_body) assert b"ip_banned" in decoded + @pytest.mark.asyncio + async def test_send_events_signature_verifies_after_decompression( + self, + agent_config: AgentConfig, + mock_client: AsyncMock, + ) -> None: + """The signature must cover the uncompressed body, as the server + verifies it against the decompressed wire body.""" + import gzip + import hashlib + import hmac + + agent_config.compression_enabled = True + agent_config.compression_threshold = 100 + agent_config.payload_signing_secret = "my-hmac-secret" + transport = HTTPTransport(agent_config) + transport._client = mock_client + + events = [ + SecurityEvent( + timestamp=datetime.now(timezone.utc), + event_type="ip_banned", + ip_address="192.168.1.1", + action_taken="banned", + reason="x" * 4000, + ) + ] + + result = await transport.send_events(events) + assert result is True + + post_call = mock_client.post.call_args + headers = post_call.kwargs.get("headers", {}) + sent_body = post_call.kwargs["content"] + assert headers.get("Content-Encoding") == "gzip" + expected = ( + "v1=" + + hmac.new( + b"my-hmac-secret", gzip.decompress(sent_body), hashlib.sha256 + ).hexdigest() + ) + assert headers["X-Payload-Signature"] == expected + class TestLogRequestError: """Tests for HTTPTransport._log_request_error. diff --git a/tests/test_version_consistency.py b/tests/test_version_consistency.py new file mode 100644 index 0000000..1dee231 --- /dev/null +++ b/tests/test_version_consistency.py @@ -0,0 +1,28 @@ +"""Guard against version drift between pyproject.toml and guard_agent._version. + +The 3.0.1 release shipped with pyproject.toml at 3.0.1 while _version.py +(the value reported as agent_version in telemetry batches and the User-Agent) +still said 3.0.0, because the release only hand-edited pyproject.toml instead +of running the bump script. This test fails if the two ever diverge again. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from guard_agent._version import __version__ + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +def test_pyproject_version_matches_version_module() -> None: + pyproject = PROJECT_ROOT / "pyproject.toml" + content = pyproject.read_text() + match = re.search(r'^version\s*=\s*"([^"]*)"', content, re.MULTILINE) + assert match is not None, "pyproject.toml has no version field" + assert match.group(1) == __version__, ( + f"pyproject.toml version {match.group(1)!r} does not match " + f"guard_agent._version.__version__ {__version__!r}; run " + f"'make bump-version VERSION=x.y.z' instead of hand-editing" + )