@topy-ai/maggie 0.7.55 → 0.7.56

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 (73) hide show
  1. package/README-zh-TW.md +5 -3
  2. package/README.md +35 -8
  3. package/bin/maggie.js +4 -0
  4. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +2 -1
  5. package/bundled-contracts/maggie-security/finding-example-v1.json +89 -0
  6. package/bundled-contracts/maggie-security/finding-v1.schema.json +63 -0
  7. package/bundled-contracts/maggie-security/findings-set-example-v1.json +148 -0
  8. package/bundled-contracts/maggie-security/findings-set-v1.schema.json +32 -0
  9. package/bundled-contracts/maggie-security/protect-plan-v1.schema.json +28 -0
  10. package/bundled-contracts/maggie-security/report-v1.schema.json +29 -0
  11. package/bundled-contracts/maggie-security/stack-example-v1.json +66 -0
  12. package/bundled-contracts/maggie-security/stack-upload-v1.schema.json +22 -0
  13. package/bundled-contracts/maggie-security/stack-v1.schema.json +37 -0
  14. package/bundled-contracts/maggie-security/tools-lock-v1.schema.json +36 -0
  15. package/bundled-skills/catalog.json +4 -0
  16. package/bundled-skills/maggie-security/SKILL.md +172 -0
  17. package/bundled-skills/maggie-security/agents/openai.yaml +4 -0
  18. package/bundled-skills/maggie-security/data/LICENSE-NOTICE.md +24 -0
  19. package/bundled-skills/maggie-security/data/asvs-map.json +209 -0
  20. package/bundled-skills/maggie-security/data/fail2ban/filter.d/maggie-wordpress.conf +6 -0
  21. package/bundled-skills/maggie-security/data/semgrep/LICENSE-NOTICE.md +5 -0
  22. package/bundled-skills/maggie-security/data/semgrep/VERSION +1 -0
  23. package/bundled-skills/maggie-security/data/semgrep/rules/dockerfile.yaml +14 -0
  24. package/bundled-skills/maggie-security/data/semgrep/rules/generic.yaml +26 -0
  25. package/bundled-skills/maggie-security/data/semgrep/rules/js.yaml +216 -0
  26. package/bundled-skills/maggie-security/data/semgrep/rules/php-debug-ok.yaml +17 -0
  27. package/bundled-skills/maggie-security/data/semgrep/rules/php-debug.yaml +17 -0
  28. package/bundled-skills/maggie-security/data/semgrep/rules/php.yaml +101 -0
  29. package/bundled-skills/maggie-security/data/semgrep/tests/dockerfile.dockerfile +10 -0
  30. package/bundled-skills/maggie-security/data/semgrep/tests/generic.astro +11 -0
  31. package/bundled-skills/maggie-security/data/semgrep/tests/generic.conf +11 -0
  32. package/bundled-skills/maggie-security/data/semgrep/tests/js.jsx +95 -0
  33. package/bundled-skills/maggie-security/data/semgrep/tests/php-debug-ok.php +5 -0
  34. package/bundled-skills/maggie-security/data/semgrep/tests/php-debug.php +3 -0
  35. package/bundled-skills/maggie-security/data/semgrep/tests/php.php +55 -0
  36. package/bundled-skills/maggie-security/evals/evals.json +97 -0
  37. package/bundled-skills/maggie-security/references/commands.md +111 -0
  38. package/bundled-skills/maggie-security/references/cve-response.md +24 -0
  39. package/bundled-skills/maggie-security/references/explaining-findings.md +57 -0
  40. package/bundled-skills/maggie-security/references/findings.md +39 -0
  41. package/bundled-skills/maggie-security/references/ownership-and-consent.md +29 -0
  42. package/bundled-skills/maggie-security/references/site-types.md +22 -0
  43. package/bundled-skills/maggie-security/references/tools-and-licences.md +16 -0
  44. package/bundled-skills/maggie-security/references/workflows.md +23 -0
  45. package/bundled-skills/maggie-security/registry/security-tools.registry.json +628 -0
  46. package/bundled-skills/maggie-security/routines.example.json +27 -0
  47. package/bundled-tools/clis/maggie_deployment_readiness.py +17 -2
  48. package/bundled-tools/clis/maggie_security.py +342 -0
  49. package/bundled-tools/clis/maggie_security_agent.py +326 -0
  50. package/bundled-tools/clis/maggie_security_code.py +650 -0
  51. package/bundled-tools/clis/maggie_security_core.py +1512 -0
  52. package/bundled-tools/clis/maggie_security_deps.py +560 -0
  53. package/bundled-tools/clis/maggie_security_health.py +415 -0
  54. package/bundled-tools/clis/maggie_security_host.py +511 -0
  55. package/bundled-tools/clis/maggie_security_hosted.py +115 -0
  56. package/bundled-tools/clis/maggie_security_intel.py +1294 -0
  57. package/bundled-tools/clis/maggie_security_inventory.py +1023 -0
  58. package/bundled-tools/clis/maggie_security_logs.py +777 -0
  59. package/bundled-tools/clis/maggie_security_mcp.py +473 -0
  60. package/bundled-tools/clis/maggie_security_mirror.py +122 -0
  61. package/bundled-tools/clis/maggie_security_protect.py +519 -0
  62. package/bundled-tools/clis/maggie_security_report.py +506 -0
  63. package/bundled-tools/clis/maggie_security_standards.py +214 -0
  64. package/bundled-tools/clis/maggie_security_versions.py +381 -0
  65. package/bundled-tools/clis/maggie_security_web.py +936 -0
  66. package/bundled-tools/clis/security_data/eol-snapshot.json +1 -0
  67. package/bundled-tools/clis/security_data/gitleaks-rules.json +3812 -0
  68. package/bundled-tools/clis/security_data/maltrail-lists.json +1 -0
  69. package/bundled-tools/clis/security_data/public-suffix-icann.txt +7374 -0
  70. package/bundled-tools/clis/security_data/retire-extractors.json +695 -0
  71. package/bundled-tools/clis/security_data/sigma-web.json +6514 -0
  72. package/bundled-tools/clis/security_data/tools.lock.json +358 -0
  73. package/package.json +1 -1
@@ -0,0 +1,936 @@
1
+ #!/usr/bin/env python3
2
+ """Web surface (docs/maggie-security/04-web-surface-prd.md):
3
+ ``domain-verify``, ``headers``, ``tls``, ``web-scan``, ``zap-baseline``.
4
+
5
+ Nothing here sends a request to a site until its owner has proved control of
6
+ the registrable domain (DNS TXT seen by the system resolver and by a public
7
+ DoH resolver, or a token file on the site). ``headers`` and ``tls`` may run
8
+ once against an unverified domain with ``--public-check`` — one ordinary GET
9
+ or TLS handshake, the same as a browser — and say so. ``web-scan`` runs
10
+ nuclei only with the safe profile (no fuzzing, brute force, OAST, DoS or
11
+ intrusive templates) at ≤ 10 requests per second.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import base64
18
+ import datetime as dt
19
+ import hashlib
20
+ import html.parser
21
+ import ipaddress
22
+ import json
23
+ import os
24
+ import random
25
+ import re
26
+ import secrets
27
+ import shutil
28
+ import socket
29
+ import ssl
30
+ import struct
31
+ import subprocess
32
+ import tempfile
33
+ import urllib.error
34
+ import urllib.parse
35
+ import urllib.request
36
+ from pathlib import Path
37
+
38
+ import maggie_security_core as core
39
+
40
+ core.SOURCES.setdefault("doh", {"hosts": ["cloudflare-dns.com"], "ttl": "0s", "url": "https://cloudflare-dns.com/dns-query"})
41
+ OWNERSHIP_DAYS = 90
42
+ RECHECK_DAYS = 7
43
+ TXT_NAME = "_maggie-security"
44
+ WELL_KNOWN = "/.well-known/maggie-security-verification.txt"
45
+ _PSL: set[str] | None = None
46
+
47
+
48
+ # --------------------------------------------------------------------------- domains and ownership
49
+
50
+
51
+ def public_suffixes() -> set[str]:
52
+ global _PSL
53
+ if _PSL is None:
54
+ text = (core.DATA_DIR / "public-suffix-icann.txt").read_text(encoding="utf-8")
55
+ _PSL = {line.strip().lower() for line in text.splitlines() if line.strip() and not line.startswith("#")}
56
+ return _PSL
57
+
58
+
59
+ def is_ip(host: str) -> bool:
60
+ try:
61
+ ipaddress.ip_address(host.strip("[]"))
62
+ return True
63
+ except ValueError:
64
+ return False
65
+
66
+
67
+ def registrable(host: str) -> str:
68
+ host = host.strip().lower().rstrip(".")
69
+ if is_ip(host) or "." not in host:
70
+ return host
71
+ labels = host.split(".")
72
+ suffixes = public_suffixes()
73
+ for i in range(len(labels)):
74
+ candidate = ".".join(labels[i:])
75
+ if candidate in suffixes:
76
+ return ".".join(labels[max(i - 1, 0):]) if i > 0 else host
77
+ return ".".join(labels[-2:])
78
+
79
+
80
+ def host_of(url_or_host: str) -> str:
81
+ if "://" in url_or_host:
82
+ return (urllib.parse.urlsplit(url_or_host).hostname or "").lower()
83
+ return url_or_host.split("/")[0].split(":")[0].lower()
84
+
85
+
86
+ def ownership_path(state: core.State, domain: str) -> Path:
87
+ return state.path("ownership", f"{registrable(domain)}.json")
88
+
89
+
90
+ def system_resolvers() -> list[str]:
91
+ servers = []
92
+ try:
93
+ for line in Path("/etc/resolv.conf").read_text().splitlines():
94
+ parts = line.split()
95
+ if len(parts) >= 2 and parts[0] == "nameserver":
96
+ servers.append(parts[1])
97
+ except OSError:
98
+ pass
99
+ return servers or ["127.0.0.53"]
100
+
101
+
102
+ def dns_txt_udp(name: str, server: str, timeout: float = 5.0, port: int = 53) -> list[str]:
103
+ """Minimal DNS TXT query over UDP (stdlib only)."""
104
+ qid = random.randint(0, 0xFFFF)
105
+ question = b"".join(bytes([len(label)]) + label.encode("idna") for label in name.rstrip(".").split(".")) + b"\x00"
106
+ packet = struct.pack(">HHHHHH", qid, 0x0100, 1, 0, 0, 0) + question + struct.pack(">HH", 16, 1)
107
+ family = socket.AF_INET6 if ":" in server else socket.AF_INET
108
+ with socket.socket(family, socket.SOCK_DGRAM) as sock:
109
+ sock.settimeout(timeout)
110
+ sock.sendto(packet, (server, port))
111
+ data, _ = sock.recvfrom(4096)
112
+ rid, flags, qd, an, _ns, _ar = struct.unpack(">HHHHHH", data[:12])
113
+ if rid != qid or flags & 0x000F not in (0, 3):
114
+ return []
115
+ offset = 12
116
+
117
+ def skip_name(pos: int) -> int:
118
+ while True:
119
+ length = data[pos]
120
+ if length == 0:
121
+ return pos + 1
122
+ if length & 0xC0 == 0xC0:
123
+ return pos + 2
124
+ pos += length + 1
125
+
126
+ for _ in range(qd):
127
+ offset = skip_name(offset) + 4
128
+ out = []
129
+ for _ in range(an):
130
+ offset = skip_name(offset)
131
+ rtype, _rclass, _ttl, rdlength = struct.unpack(">HHIH", data[offset:offset + 10])
132
+ offset += 10
133
+ rdata = data[offset:offset + rdlength]
134
+ offset += rdlength
135
+ if rtype == 16:
136
+ parts, pos = [], 0
137
+ while pos < len(rdata):
138
+ length = rdata[pos]
139
+ parts.append(rdata[pos + 1:pos + 1 + length].decode("utf-8", errors="replace"))
140
+ pos += 1 + length
141
+ out.append("".join(parts))
142
+ return out
143
+
144
+
145
+ def dns_txt_doh(ctx: core.Context, name: str) -> list[str] | None:
146
+ url = f"https://cloudflare-dns.com/dns-query?name={urllib.parse.quote(name)}&type=TXT"
147
+ ctx.guard.check("doh", url)
148
+ try:
149
+ status, _h, body = core.http_request(url, headers={"Accept": "application/dns-json"}, timeout=10, retries=1)
150
+ except core.DegradedError:
151
+ return None
152
+ if status != 200:
153
+ return None
154
+ data = json.loads(body.decode() or "{}")
155
+ return [a.get("data", "").strip('"').replace('" "', "") for a in data.get("Answer") or [] if a.get("type") == 16]
156
+
157
+
158
+ def token_hash(token: str) -> str:
159
+ return hashlib.sha256(token.strip().encode()).hexdigest()
160
+
161
+
162
+ def check_record(ctx: core.Context, record: dict, host_hint: str | None = None) -> tuple[bool, dict]:
163
+ """Re-run the record's method; returns (ok, evidence)."""
164
+ domain = record["domain"]
165
+ if record["method"] == "dns":
166
+ name = f"{TXT_NAME}.{domain}"
167
+ seen: dict[str, list[str]] = {}
168
+ try:
169
+ seen["system"] = dns_txt_udp(name, system_resolvers()[0])
170
+ except OSError as error:
171
+ seen["system_error"] = [str(error)]
172
+ doh = dns_txt_doh(ctx, name)
173
+ if doh is not None:
174
+ seen["doh"] = doh
175
+ want = record["token_sha256"]
176
+ hit = lambda values: any(token_hash(v.split("=", 1)[1] if v.startswith("maggie-site-verification=") else v) == want for v in values) # noqa: E731
177
+ ok_system = hit(seen.get("system", []))
178
+ ok_doh = hit(seen.get("doh", []))
179
+ return ok_system and ok_doh, {"txt_seen_by": [k for k, ok in (("system", ok_system), ("doh", ok_doh)) if ok],
180
+ "resolvers_answered": [k for k in ("system", "doh") if k in seen]}
181
+ host = record.get("host") or host_hint or domain
182
+ url = f"https://{host}{WELL_KNOWN}"
183
+ try:
184
+ response = fetch(url, follow=False, timeout=10)
185
+ except OSError as error:
186
+ return False, {"http_error": str(error)[:200]}
187
+ body = response["body"][:200].decode("utf-8", errors="replace").strip()
188
+ ok = response["status"] == 200 and token_hash(body) == record["token_sha256"]
189
+ return ok, {"http_status": response["status"]}
190
+
191
+
192
+ def domain_verify(ctx: core.Context) -> dict:
193
+ args = ctx.args
194
+ action = args.action
195
+ if action == "status":
196
+ directory = ctx.state.path("ownership")
197
+ records = [core.read_json(p) for p in sorted(directory.glob("*.json"))] if directory.is_dir() else []
198
+ return {"schema": "maggie-security-ownership-status.v1", "records": [ownership_summary(r) for r in records]}
199
+ if not args.domain:
200
+ raise core.UsageError("--domain is required")
201
+ domain = registrable(host_of(args.domain)) if args.method != "file" else host_of(args.domain)
202
+ path = ownership_path(ctx.state, domain)
203
+ record = core.read_json(path) if path.is_file() else None
204
+ if action == "revoke":
205
+ if path.is_file():
206
+ path.unlink()
207
+ return {"revoked": domain}
208
+ if action == "start":
209
+ if record and record.get("verified_at") and not args.rotate:
210
+ return {"schema": "maggie-security-ownership-start.v1", "domain": domain, "already": ownership_summary(record),
211
+ "note": "already verified; use --rotate for a new token"}
212
+ token = base64.b32encode(secrets.token_bytes(32)).decode().rstrip("=").lower()
213
+ record = {"schema": "maggie-security-ownership.v1", "domain": registrable(domain) if args.method == "dns" else domain,
214
+ "host": None if args.method == "dns" else domain, "scope": "registrable-domain" if args.method == "dns" else "host",
215
+ "method": args.method, "token_sha256": token_hash(token), "created_at": core.iso(), "verified_at": None,
216
+ "expires_at": None, "last_checked_at": None, "evidence": {}}
217
+ core.write_json(path, record)
218
+ how = (f"Add a DNS TXT record: name `{TXT_NAME}.{record['domain']}`, value `maggie-site-verification={token}`"
219
+ if args.method == "dns" else
220
+ f"Serve `https://{domain}{WELL_KNOWN}` with exactly this text (Content-Type text/plain): {token}")
221
+ return {"schema": "maggie-security-ownership-start.v1", "domain": record["domain"], "method": args.method, "token": token,
222
+ "instructions": how, "next": f"maggie security domain-verify check --domain {record['domain']}",
223
+ "note": "The token is shown once; Maggie stores only its hash."}
224
+ if not record:
225
+ raise core.UsageError(f"no verification started for {domain}; run domain-verify start first")
226
+ ok, evidence = check_record(ctx, record)
227
+ record["last_checked_at"] = core.iso()
228
+ record["evidence"] = evidence
229
+ if ok:
230
+ record["verified_at"] = record.get("verified_at") if record.get("verified_at") and not record.get("stale") else core.iso()
231
+ record["expires_at"] = core.iso(core.parse_time(record["verified_at"]) + dt.timedelta(days=OWNERSHIP_DAYS))
232
+ record.pop("stale", None)
233
+ core.write_json(path, record)
234
+ result = {"schema": "maggie-security-ownership-check.v1", **ownership_summary(record), "evidence": evidence}
235
+ if not ok:
236
+ if record["method"] == "dns" and not evidence.get("resolvers_answered"):
237
+ result["exit_code"] = core.EXIT_DEGRADED
238
+ else:
239
+ result["exit_code"] = core.EXIT_POLICY
240
+ result["note"] = "not verified yet: DNS changes can take a while; both the system resolver and the public resolver must see the record"
241
+ return result
242
+
243
+
244
+ def ownership_summary(record: dict) -> dict:
245
+ status = "pending"
246
+ if record.get("verified_at"):
247
+ expired = record.get("expires_at") and core.parse_time(record["expires_at"]) < core.utcnow()
248
+ status = "expired" if expired else ("stale" if record.get("stale") else "verified")
249
+ return {"domain": record["domain"], "method": record["method"], "scope": record.get("scope"), "status": status,
250
+ "verified_at": record.get("verified_at"), "expires_at": record.get("expires_at"), "last_checked_at": record.get("last_checked_at")}
251
+
252
+
253
+ def require_ownership(ctx: core.Context, host: str, public_check: bool = False) -> str:
254
+ """'verified' or 'public-check'; raises PolicyError otherwise (PRD 00 section 13.2)."""
255
+ path = ownership_path(ctx.state, host)
256
+ record = core.read_json(path) if path.is_file() else None
257
+ if record is None and not path.is_file():
258
+ exact = ctx.state.path("ownership", f"{host}.json")
259
+ record = core.read_json(exact) if exact.is_file() else None
260
+ if record and record.get("scope") == "host" and record.get("host") not in (host, None):
261
+ record = None
262
+ if record and ownership_summary(record)["status"] == "verified":
263
+ last = core.parse_time(record.get("last_checked_at") or record["verified_at"])
264
+ if core.utcnow() - last > dt.timedelta(days=RECHECK_DAYS) and record["method"] in ("dns", "file"):
265
+ ok, evidence = check_record(ctx, record, host)
266
+ record["last_checked_at"], record["evidence"] = core.iso(), evidence
267
+ if not ok:
268
+ record["stale"] = True
269
+ core.write_json(ownership_path(ctx.state, host) if record.get("scope") != "host" else ctx.state.path("ownership", f"{host}.json"), record)
270
+ if not ok:
271
+ raise core.PolicyError(f"ownership of {record['domain']} could not be re-confirmed (the TXT record or file is gone)")
272
+ return "verified"
273
+ if public_check:
274
+ if getattr(ctx.args, "ci", False) or os.environ.get("MAGGIE_ROUTINE"):
275
+ raise core.PolicyError("--public-check is for a person checking their own site interactively, not for routines or CI")
276
+ return "public-check"
277
+ raise core.PolicyError(f"{registrable(host)} is not verified; run: maggie security domain-verify start --domain {registrable(host)}")
278
+
279
+
280
+ # --------------------------------------------------------------------------- HTTP
281
+
282
+
283
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
284
+ def redirect_request(self, *args, **kwargs): # noqa: D401
285
+ return None
286
+
287
+
288
+ def fetch(url: str, follow: bool = True, timeout: float = 15.0, max_hops: int = 5, method: str = "GET") -> dict:
289
+ """GET with manual redirects that stay within the registrable domain; returns status, headers (lists), body, chain."""
290
+ opener = urllib.request.build_opener(NoRedirect, urllib.request.HTTPSHandler(context=ssl.create_default_context()))
291
+ chain = []
292
+ current = url
293
+ start_domain = registrable(host_of(url))
294
+ for _ in range(max_hops + 1):
295
+ request = urllib.request.Request(current, method=method, headers={"User-Agent": core.USER_AGENT, "Accept": "text/html,*/*"})
296
+ try:
297
+ response = opener.open(request, timeout=timeout)
298
+ status, headers, body = response.status, response.headers, response.read(512 * 1024)
299
+ except urllib.error.HTTPError as error:
300
+ status, headers, body = error.code, error.headers, error.read(64 * 1024) if error.fp else b""
301
+ multi: dict[str, list[str]] = {}
302
+ for key, value in headers.items():
303
+ multi.setdefault(key.lower(), []).append(value)
304
+ chain.append({"url": current, "status": status})
305
+ location = (multi.get("location") or [None])[0]
306
+ if follow and status in (301, 302, 303, 307, 308) and location:
307
+ nxt = urllib.parse.urljoin(current, location)
308
+ if registrable(host_of(nxt)) != start_domain:
309
+ return {"status": status, "headers": multi, "body": body, "chain": chain, "url": current, "left_domain": nxt}
310
+ current = nxt
311
+ continue
312
+ return {"status": status, "headers": multi, "body": body, "chain": chain, "url": current}
313
+ return {"status": status, "headers": multi, "body": body, "chain": chain, "url": current, "too_many_redirects": True}
314
+
315
+
316
+ # --------------------------------------------------------------------------- headers
317
+
318
+
319
+ SNIPPETS = {
320
+ "nginx": {
321
+ "strict-transport-security": 'add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;',
322
+ "x-content-type-options": 'add_header X-Content-Type-Options "nosniff" always;',
323
+ "referrer-policy": 'add_header Referrer-Policy "strict-origin-when-cross-origin" always;',
324
+ "x-frame-options": 'add_header X-Frame-Options "SAMEORIGIN" always;',
325
+ "permissions-policy": 'add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;',
326
+ "content-security-policy": 'add_header Content-Security-Policy-Report-Only "{csp}" always;',
327
+ "server": "server_tokens off; # and remove X-Powered-By in the app (PHP: expose_php = Off)",
328
+ },
329
+ "apache": {
330
+ "strict-transport-security": 'Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"',
331
+ "x-content-type-options": 'Header always set X-Content-Type-Options "nosniff"',
332
+ "referrer-policy": 'Header always set Referrer-Policy "strict-origin-when-cross-origin"',
333
+ "x-frame-options": 'Header always set X-Frame-Options "SAMEORIGIN"',
334
+ "permissions-policy": 'Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"',
335
+ "content-security-policy": 'Header always set Content-Security-Policy-Report-Only "{csp}"',
336
+ "server": "ServerTokens Prod\nServerSignature Off\nHeader unset X-Powered-By",
337
+ },
338
+ "headers-file": { # Cloudflare Pages / Workers static assets / Netlify: public/_headers
339
+ "_template": "/*\n {name}: {value}",
340
+ },
341
+ "astro": {"_template": 'res.headers.set("{name}", "{value}"); // in src/middleware.ts onRequest, after `const res = await next();`'},
342
+ "next": {"_template": "{{ key: '{name}', value: '{value}' }}, // next.config.js headers() → source: '/(.*)'"},
343
+ "php": {"_template": 'header("{name}: {value}"); // WordPress: in wp-content/mu-plugins/maggie-security-headers.php on send_headers'},
344
+ "cloudflare": {"_template": "Rules → Transform Rules → Modify Response Header → Set static: {name} = {value}"},
345
+ }
346
+ RECOMMENDED = {
347
+ "strict-transport-security": "max-age=31536000; includeSubDomains",
348
+ "x-content-type-options": "nosniff",
349
+ "referrer-policy": "strict-origin-when-cross-origin",
350
+ "x-frame-options": "SAMEORIGIN",
351
+ "permissions-policy": "camera=(), microphone=(), geolocation=()",
352
+ }
353
+ DISPLAY = {"strict-transport-security": "Strict-Transport-Security", "x-content-type-options": "X-Content-Type-Options",
354
+ "referrer-policy": "Referrer-Policy", "x-frame-options": "X-Frame-Options", "permissions-policy": "Permissions-Policy",
355
+ "content-security-policy": "Content-Security-Policy"}
356
+
357
+
358
+ def detect_serving(project: Path, headers: dict[str, list[str]], requested: str) -> str:
359
+ if requested != "auto":
360
+ return requested
361
+ server = " ".join(headers.get("server", [])).lower()
362
+ powered = " ".join(headers.get("x-powered-by", [])).lower()
363
+ if "cf-ray" in headers and any((project / n).is_file() for n in ("wrangler.toml", "wrangler.json", "wrangler.jsonc")):
364
+ return "headers-file"
365
+ if "next.js" in powered or any(k.startswith("x-nextjs") for k in headers):
366
+ return "next"
367
+ if any(project.glob("astro.config.*")):
368
+ return "astro"
369
+ if (project / "wp-config.php").is_file() or "php" in powered:
370
+ return "php"
371
+ if "nginx" in server:
372
+ return "nginx"
373
+ if "apache" in server:
374
+ return "apache"
375
+ if "cf-ray" in headers or "cloudflare" in server:
376
+ return "cloudflare"
377
+ return "nginx"
378
+
379
+
380
+ def snippet(layer: str, header: str, value: str) -> dict:
381
+ table = SNIPPETS.get(layer, SNIPPETS["nginx"])
382
+ # A new CSP always starts in report-only mode (PRD 04 section 7.4).
383
+ name = "Content-Security-Policy-Report-Only" if header == "content-security-policy" else DISPLAY.get(header, header)
384
+ text = table.get(header) or table.get("_template", SNIPPETS["nginx"].get(header, "")).format(name=name, value=value, csp=value)
385
+ if "{csp}" in text:
386
+ text = text.replace("{csp}", value)
387
+ return {"kind": layer, "text": text}
388
+
389
+
390
+ class AssetOrigins(html.parser.HTMLParser):
391
+ """Collect script/style/img/connect origins from a page, to start a CSP in report-only mode."""
392
+
393
+ def __init__(self) -> None:
394
+ super().__init__()
395
+ self.origins: dict[str, set[str]] = {"script-src": set(), "style-src": set(), "img-src": set(), "font-src": set(), "frame-src": set()}
396
+ self.inline_script = False
397
+
398
+ def handle_starttag(self, tag, attrs):
399
+ attrs = dict(attrs)
400
+ mapping = {"script": ("script-src", "src"), "img": ("img-src", "src"), "iframe": ("frame-src", "src")}
401
+ if tag == "link" and "stylesheet" in (attrs.get("rel") or ""):
402
+ self._add("style-src", attrs.get("href"))
403
+ elif tag in mapping:
404
+ directive, attr = mapping[tag]
405
+ if attrs.get(attr):
406
+ self._add(directive, attrs[attr])
407
+ elif tag == "script":
408
+ self.inline_script = True
409
+
410
+ def _add(self, directive: str, url: str | None) -> None:
411
+ if not url or url.startswith(("data:", "/", "./", "../")) or "://" not in url:
412
+ return
413
+ parts = urllib.parse.urlsplit(url)
414
+ self.origins[directive].add(f"{parts.scheme}://{parts.netloc}")
415
+
416
+
417
+ def starter_csp(body: bytes) -> str:
418
+ parser = AssetOrigins()
419
+ try:
420
+ parser.feed(body.decode("utf-8", errors="replace")[:500_000])
421
+ except Exception: # noqa: BLE001 - malformed HTML only means a weaker starting policy
422
+ pass
423
+ parts = ["default-src 'self'"]
424
+ for directive, origins in parser.origins.items():
425
+ if origins:
426
+ parts.append(f"{directive} 'self' {' '.join(sorted(origins))}")
427
+ parts += ["object-src 'none'", "base-uri 'self'", "frame-ancestors 'self'"]
428
+ return "; ".join(parts)
429
+
430
+
431
+ def csp_problems(value: str) -> list[str]:
432
+ directives: dict[str, list[str]] = {}
433
+ for part in value.split(";"):
434
+ tokens = part.strip().split()
435
+ if tokens:
436
+ directives.setdefault(tokens[0].lower(), [t.lower() for t in tokens[1:]])
437
+ script = directives.get("script-src") or directives.get("default-src") or ["*"]
438
+ has_nonce = any(t.startswith(("'nonce-", "'sha256-", "'sha384-", "'sha512-")) for t in script)
439
+ problems = []
440
+ if "'unsafe-inline'" in script and not has_nonce and "'strict-dynamic'" not in script:
441
+ problems.append("allows inline scripts ('unsafe-inline' without a nonce or hash)")
442
+ if "'unsafe-eval'" in script:
443
+ problems.append("allows eval ('unsafe-eval')")
444
+ if any(t in ("*", "https:", "http:", "data:") for t in script):
445
+ problems.append("lets scripts load from anywhere (*, https: or data:)")
446
+ if not (directives.get("object-src") == ["'none'"] or directives.get("default-src") == ["'none'"]):
447
+ problems.append("does not set object-src 'none'")
448
+ if "base-uri" not in directives:
449
+ problems.append("does not set base-uri")
450
+ if "frame-ancestors" not in directives:
451
+ problems.append("does not set frame-ancestors (clickjacking)")
452
+ return problems
453
+
454
+
455
+ def grade_response(ctx: core.Context, url: str, response: dict, layer: str, https: bool) -> list[dict]:
456
+ headers = response["headers"]
457
+ first = lambda name: (headers.get(name) or [None])[0] # noqa: E731
458
+ findings = []
459
+ host = host_of(url)
460
+
461
+ def add(rule: str, title: str, severity: str, asvs: list[str], header: str | None = None, value: str | None = None,
462
+ summary: str = "", kind: str = "header", evidence: str | None = None) -> None:
463
+ snippets = [snippet(layer, header, value)] if header and value else []
464
+ findings.append(core.make_finding(
465
+ kind=kind, command="headers", primary_id=f"header-{rule}", site=ctx.site, title=title, severity=severity, severity_source="maggie",
466
+ confidence="confirmed", asvs_ids=asvs, target={"url": url, "host": host},
467
+ evidence=[{"path": url, "excerpt": evidence}] if evidence else [],
468
+ remediation={"summary": summary or title, "steps": [], "snippets": snippets,
469
+ "references": ["https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html"], "cheat_sheet": "HTTP Headers"}))
470
+
471
+ hsts = first("strict-transport-security")
472
+ if https:
473
+ if not hsts:
474
+ add("hsts-missing", "No Strict-Transport-Security header: browsers may still load the site over plain HTTP", "high", ["V3.4.1"],
475
+ "strict-transport-security", RECOMMENDED["strict-transport-security"], "Add HSTS so browsers only use HTTPS.")
476
+ else:
477
+ match = re.search(r"max-age\s*=\s*\"?(\d+)", hsts, re.I)
478
+ age = int(match.group(1)) if match else 0
479
+ if age < 15_552_000:
480
+ add("hsts-short", f"HSTS max-age is {age} seconds (under six months)", "medium", ["V3.4.1"], "strict-transport-security",
481
+ RECOMMENDED["strict-transport-security"], "Raise max-age to at least 15552000 (one year: 31536000).", evidence=hsts)
482
+ csp = first("content-security-policy")
483
+ csp_ro = first("content-security-policy-report-only")
484
+ if not csp:
485
+ starter = starter_csp(response["body"])
486
+ add("csp-missing", "No Content-Security-Policy header" + (" (a report-only policy is set)" if csp_ro else ""), "medium", ["V3.4.3"],
487
+ "content-security-policy", starter, "Start with this policy in report-only mode for a week, then switch to enforcing.")
488
+ else:
489
+ for problem in csp_problems(csp):
490
+ add(f"csp-{re.sub(r'[^a-z]+', '-', problem.split('(')[0].lower()).strip('-')[:40]}", f"Content-Security-Policy {problem}", "medium",
491
+ ["V3.4.3"], evidence=csp[:200], summary="Tighten the policy; use nonces or hashes for inline scripts.")
492
+ xcto = first("x-content-type-options")
493
+ if not xcto or xcto.strip().lower() != "nosniff":
494
+ add("xcto", "X-Content-Type-Options is not nosniff", "medium", ["V3.4.4"], "x-content-type-options", "nosniff", evidence=xcto)
495
+ xfo = first("x-frame-options")
496
+ if not xfo and not (csp and "frame-ancestors" in csp.lower()):
497
+ add("framing", "The site can be framed by other sites (no X-Frame-Options or frame-ancestors)", "medium", ["V3.4.6"],
498
+ "x-frame-options", RECOMMENDED["x-frame-options"])
499
+ elif xfo and "allow-from" in xfo.lower():
500
+ add("xfo-allow-from", "X-Frame-Options ALLOW-FROM is ignored by browsers", "medium", ["V3.4.6"], "x-frame-options", "SAMEORIGIN", evidence=xfo)
501
+ referrer = first("referrer-policy")
502
+ if not referrer:
503
+ add("referrer-missing", "No Referrer-Policy header", "medium", ["V3.4.5"], "referrer-policy", RECOMMENDED["referrer-policy"])
504
+ elif referrer.strip().lower() in ("unsafe-url", "no-referrer-when-downgrade"):
505
+ add("referrer-weak", f"Referrer-Policy {referrer} leaks full URLs to other sites", "medium", ["V3.4.5"], "referrer-policy",
506
+ RECOMMENDED["referrer-policy"], evidence=referrer)
507
+ if not first("permissions-policy"):
508
+ add("permissions-missing", "No Permissions-Policy header", "info", [], "permissions-policy", RECOMMENDED["permissions-policy"])
509
+ if not first("cross-origin-opener-policy"):
510
+ add("coop-missing", "No Cross-Origin-Opener-Policy header", "info", ["V3.4.8"])
511
+ for name in ("x-xss-protection", "expect-ct", "public-key-pins"):
512
+ if first(name):
513
+ add(f"deprecated-{name}", f"{name} is deprecated; remove it", "info", [], evidence=first(name))
514
+ for name in ("server", "x-powered-by", "x-aspnet-version"):
515
+ value = first(name)
516
+ if value and re.search(r"\d+\.\d+", value):
517
+ add(f"version-{name}", f"{name} header discloses a version ({value[:60]})", "low", ["V13.4.6"], "server", "off", evidence=value)
518
+ for cookie in headers.get("set-cookie") or []:
519
+ name = cookie.split("=", 1)[0].strip()
520
+ flags = cookie.lower()
521
+ sensitive = re.search(r"(?i)(sess|auth|token|login|sid|jwt|remember|wordpress_logged_in|wp-settings)", name)
522
+ missing = [flag for flag, present in (("Secure", "secure" in flags), ("HttpOnly", "httponly" in flags), ("SameSite", "samesite" in flags))
523
+ if not present]
524
+ if missing and (sensitive or "Secure" in missing and https):
525
+ add(f"cookie-{name.lower()[:30]}", f"Cookie {name} is missing {', '.join(missing)}", "medium" if sensitive else "low",
526
+ ["V3.3.1"] if "Secure" in missing else ["V3.3.2"], kind="misconfiguration", evidence=f"{name}=…; {'; '.join(p.strip() for p in cookie.split(';')[1:])}"[:200],
527
+ summary="Set Secure, HttpOnly and SameSite=Lax (or Strict) on session cookies.")
528
+ origin = first("access-control-allow-origin")
529
+ if origin and origin.strip() == "*" and (first("access-control-allow-credentials") or "").lower() == "true":
530
+ add("cors-wildcard-credentials", "CORS allows any origin with credentials", "high", ["V3.4.2"], kind="misconfiguration",
531
+ evidence="Access-Control-Allow-Origin: *; Allow-Credentials: true")
532
+ return findings
533
+
534
+
535
+ def headers_cmd(ctx: core.Context):
536
+ args = ctx.args
537
+ url = args.url if "://" in (args.url or "") else f"https://{args.url}"
538
+ host = host_of(url)
539
+ mode = require_ownership(ctx, host, args.public_check)
540
+ paths = args.paths or (ctx.state.config().get("headers") or {}).get("paths") or ["/"]
541
+ findings: list[dict] = []
542
+ https = url.startswith("https://")
543
+ layer = "nginx"
544
+ for path in paths[:5]:
545
+ target = urllib.parse.urljoin(url, path)
546
+ try:
547
+ response = fetch(target)
548
+ except (OSError, ssl.SSLError) as error:
549
+ ctx.degrade("target-site", f"{target}: {str(error)[:200]}")
550
+ continue
551
+ if response.get("left_domain"):
552
+ ctx.run["inputs"].setdefault("notes", []).append(f"{target} redirects to another domain ({response['left_domain']}); not followed")
553
+ continue
554
+ layer = detect_serving(ctx.project, response["headers"], args.serving)
555
+ findings += grade_response(ctx, response["url"], response, layer, response["url"].startswith("https://"))
556
+ if mode == "public-check":
557
+ break
558
+ if https and mode == "verified":
559
+ try:
560
+ plain = fetch(f"http://{host}/", follow=False)
561
+ location = (plain["headers"].get("location") or [""])[0]
562
+ if plain["status"] not in (301, 302, 307, 308) or not location.startswith("https://"):
563
+ findings.append(core.make_finding(kind="misconfiguration", command="headers", primary_id="http-no-redirect", site=ctx.site,
564
+ title="Plain HTTP does not redirect to HTTPS", severity="medium", severity_source="maggie",
565
+ confidence="confirmed", target={"url": f"http://{host}/", "host": host}, asvs_ids=["V12.2.1"],
566
+ remediation={"summary": "Redirect every http:// request to https:// with a 301.", "steps": [],
567
+ "snippets": [{"kind": "nginx", "text": "server { listen 80; server_name _; return 301 https://$host$request_uri; }"}],
568
+ "references": [], "cheat_sheet": None}))
569
+ except OSError:
570
+ pass
571
+ ctx.run["inputs"].update({"url": url, "paths": paths[:5], "serving": layer, "ownership": mode})
572
+ if mode == "public-check":
573
+ ctx.run["inputs"]["label"] = "unverified domain (public check)"
574
+ ctx.args.no_ledger = True
575
+ return ctx.finish(findings, covered=[core.finding_locator(None, {"url": f["target"]["url"]}) for f in findings] or [f"url:{url}"])
576
+
577
+
578
+ # --------------------------------------------------------------------------- TLS
579
+
580
+
581
+ TESTSSL_SEVERITY = {"CRITICAL": "critical", "HIGH": "high", "MEDIUM": "medium", "LOW": "low", "WARN": "info"}
582
+
583
+
584
+ def testssl_to_findings(ctx: core.Context, items: list[dict], host: str, port: int) -> list[dict]:
585
+ findings = []
586
+ for item in items:
587
+ severity = TESTSSL_SEVERITY.get(str(item.get("severity", "")).upper())
588
+ if not severity:
589
+ continue
590
+ if severity == "info" and str(item.get("severity", "")).upper() == "WARN":
591
+ ctx.run["inputs"].setdefault("testssl_warnings", []).append(item.get("id"))
592
+ cves = [c for c in str(item.get("cve") or "").split() if c.upper().startswith("CVE-")]
593
+ cwes = [c for c in str(item.get("cwe") or "").split() if re.match(r"^CWE-\d+$", c)]
594
+ rule = str(item.get("id") or "tls")
595
+ asvs = ["V12.2.2"] if rule.startswith("cert") else ["V12.1.2"] if rule.lower().startswith(("cipher", "rc4", "3des", "sweet32")) else ["V12.1.1"]
596
+ findings.append(core.make_finding(
597
+ kind="tls", command="tls", primary_id=f"tls-{rule}", site=ctx.site, title=f"TLS {rule}: {core.clean_excerpt(item.get('finding') or '', 150)}",
598
+ severity=severity, severity_source="tool", confidence="confirmed", aliases=cves, cwe_ids=cwes, asvs_ids=asvs,
599
+ target={"host": host, "url": f"https://{host}:{port}"}, tool="testssl.sh", rule_id=rule,
600
+ remediation={"summary": "Adjust the server's TLS settings (protocols, ciphers, certificate).", "steps": [], "snippets": [],
601
+ "references": ["https://ssl-config.mozilla.org/"], "cheat_sheet": "Transport Layer Security"}))
602
+ return findings
603
+
604
+
605
+ def native_tls(ctx: core.Context, host: str, port: int, ip: str | None) -> list[dict]:
606
+ findings = []
607
+ address = ip or host
608
+
609
+ def add(rule: str, title: str, severity: str, summary: str, evidence: str | None = None) -> None:
610
+ asvs = ["V12.2.2"] if rule.startswith("cert") else ["V12.1.1"]
611
+ findings.append(core.make_finding(kind="tls", command="tls", primary_id=f"tls-{rule}", site=ctx.site, title=title, severity=severity,
612
+ severity_source="maggie", confidence="confirmed", asvs_ids=asvs,
613
+ target={"host": host, "url": f"https://{host}:{port}"}, tool="maggie-tls-native",
614
+ evidence=[{"path": f"{host}:{port}", "excerpt": evidence}] if evidence else [],
615
+ remediation={"summary": summary, "steps": [], "snippets": [],
616
+ "references": ["https://ssl-config.mozilla.org/"], "cheat_sheet": "Transport Layer Security"}))
617
+
618
+ context = ssl.create_default_context()
619
+ try:
620
+ with socket.create_connection((address, port), timeout=10) as raw, context.wrap_socket(raw, server_hostname=host) as conn:
621
+ cert = conn.getpeercert()
622
+ version = conn.version()
623
+ cipher = conn.cipher()
624
+ except ssl.SSLCertVerificationError as error:
625
+ add("cert-invalid", f"The certificate is not trusted for {host}: {error.verify_message}", "high",
626
+ "Install a valid certificate for this name (e.g. Let's Encrypt) including the intermediate chain.", str(error)[:200])
627
+ return findings
628
+ except (OSError, ssl.SSLError) as error:
629
+ ctx.degrade("target-site", f"{host}:{port}: {str(error)[:200]}")
630
+ return findings
631
+ not_after = dt.datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(tzinfo=dt.timezone.utc)
632
+ days = (not_after - core.utcnow()).days
633
+ if days < 0:
634
+ add("cert-expired", f"The certificate expired on {not_after.date()}", "critical", "Renew the certificate now.")
635
+ elif days < 14:
636
+ add("cert-expiring", f"The certificate expires in {days} days ({not_after.date()})", "high", "Renew it now and check automatic renewal.")
637
+ elif days < 30:
638
+ add("cert-expiring-soon", f"The certificate expires in {days} days ({not_after.date()})", "medium", "Check that automatic renewal works.")
639
+ ctx.run["inputs"]["negotiated"] = {"version": version, "cipher": cipher[0] if cipher else None, "cert_expires": not_after.date().isoformat()}
640
+ for label, proto in (("TLSv1", getattr(ssl.TLSVersion, "TLSv1", None)), ("TLSv1.1", getattr(ssl.TLSVersion, "TLSv1_1", None))):
641
+ if proto is None:
642
+ continue
643
+ probe = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
644
+ probe.check_hostname = False
645
+ probe.verify_mode = ssl.CERT_NONE
646
+ try:
647
+ probe.minimum_version = proto
648
+ probe.maximum_version = proto
649
+ probe.set_ciphers("ALL:@SECLEVEL=0")
650
+ except (ValueError, ssl.SSLError):
651
+ ctx.run["inputs"].setdefault("not_testable", []).append(label)
652
+ continue
653
+ try:
654
+ with socket.create_connection((address, port), timeout=10) as raw, probe.wrap_socket(raw, server_hostname=host) as conn:
655
+ accepted = conn.version() == label
656
+ except ssl.SSLError as error:
657
+ if "no protocols available" in str(error).lower() or "unsupported protocol" in str(error).lower() and "alert" not in str(error).lower():
658
+ ctx.run["inputs"].setdefault("not_testable", []).append(label)
659
+ accepted = False
660
+ except OSError:
661
+ accepted = False
662
+ if accepted:
663
+ add(f"protocol-{label}", f"The server still accepts {label}", "medium", f"Disable {label}; allow TLS 1.2 and 1.3 only.")
664
+ if version not in ("TLSv1.3",):
665
+ probe = ssl.create_default_context()
666
+ try:
667
+ probe.minimum_version = ssl.TLSVersion.TLSv1_3
668
+ with socket.create_connection((address, port), timeout=10) as raw, probe.wrap_socket(raw, server_hostname=host):
669
+ pass
670
+ except (OSError, ssl.SSLError, ValueError):
671
+ add("no-tls13", "TLS 1.3 is not offered", "low", "Enable TLS 1.3 (faster and safer).")
672
+ return findings
673
+
674
+
675
+ def tls_cmd(ctx: core.Context):
676
+ args = ctx.args
677
+ host = host_of(args.host)
678
+ port = args.port
679
+ mode = require_ownership(ctx, host, args.public_check)
680
+ ip = None
681
+ if args.ip:
682
+ if mode != "verified":
683
+ raise core.PolicyError("--ip needs a verified domain")
684
+ ip = args.ip
685
+ findings: list[dict] = []
686
+ engine = args.engine
687
+ if engine == "auto":
688
+ engine = "testssl" if core.resolve_tool("testssl.sh", ctx.lock)[1] is not None else "native"
689
+ if engine == "testssl":
690
+ with tempfile.TemporaryDirectory() as tmp:
691
+ out = Path(tmp) / "tls.json"
692
+ argv = ["--warnings", "off", "--sneaky", "--color", "0", "--jsonfile-pretty", str(out), "--severity", "LOW"]
693
+ if args.mode == "quick":
694
+ argv.append("--fast")
695
+ if ip:
696
+ argv += ["--ip", ip]
697
+ argv.append(f"{host}:{port}")
698
+ core.run_tool("testssl.sh", argv, ctx.lock, timeout=1200)
699
+ data = json.loads(out.read_text() or "{}") if out.is_file() else {}
700
+ items = data.get("scanResult", [{}])[0] if isinstance(data.get("scanResult"), list) else {}
701
+ flat = []
702
+ for section, entries in (items.items() if isinstance(items, dict) else []):
703
+ if isinstance(entries, list):
704
+ flat += [e for e in entries if isinstance(e, dict)]
705
+ findings = testssl_to_findings(ctx, flat, host, port)
706
+ ctx.run["tools"].append({"name": "testssl.sh", "version": ctx.lock["tools"]["testssl.sh"]["version"], "sha256": None})
707
+ else:
708
+ findings = native_tls(ctx, host, port, ip)
709
+ ctx.run["tools"].append({"name": "maggie-tls-native", "version": core.maggie_version(), "sha256": None})
710
+ try:
711
+ edge = fetch(f"https://{host}/", follow=False, timeout=10)
712
+ if "cf-ray" in edge["headers"]:
713
+ ctx.run["inputs"]["note"] = "measured at Cloudflare's edge; origin TLS is separate (use --ip with the origin address)"
714
+ except OSError:
715
+ pass
716
+ ctx.run["inputs"].update({"host": host, "port": port, "engine": engine, "ownership": mode})
717
+ if mode == "public-check":
718
+ ctx.args.no_ledger = True
719
+ return ctx.finish(findings, covered=[f"url:https://{host}:{port}"])
720
+
721
+
722
+ # --------------------------------------------------------------------------- web-scan (httpx -> nuclei, safe profile)
723
+
724
+
725
+ SAFE_EXCLUDE_TAGS = ["intrusive", "dos", "fuzz", "fuzzing", "bruteforce", "brute-force", "authenticated", "osint", "default-login", "headless",
726
+ "oast", "file-upload", "sqli-time", "rce-blind"]
727
+ NUCLEI_KIND = {"exposures": "exposure", "misconfiguration": "misconfiguration", "ssl": "tls", "vulnerabilities": "vulnerability",
728
+ "cves": "vulnerability", "default-logins": "exposure", "technologies": "misconfiguration"}
729
+
730
+
731
+ def nuclei_to_findings(ctx: core.Context, lines: list[str]) -> list[dict]:
732
+ findings = []
733
+ for line in lines:
734
+ try:
735
+ item = json.loads(line)
736
+ except ValueError:
737
+ continue
738
+ info = item.get("info") or {}
739
+ classification = info.get("classification") or {}
740
+ cves = [c.upper() for c in classification.get("cve-id") or [] if c]
741
+ cvss = None
742
+ if classification.get("cvss-score"):
743
+ try:
744
+ cvss = {"version": "3.1", "score": float(classification["cvss-score"]), "vector": classification.get("cvss-metrics") or ""}
745
+ except (TypeError, ValueError):
746
+ cvss = None
747
+ epss = None
748
+ if classification.get("epss-score") is not None:
749
+ try:
750
+ epss = {"score": float(classification["epss-score"]), "percentile": float(classification.get("epss-percentile") or 0)}
751
+ except (TypeError, ValueError):
752
+ epss = None
753
+ tags = info.get("tags") or []
754
+ tags = tags.split(",") if isinstance(tags, str) else tags
755
+ path = str(item.get("template-path") or item.get("template") or "")
756
+ kind = "vulnerability" if cves else next((v for k, v in NUCLEI_KIND.items() if f"{k}/" in path or path.startswith(k)), "misconfiguration")
757
+ meta = info.get("metadata") or {}
758
+ affected = None
759
+ if meta.get("product") or meta.get("vendor"):
760
+ component = "wordpress-plugin" if "wp-plugin" in tags else "wordpress-theme" if "wp-theme" in tags else "service"
761
+ affected = {"purl": None, "ecosystem": None, "name": str(meta.get("product") or meta.get("vendor")), "version": None,
762
+ "component_type": component, "direct": None, "scope": "runtime"}
763
+ extracted = item.get("extracted-results") or []
764
+ findings.append(core.make_finding(
765
+ kind=kind, command="web-scan", primary_id=cves[0] if cves else str(item.get("template-id")), site=ctx.site,
766
+ title=core.clean_excerpt(info.get("name") or item.get("template-id") or "nuclei finding", 200),
767
+ severity={"unknown": "info"}.get(info.get("severity"), info.get("severity") or "info"), severity_source="tool", cvss=cvss, epss=epss,
768
+ confidence="confirmed" if meta.get("verified") else "likely", aliases=cves + [str(item.get("template-id"))],
769
+ cwe_ids=[c.upper() for c in classification.get("cwe-id") or [] if re.match(r"(?i)^cwe-\d+$", c)],
770
+ kev={"listed": True} if {"kev", "vkev"} & set(tags) else None, affected=affected,
771
+ target={"url": item.get("matched-at") or item.get("host"), "host": host_of(str(item.get("host") or ""))},
772
+ evidence=[{"path": str(item.get("matched-at") or ""), "excerpt": core.clean_excerpt(", ".join(map(str, extracted))[:200])}] if extracted else [],
773
+ tool="nuclei", rule_id=str(item.get("template-id")),
774
+ remediation={"summary": core.clean_excerpt(info.get("remediation") or "See the references.", 300), "steps": [], "snippets": [],
775
+ "references": (info.get("reference") or [])[:5] if isinstance(info.get("reference"), list) else [], "cheat_sheet": None},
776
+ untrusted={"description": core.clean_excerpt(info.get("description") or "", 400)} if info.get("description") else None))
777
+ return findings
778
+
779
+
780
+ def web_scan(ctx: core.Context):
781
+ args = ctx.args
782
+ url = args.url if "://" in args.url else f"https://{args.url}"
783
+ host = host_of(url)
784
+ if require_ownership(ctx, host, False) != "verified":
785
+ raise core.PolicyError("web-scan needs a verified domain")
786
+ lock_dir = ctx.state.path("locks")
787
+ lock_dir.mkdir(parents=True, exist_ok=True)
788
+ lock = lock_dir / f"web-scan-{registrable(host)}.lock"
789
+ if lock.exists() and (core.utcnow().timestamp() - lock.stat().st_mtime) < 3600:
790
+ raise core.PolicyError(f"another web-scan of {registrable(host)} is running (lock {lock.name})")
791
+ lock.write_text(core.iso())
792
+ try:
793
+ tags = ["misconfig", "exposure", "ssl", "tech"]
794
+ with tempfile.TemporaryDirectory() as tmp:
795
+ fingerprint = {}
796
+ if core.resolve_tool("httpx", ctx.lock)[1] is not None:
797
+ out = Path(tmp) / "httpx.jsonl"
798
+ core.run_tool("httpx", ["-u", url, "-j", "-sc", "-title", "-server", "-td", "-cdn", "-tls-grab", "-fr", "-o", str(out)],
799
+ ctx.lock, timeout=300)
800
+ if out.is_file() and out.read_text().strip():
801
+ fingerprint = json.loads(out.read_text().splitlines()[0])
802
+ tech = " ".join(fingerprint.get("tech") or []).lower()
803
+ for needle, extra in (("wordpress", ["wordpress", "wp-plugin", "wp-theme"]), ("nginx", ["nginx"]), ("php", ["php"]),
804
+ ("next.js", ["nextjs"]), ("express", ["express", "nodejs"]), ("laravel", ["laravel"]), ("apache", ["apache"])):
805
+ if needle in tech:
806
+ tags += extra
807
+ if args.profile == "full-safe":
808
+ tags.append("tech")
809
+ jsonl = Path(tmp) / "nuclei.jsonl"
810
+ argv = ["-u", url, "-tags", ",".join(dict.fromkeys(tags)), "-etags", ",".join(SAFE_EXCLUDE_TAGS),
811
+ "-severity", "info,low,medium,high,critical" if args.profile == "full-safe" else "low,medium,high,critical",
812
+ "-rl", str(min(args.rate, 10)), "-c", "5", "-bs", "5", "-timeout", "10", "-retries", "1", "-mhe", "10", "-nc",
813
+ "-jle", str(jsonl)]
814
+ templates = ctx.state.path("db", "nuclei-templates")
815
+ if templates.is_dir():
816
+ argv += ["-t", str(templates)]
817
+ if args.templates_id:
818
+ argv += ["-id", args.templates_id]
819
+ proc = core.run_tool("nuclei", argv, ctx.lock, timeout=args.max_minutes * 60)
820
+ lines = jsonl.read_text(encoding="utf-8").splitlines() if jsonl.is_file() else []
821
+ if proc.returncode not in (0, 1) and not lines:
822
+ ctx.degrade("nuclei", core.clean_excerpt(proc.stderr.decode(errors="replace"), 200))
823
+ findings = nuclei_to_findings(ctx, lines)
824
+ ctx.run["tools"].append({"name": "nuclei", "version": ctx.lock["tools"]["nuclei"]["version"], "sha256": None})
825
+ ctx.run["inputs"].update({"url": url, "tags": tags, "fingerprint": {k: fingerprint.get(k) for k in ("tech", "webserver", "cdn_name", "status_code")}})
826
+ finally:
827
+ lock.unlink(missing_ok=True)
828
+ return ctx.finish(findings, covered=[f"url:{url}"] if not ctx.run["degraded"] else [])
829
+
830
+
831
+ # --------------------------------------------------------------------------- ZAP baseline (passive)
832
+
833
+
834
+ ZAP_IMAGE = "ghcr.io/zaproxy/zaproxy:stable"
835
+ ZAP_IGNORE = {"10020", "10021", "10035", "10038", "10063"} # headers already graded by `headers`
836
+
837
+
838
+ def zap_to_findings(ctx: core.Context, report: dict) -> list[dict]:
839
+ findings = []
840
+ for site in report.get("site") or []:
841
+ for alert in site.get("alerts") or []:
842
+ plugin = str(alert.get("pluginid"))
843
+ if plugin in ZAP_IGNORE:
844
+ continue
845
+ risk = {"3": "high", "2": "medium", "1": "low", "0": "info"}.get(str(alert.get("riskcode")), "info")
846
+ confidence = {"3": "confirmed", "4": "confirmed", "2": "likely", "1": "possible"}.get(str(alert.get("confidence")), "possible")
847
+ instances = alert.get("instances") or []
848
+ first = instances[0] if instances else {}
849
+ cwe = str(alert.get("cweid") or "")
850
+ findings.append(core.make_finding(
851
+ kind="misconfiguration", command="zap-baseline", primary_id=f"zap-{plugin}", site=ctx.site,
852
+ title=core.clean_excerpt(re.sub(r"<[^>]+>", "", alert.get("alert") or alert.get("name") or plugin), 200),
853
+ severity=risk, severity_source="tool", confidence=confidence, cwe_ids=[f"CWE-{cwe}"] if cwe.isdigit() and cwe != "0" else [],
854
+ target={"url": first.get("uri") or site.get("@name"), "host": host_of(site.get("@name") or "")},
855
+ evidence=[{"path": i.get("uri", ""), "excerpt": core.clean_excerpt(i.get("evidence") or "", 200)} for i in instances[:5]],
856
+ tool="zap-baseline", rule_id=plugin,
857
+ remediation={"summary": core.clean_excerpt(re.sub(r"<[^>]+>", " ", alert.get("solution") or ""), 300), "steps": [], "snippets": [],
858
+ "references": re.findall(r"https?://[^\s<]+", alert.get("reference") or "")[:4], "cheat_sheet": None}))
859
+ return findings
860
+
861
+
862
+ def zap_baseline(ctx: core.Context):
863
+ args = ctx.args
864
+ url = args.url if "://" in args.url else f"https://{args.url}"
865
+ if require_ownership(ctx, host_of(url), False) != "verified":
866
+ raise core.PolicyError("zap-baseline needs a verified domain")
867
+ docker = shutil.which("docker")
868
+ if not docker:
869
+ raise core.ToolError("zap-baseline needs Docker (it runs the official ZAP image, passive rules only)")
870
+ with tempfile.TemporaryDirectory() as tmp:
871
+ conf = Path(tmp) / "maggie-zap-rules.conf"
872
+ conf.write_text("".join(f"{rule}\tIGNORE\t(maggie headers covers this)\n" for rule in sorted(ZAP_IGNORE)))
873
+ os.chmod(tmp, 0o777)
874
+ argv = [docker, "run", "--rm", "-v", f"{tmp}:/zap/wrk:rw", args.image or ZAP_IMAGE, "zap-baseline.py", "-t", url, "-m", str(args.minutes),
875
+ "-J", "zap.json", "-I", "-c", "maggie-zap-rules.conf", "-z", "-silent"]
876
+ proc = subprocess.run(argv, capture_output=True, timeout=(args.minutes + 10) * 60, check=False)
877
+ report_path = Path(tmp) / "zap.json"
878
+ if not report_path.is_file():
879
+ ctx.degrade("zap", core.clean_excerpt(proc.stderr.decode(errors="replace"), 200))
880
+ return ctx.finish([])
881
+ findings = zap_to_findings(ctx, json.loads(report_path.read_text()))
882
+ ctx.run["tools"].append({"name": "zap-baseline", "version": args.image or ZAP_IMAGE, "sha256": None})
883
+ return ctx.finish(findings, covered=[f"url:{url}"])
884
+
885
+
886
+ # --------------------------------------------------------------------------- registration
887
+
888
+
889
+ def _dv(p: argparse.ArgumentParser) -> None:
890
+ p.add_argument("action", choices=["start", "check", "status", "revoke"])
891
+ p.add_argument("--domain")
892
+ p.add_argument("--method", choices=["dns", "file"], default="dns")
893
+ p.add_argument("--rotate", action="store_true")
894
+
895
+
896
+ def _headers(p: argparse.ArgumentParser) -> None:
897
+ p.add_argument("--url", required=True)
898
+ p.add_argument("--paths", nargs="*", help="paths to check (default / plus config headers.paths; at most 5)")
899
+ p.add_argument("--serving", choices=["auto", "nginx", "apache", "cloudflare", "headers-file", "astro", "next", "php"], default="auto")
900
+ p.add_argument("--public-check", action="store_true", help="one GET on an unverified domain, labelled unverified (interactive only)")
901
+
902
+
903
+ def _tls(p: argparse.ArgumentParser) -> None:
904
+ p.add_argument("--host", required=True)
905
+ p.add_argument("--port", type=int, default=443)
906
+ p.add_argument("--ip", help="origin IP to test behind a CDN (verified domain only)")
907
+ p.add_argument("--mode", choices=["quick", "full"], default="quick")
908
+ p.add_argument("--engine", choices=["auto", "testssl", "native"], default="auto")
909
+ p.add_argument("--public-check", action="store_true")
910
+
911
+
912
+ def _scan(p: argparse.ArgumentParser) -> None:
913
+ p.add_argument("--url", required=True)
914
+ p.add_argument("--profile", choices=["safe", "full-safe"], default="safe")
915
+ p.add_argument("--templates-id", help="run one template id (e.g. after a new CVE template)")
916
+ p.add_argument("--rate", type=int, default=10, help="requests per second (max 10)")
917
+ p.add_argument("--max-minutes", type=int, default=15)
918
+
919
+
920
+ def _zap(p: argparse.ArgumentParser) -> None:
921
+ p.add_argument("--url", required=True)
922
+ p.add_argument("--minutes", type=int, default=5)
923
+ p.add_argument("--image", help="pinned ZAP image reference (default ghcr.io/zaproxy/zaproxy:stable)")
924
+
925
+
926
+ COMMANDS = [
927
+ core.Command("domain-verify", "prove you control a domain (DNS TXT or a token file) before any active check", ["doh", "dns", "target-site"], _dv,
928
+ domain_verify, findings=False),
929
+ core.Command("headers", "grade HTTP security headers and cookies per path, with fix snippets for your server", ["target-site", "doh", "dns"],
930
+ _headers, headers_cmd),
931
+ core.Command("tls", "TLS protocols, certificate expiry and trust (testssl.sh when installed)", ["target-site", "doh", "dns"], _tls, tls_cmd),
932
+ core.Command("web-scan", "safe nuclei scan of your verified site (no fuzzing, brute force or OAST; 10 req/s)", ["target-site", "doh", "dns"],
933
+ _scan, web_scan),
934
+ core.Command("zap-baseline", "weekly passive ZAP baseline scan of your verified site (Docker)", ["target-site", "doh", "dns"], _zap, zap_baseline,
935
+ routine_safe=False),
936
+ ]