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.
Files changed (50) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +14 -14
  3. package/SECURITY.md +62 -0
  4. package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/_http.py +68 -24
  5. package/package.json +1 -1
  6. package/readme-international/README.de.md +14 -14
  7. package/readme-international/README.es.md +14 -14
  8. package/readme-international/README.fr.md +14 -14
  9. package/src/bmad-plus/agents/agent-quality/SKILL.md +1 -1
  10. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +26 -5
  11. package/src/bmad-plus/packs/pack-seo/SKILL.md +3 -1
  12. package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +2 -2
  13. package/src/bmad-plus/packs/pack-seo/requirements.txt +1 -1
  14. package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +72 -30
  15. package/src/bmad-plus/packs/pack-seo/scripts/seo_crawl.py +36 -24
  16. package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +179 -59
  17. package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +5 -6
  18. package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +176 -14
  19. package/src/bmad-plus/packs/pack-shield/README.md +12 -0
  20. package/src/bmad-plus/packs/pack-shield/SKILL.md +7 -1
  21. package/src/bmad-plus/packs/pack-shield/review-rules/access-control.md +10 -0
  22. package/src/bmad-plus/packs/pack-shield/review-rules/ai-integrations.md +10 -0
  23. package/src/bmad-plus/packs/pack-shield/review-rules/change-and-supply-chain.md +10 -0
  24. package/src/bmad-plus/packs/pack-shield/review-rules/cryptography.md +10 -0
  25. package/src/bmad-plus/packs/pack-shield/review-rules/index.yaml +134 -0
  26. package/src/bmad-plus/packs/pack-shield/review-rules/logging.md +10 -0
  27. package/src/bmad-plus/packs/pack-shield/review-rules/personal-data.md +10 -0
  28. package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register-template.yaml +53 -0
  29. package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register.md +32 -0
  30. package/src/bmad-plus/packs/pack-shield/shared/assurance-case-template.yaml +87 -0
  31. package/src/bmad-plus/packs/pack-shield/shared/assurance-case.md +50 -0
  32. package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +24 -1
  33. package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +1 -0
  34. package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +5 -4
  35. package/tools/build/generate-adapters.js +7 -0
  36. package/tools/build/generate.js +14 -0
  37. package/tools/cli/bmad-plus-cli.js +2 -0
  38. package/tools/cli/commands/ai-register.js +63 -0
  39. package/tools/cli/commands/assurance.js +162 -0
  40. package/tools/cli/commands/review.js +141 -7
  41. package/tools/cli/lib/ai-register.js +393 -0
  42. package/tools/cli/lib/assurance.js +822 -0
  43. package/tools/cli/lib/control-refs.js +132 -0
  44. package/tools/cli/lib/installation-health.js +17 -0
  45. package/tools/cli/lib/packs.js +60 -2
  46. package/tools/cli/lib/page-origins.js +582 -0
  47. package/tools/cli/lib/review-rules.js +124 -26
  48. package/tools/cli/lib/review.js +493 -10
  49. package/tools/cli/lib/uat.js +22 -5
  50. 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.is_private
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 is_safe_url(url: str) -> bool:
77
- """Block requests to private, loopback, and reserved IP addresses.
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: a missing host, a non-HTTP(S) scheme, a DNS resolution
80
- error, or an unparseable/blocked address all cause the URL to be
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
- return False
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
- return False # Fail closed: unresolvable host is treated as unsafe
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(ip_str)
147
+ ip = ipaddress.ip_address(entry[4][0])
105
148
  except ValueError:
106
- return False # Fail closed: unparseable address
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
- return False
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 ("http", "https"):
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
- # SSRF check
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 = requests.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 is_safe_url() runs on EVERY hop.
177
- # Letting requests follow redirects internally would allow a
178
- # public URL to redirect (302) to an internal/metadata endpoint
179
- # (redirect-based SSRF), bypassing the initial check.
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
- next_parsed = urlparse(next_url)
202
-
203
- if next_parsed.scheme not in ("http", "https"):
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 (no external deps)
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" font-family="Inter,sans-serif">{label}</text>')
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
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
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: 'Inter', -apple-system, sans-serif;
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
- Requires: playwright (pip install playwright && playwright install chromium)
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
- page.goto(url, wait_until="networkidle", timeout=30000)
77
- except Exception:
78
- # Fallback: wait for load event instead
79
- page.goto(url, wait_until="load", timeout=30000)
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
- metrics = capture_screenshot(
176
- url=args.url,
177
- output=args.output,
178
- viewport=args.viewport,
179
- full_page=args.full,
180
- wait_ms=args.wait,
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