bmad-plus 0.21.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 +20 -0
- package/README.md +13 -13
- 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 +13 -13
- package/readme-international/README.es.md +13 -13
- package/readme-international/README.fr.md +13 -13
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +3 -1
- 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_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 +10 -3
- 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 +92 -24
- package/tools/cli/lib/review.js +28 -1
- package/tools/cli/lib/uat.js +22 -5
|
@@ -10,6 +10,9 @@ Connects to:
|
|
|
10
10
|
Requires: GOOGLE_API_KEY environment variable (free, no OAuth).
|
|
11
11
|
Get one at: https://console.cloud.google.com/apis/credentials
|
|
12
12
|
|
|
13
|
+
The key travels in the x-goog-api-key header only: never in a request URL, where proxies
|
|
14
|
+
and server logs keep it, and never in a result, a log line or an error message.
|
|
15
|
+
|
|
13
16
|
Author: Laurent Rochetta
|
|
14
17
|
License: MIT
|
|
15
18
|
"""
|
|
@@ -17,6 +20,7 @@ License: MIT
|
|
|
17
20
|
import argparse
|
|
18
21
|
import json
|
|
19
22
|
import os
|
|
23
|
+
import re
|
|
20
24
|
import sys
|
|
21
25
|
from typing import Optional
|
|
22
26
|
|
|
@@ -27,12 +31,56 @@ except ImportError:
|
|
|
27
31
|
sys.exit(1)
|
|
28
32
|
|
|
29
33
|
|
|
30
|
-
API_KEY = os.environ.get("GOOGLE_API_KEY", "")
|
|
34
|
+
API_KEY = os.environ.get("GOOGLE_API_KEY", "").strip()
|
|
35
|
+
KEY_HEADER = "x-goog-api-key"
|
|
36
|
+
# Printable ASCII without spaces: anything else cannot be a header value, and the error
|
|
37
|
+
# requests would raise for it quotes the value.
|
|
38
|
+
KEY_SHAPE = re.compile(r"[\x21-\x7e]+")
|
|
31
39
|
|
|
32
40
|
PSI_ENDPOINT = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed"
|
|
33
41
|
CRUX_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord"
|
|
34
42
|
RICH_RESULTS_ENDPOINT = "https://searchconsole.googleapis.com/v1/urlTestingTools/mobileFriendlyTest:run"
|
|
35
43
|
|
|
44
|
+
SESSION = requests.Session()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _key_problem() -> Optional[str]:
|
|
48
|
+
"""Why no request can be sent, without ever quoting the key; None when it can."""
|
|
49
|
+
if not API_KEY:
|
|
50
|
+
return "GOOGLE_API_KEY not set. Get one at https://console.cloud.google.com/apis/credentials"
|
|
51
|
+
if not KEY_SHAPE.fullmatch(API_KEY):
|
|
52
|
+
return "GOOGLE_API_KEY is malformed: an API key is printable ASCII without spaces"
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _redact(text: str) -> str:
|
|
57
|
+
"""Remove the key from text headed for a result, a log or the terminal."""
|
|
58
|
+
return text.replace(API_KEY, "[GOOGLE_API_KEY]") if API_KEY else text
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _failure(api: str, error: Exception) -> dict:
|
|
62
|
+
return {"error": _redact(f"{api} request failed: {error}")}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _send(method: str, endpoint: str, timeout: int, **body):
|
|
66
|
+
"""
|
|
67
|
+
Send one request to a Google API with the key in its header.
|
|
68
|
+
|
|
69
|
+
Redirects are refused, not followed: requests would carry this header to whatever host
|
|
70
|
+
a redirect names (it strips only Authorization), and no Google API answers with one.
|
|
71
|
+
"""
|
|
72
|
+
response = SESSION.request(
|
|
73
|
+
method,
|
|
74
|
+
endpoint,
|
|
75
|
+
headers={KEY_HEADER: API_KEY},
|
|
76
|
+
timeout=timeout,
|
|
77
|
+
allow_redirects=False,
|
|
78
|
+
**body,
|
|
79
|
+
)
|
|
80
|
+
if 300 <= response.status_code < 400:
|
|
81
|
+
raise requests.HTTPError(f"unexpected redirect ({response.status_code}), not followed", response=response)
|
|
82
|
+
return response
|
|
83
|
+
|
|
36
84
|
|
|
37
85
|
# ── PageSpeed Insights ─────────────────────────────────────────────
|
|
38
86
|
|
|
@@ -48,25 +96,25 @@ def run_pagespeed(url: str, strategy: str = "mobile", categories: Optional[list]
|
|
|
48
96
|
Returns:
|
|
49
97
|
Structured result with scores, audits, and opportunities
|
|
50
98
|
"""
|
|
51
|
-
|
|
52
|
-
|
|
99
|
+
problem = _key_problem()
|
|
100
|
+
if problem:
|
|
101
|
+
return {"error": problem}
|
|
53
102
|
|
|
54
103
|
if categories is None:
|
|
55
104
|
categories = ["PERFORMANCE", "ACCESSIBILITY", "BEST_PRACTICES", "SEO"]
|
|
56
105
|
|
|
57
106
|
params = {
|
|
58
107
|
"url": url,
|
|
59
|
-
"key": API_KEY,
|
|
60
108
|
"strategy": strategy,
|
|
61
109
|
"category": categories,
|
|
62
110
|
}
|
|
63
111
|
|
|
64
112
|
try:
|
|
65
|
-
response =
|
|
113
|
+
response = _send("GET", PSI_ENDPOINT, 120, params=params)
|
|
66
114
|
response.raise_for_status()
|
|
67
115
|
data = response.json()
|
|
68
|
-
except requests.RequestException as e:
|
|
69
|
-
return
|
|
116
|
+
except (requests.RequestException, ValueError) as e:
|
|
117
|
+
return _failure("PSI API", e)
|
|
70
118
|
|
|
71
119
|
# Extract scores
|
|
72
120
|
result = {
|
|
@@ -161,8 +209,9 @@ def run_crux(url: str, form_factor: str = "PHONE") -> dict:
|
|
|
161
209
|
Returns:
|
|
162
210
|
Field CWV data at 75th percentile
|
|
163
211
|
"""
|
|
164
|
-
|
|
165
|
-
|
|
212
|
+
problem = _key_problem()
|
|
213
|
+
if problem:
|
|
214
|
+
return {"error": problem}
|
|
166
215
|
|
|
167
216
|
# Try URL-level first, fall back to origin
|
|
168
217
|
from urllib.parse import urlparse
|
|
@@ -175,28 +224,20 @@ def run_crux(url: str, form_factor: str = "PHONE") -> dict:
|
|
|
175
224
|
}
|
|
176
225
|
|
|
177
226
|
try:
|
|
178
|
-
response =
|
|
179
|
-
f"{CRUX_ENDPOINT}?key={API_KEY}",
|
|
180
|
-
json=payload,
|
|
181
|
-
timeout=30,
|
|
182
|
-
)
|
|
227
|
+
response = _send("POST", CRUX_ENDPOINT, 30, json=payload)
|
|
183
228
|
|
|
184
229
|
if response.status_code == 404:
|
|
185
230
|
# No URL-level data, try origin
|
|
186
231
|
payload = {"origin": origin, "formFactor": form_factor}
|
|
187
|
-
response =
|
|
188
|
-
f"{CRUX_ENDPOINT}?key={API_KEY}",
|
|
189
|
-
json=payload,
|
|
190
|
-
timeout=30,
|
|
191
|
-
)
|
|
232
|
+
response = _send("POST", CRUX_ENDPOINT, 30, json=payload)
|
|
192
233
|
|
|
193
234
|
if response.status_code == 404:
|
|
194
235
|
return {"error": f"No CrUX data available for {url} (not enough traffic)"}
|
|
195
236
|
|
|
196
237
|
response.raise_for_status()
|
|
197
238
|
data = response.json()
|
|
198
|
-
except requests.RequestException as e:
|
|
199
|
-
return
|
|
239
|
+
except (requests.RequestException, ValueError) as e:
|
|
240
|
+
return _failure("CrUX API", e)
|
|
200
241
|
|
|
201
242
|
result = {
|
|
202
243
|
"url": url,
|
|
@@ -264,21 +305,18 @@ def run_rich_results_test(url: str) -> dict:
|
|
|
264
305
|
Returns:
|
|
265
306
|
Mobile-friendly status and detected structured data
|
|
266
307
|
"""
|
|
267
|
-
|
|
268
|
-
|
|
308
|
+
problem = _key_problem()
|
|
309
|
+
if problem:
|
|
310
|
+
return {"error": problem}
|
|
269
311
|
|
|
270
312
|
payload = {"url": url}
|
|
271
313
|
|
|
272
314
|
try:
|
|
273
|
-
response =
|
|
274
|
-
f"{RICH_RESULTS_ENDPOINT}?key={API_KEY}",
|
|
275
|
-
json=payload,
|
|
276
|
-
timeout=60,
|
|
277
|
-
)
|
|
315
|
+
response = _send("POST", RICH_RESULTS_ENDPOINT, 60, json=payload)
|
|
278
316
|
response.raise_for_status()
|
|
279
317
|
data = response.json()
|
|
280
|
-
except requests.RequestException as e:
|
|
281
|
-
return
|
|
318
|
+
except (requests.RequestException, ValueError) as e:
|
|
319
|
+
return _failure("URL Testing API", e)
|
|
282
320
|
|
|
283
321
|
result = {
|
|
284
322
|
"url": url,
|
|
@@ -383,6 +421,10 @@ def main():
|
|
|
383
421
|
print(" Enable: PageSpeed Insights API + Chrome UX Report API", file=sys.stderr)
|
|
384
422
|
print(" Set: export GOOGLE_API_KEY=your_key", file=sys.stderr)
|
|
385
423
|
sys.exit(1)
|
|
424
|
+
problem = _key_problem()
|
|
425
|
+
if problem:
|
|
426
|
+
print(f"⚠️ {problem}", file=sys.stderr)
|
|
427
|
+
sys.exit(1)
|
|
386
428
|
|
|
387
429
|
if args.all:
|
|
388
430
|
result = run_all(args.url)
|
|
@@ -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
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Read the change as the person who should not get in.
|
|
2
|
+
|
|
3
|
+
- **Enforcement point.** Every new route, handler, job or command checks who is calling and what they may do, on the server, before it reads or writes. A check only in the UI, in a client-side guard or in the caller is not enforcement.
|
|
4
|
+
- **Object ownership.** A lookup by an id taken from the request is scoped to the caller (tenant, owner, organisation). Listing, export, search and bulk endpoints apply the same filter as the single-object read.
|
|
5
|
+
- **Least privilege.** A new role, scope, permission or service account grants only what the feature needs; a wildcard, an admin fallback or a default of "allow" when the policy is missing is a defect.
|
|
6
|
+
- **Authentication.** Passwords hashed with a slow, salted algorithm; comparison of secrets in constant time; MFA not bypassable through a secondary path (API token, password reset, legacy login, support impersonation).
|
|
7
|
+
- **Sessions and tokens.** Expiry and revocation exist and are checked; logout and password change invalidate what they should; tokens are bound to their audience and never accepted from a query string where they would be logged.
|
|
8
|
+
- **Access changes.** Granting, changing or removing a right is recorded with who did it; removal takes effect on the next request, not at the next login.
|
|
9
|
+
|
|
10
|
+
Name the control a finding breaks (for example `ISO27001:A.8.5` for authentication) in the finding's description.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Every model, agent or MCP server the change reaches is a recipient of data and a source of untrusted input.
|
|
2
|
+
|
|
3
|
+
- **Register.** A new provider, model endpoint, agent tool or MCP server appears in the AI processing register (`_bmad/ai-processing-register.yaml`) with its purpose, the data it sees, legal basis, retention and transfers; `bmad-plus ai-register check` reports the ones missing.
|
|
4
|
+
- **Data sent.** Prompts, context windows, retrieved documents and tool results carry only what the task needs; personal data, secrets and customer content are filtered or pseudonymised before they leave. A new field added to a prompt template is a new disclosure.
|
|
5
|
+
- **Transfers.** A provider outside the EEA, or a region setting that changed, needs a transfer mechanism; a processor needs an agreement covering training use and retention of prompts.
|
|
6
|
+
- **Untrusted output.** Model output and retrieved text reach no query, shell command, file path, HTML or permission decision without the same validation as user input; instructions found in data are data (prompt injection).
|
|
7
|
+
- **Agent tooling.** Tools, MCP servers and agent adapters get the narrowest permissions and paths; a new tool that can write, send or spend has an explicit confirmation or an allow-list.
|
|
8
|
+
- **Transparency.** A person who interacts with the system, or receives generated content presented as fact, is told it comes from an AI where the law requires it.
|
|
9
|
+
|
|
10
|
+
Name the control a finding breaks (for example `GDPR:Art.28` for a processor without an agreement) in the finding's description.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Look at what reaches production and who could change it.
|
|
2
|
+
|
|
3
|
+
- **Change path.** A pipeline change keeps review and approval before deployment: no new branch that deploys unreviewed, no `continue-on-error` or skipped job on a required check, no manual override without a record.
|
|
4
|
+
- **Separation.** The identity that writes code cannot approve and deploy it alone; a workflow that widens `permissions`, adds `pull_request_target` with a checkout of untrusted code, or exposes deployment secrets to forks breaks that.
|
|
5
|
+
- **Dependencies.** A new dependency comes from the expected publisher, is pinned by lockfile or hash, and is needed; actions and images are pinned to a digest or a full commit, not a moving tag. Install scripts of new packages are read.
|
|
6
|
+
- **Build provenance.** Artifacts are built by the pipeline from the reviewed commit; a step that downloads and runs a script from a URL, or publishes from a developer machine, removes the link between review and release.
|
|
7
|
+
- **Configuration baseline.** Hardening settings in images and manifests (non-root user, read-only filesystem, dropped capabilities, resource limits) are not weakened; a changed base image is a new supplier.
|
|
8
|
+
- **Vulnerabilities.** A dependency with a known exploitable vulnerability, or a scanner disabled for convenience, is reported with its advisory id.
|
|
9
|
+
|
|
10
|
+
Name the control a finding breaks (for example `ISO27001:A.8.32` for change management) in the finding's description.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Check that the protection claimed is the protection delivered.
|
|
2
|
+
|
|
3
|
+
- **Primitives.** Vetted library calls only; no home-made encryption, padding or random numbers. Reject MD5, SHA-1 or an unsalted fast hash for anything security-relevant, ECB mode, static or reused IVs and nonces, and `Math.random`-class generators for tokens or keys.
|
|
4
|
+
- **Keys.** Keys and secrets come from a secret store or the environment, never from source, fixtures or container images. Each key has one purpose, a rotation path, and an owner; a key used both to sign and to encrypt is a defect.
|
|
5
|
+
- **In transit.** TLS verification stays on (no `verify=False`, `rejectUnauthorized: false`, `InsecureSkipVerify`); internal calls that carry personal data or credentials are encrypted too.
|
|
6
|
+
- **At rest.** Data the change stores that needs confidentiality is encrypted at the field or volume level the design promised; exports and backups receive the same protection as the primary store.
|
|
7
|
+
- **Tokens and signatures.** Signed tokens pin the algorithm (no `none`, no algorithm taken from the token header), verify issuer, audience and expiry, and compare in constant time.
|
|
8
|
+
- **Failure.** A decryption or verification failure is an error, never a fallback to the plaintext or unsigned path.
|
|
9
|
+
|
|
10
|
+
Name the control a finding breaks (for example `ISO27001:A.8.24` for the use of cryptography) in the finding's description.
|