shield-python 0.1.4__tar.gz → 0.1.6__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.
- {shield_python-0.1.4 → shield_python-0.1.6}/PKG-INFO +10 -1
- {shield_python-0.1.4 → shield_python-0.1.6}/README.md +9 -0
- {shield_python-0.1.4 → shield_python-0.1.6}/pyproject.toml +1 -1
- {shield_python-0.1.4 → shield_python-0.1.6}/setup.py +1 -1
- {shield_python-0.1.4 → shield_python-0.1.6}/shield/__init__.py +1 -1
- {shield_python-0.1.4 → shield_python-0.1.6}/shield/client.py +34 -11
- shield_python-0.1.6/shield/exceptions.py +15 -0
- {shield_python-0.1.4 → shield_python-0.1.6}/shield_python.egg-info/PKG-INFO +10 -1
- {shield_python-0.1.4 → shield_python-0.1.6}/shield_python.egg-info/SOURCES.txt +2 -1
- shield_python-0.1.6/tests/test_signing.py +100 -0
- shield_python-0.1.4/shield/exceptions.py +0 -23
- {shield_python-0.1.4 → shield_python-0.1.6}/setup.cfg +0 -0
- {shield_python-0.1.4 → shield_python-0.1.6}/shield/resources/__init__.py +0 -0
- {shield_python-0.1.4 → shield_python-0.1.6}/shield/resources/events.py +0 -0
- {shield_python-0.1.4 → shield_python-0.1.6}/shield/resources/sessions.py +0 -0
- {shield_python-0.1.4 → shield_python-0.1.6}/shield/resources/verify.py +0 -0
- {shield_python-0.1.4 → shield_python-0.1.6}/shield_python.egg-info/dependency_links.txt +0 -0
- {shield_python-0.1.4 → shield_python-0.1.6}/shield_python.egg-info/requires.txt +0 -0
- {shield_python-0.1.4 → shield_python-0.1.6}/shield_python.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shield-python
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Official Shield SDK for Python
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://getshield.dev
|
|
@@ -264,3 +264,12 @@ Shield Standard Event Taxonomy v1.0 ??37 event types across 7 categories:
|
|
|
264
264
|
| `shield.evidence.exported` | Evidence was exported |
|
|
265
265
|
| `shield.evidence.verified` | Evidence was verified |
|
|
266
266
|
| `shield.evidence.tampered_detected` | Evidence tampering was detected |
|
|
267
|
+
|
|
268
|
+
## Versioning & API compatibility
|
|
269
|
+
|
|
270
|
+
This SDK follows [Semantic Versioning](https://semver.org/).
|
|
271
|
+
|
|
272
|
+
- **Pre-1.0** (current): minor-version bumps may ship breaking changes. Pin the full version in `requirements.txt`.
|
|
273
|
+
- **1.0 and later**: the public API is stable within a major version. Breaking changes require a major-version bump.
|
|
274
|
+
|
|
275
|
+
The Shield HTTP API is versioned at the URL path (`/api/v1`). This SDK targets `/api/v1` and will not transparently follow a server-side version bump — a new server major version will be delivered as a new SDK major version so callers opt in explicitly.
|
|
@@ -253,3 +253,12 @@ Shield Standard Event Taxonomy v1.0 ??37 event types across 7 categories:
|
|
|
253
253
|
| `shield.evidence.exported` | Evidence was exported |
|
|
254
254
|
| `shield.evidence.verified` | Evidence was verified |
|
|
255
255
|
| `shield.evidence.tampered_detected` | Evidence tampering was detected |
|
|
256
|
+
|
|
257
|
+
## Versioning & API compatibility
|
|
258
|
+
|
|
259
|
+
This SDK follows [Semantic Versioning](https://semver.org/).
|
|
260
|
+
|
|
261
|
+
- **Pre-1.0** (current): minor-version bumps may ship breaking changes. Pin the full version in `requirements.txt`.
|
|
262
|
+
- **1.0 and later**: the public API is stable within a major version. Breaking changes require a major-version bump.
|
|
263
|
+
|
|
264
|
+
The Shield HTTP API is versioned at the URL path (`/api/v1`). This SDK targets `/api/v1` and will not transparently follow a server-side version bump — a new server major version will be delivered as a new SDK major version so callers opt in explicitly.
|
|
@@ -3,7 +3,7 @@ import hmac as hmac_mod
|
|
|
3
3
|
import json
|
|
4
4
|
import time
|
|
5
5
|
from typing import Any, Dict, Optional
|
|
6
|
-
from urllib.parse import
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
7
|
|
|
8
8
|
import requests
|
|
9
9
|
|
|
@@ -12,6 +12,10 @@ from .resources.sessions import Sessions
|
|
|
12
12
|
from .resources.events import Events
|
|
13
13
|
from .resources.verify import Verify
|
|
14
14
|
|
|
15
|
+
# ARCH-019: kept static to avoid leaking Python version / OS into server logs.
|
|
16
|
+
# Bumped alongside setup.py version on each release.
|
|
17
|
+
SDK_USER_AGENT = "shield-python/0.1.6"
|
|
18
|
+
|
|
15
19
|
|
|
16
20
|
class Client:
|
|
17
21
|
"""Official Shield Python SDK client.
|
|
@@ -64,12 +68,29 @@ class Client:
|
|
|
64
68
|
Raises:
|
|
65
69
|
ShieldError: On non-2xx responses or request failures.
|
|
66
70
|
"""
|
|
67
|
-
url = f"{self.base_url}{path}"
|
|
68
71
|
method = method.upper()
|
|
69
72
|
|
|
73
|
+
# C-1 (v0.1.6): fold query params into `path` BEFORE signing so the
|
|
74
|
+
# canonical message matches what the server validates against
|
|
75
|
+
# http.Request.URL.RequestURI(). Previously `params` was passed to
|
|
76
|
+
# requests.request() separately — requests would append them to the
|
|
77
|
+
# wire URL, but the SDK signed only the bare path, guaranteeing a
|
|
78
|
+
# signature mismatch on any request with query params. We pre-encode
|
|
79
|
+
# here and pass params=None to requests so there is exactly one
|
|
80
|
+
# canonical query string, controlled by this SDK, for both signing
|
|
81
|
+
# and transmission. doseq=True matches requests' own encoding for
|
|
82
|
+
# list-valued params (?tag=a&tag=b).
|
|
83
|
+
if params:
|
|
84
|
+
path_with_query = f"{path}?{urlencode(params, doseq=True)}"
|
|
85
|
+
else:
|
|
86
|
+
path_with_query = path
|
|
87
|
+
|
|
88
|
+
url = f"{self.base_url}{path_with_query}"
|
|
89
|
+
|
|
70
90
|
headers = {
|
|
71
91
|
"X-Shield-Key": self.api_key,
|
|
72
92
|
"Content-Type": "application/json",
|
|
93
|
+
"User-Agent": SDK_USER_AGENT,
|
|
73
94
|
}
|
|
74
95
|
|
|
75
96
|
# Serialize body
|
|
@@ -83,7 +104,7 @@ class Client:
|
|
|
83
104
|
timestamp = str(int(time.time()))
|
|
84
105
|
nonce = str(uuid.uuid4())
|
|
85
106
|
body_hash = hashlib.sha256(body).hexdigest()
|
|
86
|
-
message = f"{timestamp}.{method}.{
|
|
107
|
+
message = f"{timestamp}.{method}.{path_with_query}.{body_hash}"
|
|
87
108
|
signature = hmac_mod.new(
|
|
88
109
|
self.hmac_secret.encode("utf-8"),
|
|
89
110
|
message.encode("utf-8"),
|
|
@@ -99,29 +120,31 @@ class Client:
|
|
|
99
120
|
url=url,
|
|
100
121
|
headers=headers,
|
|
101
122
|
data=body if body else None,
|
|
102
|
-
params=
|
|
123
|
+
params=None,
|
|
103
124
|
timeout=self.timeout,
|
|
104
125
|
)
|
|
105
126
|
except requests.RequestException as e:
|
|
106
127
|
raise ShieldError(
|
|
107
128
|
message=f"Request failed: {str(e)}",
|
|
108
129
|
status_code=0,
|
|
109
|
-
code="request_error",
|
|
110
130
|
)
|
|
111
131
|
|
|
112
132
|
if not (200 <= response.status_code < 300):
|
|
113
|
-
|
|
114
|
-
|
|
133
|
+
# Backend returns {"error": "..."} (always) and sometimes
|
|
134
|
+
# {"message": "..."} with extra detail. Prefer message when present.
|
|
135
|
+
error_message = response.text or response.reason
|
|
115
136
|
try:
|
|
116
137
|
error_body = response.json()
|
|
117
|
-
error_message =
|
|
118
|
-
|
|
119
|
-
|
|
138
|
+
error_message = (
|
|
139
|
+
error_body.get("message")
|
|
140
|
+
or error_body.get("error")
|
|
141
|
+
or error_message
|
|
142
|
+
)
|
|
143
|
+
except ValueError:
|
|
120
144
|
pass
|
|
121
145
|
raise ShieldError(
|
|
122
146
|
message=error_message,
|
|
123
147
|
status_code=response.status_code,
|
|
124
|
-
code=error_code,
|
|
125
148
|
)
|
|
126
149
|
|
|
127
150
|
if raw_response:
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class ShieldError(Exception):
|
|
2
|
+
"""Base exception for Shield SDK errors."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, message: str, status_code: int = None):
|
|
5
|
+
super().__init__(message)
|
|
6
|
+
self.message = message
|
|
7
|
+
self.status_code = status_code
|
|
8
|
+
|
|
9
|
+
def __str__(self):
|
|
10
|
+
if self.status_code:
|
|
11
|
+
return f"[{self.status_code}] {self.message}"
|
|
12
|
+
return self.message
|
|
13
|
+
|
|
14
|
+
def __repr__(self):
|
|
15
|
+
return f"ShieldError(message={self.message!r}, status_code={self.status_code!r})"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shield-python
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Official Shield SDK for Python
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://getshield.dev
|
|
@@ -264,3 +264,12 @@ Shield Standard Event Taxonomy v1.0 ??37 event types across 7 categories:
|
|
|
264
264
|
| `shield.evidence.exported` | Evidence was exported |
|
|
265
265
|
| `shield.evidence.verified` | Evidence was verified |
|
|
266
266
|
| `shield.evidence.tampered_detected` | Evidence tampering was detected |
|
|
267
|
+
|
|
268
|
+
## Versioning & API compatibility
|
|
269
|
+
|
|
270
|
+
This SDK follows [Semantic Versioning](https://semver.org/).
|
|
271
|
+
|
|
272
|
+
- **Pre-1.0** (current): minor-version bumps may ship breaking changes. Pin the full version in `requirements.txt`.
|
|
273
|
+
- **1.0 and later**: the public API is stable within a major version. Breaking changes require a major-version bump.
|
|
274
|
+
|
|
275
|
+
The Shield HTTP API is versioned at the URL path (`/api/v1`). This SDK targets `/api/v1` and will not transparently follow a server-side version bump — a new server major version will be delivered as a new SDK major version so callers opt in explicitly.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""C-1 (v0.1.6): verify the Python SDK's canonical HMAC message covers the
|
|
2
|
+
full request target (path + query string), not just the path.
|
|
3
|
+
|
|
4
|
+
This test asserts the invariant directly from the Client internals: given a
|
|
5
|
+
path and a params dict, the SDK must produce a signature over the SAME string
|
|
6
|
+
that the server will see via http.Request.URL.RequestURI(). Different query
|
|
7
|
+
strings → different signatures.
|
|
8
|
+
|
|
9
|
+
Run with: pytest tests/
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
from unittest.mock import patch, MagicMock
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from shield.client import Client
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _extract_signed_message(mock_request) -> str:
|
|
22
|
+
"""Reconstruct the message the SDK signed, from the mock call args.
|
|
23
|
+
|
|
24
|
+
We capture the outgoing URL (which includes the final query string the
|
|
25
|
+
wire sees) and the X-Shield-Timestamp header, then recompute what the
|
|
26
|
+
canonical message would be if the SDK signed the URL's path+query.
|
|
27
|
+
Comparing that to the signature the SDK actually sent proves coverage.
|
|
28
|
+
"""
|
|
29
|
+
call = mock_request.call_args
|
|
30
|
+
return call.kwargs["headers"]["X-Shield-Signature"], call.kwargs["url"], call.kwargs["headers"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _expected_signature(secret: str, timestamp: str, method: str, path_with_query: str, body: bytes) -> str:
|
|
34
|
+
body_hash = hashlib.sha256(body).hexdigest()
|
|
35
|
+
message = f"{timestamp}.{method}.{path_with_query}.{body_hash}"
|
|
36
|
+
return hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@patch("requests.Session.request")
|
|
40
|
+
def test_params_are_included_in_signature(mock_req):
|
|
41
|
+
mock_req.return_value = MagicMock(status_code=200, content=b"{}", json=lambda: {})
|
|
42
|
+
|
|
43
|
+
secret = "a" * 64
|
|
44
|
+
client = Client("sk_test", base_url="https://api.getshield.dev/api/v1", hmac_secret=secret)
|
|
45
|
+
|
|
46
|
+
client._request("GET", "/sessions", params={"org": "A"})
|
|
47
|
+
|
|
48
|
+
sig, url, headers = _extract_signed_message(mock_req)
|
|
49
|
+
ts = headers["X-Shield-Timestamp"]
|
|
50
|
+
|
|
51
|
+
# Wire URL must include the query string exactly once.
|
|
52
|
+
assert url == "https://api.getshield.dev/api/v1/sessions?org=A"
|
|
53
|
+
# Signature must cover path+query, not just path.
|
|
54
|
+
assert sig == _expected_signature(secret, ts, "GET", "/sessions?org=A", b"")
|
|
55
|
+
# And must NOT match signing of path alone (the pre-0.1.6 behavior).
|
|
56
|
+
assert sig != _expected_signature(secret, ts, "GET", "/sessions", b"")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@patch("requests.Session.request")
|
|
60
|
+
def test_different_params_produce_different_signatures(mock_req):
|
|
61
|
+
mock_req.return_value = MagicMock(status_code=200, content=b"{}", json=lambda: {})
|
|
62
|
+
secret = "a" * 64
|
|
63
|
+
client = Client("sk_test", hmac_secret=secret)
|
|
64
|
+
|
|
65
|
+
client._request("GET", "/sessions", params={"org": "A"})
|
|
66
|
+
sig_a, _, _ = _extract_signed_message(mock_req)
|
|
67
|
+
|
|
68
|
+
client._request("GET", "/sessions", params={"org": "B"})
|
|
69
|
+
sig_b, _, _ = _extract_signed_message(mock_req)
|
|
70
|
+
|
|
71
|
+
assert sig_a != sig_b, "query tampering must invalidate the signature"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@patch("requests.Session.request")
|
|
75
|
+
def test_no_params_preserves_legacy_signature(mock_req):
|
|
76
|
+
"""Backwards compat: requests without params sign just `path`, matching
|
|
77
|
+
the pre-0.1.6 SDK behavior. The C-1 fix only activates when params are
|
|
78
|
+
present, so existing callers see no signature change."""
|
|
79
|
+
mock_req.return_value = MagicMock(status_code=200, content=b"{}", json=lambda: {})
|
|
80
|
+
|
|
81
|
+
secret = "a" * 64
|
|
82
|
+
client = Client("sk_test", hmac_secret=secret)
|
|
83
|
+
|
|
84
|
+
client._request("POST", "/sessions", json_data={"title": "x"})
|
|
85
|
+
sig, _, headers = _extract_signed_message(mock_req)
|
|
86
|
+
ts = headers["X-Shield-Timestamp"]
|
|
87
|
+
|
|
88
|
+
body = b'{"title":"x"}'
|
|
89
|
+
assert sig == _expected_signature(secret, ts, "POST", "/sessions", body)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@patch("requests.Session.request")
|
|
93
|
+
def test_signature_is_lowercase_hex(mock_req):
|
|
94
|
+
mock_req.return_value = MagicMock(status_code=200, content=b"{}", json=lambda: {})
|
|
95
|
+
client = Client("sk_test", hmac_secret="secret")
|
|
96
|
+
client._request("GET", "/sessions")
|
|
97
|
+
sig, _, _ = _extract_signed_message(mock_req)
|
|
98
|
+
assert sig == sig.lower()
|
|
99
|
+
assert len(sig) == 64
|
|
100
|
+
assert all(c in "0123456789abcdef" for c in sig)
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
class ShieldError(Exception):
|
|
2
|
-
"""Base exception for Shield SDK errors."""
|
|
3
|
-
|
|
4
|
-
def __init__(self, message: str, status_code: int = None, code: str = None):
|
|
5
|
-
super().__init__(message)
|
|
6
|
-
self.message = message
|
|
7
|
-
self.status_code = status_code
|
|
8
|
-
self.code = code
|
|
9
|
-
|
|
10
|
-
def __str__(self):
|
|
11
|
-
parts = []
|
|
12
|
-
if self.status_code:
|
|
13
|
-
parts.append(f"[{self.status_code}]")
|
|
14
|
-
if self.code:
|
|
15
|
-
parts.append(f"({self.code})")
|
|
16
|
-
parts.append(self.message)
|
|
17
|
-
return " ".join(parts)
|
|
18
|
-
|
|
19
|
-
def __repr__(self):
|
|
20
|
-
return (
|
|
21
|
-
f"ShieldError(message={self.message!r}, "
|
|
22
|
-
f"status_code={self.status_code!r}, code={self.code!r})"
|
|
23
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|