cleanlib-sdk 0.4.1__py3-none-any.whl
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.
- cleanlib_sdk/__init__.py +118 -0
- cleanlib_sdk/client.py +236 -0
- cleanlib_sdk/derive_status.py +95 -0
- cleanlib_sdk/errors.py +119 -0
- cleanlib_sdk/http/__init__.py +30 -0
- cleanlib_sdk/http/remediation_client.py +160 -0
- cleanlib_sdk/http/types.py +106 -0
- cleanlib_sdk/reason_codes.py +54 -0
- cleanlib_sdk/schema/__init__.py +15 -0
- cleanlib_sdk/schema/verdict_envelope_v1.py +84 -0
- cleanlib_sdk/transport.py +56 -0
- cleanlib_sdk/types.py +227 -0
- cleanlib_sdk-0.4.1.dist-info/METADATA +102 -0
- cleanlib_sdk-0.4.1.dist-info/RECORD +15 -0
- cleanlib_sdk-0.4.1.dist-info/WHEEL +4 -0
cleanlib_sdk/__init__.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""CleanLibrary Python SDK — mirrors @cleanstart/cleanlib-sdk v0.4.x.
|
|
2
|
+
|
|
3
|
+
Cycle-7 v0.4.1:
|
|
4
|
+
- New per-domain HTTP client split (sister-shape with sdk-js v0.4.1)
|
|
5
|
+
- `HttpRemediationClient` — sparse 7-block /remediation responses
|
|
6
|
+
- (HttpVerdictClient + HttpEnrichClient land in v0.4.2 ripple)
|
|
7
|
+
- `cleanlib_sdk.reason_codes.ReasonCode` — 15-entry canonical registry (Enum)
|
|
8
|
+
- `cleanlib_sdk.schema` — `VerdictEnvelopeV1` JSON Schema mirror (Python dict literal)
|
|
9
|
+
- `cleanlib_sdk.derive_status.derive_status(envelope)` — algorithm per App
|
|
10
|
+
dispatch §4 binding contract (substance precedence + freshness override)
|
|
11
|
+
- `cleanlib_sdk.Client` flat-method class → `DeprecationWarning` on __init__
|
|
12
|
+
(per F5 ratification; scheduled removal v1.0.0)
|
|
13
|
+
|
|
14
|
+
Schema-locked against `@cleanstart/cleanlib-sdk@0.4.1` tarball sha-1
|
|
15
|
+
`b5f00c160907a6ea1f490f14a9bda6f6de34b8b6`. Cross-SDK contract test verifies
|
|
16
|
+
identical (status, reason_code) on every fixture in
|
|
17
|
+
`cleanlib-contract-fixtures` v1.0.0.
|
|
18
|
+
|
|
19
|
+
Earlier cycle context:
|
|
20
|
+
- Cycle-5 v0.3.0 added typed `Verdict.attestation`
|
|
21
|
+
- Cycle-6 v0.4.0 added 5-verb cascade + 9 rich-data Optional fields
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from cleanlib_sdk.client import Client
|
|
25
|
+
from cleanlib_sdk.derive_status import StatusResult, derive_status
|
|
26
|
+
from cleanlib_sdk.errors import (
|
|
27
|
+
AuthenticationError,
|
|
28
|
+
CleanLibraryError,
|
|
29
|
+
InsufficientDataError,
|
|
30
|
+
IntegrityFailureError,
|
|
31
|
+
PackageNotFoundError,
|
|
32
|
+
ParseError,
|
|
33
|
+
PolicyDenyError,
|
|
34
|
+
RateLimitExceededError,
|
|
35
|
+
RiskAcceptanceRequiredError,
|
|
36
|
+
ServerError,
|
|
37
|
+
TransportError,
|
|
38
|
+
)
|
|
39
|
+
from cleanlib_sdk.http import (
|
|
40
|
+
DEFAULT_REMEDIATION_BASE_URL,
|
|
41
|
+
REMEDIATION_CACHE_TTL_SECS,
|
|
42
|
+
HttpRemediationClient,
|
|
43
|
+
RemediationBlock,
|
|
44
|
+
RemediationOrAbsent,
|
|
45
|
+
RemediationResponse,
|
|
46
|
+
)
|
|
47
|
+
from cleanlib_sdk.reason_codes import ALL_REASON_CODES, ReasonCode
|
|
48
|
+
from cleanlib_sdk.schema import (
|
|
49
|
+
AVAILABILITY_ENUM,
|
|
50
|
+
SCHEMA_ID,
|
|
51
|
+
STATUS_ENUM,
|
|
52
|
+
VERDICT_ENVELOPE_V1_SCHEMA,
|
|
53
|
+
)
|
|
54
|
+
from cleanlib_sdk.types import (
|
|
55
|
+
Attestation,
|
|
56
|
+
AuditWindow,
|
|
57
|
+
AuditWindowResponse,
|
|
58
|
+
PackageRef,
|
|
59
|
+
PackageRisk,
|
|
60
|
+
PolicyPreviewResponse,
|
|
61
|
+
PolicyPreviewResult,
|
|
62
|
+
RecommendedVersion,
|
|
63
|
+
RiskAcceptResponse,
|
|
64
|
+
ScanResponse,
|
|
65
|
+
ScanResult,
|
|
66
|
+
SignedAttestation,
|
|
67
|
+
Verdict,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
__version__ = "0.4.1"
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
# v0.4.1 per-domain HTTP clients (primary API)
|
|
74
|
+
"HttpRemediationClient",
|
|
75
|
+
"DEFAULT_REMEDIATION_BASE_URL",
|
|
76
|
+
"REMEDIATION_CACHE_TTL_SECS",
|
|
77
|
+
"RemediationBlock",
|
|
78
|
+
"RemediationResponse",
|
|
79
|
+
"RemediationOrAbsent",
|
|
80
|
+
# v0.4.1 contract enforcement
|
|
81
|
+
"ReasonCode",
|
|
82
|
+
"ALL_REASON_CODES",
|
|
83
|
+
"VERDICT_ENVELOPE_V1_SCHEMA",
|
|
84
|
+
"SCHEMA_ID",
|
|
85
|
+
"STATUS_ENUM",
|
|
86
|
+
"AVAILABILITY_ENUM",
|
|
87
|
+
"derive_status",
|
|
88
|
+
"StatusResult",
|
|
89
|
+
# cycle-6 v0.4.0 types
|
|
90
|
+
"Verdict",
|
|
91
|
+
"Attestation",
|
|
92
|
+
"SignedAttestation",
|
|
93
|
+
"RecommendedVersion",
|
|
94
|
+
"PackageRisk",
|
|
95
|
+
"PackageRef",
|
|
96
|
+
"ScanResult",
|
|
97
|
+
"ScanResponse",
|
|
98
|
+
"AuditWindow",
|
|
99
|
+
"AuditWindowResponse",
|
|
100
|
+
"PolicyPreviewResult",
|
|
101
|
+
"PolicyPreviewResponse",
|
|
102
|
+
"RiskAcceptResponse",
|
|
103
|
+
# Deprecated v0.4.1 (v1.0.0 removal); kept for source-compat with v0.4.0 callers
|
|
104
|
+
"Client",
|
|
105
|
+
# Errors
|
|
106
|
+
"CleanLibraryError",
|
|
107
|
+
"PolicyDenyError",
|
|
108
|
+
"IntegrityFailureError",
|
|
109
|
+
"RateLimitExceededError",
|
|
110
|
+
"RiskAcceptanceRequiredError",
|
|
111
|
+
"AuthenticationError",
|
|
112
|
+
"InsufficientDataError",
|
|
113
|
+
"PackageNotFoundError",
|
|
114
|
+
"ServerError",
|
|
115
|
+
"TransportError",
|
|
116
|
+
"ParseError",
|
|
117
|
+
"__version__",
|
|
118
|
+
]
|
cleanlib_sdk/client.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Async Client — DEPRECATED as of v0.4.1; scheduled removal v1.0.0.
|
|
2
|
+
|
|
3
|
+
The flat-method `Client` class is superseded by the per-domain client split
|
|
4
|
+
in `cleanlib_sdk.http`:
|
|
5
|
+
|
|
6
|
+
- `HttpRemediationClient` (v0.4.1; sparse 7-block /remediation responses)
|
|
7
|
+
- `HttpVerdictClient` (v0.4.2 ripple; mirror sdk-js)
|
|
8
|
+
- `HttpEnrichClient` (v0.4.2 ripple; mirror sdk-js)
|
|
9
|
+
|
|
10
|
+
Existing callers continue to work — instantiating `Client` emits a
|
|
11
|
+
`DeprecationWarning` per F5 ratification (sister-shape with sdk-js v0.4.1
|
|
12
|
+
`CleanlibClient` `@deprecated` shim).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import warnings
|
|
18
|
+
from types import TracebackType
|
|
19
|
+
from typing import Optional, Type
|
|
20
|
+
from urllib.parse import quote
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from cleanlib_sdk.transport import (
|
|
25
|
+
DEFAULT_TIMEOUT_SECS,
|
|
26
|
+
auth_headers,
|
|
27
|
+
map_http_error,
|
|
28
|
+
validate_endpoint,
|
|
29
|
+
)
|
|
30
|
+
from cleanlib_sdk.types import (
|
|
31
|
+
AuditWindow,
|
|
32
|
+
AuditWindowResponse,
|
|
33
|
+
PackageRef,
|
|
34
|
+
PolicyPreviewResponse,
|
|
35
|
+
RiskAcceptResponse,
|
|
36
|
+
ScanResponse,
|
|
37
|
+
Verdict,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Client:
|
|
42
|
+
"""DEPRECATED — Async HTTP client for the CleanLibrary App customer endpoints.
|
|
43
|
+
|
|
44
|
+
.. deprecated:: 0.4.1
|
|
45
|
+
Use the per-domain clients in :mod:`cleanlib_sdk.http` instead.
|
|
46
|
+
Scheduled removal in v1.0.0. Per F5 cycle-7 ratification.
|
|
47
|
+
|
|
48
|
+
Example (still works; emits ``DeprecationWarning``)::
|
|
49
|
+
|
|
50
|
+
import asyncio
|
|
51
|
+
from cleanlib_sdk import Client
|
|
52
|
+
|
|
53
|
+
async def main():
|
|
54
|
+
async with Client(
|
|
55
|
+
endpoint="https://cleanapp.clnstrt.dev",
|
|
56
|
+
api_key="clk_std_test_001",
|
|
57
|
+
) as c:
|
|
58
|
+
v = await c.fetch_verdict("npm", "lodash", "4.17.21")
|
|
59
|
+
print(v.decision, v.composite_score)
|
|
60
|
+
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
endpoint: str,
|
|
67
|
+
api_key: Optional[str] = None,
|
|
68
|
+
api_version: str = "v1",
|
|
69
|
+
timeout_secs: float = DEFAULT_TIMEOUT_SECS,
|
|
70
|
+
) -> None:
|
|
71
|
+
warnings.warn(
|
|
72
|
+
"cleanlib_sdk.Client is deprecated as of v0.4.1; use the per-domain "
|
|
73
|
+
"clients in cleanlib_sdk.http (HttpRemediationClient + HttpVerdictClient "
|
|
74
|
+
"+ HttpEnrichClient). Scheduled removal v1.0.0.",
|
|
75
|
+
DeprecationWarning,
|
|
76
|
+
stacklevel=2,
|
|
77
|
+
)
|
|
78
|
+
self.base_url = validate_endpoint(endpoint)
|
|
79
|
+
self.api_key = api_key
|
|
80
|
+
self.api_version = api_version
|
|
81
|
+
self._http = httpx.AsyncClient(
|
|
82
|
+
headers=auth_headers(api_key),
|
|
83
|
+
timeout=timeout_secs,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async def __aenter__(self) -> "Client":
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
async def __aexit__(
|
|
90
|
+
self,
|
|
91
|
+
exc_type: Optional[Type[BaseException]],
|
|
92
|
+
exc: Optional[BaseException],
|
|
93
|
+
tb: Optional[TracebackType],
|
|
94
|
+
) -> None:
|
|
95
|
+
await self._http.aclose()
|
|
96
|
+
|
|
97
|
+
async def aclose(self) -> None:
|
|
98
|
+
await self._http.aclose()
|
|
99
|
+
|
|
100
|
+
async def fetch_verdict(self, ecosystem: str, package: str, version: str) -> Verdict:
|
|
101
|
+
"""GET /v1/customer/verdicts/{ecosystem}/{package}/{version}.
|
|
102
|
+
|
|
103
|
+
Returns the parsed Verdict on 2xx. Raises a typed CleanLibraryError
|
|
104
|
+
subclass on non-2xx per the App Rev 4 §9.2 reason-code dispatch.
|
|
105
|
+
"""
|
|
106
|
+
path = (
|
|
107
|
+
f"{self.base_url}/{self.api_version}/customer/verdicts/"
|
|
108
|
+
f"{quote(ecosystem, safe='')}/"
|
|
109
|
+
f"{quote(package, safe='')}/"
|
|
110
|
+
f"{quote(version, safe='')}"
|
|
111
|
+
)
|
|
112
|
+
resp = await self._http.get(path)
|
|
113
|
+
if resp.is_error:
|
|
114
|
+
raise map_http_error(resp)
|
|
115
|
+
data = resp.json()
|
|
116
|
+
# Inject request-context fields the App doesn't echo back
|
|
117
|
+
# (App's customer-verdict endpoint returns canonical cleanlib_core::
|
|
118
|
+
# Verdict shape without ecosystem/package/version path-param echoes)
|
|
119
|
+
data.setdefault("ecosystem", ecosystem)
|
|
120
|
+
data.setdefault("package", package)
|
|
121
|
+
data.setdefault("version", version)
|
|
122
|
+
# Derive decision from verdict_label + severity per cycle-4 §D.7
|
|
123
|
+
# demo policy mapping
|
|
124
|
+
if data.get("decision") is None:
|
|
125
|
+
data["decision"] = _derive_decision(
|
|
126
|
+
data.get("verdict") or data.get("source"),
|
|
127
|
+
data.get("severity"),
|
|
128
|
+
)
|
|
129
|
+
return Verdict.model_validate(data)
|
|
130
|
+
|
|
131
|
+
async def scan(self, packages: list[PackageRef]) -> ScanResponse:
|
|
132
|
+
"""POST /v1/scan — resolve verdicts for a batch of packages (PR #99).
|
|
133
|
+
|
|
134
|
+
Partial-success: a per-package failure surfaces as `error` on its
|
|
135
|
+
result entry rather than raising. Mirrors cleanlib-sdk-js `scan`.
|
|
136
|
+
"""
|
|
137
|
+
body = {"packages": [p.model_dump(by_alias=True) for p in packages]}
|
|
138
|
+
resp = await self._http.post(f"{self.base_url}/{self.api_version}/scan", json=body)
|
|
139
|
+
if resp.is_error:
|
|
140
|
+
raise map_http_error(resp)
|
|
141
|
+
return ScanResponse.model_validate(resp.json())
|
|
142
|
+
|
|
143
|
+
async def audit(self, window: Optional[AuditWindow] = None) -> AuditWindowResponse:
|
|
144
|
+
"""GET /v1/audit?since=&until= — audit-window query (PR #99).
|
|
145
|
+
|
|
146
|
+
`backend_status` is an honest signal: "not_wired" + empty records means
|
|
147
|
+
the endpoint is reachable but the audit store is not yet attached
|
|
148
|
+
App-side. Mirrors cleanlib-sdk-js `audit`.
|
|
149
|
+
"""
|
|
150
|
+
params: dict[str, str] = {}
|
|
151
|
+
if window is not None:
|
|
152
|
+
if window.since is not None:
|
|
153
|
+
params["since"] = window.since
|
|
154
|
+
if window.until is not None:
|
|
155
|
+
params["until"] = window.until
|
|
156
|
+
resp = await self._http.get(
|
|
157
|
+
f"{self.base_url}/{self.api_version}/audit", params=params or None
|
|
158
|
+
)
|
|
159
|
+
if resp.is_error:
|
|
160
|
+
raise map_http_error(resp)
|
|
161
|
+
return AuditWindowResponse.model_validate(resp.json())
|
|
162
|
+
|
|
163
|
+
async def policy_preview(
|
|
164
|
+
self, policy_yaml: str, packages: list[PackageRef]
|
|
165
|
+
) -> PolicyPreviewResponse:
|
|
166
|
+
"""POST /v1/policy/preview — preview a candidate policy YAML against a
|
|
167
|
+
batch of packages WITHOUT persisting it (PR #99). Raises ParseError/
|
|
168
|
+
HttpError-equivalent on 400 when the YAML fails to parse. Mirrors
|
|
169
|
+
cleanlib-sdk-js `policyPreview`.
|
|
170
|
+
"""
|
|
171
|
+
body = {
|
|
172
|
+
"policy_yaml": policy_yaml,
|
|
173
|
+
"packages": [p.model_dump(by_alias=True) for p in packages],
|
|
174
|
+
}
|
|
175
|
+
resp = await self._http.post(
|
|
176
|
+
f"{self.base_url}/{self.api_version}/policy/preview", json=body
|
|
177
|
+
)
|
|
178
|
+
if resp.is_error:
|
|
179
|
+
raise map_http_error(resp)
|
|
180
|
+
return PolicyPreviewResponse.model_validate(resp.json())
|
|
181
|
+
|
|
182
|
+
async def risk_accept(self, verdict_id: str, justification: str) -> RiskAcceptResponse:
|
|
183
|
+
"""POST /v1/risk-accept — record a risk-acceptance for a verdict (PR #99).
|
|
184
|
+
|
|
185
|
+
`persistence` is "ephemeral" until a CDP accept-write lands. Raises on
|
|
186
|
+
400 when verdict_id or justification is empty. Mirrors cleanlib-sdk-js
|
|
187
|
+
`riskAccept`.
|
|
188
|
+
"""
|
|
189
|
+
body = {"verdict_id": verdict_id, "justification": justification}
|
|
190
|
+
resp = await self._http.post(
|
|
191
|
+
f"{self.base_url}/{self.api_version}/risk-accept", json=body
|
|
192
|
+
)
|
|
193
|
+
if resp.is_error:
|
|
194
|
+
raise map_http_error(resp)
|
|
195
|
+
return RiskAcceptResponse.model_validate(resp.json())
|
|
196
|
+
|
|
197
|
+
async def fetch_bytes(self, ecosystem: str, package: str, version: str) -> bytes:
|
|
198
|
+
"""GET /v1/fetch/{ecosystem}/{package}/{version} — raw catalog bytes
|
|
199
|
+
(PR #99). Returns the application/octet-stream body. Does NOT run the
|
|
200
|
+
policy evaluator or sign an attestation. Mirrors cleanlib-sdk-js
|
|
201
|
+
`fetchBytes`.
|
|
202
|
+
"""
|
|
203
|
+
path = (
|
|
204
|
+
f"{self.base_url}/{self.api_version}/fetch/"
|
|
205
|
+
f"{quote(ecosystem, safe='')}/"
|
|
206
|
+
f"{quote(package, safe='')}/"
|
|
207
|
+
f"{quote(version, safe='')}"
|
|
208
|
+
)
|
|
209
|
+
resp = await self._http.get(path)
|
|
210
|
+
if resp.is_error:
|
|
211
|
+
raise map_http_error(resp)
|
|
212
|
+
return resp.content
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _derive_decision(verdict_label: Optional[str], severity: Optional[str]) -> str:
|
|
216
|
+
"""Map App canonical Verdict's verdict_label + severity to decision string.
|
|
217
|
+
|
|
218
|
+
Mapping rules per cycle-4 §D.7 static demo policy + cycle-5 fixtures:
|
|
219
|
+
- VECTOR_VERDICT + High/Critical severity → DENY
|
|
220
|
+
- VECTOR_VERDICT + lower severity → WARN
|
|
221
|
+
- DM_THRESHOLD_BLOCK → DENY
|
|
222
|
+
- INSUFFICIENT_DATA → WARN
|
|
223
|
+
- ALLOWED_NO_FINDINGS → ALLOW
|
|
224
|
+
- default → ALLOW
|
|
225
|
+
"""
|
|
226
|
+
label = (verdict_label or "").upper()
|
|
227
|
+
sev = (severity or "").upper()
|
|
228
|
+
if label == "VECTOR_VERDICT":
|
|
229
|
+
return "DENY" if sev in ("HIGH", "CRITICAL") else "WARN"
|
|
230
|
+
if label == "DM_THRESHOLD_BLOCK":
|
|
231
|
+
return "DENY"
|
|
232
|
+
if label == "INSUFFICIENT_DATA":
|
|
233
|
+
return "WARN"
|
|
234
|
+
if label == "ALLOWED_NO_FINDINGS":
|
|
235
|
+
return "ALLOW"
|
|
236
|
+
return "ALLOW"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""DeriveStatus — canonical algorithm per App dispatch §4 binding contract.
|
|
2
|
+
|
|
3
|
+
Each CleanLibrary SDK (sdk-js, sdk-py, sdk-go, cleanlib-client Rust) implements
|
|
4
|
+
this algorithm independently from the spec; cross-SDK contract test verifies
|
|
5
|
+
byte-identical `(status, reason_code)` across all four implementations on every
|
|
6
|
+
fixture in `cleanlib-contract-fixtures` v1.0.0.
|
|
7
|
+
|
|
8
|
+
Precedence:
|
|
9
|
+
|
|
10
|
+
1. **Substance** (signals top-to-bottom; `unavailable` substrate causes fall-through):
|
|
11
|
+
- `exploitability.exploitation_likelihood == "CRITICAL"` → DENY + VERDICT_EXPLOITATION_CRITICAL
|
|
12
|
+
- `exploitability.in_kev` AND `availability.kev == "available"` AND `exploit_risk_score >= 70` → DENY + VERDICT_KEV_LISTED
|
|
13
|
+
- `rich_data.has_obfuscation` → DENY + VERDICT_OBFUSCATED
|
|
14
|
+
- `remediation.fix` OR `remediation.recommended_version` present → WARN + VERDICT_HAS_REMEDIATION
|
|
15
|
+
- `rich_data.abandonment_score >= 0.7` → WARN + VERDICT_ABANDONED
|
|
16
|
+
- `rich_data.recommended_version` present (under rich_data, not remediation) → ALLOW + VERDICT_RECOMMENDED_VERSION_NEWER
|
|
17
|
+
- default → ALLOW + VERDICT_CLEAN
|
|
18
|
+
|
|
19
|
+
2. **Freshness override**: when the substance-driving signal's `availability ==
|
|
20
|
+
"degraded_stale"`, the substance-derived status tier is PRESERVED and the
|
|
21
|
+
`reason_code` overrides to `VERDICT_DEGRADED_STALE`.
|
|
22
|
+
|
|
23
|
+
3. **Block tie-break in remediation**: `fix` > `recommended_version` > other blocks.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any, Literal, NamedTuple, Optional
|
|
29
|
+
|
|
30
|
+
from cleanlib_sdk.reason_codes import ReasonCode
|
|
31
|
+
|
|
32
|
+
Status = Literal["ALLOW", "WARN", "DENY"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StatusResult(NamedTuple):
|
|
36
|
+
status: Status
|
|
37
|
+
reason_code: ReasonCode
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get(d: Optional[dict[str, Any]], key: str) -> Any:
|
|
41
|
+
if d is None:
|
|
42
|
+
return None
|
|
43
|
+
return d.get(key)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def derive_status(envelope: dict[str, Any]) -> StatusResult:
|
|
47
|
+
"""Apply the substance-precedence + freshness-override rule.
|
|
48
|
+
|
|
49
|
+
Accepts a parsed `VerdictEnvelopeV1` dict. Returns `(status, reason_code)`.
|
|
50
|
+
|
|
51
|
+
Empirical contract: identical output across all 4 SDK implementations for
|
|
52
|
+
every fixture in `cleanlib-contract-fixtures` v1.0.0.
|
|
53
|
+
"""
|
|
54
|
+
rich = envelope.get("rich_data") or {}
|
|
55
|
+
rem = envelope.get("remediation") or {}
|
|
56
|
+
exp = envelope.get("exploitability") or {}
|
|
57
|
+
avail = envelope.get("availability") or {}
|
|
58
|
+
|
|
59
|
+
# Substance precedence — first match wins. Each branch also captures the
|
|
60
|
+
# `freshness_signal` (the substrate availability tag for the driving signal)
|
|
61
|
+
# so the freshness-override step can rewrite the reason_code without
|
|
62
|
+
# disturbing the status tier.
|
|
63
|
+
status: Status
|
|
64
|
+
reason: ReasonCode
|
|
65
|
+
freshness_signal: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
if exp.get("exploitation_likelihood") == "CRITICAL":
|
|
68
|
+
status, reason = "DENY", ReasonCode.VERDICT_EXPLOITATION_CRITICAL
|
|
69
|
+
freshness_signal = avail.get("exploitation_fusion")
|
|
70
|
+
elif (
|
|
71
|
+
exp.get("in_kev")
|
|
72
|
+
and avail.get("kev") == "available"
|
|
73
|
+
and (exp.get("exploit_risk_score") or 0) >= 70
|
|
74
|
+
):
|
|
75
|
+
status, reason = "DENY", ReasonCode.VERDICT_KEV_LISTED
|
|
76
|
+
freshness_signal = avail.get("kev")
|
|
77
|
+
elif rich.get("has_obfuscation"):
|
|
78
|
+
status, reason = "DENY", ReasonCode.VERDICT_OBFUSCATED
|
|
79
|
+
elif rem.get("fix") is not None or rem.get("recommended_version") is not None:
|
|
80
|
+
status, reason = "WARN", ReasonCode.VERDICT_HAS_REMEDIATION
|
|
81
|
+
# Block tie-break: fix > recommended_version > other blocks
|
|
82
|
+
block = rem.get("fix") or rem.get("recommended_version") or {}
|
|
83
|
+
freshness_signal = block.get("availability") if isinstance(block, dict) else None
|
|
84
|
+
elif (rich.get("abandonment_score") or 0) >= 0.7:
|
|
85
|
+
status, reason = "WARN", ReasonCode.VERDICT_ABANDONED
|
|
86
|
+
elif rich.get("recommended_version") is not None:
|
|
87
|
+
status, reason = "ALLOW", ReasonCode.VERDICT_RECOMMENDED_VERSION_NEWER
|
|
88
|
+
else:
|
|
89
|
+
status, reason = "ALLOW", ReasonCode.VERDICT_CLEAN
|
|
90
|
+
|
|
91
|
+
# Freshness override: keep status tier, rewrite reason_code.
|
|
92
|
+
if freshness_signal == "degraded_stale":
|
|
93
|
+
reason = ReasonCode.VERDICT_DEGRADED_STALE
|
|
94
|
+
|
|
95
|
+
return StatusResult(status=status, reason_code=reason)
|
cleanlib_sdk/errors.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""CleanLibraryError hierarchy — mirrors cleanlib-client::errors per
|
|
2
|
+
App Rev 4 §9.2 reason-code enum surfaced via X-CleanLibrary-Reason."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CleanLibraryError(Exception):
|
|
10
|
+
"""Base for all CleanLibrary SDK errors. Mirrors Rust cleanlib-client
|
|
11
|
+
CleanLibraryError enum."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, reason_code: Optional[str] = None) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.message = message
|
|
16
|
+
self.reason_code = reason_code
|
|
17
|
+
|
|
18
|
+
def is_retryable(self) -> bool:
|
|
19
|
+
"""True if a transient retry could plausibly succeed."""
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PolicyDenyError(CleanLibraryError):
|
|
24
|
+
"""403 + POLICY_DENY_VERDICT / POLICY_DENY_RULE_EXPLICIT, or 451."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IntegrityFailureError(CleanLibraryError):
|
|
28
|
+
"""403 + INTEGRITY_FAILURE — package hash mismatch on serve path."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RateLimitExceededError(CleanLibraryError):
|
|
32
|
+
"""429 + Retry-After header."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str, retry_after_seconds: int = 60) -> None:
|
|
35
|
+
super().__init__(message, reason_code="RATE_LIMITED")
|
|
36
|
+
self.retry_after_seconds = retry_after_seconds
|
|
37
|
+
|
|
38
|
+
def is_retryable(self) -> bool:
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RiskAcceptanceRequiredError(CleanLibraryError):
|
|
43
|
+
"""403 + RISK_ACCEPTANCE_REQUIRED — policy permits ALLOW but customer
|
|
44
|
+
hasn't signed risk-acceptance for this package/version."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
message: str,
|
|
49
|
+
reason_code: str = "RISK_ACCEPTANCE_REQUIRED",
|
|
50
|
+
guidance: Optional[str] = None,
|
|
51
|
+
docs_url: Optional[str] = "https://docs.cleanlibrary.io/risk-acceptance",
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(message, reason_code=reason_code)
|
|
54
|
+
self.guidance = guidance
|
|
55
|
+
self.docs_url = docs_url
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AuthenticationError(CleanLibraryError):
|
|
59
|
+
"""401, or 403 + KEY_INVALID / KEY_EXPIRED / KEY_SCOPE_INSUFFICIENT."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InsufficientDataError(CleanLibraryError):
|
|
63
|
+
"""403 + INSUFFICIENT_DATA_FAIL_CLOSED — verdict unavailable + customer
|
|
64
|
+
policy fails closed on missing data."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PackageNotFoundError(CleanLibraryError):
|
|
68
|
+
"""404 — package not in catalog + ingest declined or unavailable."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ServerError(CleanLibraryError):
|
|
72
|
+
"""5xx — server error. Retryable on 502/503/504."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, message: str, status: int, reason_code: Optional[str] = None) -> None:
|
|
75
|
+
super().__init__(message, reason_code=reason_code)
|
|
76
|
+
self.status = status
|
|
77
|
+
|
|
78
|
+
def is_retryable(self) -> bool:
|
|
79
|
+
return self.status in (502, 503, 504)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TransportError(CleanLibraryError):
|
|
83
|
+
"""Network / TLS / timeout / DNS — pre-response transport-layer failure."""
|
|
84
|
+
|
|
85
|
+
def is_retryable(self) -> bool:
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ParseError(CleanLibraryError):
|
|
90
|
+
"""Response body parse failure (malformed JSON, schema mismatch)."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def from_http(status: int, reason_code: Optional[str], message: str, retry_after: Optional[int] = None) -> CleanLibraryError:
|
|
94
|
+
"""Map HTTP status + X-CleanLibrary-Reason header into a typed exception.
|
|
95
|
+
|
|
96
|
+
Mirrors cleanlib-client::errors::from_http with the same dispatch table.
|
|
97
|
+
"""
|
|
98
|
+
code = reason_code or "UNKNOWN"
|
|
99
|
+
body = message if message else f"HTTP {status}"
|
|
100
|
+
|
|
101
|
+
if status == 401:
|
|
102
|
+
return AuthenticationError(body, reason_code=code)
|
|
103
|
+
if status == 403 and code in ("KEY_INVALID", "KEY_EXPIRED", "KEY_SCOPE_INSUFFICIENT"):
|
|
104
|
+
return AuthenticationError(body, reason_code=code)
|
|
105
|
+
if (status == 403 and code in ("POLICY_DENY_VERDICT", "POLICY_DENY_RULE_EXPLICIT")) or status == 451:
|
|
106
|
+
return PolicyDenyError(body, reason_code=code)
|
|
107
|
+
if status == 403 and code == "RISK_ACCEPTANCE_REQUIRED":
|
|
108
|
+
return RiskAcceptanceRequiredError(body, reason_code=code)
|
|
109
|
+
if status == 403 and code == "INTEGRITY_FAILURE":
|
|
110
|
+
return IntegrityFailureError(body, reason_code=code)
|
|
111
|
+
if status == 403 and code == "INSUFFICIENT_DATA_FAIL_CLOSED":
|
|
112
|
+
return InsufficientDataError(body, reason_code=code)
|
|
113
|
+
if status == 429:
|
|
114
|
+
return RateLimitExceededError(body, retry_after_seconds=retry_after or 60)
|
|
115
|
+
if status == 404:
|
|
116
|
+
return PackageNotFoundError(body, reason_code=code)
|
|
117
|
+
if 500 <= status <= 599:
|
|
118
|
+
return ServerError(body, status=status, reason_code=code)
|
|
119
|
+
return ServerError(body, status=status, reason_code=code)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Per-domain HTTP clients (sdk-js v0.4.1 sister-shape).
|
|
2
|
+
|
|
3
|
+
v0.4.1 introduces the per-domain client split alongside the existing
|
|
4
|
+
`cleanlib_sdk.client.Client` (which becomes deprecated; full removal in v1.0.0).
|
|
5
|
+
|
|
6
|
+
| Client | Domain | Endpoint default |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| `HttpRemediationClient` | sparse 7-block /remediation responses | vva URL (per F4 ratification; v0.4.2 flips to cleanlib-enrich) |
|
|
9
|
+
| (HttpVerdictClient / HttpEnrichClient — v0.4.2 ripple) | | |
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from cleanlib_sdk.http.remediation_client import (
|
|
13
|
+
DEFAULT_REMEDIATION_BASE_URL,
|
|
14
|
+
REMEDIATION_CACHE_TTL_SECS,
|
|
15
|
+
HttpRemediationClient,
|
|
16
|
+
)
|
|
17
|
+
from cleanlib_sdk.http.types import (
|
|
18
|
+
RemediationBlock,
|
|
19
|
+
RemediationOrAbsent,
|
|
20
|
+
RemediationResponse,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"HttpRemediationClient",
|
|
25
|
+
"DEFAULT_REMEDIATION_BASE_URL",
|
|
26
|
+
"REMEDIATION_CACHE_TTL_SECS",
|
|
27
|
+
"RemediationBlock",
|
|
28
|
+
"RemediationResponse",
|
|
29
|
+
"RemediationOrAbsent",
|
|
30
|
+
]
|