classifinder 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- classifinder-0.1.0/LICENSE +21 -0
- classifinder-0.1.0/PKG-INFO +116 -0
- classifinder-0.1.0/README.md +99 -0
- classifinder-0.1.0/pyproject.toml +30 -0
- classifinder-0.1.0/src/classifinder/__init__.py +49 -0
- classifinder-0.1.0/src/classifinder/_async_client.py +139 -0
- classifinder-0.1.0/src/classifinder/_base.py +104 -0
- classifinder-0.1.0/src/classifinder/_client.py +139 -0
- classifinder-0.1.0/src/classifinder/_exceptions.py +67 -0
- classifinder-0.1.0/src/classifinder/_models.py +88 -0
- classifinder-0.1.0/src/classifinder/integrations/__init__.py +0 -0
- classifinder-0.1.0/src/classifinder/integrations/langchain.py +126 -0
- classifinder-0.1.0/tests/conftest.py +113 -0
- classifinder-0.1.0/tests/test_async_client.py +85 -0
- classifinder-0.1.0/tests/test_client.py +138 -0
- classifinder-0.1.0/tests/test_exceptions.py +136 -0
- classifinder-0.1.0/tests/test_langchain.py +142 -0
- classifinder-0.1.0/tests/test_models.py +83 -0
- classifinder-0.1.0/tests/test_retry.py +103 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ClassiFinder
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: classifinder
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the ClassiFinder secret detection API
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: httpx>=0.27.0
|
|
9
|
+
Requires-Dist: pydantic>=2.7.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: respx>=0.22.0; extra == 'dev'
|
|
14
|
+
Provides-Extra: langchain
|
|
15
|
+
Requires-Dist: langchain-core>=0.3.0; extra == 'langchain'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# ClassiFinder
|
|
19
|
+
|
|
20
|
+
Python SDK for the ClassiFinder secret detection API.
|
|
21
|
+
|
|
22
|
+
Scan text for leaked secrets and credentials, get structured findings, and redact
|
|
23
|
+
sensitive values -- all in a few lines of Python.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install classifinder
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from classifinder import ClassiFinder
|
|
35
|
+
|
|
36
|
+
client = ClassiFinder(api_key="ss_live_...")
|
|
37
|
+
# or set the CLASSIFINDER_API_KEY environment variable
|
|
38
|
+
|
|
39
|
+
result = client.scan("My AWS key is AKIAIOSFODNN7EXAMPLE")
|
|
40
|
+
|
|
41
|
+
for finding in result.findings:
|
|
42
|
+
print(f"{finding.type_name} (severity={finding.severity}, confidence={finding.confidence})")
|
|
43
|
+
print(f" Preview: {finding.value_preview}")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Redact Secrets
|
|
47
|
+
|
|
48
|
+
The `/v1/redact` endpoint replaces secrets in-place so you can safely pass text
|
|
49
|
+
downstream to LLMs or logging systems.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
result = client.redact("DB password is SuperSecret123!")
|
|
53
|
+
|
|
54
|
+
print(result.redacted_text)
|
|
55
|
+
# "DB password is [DATABASE_PASSWORD]!"
|
|
56
|
+
|
|
57
|
+
print(f"Redacted {result.findings_count} secret(s)")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Async Support
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from classifinder import AsyncClassiFinder
|
|
64
|
+
|
|
65
|
+
async def main():
|
|
66
|
+
client = AsyncClassiFinder(api_key="ss_live_...")
|
|
67
|
+
result = await client.scan("AKIA...")
|
|
68
|
+
await client.close()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## LangChain Integration
|
|
72
|
+
|
|
73
|
+
Guard your LLM chains against secret leakage.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install classifinder[langchain]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from classifinder.integrations.langchain import ClassiFinderGuard
|
|
81
|
+
|
|
82
|
+
guard = ClassiFinderGuard(api_key="ss_live_...", mode="redact")
|
|
83
|
+
|
|
84
|
+
# Use as a standalone runnable
|
|
85
|
+
clean_text = guard.invoke("My token is ghp_abc123secret")
|
|
86
|
+
|
|
87
|
+
# Chain with other LangChain runnables
|
|
88
|
+
chain = guard | your_llm | output_parser
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Set `mode="block"` to raise `SecretsDetectedError` instead of redacting.
|
|
92
|
+
|
|
93
|
+
## Error Handling
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from classifinder import ClassiFinder, AuthenticationError, RateLimitError, ClassiFinderError
|
|
97
|
+
|
|
98
|
+
client = ClassiFinder(api_key="ss_live_...")
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
result = client.scan("check this text")
|
|
102
|
+
except AuthenticationError:
|
|
103
|
+
print("Invalid API key")
|
|
104
|
+
except RateLimitError as e:
|
|
105
|
+
print(f"Rate limited. Retry after {e.retry_after}s")
|
|
106
|
+
except ClassiFinderError as e:
|
|
107
|
+
print(f"API error ({e.status_code}): {e.message}")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Documentation
|
|
111
|
+
|
|
112
|
+
Full API documentation: [https://classifinder.tech](https://classifinder.tech)
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# ClassiFinder
|
|
2
|
+
|
|
3
|
+
Python SDK for the ClassiFinder secret detection API.
|
|
4
|
+
|
|
5
|
+
Scan text for leaked secrets and credentials, get structured findings, and redact
|
|
6
|
+
sensitive values -- all in a few lines of Python.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install classifinder
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from classifinder import ClassiFinder
|
|
18
|
+
|
|
19
|
+
client = ClassiFinder(api_key="ss_live_...")
|
|
20
|
+
# or set the CLASSIFINDER_API_KEY environment variable
|
|
21
|
+
|
|
22
|
+
result = client.scan("My AWS key is AKIAIOSFODNN7EXAMPLE")
|
|
23
|
+
|
|
24
|
+
for finding in result.findings:
|
|
25
|
+
print(f"{finding.type_name} (severity={finding.severity}, confidence={finding.confidence})")
|
|
26
|
+
print(f" Preview: {finding.value_preview}")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Redact Secrets
|
|
30
|
+
|
|
31
|
+
The `/v1/redact` endpoint replaces secrets in-place so you can safely pass text
|
|
32
|
+
downstream to LLMs or logging systems.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
result = client.redact("DB password is SuperSecret123!")
|
|
36
|
+
|
|
37
|
+
print(result.redacted_text)
|
|
38
|
+
# "DB password is [DATABASE_PASSWORD]!"
|
|
39
|
+
|
|
40
|
+
print(f"Redacted {result.findings_count} secret(s)")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Async Support
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from classifinder import AsyncClassiFinder
|
|
47
|
+
|
|
48
|
+
async def main():
|
|
49
|
+
client = AsyncClassiFinder(api_key="ss_live_...")
|
|
50
|
+
result = await client.scan("AKIA...")
|
|
51
|
+
await client.close()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## LangChain Integration
|
|
55
|
+
|
|
56
|
+
Guard your LLM chains against secret leakage.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install classifinder[langchain]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from classifinder.integrations.langchain import ClassiFinderGuard
|
|
64
|
+
|
|
65
|
+
guard = ClassiFinderGuard(api_key="ss_live_...", mode="redact")
|
|
66
|
+
|
|
67
|
+
# Use as a standalone runnable
|
|
68
|
+
clean_text = guard.invoke("My token is ghp_abc123secret")
|
|
69
|
+
|
|
70
|
+
# Chain with other LangChain runnables
|
|
71
|
+
chain = guard | your_llm | output_parser
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Set `mode="block"` to raise `SecretsDetectedError` instead of redacting.
|
|
75
|
+
|
|
76
|
+
## Error Handling
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from classifinder import ClassiFinder, AuthenticationError, RateLimitError, ClassiFinderError
|
|
80
|
+
|
|
81
|
+
client = ClassiFinder(api_key="ss_live_...")
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
result = client.scan("check this text")
|
|
85
|
+
except AuthenticationError:
|
|
86
|
+
print("Invalid API key")
|
|
87
|
+
except RateLimitError as e:
|
|
88
|
+
print(f"Rate limited. Retry after {e.retry_after}s")
|
|
89
|
+
except ClassiFinderError as e:
|
|
90
|
+
print(f"API error ({e.status_code}): {e.message}")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Documentation
|
|
94
|
+
|
|
95
|
+
Full API documentation: [https://classifinder.tech](https://classifinder.tech)
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "classifinder"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for the ClassiFinder secret detection API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"httpx>=0.27.0",
|
|
10
|
+
"pydantic>=2.7.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
langchain = ["langchain-core>=0.3.0"]
|
|
15
|
+
dev = [
|
|
16
|
+
"pytest>=8.0.0",
|
|
17
|
+
"pytest-asyncio>=0.24.0",
|
|
18
|
+
"respx>=0.22.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/classifinder"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
30
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""ClassiFinder Python SDK — scan and redact secrets from text."""
|
|
2
|
+
|
|
3
|
+
from ._client import ClassiFinder
|
|
4
|
+
from ._async_client import AsyncClassiFinder
|
|
5
|
+
from ._exceptions import (
|
|
6
|
+
ClassiFinderError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
InvalidRequestError,
|
|
10
|
+
ForbiddenError,
|
|
11
|
+
ServerError,
|
|
12
|
+
APIConnectionError,
|
|
13
|
+
SecretsDetectedError,
|
|
14
|
+
)
|
|
15
|
+
from ._models import (
|
|
16
|
+
ScanResult,
|
|
17
|
+
RedactResult,
|
|
18
|
+
TypesResult,
|
|
19
|
+
HealthResult,
|
|
20
|
+
FeedbackResult,
|
|
21
|
+
Finding,
|
|
22
|
+
RedactFinding,
|
|
23
|
+
Span,
|
|
24
|
+
SeveritySummary,
|
|
25
|
+
TypeInfo,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ClassiFinder",
|
|
30
|
+
"AsyncClassiFinder",
|
|
31
|
+
"ClassiFinderError",
|
|
32
|
+
"AuthenticationError",
|
|
33
|
+
"RateLimitError",
|
|
34
|
+
"InvalidRequestError",
|
|
35
|
+
"ForbiddenError",
|
|
36
|
+
"ServerError",
|
|
37
|
+
"APIConnectionError",
|
|
38
|
+
"SecretsDetectedError",
|
|
39
|
+
"ScanResult",
|
|
40
|
+
"RedactResult",
|
|
41
|
+
"TypesResult",
|
|
42
|
+
"HealthResult",
|
|
43
|
+
"FeedbackResult",
|
|
44
|
+
"Finding",
|
|
45
|
+
"RedactFinding",
|
|
46
|
+
"Span",
|
|
47
|
+
"SeveritySummary",
|
|
48
|
+
"TypeInfo",
|
|
49
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Asynchronous ClassiFinder client."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._base import (
|
|
8
|
+
DEFAULT_BASE_URL,
|
|
9
|
+
DEFAULT_MAX_RETRIES,
|
|
10
|
+
DEFAULT_TIMEOUT,
|
|
11
|
+
async_sleep_for_retry,
|
|
12
|
+
build_headers,
|
|
13
|
+
is_retryable,
|
|
14
|
+
raise_for_status,
|
|
15
|
+
resolve_api_key,
|
|
16
|
+
)
|
|
17
|
+
from ._exceptions import APIConnectionError
|
|
18
|
+
from ._models import (
|
|
19
|
+
FeedbackResult,
|
|
20
|
+
HealthResult,
|
|
21
|
+
RedactResult,
|
|
22
|
+
ScanResult,
|
|
23
|
+
TypesResult,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AsyncClassiFinder:
|
|
28
|
+
"""Asynchronous client for the ClassiFinder API."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
api_key: Optional[str] = None,
|
|
33
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
34
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
35
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._api_key = resolve_api_key(api_key)
|
|
38
|
+
self._base_url = base_url.rstrip("/")
|
|
39
|
+
self._max_retries = max_retries
|
|
40
|
+
self._client = httpx.AsyncClient(
|
|
41
|
+
headers=build_headers(self._api_key),
|
|
42
|
+
timeout=timeout,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
async def close(self) -> None:
|
|
46
|
+
"""Close the underlying HTTP connection pool."""
|
|
47
|
+
await self._client.aclose()
|
|
48
|
+
|
|
49
|
+
async def __aenter__(self) -> "AsyncClassiFinder":
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
async def __aexit__(self, *args) -> None:
|
|
53
|
+
await self.close()
|
|
54
|
+
|
|
55
|
+
async def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
56
|
+
"""Make an HTTP request with retry logic."""
|
|
57
|
+
url = f"{self._base_url}{path}"
|
|
58
|
+
last_exc: Optional[Exception] = None
|
|
59
|
+
|
|
60
|
+
for attempt in range(self._max_retries + 1):
|
|
61
|
+
try:
|
|
62
|
+
response = await self._client.request(method, url, **kwargs)
|
|
63
|
+
raise_for_status(response)
|
|
64
|
+
return response
|
|
65
|
+
except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError) as exc:
|
|
66
|
+
api_exc = APIConnectionError(str(exc))
|
|
67
|
+
last_exc = api_exc
|
|
68
|
+
if attempt >= self._max_retries:
|
|
69
|
+
raise api_exc from exc
|
|
70
|
+
await async_sleep_for_retry(attempt, api_exc)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
last_exc = exc
|
|
73
|
+
if not is_retryable(exc) or attempt >= self._max_retries:
|
|
74
|
+
raise
|
|
75
|
+
await async_sleep_for_retry(attempt, exc)
|
|
76
|
+
|
|
77
|
+
raise last_exc # pragma: no cover
|
|
78
|
+
|
|
79
|
+
async def scan(
|
|
80
|
+
self,
|
|
81
|
+
text: str,
|
|
82
|
+
types: Optional[List[str]] = None,
|
|
83
|
+
min_confidence: float = 0.5,
|
|
84
|
+
include_context: bool = True,
|
|
85
|
+
) -> ScanResult:
|
|
86
|
+
"""Scan text for secrets."""
|
|
87
|
+
body = {
|
|
88
|
+
"text": text,
|
|
89
|
+
"types": types or ["all"],
|
|
90
|
+
"min_confidence": min_confidence,
|
|
91
|
+
"include_context": include_context,
|
|
92
|
+
}
|
|
93
|
+
response = await self._request("POST", "/v1/scan", json=body)
|
|
94
|
+
return ScanResult.model_validate(response.json())
|
|
95
|
+
|
|
96
|
+
async def redact(
|
|
97
|
+
self,
|
|
98
|
+
text: str,
|
|
99
|
+
types: Optional[List[str]] = None,
|
|
100
|
+
min_confidence: float = 0.5,
|
|
101
|
+
redaction_style: str = "label",
|
|
102
|
+
) -> RedactResult:
|
|
103
|
+
"""Scan and redact secrets from text."""
|
|
104
|
+
body = {
|
|
105
|
+
"text": text,
|
|
106
|
+
"types": types or ["all"],
|
|
107
|
+
"min_confidence": min_confidence,
|
|
108
|
+
"redaction_style": redaction_style,
|
|
109
|
+
}
|
|
110
|
+
response = await self._request("POST", "/v1/redact", json=body)
|
|
111
|
+
return RedactResult.model_validate(response.json())
|
|
112
|
+
|
|
113
|
+
async def get_types(self) -> TypesResult:
|
|
114
|
+
"""List all detectable secret types."""
|
|
115
|
+
response = await self._request("GET", "/v1/types")
|
|
116
|
+
return TypesResult.model_validate(response.json())
|
|
117
|
+
|
|
118
|
+
async def health(self) -> HealthResult:
|
|
119
|
+
"""Check API health."""
|
|
120
|
+
response = await self._request("GET", "/v1/health")
|
|
121
|
+
return HealthResult.model_validate(response.json())
|
|
122
|
+
|
|
123
|
+
async def feedback(
|
|
124
|
+
self,
|
|
125
|
+
request_id: str,
|
|
126
|
+
finding_id: str,
|
|
127
|
+
feedback_type: str,
|
|
128
|
+
comment: Optional[str] = None,
|
|
129
|
+
) -> FeedbackResult:
|
|
130
|
+
"""Report a false positive or false negative."""
|
|
131
|
+
body = {
|
|
132
|
+
"request_id": request_id,
|
|
133
|
+
"finding_id": finding_id,
|
|
134
|
+
"feedback_type": feedback_type,
|
|
135
|
+
}
|
|
136
|
+
if comment is not None:
|
|
137
|
+
body["comment"] = comment
|
|
138
|
+
response = await self._request("POST", "/v1/feedback", json=body)
|
|
139
|
+
return FeedbackResult.model_validate(response.json())
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Shared logic for sync and async clients: error mapping, retry, request building."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._exceptions import (
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
InvalidRequestError,
|
|
13
|
+
ForbiddenError,
|
|
14
|
+
ServerError,
|
|
15
|
+
APIConnectionError,
|
|
16
|
+
ClassiFinderError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "https://api.classifinder.tech"
|
|
20
|
+
DEFAULT_TIMEOUT = 30.0
|
|
21
|
+
DEFAULT_MAX_RETRIES = 2
|
|
22
|
+
|
|
23
|
+
_RETRYABLE_STATUS_CODES = {429, 500}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_api_key(api_key: Optional[str]) -> str:
|
|
27
|
+
"""Resolve API key from argument or CLASSIFINDER_API_KEY env var."""
|
|
28
|
+
key = api_key or os.environ.get("CLASSIFINDER_API_KEY")
|
|
29
|
+
if not key:
|
|
30
|
+
raise AuthenticationError(
|
|
31
|
+
"No API key provided. Pass api_key= or set the CLASSIFINDER_API_KEY environment variable."
|
|
32
|
+
)
|
|
33
|
+
return key
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_headers(api_key: str) -> Dict[str, str]:
|
|
37
|
+
"""Build default request headers."""
|
|
38
|
+
return {
|
|
39
|
+
"X-API-Key": api_key,
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def raise_for_status(response: httpx.Response) -> None:
|
|
45
|
+
"""Raise the appropriate ClassiFinderError for non-2xx responses."""
|
|
46
|
+
if response.status_code < 400:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
body = response.json()
|
|
51
|
+
error = body.get("error", {})
|
|
52
|
+
message = error.get("message", response.text)
|
|
53
|
+
code = error.get("code", "")
|
|
54
|
+
retry_after = error.get("retry_after")
|
|
55
|
+
except Exception:
|
|
56
|
+
message = response.text or f"HTTP {response.status_code}"
|
|
57
|
+
code = ""
|
|
58
|
+
retry_after = None
|
|
59
|
+
|
|
60
|
+
status = response.status_code
|
|
61
|
+
|
|
62
|
+
if status == 401:
|
|
63
|
+
raise AuthenticationError(message)
|
|
64
|
+
elif status == 400:
|
|
65
|
+
raise InvalidRequestError(message, code=code)
|
|
66
|
+
elif status == 403:
|
|
67
|
+
raise ForbiddenError(message, code=code)
|
|
68
|
+
elif status == 429:
|
|
69
|
+
raise RateLimitError(message, retry_after=retry_after or 0)
|
|
70
|
+
elif status >= 500:
|
|
71
|
+
raise ServerError(message)
|
|
72
|
+
else:
|
|
73
|
+
raise ClassiFinderError(message, status_code=status)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_retryable(exc: Exception) -> bool:
|
|
77
|
+
"""Check if an exception is retryable."""
|
|
78
|
+
if isinstance(exc, RateLimitError):
|
|
79
|
+
return True
|
|
80
|
+
if isinstance(exc, ServerError):
|
|
81
|
+
return True
|
|
82
|
+
if isinstance(exc, APIConnectionError):
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_retry_delay(attempt: int, exc: Exception) -> float:
|
|
88
|
+
"""Calculate delay before next retry attempt."""
|
|
89
|
+
if isinstance(exc, RateLimitError) and exc.retry_after > 0:
|
|
90
|
+
return float(exc.retry_after)
|
|
91
|
+
return float(2**attempt)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def sleep_for_retry(attempt: int, exc: Exception) -> None:
|
|
95
|
+
"""Sleep before a sync retry."""
|
|
96
|
+
delay = get_retry_delay(attempt, exc)
|
|
97
|
+
time.sleep(delay)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def async_sleep_for_retry(attempt: int, exc: Exception) -> None:
|
|
101
|
+
"""Sleep before an async retry."""
|
|
102
|
+
import asyncio
|
|
103
|
+
delay = get_retry_delay(attempt, exc)
|
|
104
|
+
await asyncio.sleep(delay)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Synchronous ClassiFinder client."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._base import (
|
|
8
|
+
DEFAULT_BASE_URL,
|
|
9
|
+
DEFAULT_MAX_RETRIES,
|
|
10
|
+
DEFAULT_TIMEOUT,
|
|
11
|
+
build_headers,
|
|
12
|
+
is_retryable,
|
|
13
|
+
raise_for_status,
|
|
14
|
+
resolve_api_key,
|
|
15
|
+
sleep_for_retry,
|
|
16
|
+
)
|
|
17
|
+
from ._exceptions import APIConnectionError
|
|
18
|
+
from ._models import (
|
|
19
|
+
FeedbackResult,
|
|
20
|
+
HealthResult,
|
|
21
|
+
RedactResult,
|
|
22
|
+
ScanResult,
|
|
23
|
+
TypesResult,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ClassiFinder:
|
|
28
|
+
"""Synchronous client for the ClassiFinder API."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
api_key: Optional[str] = None,
|
|
33
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
34
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
35
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._api_key = resolve_api_key(api_key)
|
|
38
|
+
self._base_url = base_url.rstrip("/")
|
|
39
|
+
self._max_retries = max_retries
|
|
40
|
+
self._client = httpx.Client(
|
|
41
|
+
headers=build_headers(self._api_key),
|
|
42
|
+
timeout=timeout,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
"""Close the underlying HTTP connection pool."""
|
|
47
|
+
self._client.close()
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> "ClassiFinder":
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def __exit__(self, *args) -> None:
|
|
53
|
+
self.close()
|
|
54
|
+
|
|
55
|
+
def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
56
|
+
"""Make an HTTP request with retry logic."""
|
|
57
|
+
url = f"{self._base_url}{path}"
|
|
58
|
+
last_exc: Optional[Exception] = None
|
|
59
|
+
|
|
60
|
+
for attempt in range(self._max_retries + 1):
|
|
61
|
+
try:
|
|
62
|
+
response = self._client.request(method, url, **kwargs)
|
|
63
|
+
raise_for_status(response)
|
|
64
|
+
return response
|
|
65
|
+
except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError) as exc:
|
|
66
|
+
api_exc = APIConnectionError(str(exc))
|
|
67
|
+
last_exc = api_exc
|
|
68
|
+
if attempt >= self._max_retries:
|
|
69
|
+
raise api_exc from exc
|
|
70
|
+
sleep_for_retry(attempt, api_exc)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
last_exc = exc
|
|
73
|
+
if not is_retryable(exc) or attempt >= self._max_retries:
|
|
74
|
+
raise
|
|
75
|
+
sleep_for_retry(attempt, exc)
|
|
76
|
+
|
|
77
|
+
raise last_exc # pragma: no cover
|
|
78
|
+
|
|
79
|
+
def scan(
|
|
80
|
+
self,
|
|
81
|
+
text: str,
|
|
82
|
+
types: Optional[List[str]] = None,
|
|
83
|
+
min_confidence: float = 0.5,
|
|
84
|
+
include_context: bool = True,
|
|
85
|
+
) -> ScanResult:
|
|
86
|
+
"""Scan text for secrets."""
|
|
87
|
+
body = {
|
|
88
|
+
"text": text,
|
|
89
|
+
"types": types or ["all"],
|
|
90
|
+
"min_confidence": min_confidence,
|
|
91
|
+
"include_context": include_context,
|
|
92
|
+
}
|
|
93
|
+
response = self._request("POST", "/v1/scan", json=body)
|
|
94
|
+
return ScanResult.model_validate(response.json())
|
|
95
|
+
|
|
96
|
+
def redact(
|
|
97
|
+
self,
|
|
98
|
+
text: str,
|
|
99
|
+
types: Optional[List[str]] = None,
|
|
100
|
+
min_confidence: float = 0.5,
|
|
101
|
+
redaction_style: str = "label",
|
|
102
|
+
) -> RedactResult:
|
|
103
|
+
"""Scan and redact secrets from text."""
|
|
104
|
+
body = {
|
|
105
|
+
"text": text,
|
|
106
|
+
"types": types or ["all"],
|
|
107
|
+
"min_confidence": min_confidence,
|
|
108
|
+
"redaction_style": redaction_style,
|
|
109
|
+
}
|
|
110
|
+
response = self._request("POST", "/v1/redact", json=body)
|
|
111
|
+
return RedactResult.model_validate(response.json())
|
|
112
|
+
|
|
113
|
+
def get_types(self) -> TypesResult:
|
|
114
|
+
"""List all detectable secret types."""
|
|
115
|
+
response = self._request("GET", "/v1/types")
|
|
116
|
+
return TypesResult.model_validate(response.json())
|
|
117
|
+
|
|
118
|
+
def health(self) -> HealthResult:
|
|
119
|
+
"""Check API health."""
|
|
120
|
+
response = self._request("GET", "/v1/health")
|
|
121
|
+
return HealthResult.model_validate(response.json())
|
|
122
|
+
|
|
123
|
+
def feedback(
|
|
124
|
+
self,
|
|
125
|
+
request_id: str,
|
|
126
|
+
finding_id: str,
|
|
127
|
+
feedback_type: str,
|
|
128
|
+
comment: Optional[str] = None,
|
|
129
|
+
) -> FeedbackResult:
|
|
130
|
+
"""Report a false positive or false negative."""
|
|
131
|
+
body = {
|
|
132
|
+
"request_id": request_id,
|
|
133
|
+
"finding_id": finding_id,
|
|
134
|
+
"feedback_type": feedback_type,
|
|
135
|
+
}
|
|
136
|
+
if comment is not None:
|
|
137
|
+
body["comment"] = comment
|
|
138
|
+
response = self._request("POST", "/v1/feedback", json=body)
|
|
139
|
+
return FeedbackResult.model_validate(response.json())
|