From 647fd96156624243d1b9fac37887b3bbfaef39d3 Mon Sep 17 00:00:00 2001 From: Punchy25 <244146053+Punchy25@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:47:29 +0000 Subject: [PATCH] fix(client): redact the api key from error bodies the api answers a wrong key with 401 "Invalid api key: " and every command prints error bodies to stderr (batch writes them to .err files), so the full key ended up in terminals, logs and agent transcripts the client now replaces the key with [redacted] in any 4xx/5xx body, in both _get and _request, so every command gets it for free --- src/scrapingbee_cli/client.py | 11 +++++- tests/unit/test_key_redaction.py | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_key_redaction.py diff --git a/src/scrapingbee_cli/client.py b/src/scrapingbee_cli/client.py index ea4c25a..66e35c0 100644 --- a/src/scrapingbee_cli/client.py +++ b/src/scrapingbee_cli/client.py @@ -58,6 +58,13 @@ async def __aexit__(self, *args: object) -> None: await self._session.close() self._session = None + def _redact(self, body: bytes, status: int) -> bytes: + """The API echoes a wrong key back in its 401 body; keep it out of output and .err files.""" + key = self.api_key.strip() + if status >= 400 and key: + return body.replace(key.encode(), b"[redacted]") + return body + def _ensure_session(self) -> aiohttp.ClientSession: if self._session is None: raise RuntimeError("Client must be used as async with Client(...) as client") @@ -76,7 +83,7 @@ async def _get( if headers: req_kwargs["headers"] = headers async with session.get(url, **req_kwargs) as resp: - body = await resp.read() + body = self._redact(await resp.read(), resp.status) out_headers = dict(resp.headers) return body, out_headers, resp.status @@ -131,7 +138,7 @@ async def _request( else {"Content-Type": content_type} ) async with session.request(method, url, **req_kwargs) as resp: - body = await resp.read() + body = self._redact(await resp.read(), resp.status) return body, dict(resp.headers), resp.status @staticmethod diff --git a/tests/unit/test_key_redaction.py b/tests/unit/test_key_redaction.py new file mode 100644 index 0000000..24e2dca --- /dev/null +++ b/tests/unit/test_key_redaction.py @@ -0,0 +1,65 @@ +"""Regression tests: the API key must not come back out of an error body. + +The API answers a wrong key with 401 ``{"message": "Invalid api key: "}``. +Every command prints error bodies to stderr (and batch writes them to .err +files), so the full key ended up in terminals, logs and agent transcripts. + +These tests run the real Client against a local fake API endpoint. +""" + +from __future__ import annotations + +import asyncio + +from aiohttp import web + +from scrapingbee_cli.client import Client + +KEY = "FAKEKEY1234567890abcdef" + + +def _call_fake_api(status, call, key=KEY): + """Run ``call(client)`` against an endpoint that echoes the key with ``status``.""" + + async def endpoint(request): + return web.json_response({"message": f"Invalid api key: {KEY}"}, status=status) + + async def run(): + app = web.Application() + app.router.add_route("*", "/{tail:.*}", endpoint) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = runner.addresses[0][1] + try: + async with Client(key, base_url=f"http://127.0.0.1:{port}") as client: + return await call(client) + finally: + await runner.cleanup() + + return asyncio.run(run()) + + +class TestKeyIsRedactedInErrorBodies: + def test_get_401_body_does_not_contain_the_key(self): + data, _, status = _call_fake_api(401, lambda c: c.usage(retries=0)) + assert status == 401 + assert KEY.encode() not in data + assert b"Invalid api key: [redacted]" in data + + def test_post_401_body_does_not_contain_the_key(self): + data, _, status = _call_fake_api( + 401, lambda c: c.scrape("https://example.com", method="post", body="x=1", retries=0) + ) + assert status == 401 + assert KEY.encode() not in data + + def test_key_with_trailing_whitespace_is_still_redacted(self): + data, _, _ = _call_fake_api(401, lambda c: c.usage(retries=0), key=KEY + " ") + assert KEY.encode() not in data + + def test_success_body_is_left_alone(self): + data, _, status = _call_fake_api(200, lambda c: c.usage(retries=0)) + assert status == 200 + assert KEY.encode() in data