diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c48cbfa..9fb76bc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,6 +9,9 @@ on: # pull_request: # branches: [ main, develop ] +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7bc21b1..dc52240 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,6 +5,10 @@ on: types: [published] workflow_dispatch: +permissions: + contents: read + id-token: write + jobs: build-and-publish: runs-on: ubuntu-latest diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..2ee80d8 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,31 @@ +name: Security + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +permissions: + contents: read + +jobs: + bandit: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run bandit security linter + run: uv run bandit -r tango/ -c pyproject.toml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2593369..f3a9ed1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [ main, develop ] +permissions: + contents: read + jobs: test: runs-on: ${{ matrix.os }} diff --git a/README.md b/README.md index d154abf..d396382 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ print(contract["recipient"]["display_name"]) # Nested fields too ## Development -This project uses [uv](https://docs.astral.sh/uv/) for dependency management and tooling. +This project uses [uv](https://docs.astral.sh/uv/) for dependency management and [just](https://github.com/casey/just) as a task runner. Integration tests use [1Password CLI](https://developer.1password.com/docs/cli/) (`op`) to inject the API key at runtime. ### Setup @@ -357,11 +357,19 @@ cd tango-python # Install dependencies with uv uv sync --all-extras +``` + +### Secrets Management + +API keys are stored in 1Password and injected at runtime via `op run`. The `.env` file uses secret references instead of real values: -# Or install dev dependencies only -uv sync --group dev +```bash +# .env +TANGO_API_KEY=op://Vault/tango-api/credential ``` +Recipes that need the API key (integration tests, live tests, cassette refresh) handle this automatically through `just`. + ### Testing The SDK includes a comprehensive test suite with: @@ -370,23 +378,19 @@ The SDK includes a comprehensive test suite with: ```bash # Run all tests -uv run pytest +just test # Run only unit tests -uv run pytest tests/ -m "not integration" +just test-unit -# Run only integration tests -uv run pytest tests/integration/ +# Run integration tests (API key injected via 1Password) +just test-integration -# Run integration tests with live API (requires TANGO_API_KEY) -export TANGO_API_KEY=your-api-key -export TANGO_USE_LIVE_API=true -uv run pytest tests/integration/ +# Run integration tests with live API +just test-live # Refresh cassettes with fresh API responses -export TANGO_API_KEY=your-api-key -export TANGO_REFRESH_CASSETTES=true -uv run pytest tests/integration/ +just refresh-cassettes ``` See [tests/integration/README.md](tests/integration/README.md) for detailed testing documentation. @@ -395,16 +399,19 @@ See [tests/integration/README.md](tests/integration/README.md) for detailed test ```bash # Format code -uv run ruff format tango/ +just fmt # Lint code -uv run ruff check tango/ +just lint # Type checking -uv run mypy tango/ +just typecheck + +# Security scan +just bandit -# Run all checks -uv run ruff format tango/ && uv run ruff check tango/ && uv run mypy tango/ +# Run all checks (format, lint, typecheck, bandit) +just check ``` ### Project Structure @@ -482,6 +489,8 @@ tango-python/ - Python 3.12 or higher - httpx >= 0.27.0 +- [just](https://github.com/casey/just) (for development task runner) +- [1Password CLI](https://developer.1password.com/docs/cli/) (for integration tests) ## License @@ -501,12 +510,9 @@ Contributions are welcome! Please feel free to submit a Pull Request. 1. Fork the repository 2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Run lint and format: `uv run ruff format tango/ && uv run ruff check tango/` -4. Run type checking: `uv run mypy tango/` -5. Run tests: `uv run pytest` -6. (Optional) Run [filter and shape conformance](scripts/README.md#filter-and-shape-conformance) if you have the tango API manifest; CI will run it on push/PR -7. Commit your changes (`git commit -m 'Add amazing feature'`) -8. Push to the branch (`git push origin feature/amazing-feature`) -9. Open a Pull Request - -For a single command that runs formatting, linting, type checking, and tests (and conformance when the manifest is present), use: `uv run python scripts/pr_review.py --mode full` +3. Run all checks: `just check` +4. Run tests: `just test` +5. (Optional) Run full PR review: `just pr-review` +6. Commit your changes (`git commit -m 'Add amazing feature'`) +7. Push to the branch (`git push origin feature/amazing-feature`) +8. Open a Pull Request diff --git a/justfile b/justfile new file mode 100644 index 0000000..cf16958 --- /dev/null +++ b/justfile @@ -0,0 +1,49 @@ +# Tango Python SDK task runner +# Requires: just (https://github.com/casey/just), uv, op (1Password CLI) + +# Default: list available recipes +default: + @just --list + +# Run all tests +test *args: + uv run pytest {{ args }} + +# Run unit tests only +test-unit *args: + uv run pytest tests/ -m "not integration" {{ args }} + +# Run integration tests (requires API key in 1Password) +test-integration *args: + op run --env-file .env -- uv run pytest tests/integration/ {{ args }} + +# Run integration tests with live API +test-live *args: + TANGO_USE_LIVE_API=true op run --env-file .env -- uv run pytest tests/integration/ {{ args }} + +# Refresh VCR cassettes with fresh API responses +refresh-cassettes *args: + TANGO_REFRESH_CASSETTES=true op run --env-file .env -- uv run pytest tests/integration/ {{ args }} + +# Format code +fmt: + uv run ruff format tango/ + +# Lint code +lint: + uv run ruff check tango/ + +# Type checking +typecheck: + uv run mypy tango/ + +# Security scan with bandit +bandit: + uv run bandit -r tango/ -c pyproject.toml + +# Run all code quality checks +check: fmt lint typecheck bandit + +# Full PR review (format, lint, types, tests, conformance) +pr-review *args: + uv run python scripts/pr_review.py --mode full {{ args }} diff --git a/pyproject.toml b/pyproject.toml index 640b5c2..f92b59d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dev = [ "pytest-recording>=0.13.0", "python-dotenv>=1.0.0", "pyyaml>=6.0", + "bandit>=1.7.0", ] notebooks = [ "jupyter>=1.0.0", @@ -113,6 +114,10 @@ exclude_lines = [ "if __name__ == .__main__.:", ] +[tool.bandit] +exclude_dirs = ["tests", "scripts"] +skips = [] + [tool.hatch.build.targets.wheel] packages = ["tango"] diff --git a/tango/client.py b/tango/client.py index 2d29c36..6a51436 100644 --- a/tango/client.py +++ b/tango/client.py @@ -1,6 +1,7 @@ """Tango API Client""" import os +import re from datetime import date, datetime from decimal import Decimal from typing import Any @@ -143,6 +144,28 @@ def _int_or_none(val: str | None) -> int | None: burst_reset=_int_or_none(headers.get("X-RateLimit-Burst-Reset")), ) + _SENSITIVE_PATTERNS = re.compile( + r""" + (?:api[_-]?key|token|secret|password|authorization|bearer|credential) + \s*[=:]\s*\S+ + """, + re.IGNORECASE | re.VERBOSE, + ) + + @staticmethod + def _sanitize_error_detail(detail: Any, max_length: int = 200) -> str: + """Sanitize an error detail from an API response for safe inclusion in exception messages. + + Redacts values that look like API keys, tokens, or credentials, and + truncates long messages to prevent information disclosure in logs + and error tracking systems. + """ + text = str(detail) + text = TangoClient._SENSITIVE_PATTERNS.sub("[REDACTED]", text) + if len(text) > max_length: + text = text[:max_length] + "..." + return text + def _request( self, method: str, @@ -176,7 +199,10 @@ def _request( or error_data.get("error") ) if detail: - error_msg = f"Invalid request parameters: {detail}" + error_msg = ( + f"Invalid request parameters: " + f"{self._sanitize_error_detail(detail)}" + ) raise TangoValidationError( error_msg, response.status_code, @@ -185,7 +211,9 @@ def _request( elif response.status_code == 429: error_data = response.json() if response.content else {} detail = error_data.get("detail", "Rate limit exceeded") - raise TangoRateLimitError(detail, response.status_code, error_data) + raise TangoRateLimitError( + self._sanitize_error_detail(detail), response.status_code, error_data + ) elif not response.is_success: raise TangoAPIError( f"API request failed with status {response.status_code}", response.status_code @@ -194,7 +222,9 @@ def _request( return response.json() if response.content else {} except httpx.HTTPError as e: - raise TangoAPIError(f"Request failed: {str(e)}") from e + raise TangoAPIError( + f"Request failed: {self._sanitize_error_detail(e)}" + ) from e def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]: """Make a GET request""" diff --git a/tests/test_client.py b/tests/test_client.py index 2bc9004..1f310d3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1336,6 +1336,70 @@ def test_network_error(self, mock_request): assert "Connection failed" in str(exc_info.value) + @patch("tango.client.httpx.Client.request") + def test_400_error_redacts_api_key_in_detail(self, mock_request): + """Test that API keys echoed in error details are redacted""" + mock_response = Mock() + mock_response.is_success = False + mock_response.status_code = 400 + mock_response.content = b'{"detail": "Invalid api_key=fake_test_key_do_not_use_1234567890"}' + mock_response.json.return_value = { + "detail": "Invalid api_key=fake_test_key_do_not_use_1234567890" + } + mock_response.headers = {} + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + + with pytest.raises(TangoValidationError) as exc_info: + client.list_agencies() + + assert "fake_test_key" not in str(exc_info.value) + assert "[REDACTED]" in str(exc_info.value) + # Raw response_data still has the original for programmatic access + assert "fake_test_key" in exc_info.value.response_data["detail"] + + @patch("tango.client.httpx.Client.request") + def test_429_error_redacts_sensitive_values(self, mock_request): + """Test that sensitive values in rate limit error details are redacted""" + mock_response = Mock() + mock_response.is_success = False + mock_response.status_code = 429 + mock_response.content = b'{"detail": "Rate limited. token=sk-abc123secret"}' + mock_response.json.return_value = { + "detail": "Rate limited. token=sk-abc123secret" + } + mock_response.headers = {} + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + + with pytest.raises(TangoRateLimitError) as exc_info: + client.list_agencies() + + assert "sk-abc123secret" not in str(exc_info.value) + assert "[REDACTED]" in str(exc_info.value) + + def test_sanitize_error_detail_truncates_long_messages(self): + """Test that very long error details are truncated""" + long_detail = "A" * 300 + result = TangoClient._sanitize_error_detail(long_detail) + assert len(result) == 203 # 200 + "..." + assert result.endswith("...") + + def test_sanitize_error_detail_redacts_credentials(self): + """Test that various credential patterns are redacted""" + cases = [ + ("Invalid api_key=abc123", "[REDACTED]"), + ("Bad token: secretvalue", "[REDACTED]"), + ("Error: password=hunter2", "[REDACTED]"), + ("Failed authorization: Bearer xyz", "[REDACTED]"), + ("secret=mysecretvalue in request", "[REDACTED]"), + ] + for input_text, expected_replacement in cases: + result = TangoClient._sanitize_error_detail(input_text) + assert expected_replacement in result, f"Failed to redact: {input_text}" + @patch("tango.client.httpx.Client.request") def test_empty_response_content(self, mock_request): """Test handling of empty response content""" diff --git a/uv.lock b/uv.lock index 0edfd6b..39e6626 100644 --- a/uv.lock +++ b/uv.lock @@ -123,6 +123,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, ] +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.2" @@ -863,6 +878,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -938,6 +965,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mistune" version = "3.1.4" @@ -1663,6 +1699,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, ] +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + [[package]] name = "rpds-py" version = "0.28.0" @@ -1829,6 +1878,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "stevedore" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, +] + [[package]] name = "tango-python" version = "0.5.0" @@ -1839,6 +1897,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "bandit" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1860,6 +1919,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "ipykernel", marker = "extra == 'notebooks'", specifier = ">=6.25.0" }, { name = "jupyter", marker = "extra == 'notebooks'", specifier = ">=1.0.0" },