bmad-plus 0.20.0 → 0.22.0
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.
- package/CHANGELOG.md +41 -0
- package/README.md +14 -14
- package/SECURITY.md +62 -0
- package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/_http.py +68 -24
- package/package.json +1 -1
- package/readme-international/README.de.md +14 -14
- package/readme-international/README.es.md +14 -14
- package/readme-international/README.fr.md +14 -14
- package/src/bmad-plus/agents/agent-quality/SKILL.md +1 -1
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +26 -5
- package/src/bmad-plus/packs/pack-seo/SKILL.md +3 -1
- package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +2 -2
- package/src/bmad-plus/packs/pack-seo/requirements.txt +1 -1
- package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +72 -30
- package/src/bmad-plus/packs/pack-seo/scripts/seo_crawl.py +36 -24
- package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +179 -59
- package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +5 -6
- package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +176 -14
- package/src/bmad-plus/packs/pack-shield/README.md +12 -0
- package/src/bmad-plus/packs/pack-shield/SKILL.md +7 -1
- package/src/bmad-plus/packs/pack-shield/review-rules/access-control.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/ai-integrations.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/change-and-supply-chain.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/cryptography.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/index.yaml +134 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/logging.md +10 -0
- package/src/bmad-plus/packs/pack-shield/review-rules/personal-data.md +10 -0
- package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register-template.yaml +53 -0
- package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register.md +32 -0
- package/src/bmad-plus/packs/pack-shield/shared/assurance-case-template.yaml +87 -0
- package/src/bmad-plus/packs/pack-shield/shared/assurance-case.md +50 -0
- package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +24 -1
- package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +1 -0
- package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +5 -4
- package/tools/build/generate-adapters.js +7 -0
- package/tools/build/generate.js +14 -0
- package/tools/cli/bmad-plus-cli.js +2 -0
- package/tools/cli/commands/ai-register.js +63 -0
- package/tools/cli/commands/assurance.js +162 -0
- package/tools/cli/commands/review.js +141 -7
- package/tools/cli/lib/ai-register.js +393 -0
- package/tools/cli/lib/assurance.js +822 -0
- package/tools/cli/lib/control-refs.js +132 -0
- package/tools/cli/lib/installation-health.js +17 -0
- package/tools/cli/lib/packs.js +60 -2
- package/tools/cli/lib/page-origins.js +582 -0
- package/tools/cli/lib/review-rules.js +124 -26
- package/tools/cli/lib/review.js +493 -10
- package/tools/cli/lib/uat.js +22 -5
- package/tools/cli/review-rules/index.yaml +9 -0
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
SEO Fetch — Secure HTTP page fetcher for SEO analysis.
|
|
4
4
|
|
|
5
5
|
Features:
|
|
6
|
-
- SSRF protection (blocks private/loopback/reserved IPs
|
|
6
|
+
- SSRF protection (blocks private/loopback/reserved IPs, re-checked on every
|
|
7
|
+
redirect hop, connection pinned to the validated address)
|
|
7
8
|
- Multi-UA support (standard, Googlebot, GPTBot, ClaudeBot)
|
|
8
9
|
- Redirect chain tracking
|
|
9
10
|
- Cookie handling
|
|
@@ -16,12 +17,16 @@ License: MIT
|
|
|
16
17
|
import argparse
|
|
17
18
|
import ipaddress
|
|
18
19
|
import json
|
|
20
|
+
import os
|
|
19
21
|
import socket
|
|
20
22
|
import sys
|
|
23
|
+
import time
|
|
21
24
|
from urllib.parse import urljoin, urlparse
|
|
22
25
|
|
|
23
26
|
try:
|
|
24
27
|
import requests
|
|
28
|
+
from requests.adapters import DEFAULT_POOLBLOCK, HTTPAdapter
|
|
29
|
+
from urllib3 import PoolManager
|
|
25
30
|
except ImportError:
|
|
26
31
|
print("Error: requests library required. Install: pip install requests", file=sys.stderr)
|
|
27
32
|
sys.exit(1)
|
|
@@ -61,10 +66,37 @@ DEFAULT_HEADERS = {
|
|
|
61
66
|
|
|
62
67
|
# ── Security: SSRF Prevention ──────────────────────────────────────
|
|
63
68
|
|
|
69
|
+
ALLOWED_SCHEMES = frozenset({"http", "https"})
|
|
70
|
+
|
|
71
|
+
# RFC 6052 well-known NAT64 prefix: the last 32 bits are the IPv4 host the
|
|
72
|
+
# translator will reach, so the address is only as safe as that IPv4 host.
|
|
73
|
+
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class UnsafeURLError(requests.exceptions.InvalidURL):
|
|
77
|
+
"""A URL, or the address it resolves to, must never be fetched."""
|
|
78
|
+
|
|
79
|
+
|
|
64
80
|
def _ip_is_blocked(ip: "ipaddress._BaseAddress") -> bool:
|
|
65
|
-
"""Return True if an IP falls in any range that must never be reached.
|
|
81
|
+
"""Return True if an IP falls in any range that must never be reached.
|
|
82
|
+
|
|
83
|
+
Anything that is not globally routable is refused: private, loopback,
|
|
84
|
+
link-local (cloud metadata at 169.254.169.254), carrier-grade NAT
|
|
85
|
+
(100.64.0.0/10, e.g. Alibaba metadata at 100.100.100.200), documentation
|
|
86
|
+
and benchmarking ranges, multicast and reserved space. IPv6 forms that
|
|
87
|
+
embed an IPv4 destination are judged by that destination.
|
|
88
|
+
"""
|
|
89
|
+
if ip.version == 6:
|
|
90
|
+
embedded = ip.ipv4_mapped
|
|
91
|
+
if embedded is None and ip in _NAT64_PREFIX:
|
|
92
|
+
embedded = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
|
|
93
|
+
if embedded is not None:
|
|
94
|
+
return _ip_is_blocked(embedded)
|
|
95
|
+
if ip.is_site_local:
|
|
96
|
+
return True
|
|
66
97
|
return bool(
|
|
67
|
-
ip.
|
|
98
|
+
not ip.is_global
|
|
99
|
+
or ip.is_private
|
|
68
100
|
or ip.is_loopback
|
|
69
101
|
or ip.is_reserved
|
|
70
102
|
or ip.is_link_local
|
|
@@ -73,47 +105,152 @@ def _ip_is_blocked(ip: "ipaddress._BaseAddress") -> bool:
|
|
|
73
105
|
)
|
|
74
106
|
|
|
75
107
|
|
|
76
|
-
def
|
|
77
|
-
"""
|
|
108
|
+
def check_url(url: str) -> str:
|
|
109
|
+
"""Apply the URL-level rules and return the hostname, without resolving it.
|
|
78
110
|
|
|
79
|
-
Fails CLOSED
|
|
80
|
-
|
|
81
|
-
rejected. Every resolved address (IPv4 and IPv6) must be public.
|
|
111
|
+
Fails CLOSED with UnsafeURLError: a scheme outside http/https, embedded
|
|
112
|
+
credentials, a missing host or an invalid port rejects the URL.
|
|
82
113
|
"""
|
|
83
114
|
parsed = urlparse(url)
|
|
115
|
+
if parsed.scheme not in ALLOWED_SCHEMES:
|
|
116
|
+
raise UnsafeURLError(f"scheme not allowed: {parsed.scheme or '(none)'}")
|
|
117
|
+
# "user:pass@" would be sent as an Authorization header and makes
|
|
118
|
+
# "https://trusted.example@evil.example/" style URLs misleading.
|
|
119
|
+
if parsed.username is not None or parsed.password is not None:
|
|
120
|
+
raise UnsafeURLError("credentials in URL are not allowed")
|
|
84
121
|
hostname = parsed.hostname
|
|
85
|
-
|
|
86
122
|
if not hostname:
|
|
87
|
-
|
|
123
|
+
raise UnsafeURLError("URL has no host")
|
|
124
|
+
try:
|
|
125
|
+
parsed.port
|
|
126
|
+
except ValueError:
|
|
127
|
+
raise UnsafeURLError("URL has an invalid port") from None
|
|
128
|
+
return hostname
|
|
88
129
|
|
|
89
|
-
if parsed.scheme not in ("http", "https"):
|
|
90
|
-
return False
|
|
91
130
|
|
|
131
|
+
def resolve_public_address(hostname: str) -> str:
|
|
132
|
+
"""Resolve a hostname and return the public address to connect to.
|
|
133
|
+
|
|
134
|
+
Fails CLOSED with UnsafeURLError on a DNS error or when any resolved
|
|
135
|
+
address (IPv4 or IPv6) is not public: every address the name resolves to
|
|
136
|
+
must be public, so the returned one is safe whichever the resolver would
|
|
137
|
+
have preferred.
|
|
138
|
+
"""
|
|
92
139
|
try:
|
|
93
|
-
# Resolve ALL IP addresses (IPv4 and IPv6) via getaddrinfo
|
|
94
140
|
addrinfo = socket.getaddrinfo(hostname, None)
|
|
95
|
-
except socket.gaierror:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if not addrinfo:
|
|
99
|
-
return False # Fail closed: no addresses resolved
|
|
141
|
+
except (socket.gaierror, UnicodeError):
|
|
142
|
+
raise UnsafeURLError(f"cannot resolve host {hostname}") from None
|
|
100
143
|
|
|
144
|
+
addresses = []
|
|
101
145
|
for entry in addrinfo:
|
|
102
|
-
ip_str = entry[4][0] # sockaddr[0] contains the IP string
|
|
103
146
|
try:
|
|
104
|
-
ip = ipaddress.ip_address(
|
|
147
|
+
ip = ipaddress.ip_address(entry[4][0])
|
|
105
148
|
except ValueError:
|
|
106
|
-
|
|
107
|
-
# IPv4-mapped IPv6 (::ffff:a.b.c.d) must be checked as its IPv4 form
|
|
108
|
-
mapped = getattr(ip, "ipv4_mapped", None)
|
|
109
|
-
if mapped is not None and _ip_is_blocked(mapped):
|
|
110
|
-
return False
|
|
149
|
+
raise UnsafeURLError(f"{hostname} resolved to an unparseable address") from None
|
|
111
150
|
if _ip_is_blocked(ip):
|
|
112
|
-
|
|
151
|
+
raise UnsafeURLError(f"{hostname} resolves to a private/internal address ({ip})")
|
|
152
|
+
addresses.append(ip)
|
|
153
|
+
if not addresses:
|
|
154
|
+
raise UnsafeURLError(f"cannot resolve host {hostname}")
|
|
155
|
+
|
|
156
|
+
# A pinned connection cannot fall back to another address, and IPv6 routes
|
|
157
|
+
# are often missing in containers and CI: prefer IPv4 on dual-stack hosts.
|
|
158
|
+
preferred = next((ip for ip in addresses if ip.version == 4), addresses[0])
|
|
159
|
+
return str(preferred)
|
|
160
|
+
|
|
113
161
|
|
|
162
|
+
def resolve_target(url: str) -> "tuple[str, str]":
|
|
163
|
+
"""Validate a URL and return ``(hostname, address)`` to connect to.
|
|
164
|
+
|
|
165
|
+
Combines check_url() and resolve_public_address(); see their rules.
|
|
166
|
+
"""
|
|
167
|
+
hostname = check_url(url)
|
|
168
|
+
return hostname, resolve_public_address(hostname)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def is_safe_url(url: str) -> bool:
|
|
172
|
+
"""Return True when resolve_target() accepts the URL (see its rules)."""
|
|
173
|
+
try:
|
|
174
|
+
resolve_target(url)
|
|
175
|
+
except UnsafeURLError:
|
|
176
|
+
return False
|
|
114
177
|
return True
|
|
115
178
|
|
|
116
179
|
|
|
180
|
+
class _PinnedPoolManager(PoolManager):
|
|
181
|
+
"""urllib3 pool manager that only opens pools on validated IP literals.
|
|
182
|
+
|
|
183
|
+
Every requests version reaches the network through
|
|
184
|
+
PoolManager.connection_from_host (directly, or via connection_from_url),
|
|
185
|
+
whatever adapter hook it calls first. Validating here, instead of in a
|
|
186
|
+
requests hook that has been renamed across releases, keeps the guard
|
|
187
|
+
closed on old and future requests versions alike.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def connection_from_host(self, host, port=None, scheme="http", pool_kwargs=None):
|
|
191
|
+
hostname = (host or "").strip("[]")
|
|
192
|
+
if not hostname:
|
|
193
|
+
raise UnsafeURLError("URL has no host")
|
|
194
|
+
address = resolve_public_address(hostname)
|
|
195
|
+
if scheme == "https":
|
|
196
|
+
pool_kwargs = dict(pool_kwargs or {})
|
|
197
|
+
pool_kwargs["server_hostname"] = hostname
|
|
198
|
+
pool_kwargs["assert_hostname"] = hostname
|
|
199
|
+
return super().connection_from_host(
|
|
200
|
+
address, port=port, scheme=scheme, pool_kwargs=pool_kwargs
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class PinnedAddressAdapter(HTTPAdapter):
|
|
205
|
+
"""Transport adapter that connects to the exact address it validated.
|
|
206
|
+
|
|
207
|
+
A plain requests call resolves the host again when urllib3 opens the
|
|
208
|
+
socket, so a hostile DNS server can answer a public address to the SSRF
|
|
209
|
+
check and a private one to the connection (DNS rebinding). This adapter's
|
|
210
|
+
pool manager resolves and validates each host itself, then opens the
|
|
211
|
+
connection pool on that IP literal. The Host header, TLS SNI and the
|
|
212
|
+
certificate hostname check still use the original name, so HTTPS
|
|
213
|
+
verification is unchanged. Proxies are refused: a proxy would resolve the
|
|
214
|
+
name itself and bypass the pinned address.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def init_poolmanager(self, connections, maxsize, block=DEFAULT_POOLBLOCK, **pool_kwargs):
|
|
218
|
+
super().init_poolmanager(connections, maxsize, block, **pool_kwargs)
|
|
219
|
+
# Rebuild with the exact settings requests chose for this version.
|
|
220
|
+
self.poolmanager = _PinnedPoolManager(
|
|
221
|
+
num_pools=connections, **self.poolmanager.connection_pool_kw
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def proxy_manager_for(self, proxy, **proxy_kwargs):
|
|
225
|
+
raise UnsafeURLError("proxies are not supported by the pinned transport")
|
|
226
|
+
|
|
227
|
+
def send(self, request, **kwargs):
|
|
228
|
+
check_url(request.url)
|
|
229
|
+
# urllib3 would derive Host from the pool's IP literal; keep the name.
|
|
230
|
+
if "Host" not in request.headers:
|
|
231
|
+
request.headers["Host"] = urlparse(request.url).netloc.rpartition("@")[2]
|
|
232
|
+
return super().send(request, **kwargs)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def create_session() -> requests.Session:
|
|
236
|
+
"""Build a requests session whose every connection is SSRF-checked and pinned.
|
|
237
|
+
|
|
238
|
+
Environment proxies and ~/.netrc credentials are ignored: a proxy defeats
|
|
239
|
+
address pinning and netrc would attach credentials to attacker-chosen hosts.
|
|
240
|
+
A custom CA bundle named by REQUESTS_CA_BUNDLE or CURL_CA_BUNDLE (corporate
|
|
241
|
+
TLS inspection) is still honoured, since it does not weaken pinning.
|
|
242
|
+
"""
|
|
243
|
+
session = requests.Session()
|
|
244
|
+
session.trust_env = False
|
|
245
|
+
ca_bundle = os.environ.get("REQUESTS_CA_BUNDLE") or os.environ.get("CURL_CA_BUNDLE")
|
|
246
|
+
if ca_bundle:
|
|
247
|
+
session.verify = ca_bundle
|
|
248
|
+
adapter = PinnedAddressAdapter()
|
|
249
|
+
session.mount("http://", adapter)
|
|
250
|
+
session.mount("https://", adapter)
|
|
251
|
+
return session
|
|
252
|
+
|
|
253
|
+
|
|
117
254
|
# ── Core Fetcher ───────────────────────────────────────────────────
|
|
118
255
|
|
|
119
256
|
def fetch_page(
|
|
@@ -147,37 +284,24 @@ def fetch_page(
|
|
|
147
284
|
url = f"https://{url}"
|
|
148
285
|
parsed = urlparse(url)
|
|
149
286
|
|
|
150
|
-
if parsed.scheme not in
|
|
287
|
+
if parsed.scheme not in ALLOWED_SCHEMES:
|
|
151
288
|
result["error"] = f"Invalid URL scheme: {parsed.scheme}"
|
|
152
289
|
return result
|
|
153
290
|
|
|
154
|
-
|
|
155
|
-
if not is_safe_url(url):
|
|
156
|
-
resolved = "unknown"
|
|
157
|
-
try:
|
|
158
|
-
# Use getaddrinfo for consistent multi-address resolution
|
|
159
|
-
addrinfo = socket.getaddrinfo(parsed.hostname, None)
|
|
160
|
-
resolved = ", ".join(set(entry[4][0] for entry in addrinfo))
|
|
161
|
-
except Exception:
|
|
162
|
-
pass
|
|
163
|
-
result["error"] = f"Blocked: URL resolves to private/internal IP ({resolved})"
|
|
164
|
-
return result
|
|
165
|
-
|
|
291
|
+
current_url = url
|
|
166
292
|
try:
|
|
167
|
-
session =
|
|
293
|
+
session = create_session()
|
|
168
294
|
|
|
169
295
|
headers = dict(DEFAULT_HEADERS)
|
|
170
296
|
ua_string = USER_AGENTS.get(user_agent, user_agent)
|
|
171
297
|
headers["User-Agent"] = ua_string
|
|
172
298
|
|
|
173
|
-
import time
|
|
174
299
|
start = time.monotonic()
|
|
175
300
|
|
|
176
|
-
# Follow redirects manually so
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
current_url = url
|
|
301
|
+
# Follow redirects manually so every hop goes back through the pinned
|
|
302
|
+
# adapter, which validates the target before connecting. Letting
|
|
303
|
+
# requests follow redirects internally would hide the hop count and
|
|
304
|
+
# the chain; the adapter still guards each connection either way.
|
|
181
305
|
redirect_chain = []
|
|
182
306
|
hops = 0
|
|
183
307
|
response = None
|
|
@@ -198,19 +322,9 @@ def fetch_page(
|
|
|
198
322
|
break
|
|
199
323
|
|
|
200
324
|
next_url = urljoin(current_url, location)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
result["error"] = (
|
|
205
|
-
f"Blocked redirect to non-HTTP(S) scheme: {next_parsed.scheme}"
|
|
206
|
-
)
|
|
207
|
-
return result
|
|
208
|
-
|
|
209
|
-
# Re-validate the redirect target (blocks redirect-based SSRF)
|
|
210
|
-
if not is_safe_url(next_url):
|
|
211
|
-
result["error"] = (
|
|
212
|
-
f"Blocked: redirect to private/internal URL ({next_url})"
|
|
213
|
-
)
|
|
325
|
+
next_scheme = urlparse(next_url).scheme
|
|
326
|
+
if next_scheme not in ALLOWED_SCHEMES:
|
|
327
|
+
result["error"] = f"Blocked redirect to non-HTTP(S) scheme: {next_scheme}"
|
|
214
328
|
return result
|
|
215
329
|
|
|
216
330
|
hops += 1
|
|
@@ -221,6 +335,7 @@ def fetch_page(
|
|
|
221
335
|
redirect_chain.append(
|
|
222
336
|
{"url": current_url, "status": response.status_code}
|
|
223
337
|
)
|
|
338
|
+
response.close()
|
|
224
339
|
current_url = next_url
|
|
225
340
|
|
|
226
341
|
elapsed_ms = round((time.monotonic() - start) * 1000)
|
|
@@ -233,6 +348,11 @@ def fetch_page(
|
|
|
233
348
|
result["response_time_ms"] = elapsed_ms
|
|
234
349
|
result["redirect_chain"] = redirect_chain
|
|
235
350
|
|
|
351
|
+
except UnsafeURLError as e:
|
|
352
|
+
if current_url == url:
|
|
353
|
+
result["error"] = f"Blocked: {e}"
|
|
354
|
+
else:
|
|
355
|
+
result["error"] = f"Blocked: redirect to private/internal URL ({current_url}): {e}"
|
|
236
356
|
except requests.exceptions.Timeout:
|
|
237
357
|
result["error"] = f"Request timed out after {timeout}s"
|
|
238
358
|
except requests.exceptions.TooManyRedirects:
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
SEO Report — Professional HTML audit report generator.
|
|
4
4
|
|
|
5
5
|
Features:
|
|
6
|
-
- Single-file HTML with inline CSS
|
|
6
|
+
- Single-file HTML with inline CSS and system fonts: opening it requests nothing
|
|
7
7
|
- SVG radar chart for score visualization
|
|
8
8
|
- Color-coded issue cards (Critical/High/Medium/Low)
|
|
9
9
|
- Quick Wins section
|
|
@@ -67,8 +67,8 @@ def generate_radar_svg(scores: dict, size: int = 300) -> str:
|
|
|
67
67
|
svg_parts.append(f'<line x1="{cx}" y1="{cy}" x2="{x2}" y2="{y2}" stroke="#e2e8f0" stroke-width="1"/>')
|
|
68
68
|
|
|
69
69
|
lx, ly = point(angle, radius + 20)
|
|
70
|
-
label = short_labels.get(categories[i], categories[i][:6])
|
|
71
|
-
svg_parts.append(f'<text x="{lx}" y="{ly}" text-anchor="middle" font-size="11" fill="#64748b"
|
|
70
|
+
label = html.escape(short_labels.get(categories[i], str(categories[i])[:6]))
|
|
71
|
+
svg_parts.append(f'<text x="{lx}" y="{ly}" text-anchor="middle" font-size="11" fill="#64748b">{label}</text>')
|
|
72
72
|
|
|
73
73
|
# Data polygon
|
|
74
74
|
data_points = []
|
|
@@ -203,11 +203,10 @@ def generate_html_report(audit_data: dict) -> str:
|
|
|
203
203
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
204
204
|
<title>SEO Audit Report — {domain_esc}</title>
|
|
205
205
|
<style>
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
/* Installed faces only: opening the report asks nothing of any server. */
|
|
208
207
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
209
208
|
body {{
|
|
210
|
-
font-family:
|
|
209
|
+
font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
|
|
211
210
|
background: #f8fafc;
|
|
212
211
|
color: #1e293b;
|
|
213
212
|
line-height: 1.6;
|
|
@@ -7,16 +7,57 @@ Features:
|
|
|
7
7
|
- Above-the-fold element detection
|
|
8
8
|
- Full-page capture option
|
|
9
9
|
- PNG output with configurable quality
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
- SSRF protection shared with seo_fetch.py: the target is validated once and
|
|
11
|
+
Chromium is pinned to that address, so the browser reaches the audited host
|
|
12
|
+
and nothing else
|
|
13
|
+
|
|
14
|
+
Network policy. Chromium resolves names itself, so validating the URL is not
|
|
15
|
+
enough: a DNS-rebinding answer, a redirect or any subresource could take it
|
|
16
|
+
to an internal address. The browser is therefore confined by three guards:
|
|
17
|
+
|
|
18
|
+
1. Resolver pinning: --host-resolver-rules maps the audited host to the
|
|
19
|
+
address seo_fetch validated and makes every other name unresolvable.
|
|
20
|
+
2. Proxy trap: every request except those for the audited host goes to a
|
|
21
|
+
proxy whose name cannot resolve, so it fails even when it names an IP
|
|
22
|
+
literal, loopback included (the bypass list drops Chromium's implicit
|
|
23
|
+
loopback bypass).
|
|
24
|
+
3. Interception: each request and WebSocket the page opens is checked
|
|
25
|
+
before it leaves; anything outside the audited host is aborted.
|
|
26
|
+
|
|
27
|
+
Playwright routes only the first URL of a redirect chain, so the hops of a
|
|
28
|
+
redirect (of the page or of a subresource) are held by guards 1 and 2, each
|
|
29
|
+
of which holds on its own. Every request a guard stops, redirect hops
|
|
30
|
+
included, is reported in ``blocked_requests``.
|
|
31
|
+
|
|
32
|
+
Third-party subresources (CDN images, fonts, analytics, embeds) are blocked,
|
|
33
|
+
not validated one by one: the resolver rules are fixed at launch, so a host
|
|
34
|
+
discovered while rendering could not be pinned, and a capture that contacted
|
|
35
|
+
it would send the auditor's IP address to parties nobody chose. The
|
|
36
|
+
screenshot shows the page as its own host serves it. A redirect to another
|
|
37
|
+
host fails the capture; capture the final URL instead. Service workers are
|
|
38
|
+
blocked and WebRTC may not open UDP sockets of its own.
|
|
39
|
+
|
|
40
|
+
Requires: playwright >= 1.48 (pip install playwright && playwright install chromium)
|
|
12
41
|
|
|
13
42
|
Author: Laurent Rochetta
|
|
14
43
|
License: MIT
|
|
15
44
|
"""
|
|
16
45
|
|
|
17
46
|
import argparse
|
|
47
|
+
import ipaddress
|
|
48
|
+
import re
|
|
18
49
|
import sys
|
|
50
|
+
from urllib.parse import urlparse
|
|
51
|
+
|
|
52
|
+
import seo_fetch
|
|
19
53
|
|
|
54
|
+
# The proxy that non-audited requests are sent to. ".invalid" never resolves
|
|
55
|
+
# (RFC 6761) and the resolver rules refuse every name but the audited host.
|
|
56
|
+
TRAP_PROXY = "http://blocked.invalid:9"
|
|
57
|
+
LOCAL_SCHEMES = frozenset({"about", "blob", "data"})
|
|
58
|
+
NETWORK_SCHEMES = frozenset({"http", "https", "ws", "wss"})
|
|
59
|
+
_HOSTNAME = re.compile(r"[a-z0-9_-]+(?:\.[a-z0-9_-]+)*\.?")
|
|
60
|
+
_MAX_REPORTED = 50
|
|
20
61
|
|
|
21
62
|
VIEWPORTS = {
|
|
22
63
|
"mobile": {"width": 375, "height": 812, "device_scale_factor": 3, "is_mobile": True},
|
|
@@ -26,6 +67,93 @@ VIEWPORTS = {
|
|
|
26
67
|
}
|
|
27
68
|
|
|
28
69
|
|
|
70
|
+
class CaptureError(RuntimeError):
|
|
71
|
+
"""The page could not be loaded under the network policy."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pin_target(url: str) -> "tuple[str, str, str]":
|
|
75
|
+
"""Validate a URL with seo_fetch's rules and return ``(url, host, address)``.
|
|
76
|
+
|
|
77
|
+
``host`` is the name as Chromium will send it (lowercase ASCII, IDNA
|
|
78
|
+
encoded, no brackets) and ``address`` the public address it is pinned to.
|
|
79
|
+
Raises seo_fetch.UnsafeURLError on anything seo_fetch would refuse, and on
|
|
80
|
+
a host that cannot be written safely into a resolver rule.
|
|
81
|
+
"""
|
|
82
|
+
if not urlparse(url).scheme:
|
|
83
|
+
url = f"https://{url}"
|
|
84
|
+
hostname, address = seo_fetch.resolve_target(url)
|
|
85
|
+
try:
|
|
86
|
+
host = hostname.encode("idna").decode("ascii").lower()
|
|
87
|
+
except UnicodeError:
|
|
88
|
+
raise seo_fetch.UnsafeURLError(f"host name cannot be encoded: {hostname}") from None
|
|
89
|
+
try:
|
|
90
|
+
ipaddress.ip_address(host)
|
|
91
|
+
except ValueError:
|
|
92
|
+
if not _HOSTNAME.fullmatch(host):
|
|
93
|
+
raise seo_fetch.UnsafeURLError(f"unsupported host name: {hostname}") from None
|
|
94
|
+
return url, host, address
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def isolation_options(host: str, address: str) -> dict:
|
|
98
|
+
"""Chromium launch options that confine the browser to ``host`` at ``address``."""
|
|
99
|
+
pinned = f"[{address}]" if ":" in address else address
|
|
100
|
+
rule_host = f"[{host}]" if ":" in host else host
|
|
101
|
+
return {
|
|
102
|
+
"args": [
|
|
103
|
+
f"--host-resolver-rules=MAP {rule_host} {pinned}, MAP * ~NOTFOUND",
|
|
104
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
105
|
+
"--webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
106
|
+
"--dns-prefetch-disable",
|
|
107
|
+
# The last matching bypass rule wins: "<-loopback>" first drops
|
|
108
|
+
# Chromium's implicit loopback bypass without overriding the host.
|
|
109
|
+
f"--proxy-server={TRAP_PROXY}",
|
|
110
|
+
f"--proxy-bypass-list=<-loopback>;{rule_host}",
|
|
111
|
+
],
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RequestGuard:
|
|
116
|
+
"""Let through the requests of one host; abort and record all others."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, host: str):
|
|
119
|
+
self.host = host
|
|
120
|
+
self.blocked: "list[str]" = []
|
|
121
|
+
|
|
122
|
+
def allows(self, url: str) -> bool:
|
|
123
|
+
parsed = urlparse(url)
|
|
124
|
+
if parsed.scheme in LOCAL_SCHEMES:
|
|
125
|
+
return True
|
|
126
|
+
return parsed.scheme in NETWORK_SCHEMES and parsed.hostname == self.host
|
|
127
|
+
|
|
128
|
+
def _refuse(self, url: str) -> None:
|
|
129
|
+
if url not in self.blocked and len(self.blocked) < _MAX_REPORTED:
|
|
130
|
+
self.blocked.append(url)
|
|
131
|
+
|
|
132
|
+
def on_request(self, route) -> None:
|
|
133
|
+
url = route.request.url
|
|
134
|
+
if self.allows(url):
|
|
135
|
+
route.continue_()
|
|
136
|
+
else:
|
|
137
|
+
self._refuse(url)
|
|
138
|
+
route.abort("blockedbyclient")
|
|
139
|
+
|
|
140
|
+
def on_request_failed(self, request) -> None:
|
|
141
|
+
# A redirect hop off the host never reaches on_request: the resolver
|
|
142
|
+
# rules or the trap proxy stop it, and it fails here instead.
|
|
143
|
+
if not self.allows(request.url):
|
|
144
|
+
self._refuse(request.url)
|
|
145
|
+
|
|
146
|
+
def on_websocket(self, ws) -> None:
|
|
147
|
+
# A route that is not connected to the server stays a local mock:
|
|
148
|
+
# nothing leaves the browser. It is not closed, because Playwright
|
|
149
|
+
# runs this handler on its event loop, where the synchronous close()
|
|
150
|
+
# would wait for itself forever.
|
|
151
|
+
if self.allows(ws.url):
|
|
152
|
+
ws.connect_to_server()
|
|
153
|
+
else:
|
|
154
|
+
self._refuse(ws.url)
|
|
155
|
+
|
|
156
|
+
|
|
29
157
|
def capture_screenshot(
|
|
30
158
|
url: str,
|
|
31
159
|
output: str = "screenshot.png",
|
|
@@ -42,8 +170,15 @@ def capture_screenshot(
|
|
|
42
170
|
viewport: Viewport preset (mobile, tablet, desktop, desktop-hd)
|
|
43
171
|
full_page: Capture full page scroll or just viewport
|
|
44
172
|
wait_ms: Wait time after page load (ms)
|
|
173
|
+
|
|
174
|
+
Raises seo_fetch.UnsafeURLError before any browser starts when the URL
|
|
175
|
+
breaks seo_fetch's rules, and CaptureError when the page cannot load
|
|
176
|
+
under the network policy (see the module docstring).
|
|
45
177
|
"""
|
|
178
|
+
url, host, address = pin_target(url)
|
|
46
179
|
try:
|
|
180
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
181
|
+
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
|
47
182
|
from playwright.sync_api import sync_playwright
|
|
48
183
|
except ImportError:
|
|
49
184
|
print(
|
|
@@ -55,9 +190,15 @@ def capture_screenshot(
|
|
|
55
190
|
|
|
56
191
|
vp = VIEWPORTS.get(viewport, VIEWPORTS["desktop"])
|
|
57
192
|
|
|
193
|
+
guard = RequestGuard(host)
|
|
194
|
+
|
|
195
|
+
# Leaving the block stops the Playwright driver, which ends the browser
|
|
196
|
+
# on every path, errors included.
|
|
58
197
|
with sync_playwright() as p:
|
|
59
|
-
browser = p.chromium.launch(headless=True)
|
|
198
|
+
browser = p.chromium.launch(headless=True, **isolation_options(host, address))
|
|
60
199
|
context = browser.new_context(
|
|
200
|
+
service_workers="block",
|
|
201
|
+
accept_downloads=False,
|
|
61
202
|
viewport={"width": vp["width"], "height": vp["height"]},
|
|
62
203
|
device_scale_factor=vp["device_scale_factor"],
|
|
63
204
|
is_mobile=vp["is_mobile"],
|
|
@@ -70,13 +211,24 @@ def capture_screenshot(
|
|
|
70
211
|
),
|
|
71
212
|
)
|
|
72
213
|
|
|
214
|
+
if not hasattr(context, "route_web_socket"):
|
|
215
|
+
raise CaptureError("Playwright 1.48 or later is required to guard WebSockets")
|
|
216
|
+
context.route("**/*", guard.on_request)
|
|
217
|
+
context.route_web_socket("**/*", guard.on_websocket)
|
|
218
|
+
context.on("requestfailed", guard.on_request_failed)
|
|
73
219
|
page = context.new_page()
|
|
74
220
|
|
|
75
221
|
try:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
222
|
+
try:
|
|
223
|
+
page.goto(url, wait_until="networkidle", timeout=30000)
|
|
224
|
+
except PlaywrightTimeoutError:
|
|
225
|
+
# Fallback: wait for load event instead
|
|
226
|
+
page.goto(url, wait_until="load", timeout=30000)
|
|
227
|
+
except PlaywrightError as e:
|
|
228
|
+
raise CaptureError(
|
|
229
|
+
f"{url} did not load under the network policy (a redirect to another host"
|
|
230
|
+
f" or a non-public address is refused): {e.message.splitlines()[0]}"
|
|
231
|
+
) from None
|
|
80
232
|
|
|
81
233
|
# Wait for dynamic content
|
|
82
234
|
page.wait_for_timeout(wait_ms)
|
|
@@ -148,6 +300,7 @@ def capture_screenshot(
|
|
|
148
300
|
|
|
149
301
|
browser.close()
|
|
150
302
|
|
|
303
|
+
metrics["blocked_requests"] = guard.blocked
|
|
151
304
|
return metrics
|
|
152
305
|
|
|
153
306
|
|
|
@@ -172,13 +325,20 @@ def main():
|
|
|
172
325
|
|
|
173
326
|
import json
|
|
174
327
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
328
|
+
try:
|
|
329
|
+
metrics = capture_screenshot(
|
|
330
|
+
url=args.url,
|
|
331
|
+
output=args.output,
|
|
332
|
+
viewport=args.viewport,
|
|
333
|
+
full_page=args.full,
|
|
334
|
+
wait_ms=args.wait,
|
|
335
|
+
)
|
|
336
|
+
except seo_fetch.UnsafeURLError as e:
|
|
337
|
+
print(f"Error: Blocked: {e}", file=sys.stderr)
|
|
338
|
+
sys.exit(1)
|
|
339
|
+
except CaptureError as e:
|
|
340
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
341
|
+
sys.exit(1)
|
|
182
342
|
|
|
183
343
|
print(f"Screenshot saved: {args.output}", file=sys.stderr)
|
|
184
344
|
|
|
@@ -196,6 +356,8 @@ def main():
|
|
|
196
356
|
print(f" Horizontal scroll: {'⚠️ YES' if metrics['has_horizontal_scroll'] else '✅ No'}")
|
|
197
357
|
print(f" Body font size: {metrics['body_font_size_px']}px {'✅' if metrics['body_font_size_px'] >= 16 else '⚠️ <16px'}")
|
|
198
358
|
print(f" DOM elements: {metrics['dom_element_count']:,}")
|
|
359
|
+
if metrics["blocked_requests"]:
|
|
360
|
+
print(f" Blocked requests (not the audited host): {len(metrics['blocked_requests'])}")
|
|
199
361
|
|
|
200
362
|
|
|
201
363
|
if __name__ == "__main__":
|
|
@@ -88,6 +88,18 @@ Pack Shield transforms BMAD+ into a comprehensive GRC (Governance, Risk & Compli
|
|
|
88
88
|
- `shared/cross-framework-mapper.md` — Control mapping between frameworks
|
|
89
89
|
- `shared/gap-analysis-template.md` — Standardized gap analysis format
|
|
90
90
|
- `shared/audit-report-template.md` — Compliance audit report format
|
|
91
|
+
- `shared/assurance-case.md` + `shared/assurance-case-template.yaml` — Security assurance case bound to executed checks
|
|
92
|
+
- `shared/ai-processing-register.md` + `shared/ai-processing-register-template.yaml` — Register of the AI tools a project uses
|
|
93
|
+
|
|
94
|
+
## Evidence & AI Tooling
|
|
95
|
+
|
|
96
|
+
These capabilities are backed by BMAD+ CLI commands, so their results are verified by code:
|
|
97
|
+
|
|
98
|
+
| Capability | Command | Behaviour |
|
|
99
|
+
|------------|---------|-----------|
|
|
100
|
+
| Security assurance case | `bmad-plus assurance init <case>`, `run <case>`, then `verify <case> --ledger-head <head>` | Claims → arguments → evidence; evidence is only a check that ran, recorded in a hash-chained ledger with its exit code, output digest and the digests of the artifacts it wrote. Missing, failed or stale evidence (other commit, uncommitted changes or untracked files, changed command or artifact, too old) leaves the claim unsupported. `BMAD_PLUS_ASSURANCE_KEY` authenticates every record and `--ledger-head` catches removed runs; without them the ledger shows accidental edits only. `--emit-check` writes a CI check result. |
|
|
101
|
+
| AI processing register | `bmad-plus ai-register init`, `bmad-plus ai-register check` | Compares `_bmad/ai-processing-register.yaml` with the AI integrations found in the project (BMAD+ adapters, tool folders, MCP servers) and warns about any not registered. Soft gate: warnings never fail; `bmad-plus doctor` reports the same. |
|
|
102
|
+
| Compliance review rules | `bmad-plus review scope`, `bmad-plus review rules <path>` | `review-rules/` adds path-scoped checklists (access control, personal data, cryptography, logging, AI integrations, change and supply chain) in the `compliance` group, each tagged with the controls it examines, e.g. `ISO27001:A.8.5`, `SOC2:CC8.1`, `GDPR:Art.32`. The review checklist opens with the controls the change touches, `scope.json` records them per file, and a finding may name the ones it breaks in `controls`. A project replaces or disables them in `_bmad/review-rules.yaml`. |
|
|
91
103
|
|
|
92
104
|
## Reference Files
|
|
93
105
|
- `references/` — 79 regulatory reference files extracted from upstream skills
|
|
@@ -56,6 +56,11 @@ Shield transforms BMAD+ into a comprehensive GRC (Governance, Risk & Compliance)
|
|
|
56
56
|
- Privacy Notice/Policy/Cookie Generators
|
|
57
57
|
- AI Act Classifier, Roles, FRIA, Incident Reporting
|
|
58
58
|
|
|
59
|
+
### Evidence & AI Tooling
|
|
60
|
+
- **Security assurance case** — claims, arguments and evidence, where evidence is only a check that ran at the commit concerned (`bmad-plus assurance run|verify`)
|
|
61
|
+
- **AI processing register** — the AI tools a project uses, their data, legal basis, retention and transfers, with a soft gate on unregistered tools (`bmad-plus ai-register check`, `bmad-plus doctor`)
|
|
62
|
+
- **Compliance review rules** — path-scoped review checklists tagged with ISO 27001, SOC 2, GDPR, NIS2, NIST 800-53 and EU AI Act controls, so a review lists the controls a change touches
|
|
63
|
+
|
|
59
64
|
## Activation
|
|
60
65
|
|
|
61
66
|
To use Shield, include this pack in your BMAD+ installation:
|
|
@@ -75,7 +80,8 @@ Then invoke the orchestrator from any conversation:
|
|
|
75
80
|
- `shield-orchestrator.md` — Intelligent routing entry point
|
|
76
81
|
- `categories/` — Framework-specific agent prompts
|
|
77
82
|
- `references/` — 79 regulatory reference files
|
|
78
|
-
- `shared/` — Cross-framework mapper, gap analysis & audit templates
|
|
83
|
+
- `shared/` — Cross-framework mapper, gap analysis & audit templates; assurance case and AI processing register procedures with their templates
|
|
84
|
+
- `review-rules/` — Control-tagged review rules, loaded by `bmad-plus review` once the pack is installed
|
|
79
85
|
|
|
80
86
|
## Attribution
|
|
81
87
|
|