shipwright-kit 0.8.0__tar.gz → 0.9.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.
- {shipwright_kit-0.8.0/shipwright_kit.egg-info → shipwright_kit-0.9.0}/PKG-INFO +1 -1
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/pyproject.toml +1 -1
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/__init__.py +1 -1
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/security/__init__.py +10 -1
- shipwright_kit-0.9.0/shipwright_kit/security/ssrf.py +123 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0/shipwright_kit.egg-info}/PKG-INFO +1 -1
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit.egg-info/SOURCES.txt +1 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/LICENSE +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/README.md +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/setup.cfg +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/cli.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/config.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/design/__init__.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/design/banner.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/design/console.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/design/glyphs.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/design/output.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/design/palette.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/design/tiers.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/eval/__init__.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/eval/corpus.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/eval/harness.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/eval/metrics.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/py.typed +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/security/eval.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/security/injection.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit/security/theme.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit.egg-info/dependency_links.txt +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit.egg-info/entry_points.txt +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit.egg-info/requires.txt +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/shipwright_kit.egg-info/top_level.txt +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/tests/test_cli.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/tests/test_config.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/tests/test_packaging.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/tests/test_packs_entrypoint.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/tests/test_release_config.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/tests/test_template_wiring.py +0 -0
- {shipwright_kit-0.8.0 → shipwright_kit-0.9.0}/tests/test_tooling.py +0 -0
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
# PyPI distribution name. The bare `shipwright` is taken on PyPI (unrelated 6si
|
|
8
8
|
# tool), so the dist is `shipwright-kit`; the IMPORT name is `shipwright_kit`.
|
|
9
9
|
name = "shipwright-kit"
|
|
10
|
-
version = "0.
|
|
10
|
+
version = "0.9.0"
|
|
11
11
|
description = "Shipwright — AI-agent dev framework + import-light design/eval/security library"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""Security pack: threat-verdict theme + labels + shared injection defense
|
|
1
|
+
"""Security pack: threat-verdict theme + labels + shared injection defense
|
|
2
|
+
+ SSRF host-allowlist guard."""
|
|
2
3
|
|
|
3
4
|
from shipwright_kit.security.injection import (
|
|
4
5
|
INJECTION_PATTERNS_VERSION,
|
|
@@ -7,11 +8,19 @@ from shipwright_kit.security.injection import (
|
|
|
7
8
|
SeverityLevel,
|
|
8
9
|
scan,
|
|
9
10
|
)
|
|
11
|
+
from shipwright_kit.security.ssrf import (
|
|
12
|
+
UnsafeURLError,
|
|
13
|
+
assert_safe_url,
|
|
14
|
+
is_safe_url,
|
|
15
|
+
)
|
|
10
16
|
|
|
11
17
|
__all__ = [
|
|
12
18
|
"INJECTION_PATTERNS_VERSION",
|
|
13
19
|
"InjectionFinding",
|
|
14
20
|
"PromptInjectionDetector",
|
|
15
21
|
"SeverityLevel",
|
|
22
|
+
"UnsafeURLError",
|
|
23
|
+
"assert_safe_url",
|
|
24
|
+
"is_safe_url",
|
|
16
25
|
"scan",
|
|
17
26
|
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""SSRF host-allowlist guard for outbound HTTP requests.
|
|
2
|
+
|
|
3
|
+
Pre-committed guardrail for W3 (the LLM-provider layer making ollama's
|
|
4
|
+
``base_url`` configurable). This module is the primitive ONLY — it is not
|
|
5
|
+
wired into any tool or into ``ollama.py`` yet (``base_url`` is hardcoded
|
|
6
|
+
today, so wiring now would be dead code in the shipped path). W3 does the
|
|
7
|
+
wiring.
|
|
8
|
+
|
|
9
|
+
Import-light: stdlib only (``ipaddress``, ``urllib.parse``, ``socket``) — no
|
|
10
|
+
third-party deps, no import-time side effects. Mirrors the import-light
|
|
11
|
+
invariant of ``shipwright_kit.security.injection``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ipaddress
|
|
17
|
+
import socket
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"UnsafeURLError",
|
|
22
|
+
"assert_safe_url",
|
|
23
|
+
"is_safe_url",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
_ALLOWED_SCHEMES = {"http", "https"}
|
|
27
|
+
|
|
28
|
+
_IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnsafeURLError(ValueError):
|
|
32
|
+
"""Raised by :func:`assert_safe_url` when a URL is unsafe to fetch."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_blocked_ip(ip: _IPAddress, *, allow_loopback: bool) -> bool:
|
|
36
|
+
"""True if `ip` should be blocked as an SSRF target.
|
|
37
|
+
|
|
38
|
+
Loopback (127.0.0.0/8, ::1) is carved out first so ``allow_loopback``
|
|
39
|
+
can permit it — every other non-globally-routable range stays blocked
|
|
40
|
+
unconditionally: link-local (169.254.0.0/16, incl. the 169.254.169.254
|
|
41
|
+
cloud-metadata address, and IPv6 fe80::/10), RFC1918 private ranges and
|
|
42
|
+
IPv6 unique-local (fc00::/7) via ``is_private``, plus multicast/
|
|
43
|
+
reserved/unspecified as defense in depth.
|
|
44
|
+
"""
|
|
45
|
+
if ip.is_loopback:
|
|
46
|
+
return not allow_loopback
|
|
47
|
+
if ip.is_link_local:
|
|
48
|
+
return True
|
|
49
|
+
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
|
50
|
+
return True
|
|
51
|
+
if ip.is_private:
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def assert_safe_url(url: str, *, allow_loopback: bool = False, resolve: bool = False) -> None:
|
|
57
|
+
"""Raise :class:`UnsafeURLError` if `url` is unsafe to fetch outbound; else return None.
|
|
58
|
+
|
|
59
|
+
Blocks:
|
|
60
|
+
- non-http(s) schemes (``file://``, ``ftp://``, etc.) and malformed URLs.
|
|
61
|
+
- IP-literal hosts in RFC1918 private ranges (10/8, 172.16/12, 192.168/16),
|
|
62
|
+
link-local ranges including the 169.254.169.254 cloud-metadata address
|
|
63
|
+
(169.254.0.0/16), IPv6 unique-local (fc00::/7), IPv6 link-local
|
|
64
|
+
(fe80::/10), and loopback (127.0.0.0/8, ::1) — unless
|
|
65
|
+
``allow_loopback=True``.
|
|
66
|
+
|
|
67
|
+
When ``resolve=True``, a non-IP-literal hostname is resolved via
|
|
68
|
+
``socket.getaddrinfo`` and rejected if ANY resolved address is blocked.
|
|
69
|
+
|
|
70
|
+
Caveat: this is a best-effort, TOCTOU-prone check. DNS is not pinned —
|
|
71
|
+
the hostname can re-resolve to a different (unsafe) address between this
|
|
72
|
+
check and the actual outbound request (a classic DNS-rebinding SSRF
|
|
73
|
+
bypass). Callers with a hard safety requirement should connect to the
|
|
74
|
+
resolved IP directly (not re-resolve the hostname) or maintain an
|
|
75
|
+
explicit host allowlist; this function is a guardrail, not a substitute
|
|
76
|
+
for that.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
parsed = urlsplit(url)
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
raise UnsafeURLError(f"malformed URL: {url!r}") from exc
|
|
82
|
+
|
|
83
|
+
scheme = parsed.scheme.lower()
|
|
84
|
+
if scheme not in _ALLOWED_SCHEMES:
|
|
85
|
+
raise UnsafeURLError(f"unsupported scheme: {scheme or '(none)'!r}")
|
|
86
|
+
|
|
87
|
+
# Not wrapped in try/except: urlsplit() above already rejects malformed
|
|
88
|
+
# IPv6-bracket netlocs before .hostname would ever raise.
|
|
89
|
+
host = parsed.hostname
|
|
90
|
+
if not host:
|
|
91
|
+
raise UnsafeURLError(f"URL has no host: {url!r}")
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
literal_ip: _IPAddress | None = ipaddress.ip_address(host)
|
|
95
|
+
except ValueError:
|
|
96
|
+
literal_ip = None
|
|
97
|
+
|
|
98
|
+
if literal_ip is not None:
|
|
99
|
+
if _is_blocked_ip(literal_ip, allow_loopback=allow_loopback):
|
|
100
|
+
raise UnsafeURLError(f"blocked address: {host}")
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
if resolve:
|
|
104
|
+
try:
|
|
105
|
+
infos = socket.getaddrinfo(host, None)
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
raise UnsafeURLError(f"could not resolve host: {host!r} ({exc})") from exc
|
|
108
|
+
for info in infos:
|
|
109
|
+
sockaddr = info[4]
|
|
110
|
+
resolved_ip = ipaddress.ip_address(sockaddr[0])
|
|
111
|
+
if _is_blocked_ip(resolved_ip, allow_loopback=allow_loopback):
|
|
112
|
+
raise UnsafeURLError(f"host {host!r} resolves to blocked address: {sockaddr[0]}")
|
|
113
|
+
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_safe_url(url: str, **kwargs: bool) -> bool:
|
|
118
|
+
"""Convenience wrapper: True if `url` passes :func:`assert_safe_url`, else False."""
|
|
119
|
+
try:
|
|
120
|
+
assert_safe_url(url, **kwargs)
|
|
121
|
+
except UnsafeURLError:
|
|
122
|
+
return False
|
|
123
|
+
return True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|