Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ on:
# pull_request:
# branches: [ main, develop ]

permissions:
contents: read

jobs:
lint:
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
types: [published]
workflow_dispatch:

permissions:
contents: read
id-token: write

jobs:
build-and-publish:
runs-on: ubuntu-latest
Expand Down
31 changes: 31 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [ main, develop ]

permissions:
contents: read

jobs:
test:
runs-on: ${{ matrix.os }}
Expand Down
62 changes: 34 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
49 changes: 49 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
@@ -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 }}
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -113,6 +114,10 @@ exclude_lines = [
"if __name__ == .__main__.:",
]

[tool.bandit]
exclude_dirs = ["tests", "scripts"]
skips = []

[tool.hatch.build.targets.wheel]
packages = ["tango"]

Expand Down
36 changes: 33 additions & 3 deletions tango/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tango API Client"""

import os
import re
from datetime import date, datetime
from decimal import Decimal
from typing import Any
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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"""
Expand Down
64 changes: 64 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
Loading
Loading