@topy-ai/maggie 0.7.55 → 0.7.57

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 +7 -3
  2. package/README.md +42 -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 +173 -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 +125 -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 +654 -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 +484 -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 +1247 -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,1247 @@
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 time
34
+ import urllib.error
35
+ import urllib.parse
36
+ import urllib.request
37
+ from pathlib import Path
38
+
39
+ import maggie_security_core as core
40
+
41
+ core.SOURCES.setdefault("doh", {"hosts": ["cloudflare-dns.com"], "ttl": "0s", "url": "https://cloudflare-dns.com/dns-query"})
42
+ OWNERSHIP_DAYS = 90
43
+ RECHECK_DAYS = 7
44
+ TXT_NAME = "_maggie-security"
45
+ WELL_KNOWN = "/.well-known/maggie-security-verification.txt"
46
+ _PSL: set[str] | None = None
47
+
48
+
49
+ # --------------------------------------------------------------------------- domains and ownership
50
+
51
+
52
+ def public_suffixes() -> set[str]:
53
+ global _PSL
54
+ if _PSL is None:
55
+ text = (core.DATA_DIR / "public-suffix-icann.txt").read_text(encoding="utf-8")
56
+ _PSL = {line.strip().lower() for line in text.splitlines() if line.strip() and not line.startswith("#")}
57
+ return _PSL
58
+
59
+
60
+ def is_ip(host: str) -> bool:
61
+ try:
62
+ ipaddress.ip_address(host.strip("[]"))
63
+ return True
64
+ except ValueError:
65
+ return False
66
+
67
+
68
+ def registrable(host: str) -> str:
69
+ host = host.strip().lower().rstrip(".")
70
+ if is_ip(host) or "." not in host:
71
+ return host
72
+ labels = host.split(".")
73
+ suffixes = public_suffixes()
74
+ for i in range(len(labels)):
75
+ candidate = ".".join(labels[i:])
76
+ if candidate in suffixes:
77
+ return ".".join(labels[max(i - 1, 0):]) if i > 0 else host
78
+ return ".".join(labels[-2:])
79
+
80
+
81
+ def host_of(url_or_host: str) -> str:
82
+ if "://" in url_or_host:
83
+ return (urllib.parse.urlsplit(url_or_host).hostname or "").lower()
84
+ return url_or_host.split("/")[0].split(":")[0].lower()
85
+
86
+
87
+ def ownership_path(state: core.State, domain: str) -> Path:
88
+ return state.path("ownership", f"{registrable(domain)}.json")
89
+
90
+
91
+ def system_resolvers() -> list[str]:
92
+ servers = []
93
+ try:
94
+ for line in Path("/etc/resolv.conf").read_text().splitlines():
95
+ parts = line.split()
96
+ if len(parts) >= 2 and parts[0] == "nameserver":
97
+ servers.append(parts[1])
98
+ except OSError:
99
+ pass
100
+ return servers or ["127.0.0.53"]
101
+
102
+
103
+ def dns_txt_udp(name: str, server: str, timeout: float = 5.0, port: int = 53) -> list[str]:
104
+ """Minimal DNS TXT query over UDP (stdlib only)."""
105
+ qid = random.randint(0, 0xFFFF)
106
+ question = b"".join(bytes([len(label)]) + label.encode("idna") for label in name.rstrip(".").split(".")) + b"\x00"
107
+ packet = struct.pack(">HHHHHH", qid, 0x0100, 1, 0, 0, 0) + question + struct.pack(">HH", 16, 1)
108
+ family = socket.AF_INET6 if ":" in server else socket.AF_INET
109
+ with socket.socket(family, socket.SOCK_DGRAM) as sock:
110
+ sock.settimeout(timeout)
111
+ sock.sendto(packet, (server, port))
112
+ data, _ = sock.recvfrom(4096)
113
+ rid, flags, qd, an, _ns, _ar = struct.unpack(">HHHHHH", data[:12])
114
+ if rid != qid or flags & 0x000F not in (0, 3):
115
+ return []
116
+ offset = 12
117
+
118
+ def skip_name(pos: int) -> int:
119
+ while True:
120
+ length = data[pos]
121
+ if length == 0:
122
+ return pos + 1
123
+ if length & 0xC0 == 0xC0:
124
+ return pos + 2
125
+ pos += length + 1
126
+
127
+ for _ in range(qd):
128
+ offset = skip_name(offset) + 4
129
+ out = []
130
+ for _ in range(an):
131
+ offset = skip_name(offset)
132
+ rtype, _rclass, _ttl, rdlength = struct.unpack(">HHIH", data[offset:offset + 10])
133
+ offset += 10
134
+ rdata = data[offset:offset + rdlength]
135
+ offset += rdlength
136
+ if rtype == 16:
137
+ parts, pos = [], 0
138
+ while pos < len(rdata):
139
+ length = rdata[pos]
140
+ parts.append(rdata[pos + 1:pos + 1 + length].decode("utf-8", errors="replace"))
141
+ pos += 1 + length
142
+ out.append("".join(parts))
143
+ return out
144
+
145
+
146
+ def dns_txt_doh(ctx: core.Context, name: str) -> list[str] | None:
147
+ url = f"https://cloudflare-dns.com/dns-query?name={urllib.parse.quote(name)}&type=TXT"
148
+ ctx.guard.check("doh", url)
149
+ try:
150
+ status, _h, body = core.http_request(url, headers={"Accept": "application/dns-json"}, timeout=10, retries=1)
151
+ except core.DegradedError:
152
+ return None
153
+ if status != 200:
154
+ return None
155
+ data = json.loads(body.decode() or "{}")
156
+ return [a.get("data", "").strip('"').replace('" "', "") for a in data.get("Answer") or [] if a.get("type") == 16]
157
+
158
+
159
+ def token_hash(token: str) -> str:
160
+ return hashlib.sha256(token.strip().encode()).hexdigest()
161
+
162
+
163
+ def check_record(ctx: core.Context, record: dict, host_hint: str | None = None) -> tuple[bool, dict]:
164
+ """Re-run the record's method; returns (ok, evidence)."""
165
+ domain = record["domain"]
166
+ if record["method"] == "dns":
167
+ name = f"{TXT_NAME}.{domain}"
168
+ seen: dict[str, list[str]] = {}
169
+ try:
170
+ seen["system"] = dns_txt_udp(name, system_resolvers()[0])
171
+ except OSError as error:
172
+ seen["system_error"] = [str(error)]
173
+ doh = dns_txt_doh(ctx, name)
174
+ if doh is not None:
175
+ seen["doh"] = doh
176
+ want = record["token_sha256"]
177
+ 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
178
+ ok_system = hit(seen.get("system", []))
179
+ ok_doh = hit(seen.get("doh", []))
180
+ return ok_system and ok_doh, {"txt_seen_by": [k for k, ok in (("system", ok_system), ("doh", ok_doh)) if ok],
181
+ "resolvers_answered": [k for k in ("system", "doh") if k in seen]}
182
+ host = record.get("host") or host_hint or domain
183
+ url = f"https://{host}{WELL_KNOWN}"
184
+ try:
185
+ response = fetch(url, follow=False, timeout=10)
186
+ except OSError as error:
187
+ return False, {"http_error": str(error)[:200]}
188
+ body = response["body"][:200].decode("utf-8", errors="replace").strip()
189
+ ok = response["status"] == 200 and token_hash(body) == record["token_sha256"]
190
+ return ok, {"http_status": response["status"]}
191
+
192
+
193
+ def domain_verify(ctx: core.Context) -> dict:
194
+ args = ctx.args
195
+ action = args.action
196
+ if action == "status":
197
+ directory = ctx.state.path("ownership")
198
+ records = [core.read_json(p) for p in sorted(directory.glob("*.json"))] if directory.is_dir() else []
199
+ return {"schema": "maggie-security-ownership-status.v1", "records": [ownership_summary(r) for r in records]}
200
+ if not args.domain:
201
+ raise core.UsageError("--domain is required")
202
+ domain = registrable(host_of(args.domain)) if args.method != "file" else host_of(args.domain)
203
+ path = ownership_path(ctx.state, domain)
204
+ record = core.read_json(path) if path.is_file() else None
205
+ if action == "revoke":
206
+ if path.is_file():
207
+ path.unlink()
208
+ return {"revoked": domain}
209
+ if action == "start":
210
+ if record and record.get("verified_at") and not args.rotate:
211
+ return {"schema": "maggie-security-ownership-start.v1", "domain": domain, "already": ownership_summary(record),
212
+ "note": "already verified; use --rotate for a new token"}
213
+ token = base64.b32encode(secrets.token_bytes(32)).decode().rstrip("=").lower()
214
+ record = {"schema": "maggie-security-ownership.v1", "domain": registrable(domain) if args.method == "dns" else domain,
215
+ "host": None if args.method == "dns" else domain, "scope": "registrable-domain" if args.method == "dns" else "host",
216
+ "method": args.method, "token_sha256": token_hash(token), "created_at": core.iso(), "verified_at": None,
217
+ "expires_at": None, "last_checked_at": None, "evidence": {}}
218
+ core.write_json(path, record)
219
+ how = (f"Add a DNS TXT record: name `{TXT_NAME}.{record['domain']}`, value `maggie-site-verification={token}`"
220
+ if args.method == "dns" else
221
+ f"Serve `https://{domain}{WELL_KNOWN}` with exactly this text (Content-Type text/plain): {token}")
222
+ return {"schema": "maggie-security-ownership-start.v1", "domain": record["domain"], "method": args.method, "token": token,
223
+ "instructions": how, "next": f"maggie security domain-verify check --domain {record['domain']}",
224
+ "note": "The token is shown once; Maggie stores only its hash."}
225
+ if not record:
226
+ raise core.UsageError(f"no verification started for {domain}; run domain-verify start first")
227
+ ok, evidence = check_record(ctx, record)
228
+ record["last_checked_at"] = core.iso()
229
+ record["evidence"] = evidence
230
+ if ok:
231
+ record["verified_at"] = record.get("verified_at") if record.get("verified_at") and not record.get("stale") else core.iso()
232
+ record["expires_at"] = core.iso(core.parse_time(record["verified_at"]) + dt.timedelta(days=OWNERSHIP_DAYS))
233
+ record.pop("stale", None)
234
+ core.write_json(path, record)
235
+ result = {"schema": "maggie-security-ownership-check.v1", **ownership_summary(record), "evidence": evidence}
236
+ if not ok:
237
+ if record["method"] == "dns" and not evidence.get("resolvers_answered"):
238
+ result["exit_code"] = core.EXIT_DEGRADED
239
+ else:
240
+ result["exit_code"] = core.EXIT_POLICY
241
+ result["note"] = "not verified yet: DNS changes can take a while; both the system resolver and the public resolver must see the record"
242
+ return result
243
+
244
+
245
+ def ownership_summary(record: dict) -> dict:
246
+ status = "pending"
247
+ if record.get("verified_at"):
248
+ expired = record.get("expires_at") and core.parse_time(record["expires_at"]) < core.utcnow()
249
+ status = "expired" if expired else ("stale" if record.get("stale") else "verified")
250
+ return {"domain": record["domain"], "method": record["method"], "scope": record.get("scope"), "status": status,
251
+ "verified_at": record.get("verified_at"), "expires_at": record.get("expires_at"), "last_checked_at": record.get("last_checked_at")}
252
+
253
+
254
+ def require_ownership(ctx: core.Context, host: str, public_check: bool = False) -> str:
255
+ """'verified' or 'public-check'; raises PolicyError otherwise (PRD 00 section 13.2)."""
256
+ path = ownership_path(ctx.state, host)
257
+ record = core.read_json(path) if path.is_file() else None
258
+ if record is None and not path.is_file():
259
+ exact = ctx.state.path("ownership", f"{host}.json")
260
+ record = core.read_json(exact) if exact.is_file() else None
261
+ if record and record.get("scope") == "host" and record.get("host") not in (host, None):
262
+ record = None
263
+ if record and ownership_summary(record)["status"] == "verified":
264
+ last = core.parse_time(record.get("last_checked_at") or record["verified_at"])
265
+ if core.utcnow() - last > dt.timedelta(days=RECHECK_DAYS) and record["method"] in ("dns", "file"):
266
+ ok, evidence = check_record(ctx, record, host)
267
+ record["last_checked_at"], record["evidence"] = core.iso(), evidence
268
+ if not ok:
269
+ record["stale"] = True
270
+ core.write_json(ownership_path(ctx.state, host) if record.get("scope") != "host" else ctx.state.path("ownership", f"{host}.json"), record)
271
+ if not ok:
272
+ raise core.PolicyError(f"ownership of {record['domain']} could not be re-confirmed (the TXT record or file is gone)")
273
+ return "verified"
274
+ if public_check:
275
+ if getattr(ctx.args, "ci", False) or os.environ.get("MAGGIE_ROUTINE"):
276
+ raise core.PolicyError("--public-check is for a person checking their own site interactively, not for routines or CI")
277
+ return "public-check"
278
+ raise core.PolicyError(f"{registrable(host)} is not verified; run: maggie security domain-verify start --domain {registrable(host)}")
279
+
280
+
281
+ # --------------------------------------------------------------------------- HTTP
282
+
283
+
284
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
285
+ def redirect_request(self, *args, **kwargs): # noqa: D401
286
+ return None
287
+
288
+
289
+ def fetch(url: str, follow: bool = True, timeout: float = 15.0, max_hops: int = 5, method: str = "GET") -> dict:
290
+ """GET with manual redirects that stay within the registrable domain; returns status, headers (lists), body, chain."""
291
+ opener = urllib.request.build_opener(NoRedirect, urllib.request.HTTPSHandler(context=ssl.create_default_context()))
292
+ chain = []
293
+ current = url
294
+ start_domain = registrable(host_of(url))
295
+ for _ in range(max_hops + 1):
296
+ request = urllib.request.Request(current, method=method, headers={"User-Agent": core.USER_AGENT, "Accept": "text/html,*/*"})
297
+ try:
298
+ response = opener.open(request, timeout=timeout)
299
+ status, headers, body = response.status, response.headers, response.read(512 * 1024)
300
+ except urllib.error.HTTPError as error:
301
+ status, headers, body = error.code, error.headers, error.read(64 * 1024) if error.fp else b""
302
+ multi: dict[str, list[str]] = {}
303
+ for key, value in headers.items():
304
+ multi.setdefault(key.lower(), []).append(value)
305
+ chain.append({"url": current, "status": status})
306
+ location = (multi.get("location") or [None])[0]
307
+ if follow and status in (301, 302, 303, 307, 308) and location:
308
+ nxt = urllib.parse.urljoin(current, location)
309
+ if registrable(host_of(nxt)) != start_domain:
310
+ return {"status": status, "headers": multi, "body": body, "chain": chain, "url": current, "left_domain": nxt}
311
+ current = nxt
312
+ continue
313
+ return {"status": status, "headers": multi, "body": body, "chain": chain, "url": current}
314
+ return {"status": status, "headers": multi, "body": body, "chain": chain, "url": current, "too_many_redirects": True}
315
+
316
+
317
+ # --------------------------------------------------------------------------- headers
318
+
319
+
320
+ SNIPPETS = {
321
+ "nginx": {
322
+ "strict-transport-security": 'add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;',
323
+ "x-content-type-options": 'add_header X-Content-Type-Options "nosniff" always;',
324
+ "referrer-policy": 'add_header Referrer-Policy "strict-origin-when-cross-origin" always;',
325
+ "x-frame-options": 'add_header X-Frame-Options "SAMEORIGIN" always;',
326
+ "permissions-policy": 'add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;',
327
+ "content-security-policy": 'add_header Content-Security-Policy-Report-Only "{csp}" always;',
328
+ "server": "server_tokens off; # and remove X-Powered-By in the app (PHP: expose_php = Off)",
329
+ },
330
+ "apache": {
331
+ "strict-transport-security": 'Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"',
332
+ "x-content-type-options": 'Header always set X-Content-Type-Options "nosniff"',
333
+ "referrer-policy": 'Header always set Referrer-Policy "strict-origin-when-cross-origin"',
334
+ "x-frame-options": 'Header always set X-Frame-Options "SAMEORIGIN"',
335
+ "permissions-policy": 'Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"',
336
+ "content-security-policy": 'Header always set Content-Security-Policy-Report-Only "{csp}"',
337
+ "server": "ServerTokens Prod\nServerSignature Off\nHeader unset X-Powered-By",
338
+ },
339
+ "headers-file": { # Cloudflare Pages / Workers static assets / Netlify: public/_headers
340
+ "_template": "/*\n {name}: {value}",
341
+ },
342
+ "astro": {"_template": 'res.headers.set("{name}", "{value}"); // in src/middleware.ts onRequest, after `const res = await next();`'},
343
+ "next": {"_template": "{{ key: '{name}', value: '{value}' }}, // next.config.js headers() → source: '/(.*)'"},
344
+ "php": {"_template": 'header("{name}: {value}"); // WordPress: in wp-content/mu-plugins/maggie-security-headers.php on send_headers'},
345
+ "cloudflare": {"_template": "Rules → Transform Rules → Modify Response Header → Set static: {name} = {value}"},
346
+ }
347
+ RECOMMENDED = {
348
+ "strict-transport-security": "max-age=31536000; includeSubDomains",
349
+ "x-content-type-options": "nosniff",
350
+ "referrer-policy": "strict-origin-when-cross-origin",
351
+ "x-frame-options": "SAMEORIGIN",
352
+ "permissions-policy": "camera=(), microphone=(), geolocation=()",
353
+ }
354
+ DISPLAY = {"strict-transport-security": "Strict-Transport-Security", "x-content-type-options": "X-Content-Type-Options",
355
+ "referrer-policy": "Referrer-Policy", "x-frame-options": "X-Frame-Options", "permissions-policy": "Permissions-Policy",
356
+ "content-security-policy": "Content-Security-Policy"}
357
+
358
+
359
+ def detect_serving(project: Path, headers: dict[str, list[str]], requested: str) -> str:
360
+ if requested != "auto":
361
+ return requested
362
+ server = " ".join(headers.get("server", [])).lower()
363
+ powered = " ".join(headers.get("x-powered-by", [])).lower()
364
+ if "cf-ray" in headers and any((project / n).is_file() for n in ("wrangler.toml", "wrangler.json", "wrangler.jsonc")):
365
+ return "headers-file"
366
+ if "next.js" in powered or any(k.startswith("x-nextjs") for k in headers):
367
+ return "next"
368
+ if any(project.glob("astro.config.*")):
369
+ return "astro"
370
+ if (project / "wp-config.php").is_file() or "php" in powered:
371
+ return "php"
372
+ if "nginx" in server:
373
+ return "nginx"
374
+ if "apache" in server:
375
+ return "apache"
376
+ if "cf-ray" in headers or "cloudflare" in server:
377
+ return "cloudflare"
378
+ return "nginx"
379
+
380
+
381
+ def snippet(layer: str, header: str, value: str) -> dict:
382
+ table = SNIPPETS.get(layer, SNIPPETS["nginx"])
383
+ # A new CSP always starts in report-only mode (PRD 04 section 7.4).
384
+ name = "Content-Security-Policy-Report-Only" if header == "content-security-policy" else DISPLAY.get(header, header)
385
+ text = table.get(header) or table.get("_template", SNIPPETS["nginx"].get(header, "")).format(name=name, value=value, csp=value)
386
+ if "{csp}" in text:
387
+ text = text.replace("{csp}", value)
388
+ return {"kind": layer, "text": text}
389
+
390
+
391
+ class AssetOrigins(html.parser.HTMLParser):
392
+ """Collect script/style/img/connect origins from a page, to start a CSP in report-only mode."""
393
+
394
+ def __init__(self) -> None:
395
+ super().__init__()
396
+ self.origins: dict[str, set[str]] = {"script-src": set(), "style-src": set(), "img-src": set(), "font-src": set(), "frame-src": set()}
397
+ self.inline_script = False
398
+
399
+ def handle_starttag(self, tag, attrs):
400
+ attrs = dict(attrs)
401
+ mapping = {"script": ("script-src", "src"), "img": ("img-src", "src"), "iframe": ("frame-src", "src")}
402
+ if tag == "link" and "stylesheet" in (attrs.get("rel") or ""):
403
+ self._add("style-src", attrs.get("href"))
404
+ elif tag in mapping:
405
+ directive, attr = mapping[tag]
406
+ if attrs.get(attr):
407
+ self._add(directive, attrs[attr])
408
+ elif tag == "script":
409
+ self.inline_script = True
410
+
411
+ def _add(self, directive: str, url: str | None) -> None:
412
+ if not url or url.startswith(("data:", "/", "./", "../")) or "://" not in url:
413
+ return
414
+ parts = urllib.parse.urlsplit(url)
415
+ self.origins[directive].add(f"{parts.scheme}://{parts.netloc}")
416
+
417
+
418
+ def starter_csp(body: bytes) -> str:
419
+ parser = AssetOrigins()
420
+ try:
421
+ parser.feed(body.decode("utf-8", errors="replace")[:500_000])
422
+ except Exception: # noqa: BLE001 - malformed HTML only means a weaker starting policy
423
+ pass
424
+ parts = ["default-src 'self'"]
425
+ for directive, origins in parser.origins.items():
426
+ if origins:
427
+ parts.append(f"{directive} 'self' {' '.join(sorted(origins))}")
428
+ parts += ["object-src 'none'", "base-uri 'self'", "frame-ancestors 'self'"]
429
+ return "; ".join(parts)
430
+
431
+
432
+ def csp_problems(value: str) -> list[str]:
433
+ directives: dict[str, list[str]] = {}
434
+ for part in value.split(";"):
435
+ tokens = part.strip().split()
436
+ if tokens:
437
+ directives.setdefault(tokens[0].lower(), [t.lower() for t in tokens[1:]])
438
+ script = directives.get("script-src") or directives.get("default-src") or ["*"]
439
+ has_nonce = any(t.startswith(("'nonce-", "'sha256-", "'sha384-", "'sha512-")) for t in script)
440
+ problems = []
441
+ if "'unsafe-inline'" in script and not has_nonce and "'strict-dynamic'" not in script:
442
+ problems.append("allows inline scripts ('unsafe-inline' without a nonce or hash)")
443
+ if "'unsafe-eval'" in script:
444
+ problems.append("allows eval ('unsafe-eval')")
445
+ if any(t in ("*", "https:", "http:", "data:") for t in script):
446
+ problems.append("lets scripts load from anywhere (*, https: or data:)")
447
+ if not (directives.get("object-src") == ["'none'"] or directives.get("default-src") == ["'none'"]):
448
+ problems.append("does not set object-src 'none'")
449
+ if "base-uri" not in directives:
450
+ problems.append("does not set base-uri")
451
+ if "frame-ancestors" not in directives:
452
+ problems.append("does not set frame-ancestors (clickjacking)")
453
+ return problems
454
+
455
+
456
+ def grade_response(ctx: core.Context, url: str, response: dict, layer: str, https: bool) -> list[dict]:
457
+ headers = response["headers"]
458
+ first = lambda name: (headers.get(name) or [None])[0] # noqa: E731
459
+ findings = []
460
+ host = host_of(url)
461
+
462
+ def add(rule: str, title: str, severity: str, asvs: list[str], header: str | None = None, value: str | None = None,
463
+ summary: str = "", kind: str = "header", evidence: str | None = None) -> None:
464
+ snippets = [snippet(layer, header, value)] if header and value else []
465
+ findings.append(core.make_finding(
466
+ kind=kind, command="headers", primary_id=f"header-{rule}", site=ctx.site, title=title, severity=severity, severity_source="maggie",
467
+ confidence="confirmed", asvs_ids=asvs, target={"url": url, "host": host},
468
+ evidence=[{"path": url, "excerpt": evidence}] if evidence else [],
469
+ remediation={"summary": summary or title, "steps": [], "snippets": snippets,
470
+ "references": ["https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html"], "cheat_sheet": "HTTP Headers"}))
471
+
472
+ hsts = first("strict-transport-security")
473
+ if https:
474
+ if not hsts:
475
+ add("hsts-missing", "No Strict-Transport-Security header: browsers may still load the site over plain HTTP", "high", ["V3.4.1"],
476
+ "strict-transport-security", RECOMMENDED["strict-transport-security"], "Add HSTS so browsers only use HTTPS.")
477
+ else:
478
+ match = re.search(r"max-age\s*=\s*\"?(\d+)", hsts, re.I)
479
+ age = int(match.group(1)) if match else 0
480
+ if age < 15_552_000:
481
+ add("hsts-short", f"HSTS max-age is {age} seconds (under six months)", "medium", ["V3.4.1"], "strict-transport-security",
482
+ RECOMMENDED["strict-transport-security"], "Raise max-age to at least 15552000 (one year: 31536000).", evidence=hsts)
483
+ csp = first("content-security-policy")
484
+ csp_ro = first("content-security-policy-report-only")
485
+ if not csp:
486
+ starter = starter_csp(response["body"])
487
+ add("csp-missing", "No Content-Security-Policy header" + (" (a report-only policy is set)" if csp_ro else ""), "medium", ["V3.4.3"],
488
+ "content-security-policy", starter, "Start with this policy in report-only mode for a week, then switch to enforcing.")
489
+ else:
490
+ for problem in csp_problems(csp):
491
+ add(f"csp-{re.sub(r'[^a-z]+', '-', problem.split('(')[0].lower()).strip('-')[:40]}", f"Content-Security-Policy {problem}", "medium",
492
+ ["V3.4.3"], evidence=csp[:200], summary="Tighten the policy; use nonces or hashes for inline scripts.")
493
+ xcto = first("x-content-type-options")
494
+ if not xcto or xcto.strip().lower() != "nosniff":
495
+ add("xcto", "X-Content-Type-Options is not nosniff", "medium", ["V3.4.4"], "x-content-type-options", "nosniff", evidence=xcto)
496
+ xfo = first("x-frame-options")
497
+ if not xfo and not (csp and "frame-ancestors" in csp.lower()):
498
+ add("framing", "The site can be framed by other sites (no X-Frame-Options or frame-ancestors)", "medium", ["V3.4.6"],
499
+ "x-frame-options", RECOMMENDED["x-frame-options"])
500
+ elif xfo and "allow-from" in xfo.lower():
501
+ add("xfo-allow-from", "X-Frame-Options ALLOW-FROM is ignored by browsers", "medium", ["V3.4.6"], "x-frame-options", "SAMEORIGIN", evidence=xfo)
502
+ referrer = first("referrer-policy")
503
+ if not referrer:
504
+ add("referrer-missing", "No Referrer-Policy header", "medium", ["V3.4.5"], "referrer-policy", RECOMMENDED["referrer-policy"])
505
+ elif referrer.strip().lower() in ("unsafe-url", "no-referrer-when-downgrade"):
506
+ add("referrer-weak", f"Referrer-Policy {referrer} leaks full URLs to other sites", "medium", ["V3.4.5"], "referrer-policy",
507
+ RECOMMENDED["referrer-policy"], evidence=referrer)
508
+ if not first("permissions-policy"):
509
+ add("permissions-missing", "No Permissions-Policy header", "info", [], "permissions-policy", RECOMMENDED["permissions-policy"])
510
+ if not first("cross-origin-opener-policy"):
511
+ add("coop-missing", "No Cross-Origin-Opener-Policy header", "info", ["V3.4.8"])
512
+ for name in ("x-xss-protection", "expect-ct", "public-key-pins"):
513
+ if first(name):
514
+ add(f"deprecated-{name}", f"{name} is deprecated; remove it", "info", [], evidence=first(name))
515
+ for name in ("server", "x-powered-by", "x-aspnet-version"):
516
+ value = first(name)
517
+ if value and re.search(r"\d+\.\d+", value):
518
+ add(f"version-{name}", f"{name} header discloses a version ({value[:60]})", "low", ["V13.4.6"], "server", "off", evidence=value)
519
+ for cookie in headers.get("set-cookie") or []:
520
+ name = cookie.split("=", 1)[0].strip()
521
+ flags = cookie.lower()
522
+ sensitive = re.search(r"(?i)(sess|auth|token|login|sid|jwt|remember|wordpress_logged_in|wp-settings)", name)
523
+ missing = [flag for flag, present in (("Secure", "secure" in flags), ("HttpOnly", "httponly" in flags), ("SameSite", "samesite" in flags))
524
+ if not present]
525
+ if missing and (sensitive or "Secure" in missing and https):
526
+ add(f"cookie-{name.lower()[:30]}", f"Cookie {name} is missing {', '.join(missing)}", "medium" if sensitive else "low",
527
+ ["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],
528
+ summary="Set Secure, HttpOnly and SameSite=Lax (or Strict) on session cookies.")
529
+ origin = first("access-control-allow-origin")
530
+ if origin and origin.strip() == "*" and (first("access-control-allow-credentials") or "").lower() == "true":
531
+ add("cors-wildcard-credentials", "CORS allows any origin with credentials", "high", ["V3.4.2"], kind="misconfiguration",
532
+ evidence="Access-Control-Allow-Origin: *; Allow-Credentials: true")
533
+ return findings
534
+
535
+
536
+ def headers_cmd(ctx: core.Context):
537
+ args = ctx.args
538
+ url = args.url if "://" in (args.url or "") else f"https://{args.url}"
539
+ host = host_of(url)
540
+ mode = require_ownership(ctx, host, args.public_check)
541
+ paths = args.paths or (ctx.state.config().get("headers") or {}).get("paths") or ["/"]
542
+ findings: list[dict] = []
543
+ https = url.startswith("https://")
544
+ layer = "nginx"
545
+ for path in paths[:5]:
546
+ target = urllib.parse.urljoin(url, path)
547
+ try:
548
+ response = fetch(target)
549
+ except (OSError, ssl.SSLError) as error:
550
+ ctx.degrade("target-site", f"{target}: {str(error)[:200]}")
551
+ continue
552
+ if response.get("left_domain"):
553
+ ctx.run["inputs"].setdefault("notes", []).append(f"{target} redirects to another domain ({response['left_domain']}); not followed")
554
+ continue
555
+ layer = detect_serving(ctx.project, response["headers"], args.serving)
556
+ findings += grade_response(ctx, response["url"], response, layer, response["url"].startswith("https://"))
557
+ if mode == "public-check":
558
+ break
559
+ if https and mode == "verified":
560
+ try:
561
+ plain = fetch(f"http://{host}/", follow=False)
562
+ location = (plain["headers"].get("location") or [""])[0]
563
+ if plain["status"] not in (301, 302, 307, 308) or not location.startswith("https://"):
564
+ findings.append(core.make_finding(kind="misconfiguration", command="headers", primary_id="http-no-redirect", site=ctx.site,
565
+ title="Plain HTTP does not redirect to HTTPS", severity="medium", severity_source="maggie",
566
+ confidence="confirmed", target={"url": f"http://{host}/", "host": host}, asvs_ids=["V12.2.1"],
567
+ remediation={"summary": "Redirect every http:// request to https:// with a 301.", "steps": [],
568
+ "snippets": [{"kind": "nginx", "text": "server { listen 80; server_name _; return 301 https://$host$request_uri; }"}],
569
+ "references": [], "cheat_sheet": None}))
570
+ except OSError:
571
+ pass
572
+ ctx.run["inputs"].update({"url": url, "paths": paths[:5], "serving": layer, "ownership": mode})
573
+ if mode == "public-check":
574
+ ctx.run["inputs"]["label"] = "unverified domain (public check)"
575
+ ctx.args.no_ledger = True
576
+ return ctx.finish(findings, covered=[core.finding_locator(None, {"url": f["target"]["url"]}) for f in findings] or [f"url:{url}"])
577
+
578
+
579
+ # --------------------------------------------------------------------------- TLS
580
+
581
+
582
+ TESTSSL_SEVERITY = {"CRITICAL": "critical", "HIGH": "high", "MEDIUM": "medium", "LOW": "low", "WARN": "info"}
583
+
584
+
585
+ def testssl_to_findings(ctx: core.Context, items: list[dict], host: str, port: int) -> list[dict]:
586
+ findings = []
587
+ for item in items:
588
+ severity = TESTSSL_SEVERITY.get(str(item.get("severity", "")).upper())
589
+ if not severity:
590
+ continue
591
+ if severity == "info" and str(item.get("severity", "")).upper() == "WARN":
592
+ ctx.run["inputs"].setdefault("testssl_warnings", []).append(item.get("id"))
593
+ cves = [c for c in str(item.get("cve") or "").split() if c.upper().startswith("CVE-")]
594
+ cwes = [c for c in str(item.get("cwe") or "").split() if re.match(r"^CWE-\d+$", c)]
595
+ rule = str(item.get("id") or "tls")
596
+ asvs = ["V12.2.2"] if rule.startswith("cert") else ["V12.1.2"] if rule.lower().startswith(("cipher", "rc4", "3des", "sweet32")) else ["V12.1.1"]
597
+ findings.append(core.make_finding(
598
+ kind="tls", command="tls", primary_id=f"tls-{rule}", site=ctx.site, title=f"TLS {rule}: {core.clean_excerpt(item.get('finding') or '', 150)}",
599
+ severity=severity, severity_source="tool", confidence="confirmed", aliases=cves, cwe_ids=cwes, asvs_ids=asvs,
600
+ target={"host": host, "url": f"https://{host}:{port}"}, tool="testssl.sh", rule_id=rule,
601
+ remediation={"summary": "Adjust the server's TLS settings (protocols, ciphers, certificate).", "steps": [], "snippets": [],
602
+ "references": ["https://ssl-config.mozilla.org/"], "cheat_sheet": "Transport Layer Security"}))
603
+ return findings
604
+
605
+
606
+ def native_tls(ctx: core.Context, host: str, port: int, ip: str | None) -> list[dict]:
607
+ findings = []
608
+ address = ip or host
609
+
610
+ def add(rule: str, title: str, severity: str, summary: str, evidence: str | None = None) -> None:
611
+ asvs = ["V12.2.2"] if rule.startswith("cert") else ["V12.1.1"]
612
+ findings.append(core.make_finding(kind="tls", command="tls", primary_id=f"tls-{rule}", site=ctx.site, title=title, severity=severity,
613
+ severity_source="maggie", confidence="confirmed", asvs_ids=asvs,
614
+ target={"host": host, "url": f"https://{host}:{port}"}, tool="maggie-tls-native",
615
+ evidence=[{"path": f"{host}:{port}", "excerpt": evidence}] if evidence else [],
616
+ remediation={"summary": summary, "steps": [], "snippets": [],
617
+ "references": ["https://ssl-config.mozilla.org/"], "cheat_sheet": "Transport Layer Security"}))
618
+
619
+ context = ssl.create_default_context()
620
+ try:
621
+ with socket.create_connection((address, port), timeout=10) as raw, context.wrap_socket(raw, server_hostname=host) as conn:
622
+ cert = conn.getpeercert()
623
+ version = conn.version()
624
+ cipher = conn.cipher()
625
+ except ssl.SSLCertVerificationError as error:
626
+ add("cert-invalid", f"The certificate is not trusted for {host}: {error.verify_message}", "high",
627
+ "Install a valid certificate for this name (e.g. Let's Encrypt) including the intermediate chain.", str(error)[:200])
628
+ return findings
629
+ except (OSError, ssl.SSLError) as error:
630
+ ctx.degrade("target-site", f"{host}:{port}: {str(error)[:200]}")
631
+ return findings
632
+ not_after = dt.datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(tzinfo=dt.timezone.utc)
633
+ days = (not_after - core.utcnow()).days
634
+ if days < 0:
635
+ add("cert-expired", f"The certificate expired on {not_after.date()}", "critical", "Renew the certificate now.")
636
+ elif days < 14:
637
+ add("cert-expiring", f"The certificate expires in {days} days ({not_after.date()})", "high", "Renew it now and check automatic renewal.")
638
+ elif days < 30:
639
+ add("cert-expiring-soon", f"The certificate expires in {days} days ({not_after.date()})", "medium", "Check that automatic renewal works.")
640
+ ctx.run["inputs"]["negotiated"] = {"version": version, "cipher": cipher[0] if cipher else None, "cert_expires": not_after.date().isoformat()}
641
+ for label, proto in (("TLSv1", getattr(ssl.TLSVersion, "TLSv1", None)), ("TLSv1.1", getattr(ssl.TLSVersion, "TLSv1_1", None))):
642
+ if proto is None:
643
+ continue
644
+ probe = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
645
+ probe.check_hostname = False
646
+ probe.verify_mode = ssl.CERT_NONE
647
+ try:
648
+ probe.minimum_version = proto
649
+ probe.maximum_version = proto
650
+ probe.set_ciphers("ALL:@SECLEVEL=0")
651
+ except (ValueError, ssl.SSLError):
652
+ ctx.run["inputs"].setdefault("not_testable", []).append(label)
653
+ continue
654
+ try:
655
+ with socket.create_connection((address, port), timeout=10) as raw, probe.wrap_socket(raw, server_hostname=host) as conn:
656
+ accepted = conn.version() == label
657
+ except ssl.SSLError as error:
658
+ if "no protocols available" in str(error).lower() or "unsupported protocol" in str(error).lower() and "alert" not in str(error).lower():
659
+ ctx.run["inputs"].setdefault("not_testable", []).append(label)
660
+ accepted = False
661
+ except OSError:
662
+ accepted = False
663
+ if accepted:
664
+ add(f"protocol-{label}", f"The server still accepts {label}", "medium", f"Disable {label}; allow TLS 1.2 and 1.3 only.")
665
+ if version not in ("TLSv1.3",):
666
+ probe = ssl.create_default_context()
667
+ try:
668
+ probe.minimum_version = ssl.TLSVersion.TLSv1_3
669
+ with socket.create_connection((address, port), timeout=10) as raw, probe.wrap_socket(raw, server_hostname=host):
670
+ pass
671
+ except (OSError, ssl.SSLError, ValueError):
672
+ add("no-tls13", "TLS 1.3 is not offered", "low", "Enable TLS 1.3 (faster and safer).")
673
+ return findings
674
+
675
+
676
+ def tls_cmd(ctx: core.Context):
677
+ args = ctx.args
678
+ host = host_of(args.host)
679
+ port = args.port
680
+ mode = require_ownership(ctx, host, args.public_check)
681
+ ip = None
682
+ if args.ip:
683
+ if mode != "verified":
684
+ raise core.PolicyError("--ip needs a verified domain")
685
+ ip = args.ip
686
+ findings: list[dict] = []
687
+ engine = args.engine
688
+ if engine == "auto":
689
+ engine = "testssl" if core.resolve_tool("testssl.sh", ctx.lock)[1] is not None else "native"
690
+ if engine == "testssl":
691
+ with tempfile.TemporaryDirectory() as tmp:
692
+ out = Path(tmp) / "tls.json"
693
+ argv = ["--warnings", "off", "--sneaky", "--color", "0", "--jsonfile-pretty", str(out), "--severity", "LOW"]
694
+ if args.mode == "quick":
695
+ argv.append("--fast")
696
+ if ip:
697
+ argv += ["--ip", ip]
698
+ argv.append(f"{host}:{port}")
699
+ core.run_tool("testssl.sh", argv, ctx.lock, timeout=1200)
700
+ data = json.loads(out.read_text() or "{}") if out.is_file() else {}
701
+ items = data.get("scanResult", [{}])[0] if isinstance(data.get("scanResult"), list) else {}
702
+ flat = []
703
+ for section, entries in (items.items() if isinstance(items, dict) else []):
704
+ if isinstance(entries, list):
705
+ flat += [e for e in entries if isinstance(e, dict)]
706
+ findings = testssl_to_findings(ctx, flat, host, port)
707
+ ctx.run["tools"].append({"name": "testssl.sh", "version": ctx.lock["tools"]["testssl.sh"]["version"], "sha256": None})
708
+ else:
709
+ findings = native_tls(ctx, host, port, ip)
710
+ ctx.run["tools"].append({"name": "maggie-tls-native", "version": core.maggie_version(), "sha256": None})
711
+ try:
712
+ edge = fetch(f"https://{host}/", follow=False, timeout=10)
713
+ if "cf-ray" in edge["headers"]:
714
+ ctx.run["inputs"]["note"] = "measured at Cloudflare's edge; origin TLS is separate (use --ip with the origin address)"
715
+ except OSError:
716
+ pass
717
+ ctx.run["inputs"].update({"host": host, "port": port, "engine": engine, "ownership": mode})
718
+ if mode == "public-check":
719
+ ctx.args.no_ledger = True
720
+ return ctx.finish(findings, covered=[f"url:https://{host}:{port}"])
721
+
722
+
723
+ # --------------------------------------------------------------------------- web-scan (httpx -> nuclei, safe profile)
724
+
725
+
726
+ SAFE_EXCLUDE_TAGS = ["intrusive", "dos", "fuzz", "fuzzing", "bruteforce", "brute-force", "authenticated", "osint", "default-login", "headless",
727
+ "oast", "file-upload", "sqli-time", "rce-blind"]
728
+ NUCLEI_KIND = {"exposures": "exposure", "misconfiguration": "misconfiguration", "ssl": "tls", "vulnerabilities": "vulnerability",
729
+ "cves": "vulnerability", "default-logins": "exposure", "technologies": "misconfiguration"}
730
+
731
+
732
+ def nuclei_to_findings(ctx: core.Context, lines: list[str]) -> list[dict]:
733
+ findings = []
734
+ for line in lines:
735
+ try:
736
+ item = json.loads(line)
737
+ except ValueError:
738
+ continue
739
+ info = item.get("info") or {}
740
+ classification = info.get("classification") or {}
741
+ cves = [c.upper() for c in classification.get("cve-id") or [] if c]
742
+ cvss = None
743
+ if classification.get("cvss-score"):
744
+ try:
745
+ cvss = {"version": "3.1", "score": float(classification["cvss-score"]), "vector": classification.get("cvss-metrics") or ""}
746
+ except (TypeError, ValueError):
747
+ cvss = None
748
+ epss = None
749
+ if classification.get("epss-score") is not None:
750
+ try:
751
+ epss = {"score": float(classification["epss-score"]), "percentile": float(classification.get("epss-percentile") or 0)}
752
+ except (TypeError, ValueError):
753
+ epss = None
754
+ tags = info.get("tags") or []
755
+ tags = tags.split(",") if isinstance(tags, str) else tags
756
+ path = str(item.get("template-path") or item.get("template") or "")
757
+ kind = "vulnerability" if cves else next((v for k, v in NUCLEI_KIND.items() if f"{k}/" in path or path.startswith(k)), "misconfiguration")
758
+ meta = info.get("metadata") or {}
759
+ affected = None
760
+ if meta.get("product") or meta.get("vendor"):
761
+ component = "wordpress-plugin" if "wp-plugin" in tags else "wordpress-theme" if "wp-theme" in tags else "service"
762
+ affected = {"purl": None, "ecosystem": None, "name": str(meta.get("product") or meta.get("vendor")), "version": None,
763
+ "component_type": component, "direct": None, "scope": "runtime"}
764
+ extracted = item.get("extracted-results") or []
765
+ findings.append(core.make_finding(
766
+ kind=kind, command="web-scan", primary_id=cves[0] if cves else str(item.get("template-id")), site=ctx.site,
767
+ title=core.clean_excerpt(info.get("name") or item.get("template-id") or "nuclei finding", 200),
768
+ severity={"unknown": "info"}.get(info.get("severity"), info.get("severity") or "info"), severity_source="tool", cvss=cvss, epss=epss,
769
+ confidence="confirmed" if meta.get("verified") else "likely", aliases=cves + [str(item.get("template-id"))],
770
+ cwe_ids=[c.upper() for c in classification.get("cwe-id") or [] if re.match(r"(?i)^cwe-\d+$", c)],
771
+ kev={"listed": True} if {"kev", "vkev"} & set(tags) else None, affected=affected,
772
+ target={"url": item.get("matched-at") or item.get("host"), "host": host_of(str(item.get("host") or ""))},
773
+ evidence=[{"path": str(item.get("matched-at") or ""), "excerpt": core.clean_excerpt(", ".join(map(str, extracted))[:200])}] if extracted else [],
774
+ tool="nuclei", rule_id=str(item.get("template-id")),
775
+ remediation={"summary": core.clean_excerpt(info.get("remediation") or "See the references.", 300), "steps": [], "snippets": [],
776
+ "references": (info.get("reference") or [])[:5] if isinstance(info.get("reference"), list) else [], "cheat_sheet": None},
777
+ untrusted={"description": core.clean_excerpt(info.get("description") or "", 400)} if info.get("description") else None))
778
+ return findings
779
+
780
+
781
+ def web_scan(ctx: core.Context):
782
+ args = ctx.args
783
+ url = args.url if "://" in args.url else f"https://{args.url}"
784
+ host = host_of(url)
785
+ if require_ownership(ctx, host, False) != "verified":
786
+ raise core.PolicyError("web-scan needs a verified domain")
787
+ lock_dir = ctx.state.path("locks")
788
+ lock_dir.mkdir(parents=True, exist_ok=True)
789
+ lock = lock_dir / f"web-scan-{registrable(host)}.lock"
790
+ if lock.exists() and (core.utcnow().timestamp() - lock.stat().st_mtime) < 3600:
791
+ raise core.PolicyError(f"another web-scan of {registrable(host)} is running (lock {lock.name})")
792
+ lock.write_text(core.iso())
793
+ try:
794
+ tags = ["misconfig", "exposure", "ssl", "tech"]
795
+ with tempfile.TemporaryDirectory() as tmp:
796
+ fingerprint = {}
797
+ if core.resolve_tool("httpx", ctx.lock)[1] is not None:
798
+ out = Path(tmp) / "httpx.jsonl"
799
+ core.run_tool("httpx", ["-u", url, "-j", "-sc", "-title", "-server", "-td", "-cdn", "-tls-grab", "-fr", "-o", str(out)],
800
+ ctx.lock, timeout=300)
801
+ if out.is_file() and out.read_text().strip():
802
+ fingerprint = json.loads(out.read_text().splitlines()[0])
803
+ tech = " ".join(fingerprint.get("tech") or []).lower()
804
+ for needle, extra in (("wordpress", ["wordpress", "wp-plugin", "wp-theme"]), ("nginx", ["nginx"]), ("php", ["php"]),
805
+ ("next.js", ["nextjs"]), ("express", ["express", "nodejs"]), ("laravel", ["laravel"]), ("apache", ["apache"])):
806
+ if needle in tech:
807
+ tags += extra
808
+ if args.profile == "full-safe":
809
+ tags.append("tech")
810
+ jsonl = Path(tmp) / "nuclei.jsonl"
811
+ argv = ["-u", url, "-tags", ",".join(dict.fromkeys(tags)), "-etags", ",".join(SAFE_EXCLUDE_TAGS),
812
+ "-severity", "info,low,medium,high,critical" if args.profile == "full-safe" else "low,medium,high,critical",
813
+ "-rl", str(min(args.rate, 10)), "-c", "5", "-bs", "5", "-timeout", "10", "-retries", "1", "-mhe", "10", "-nc",
814
+ "-jle", str(jsonl)]
815
+ templates = ctx.state.path("db", "nuclei-templates")
816
+ if templates.is_dir():
817
+ argv += ["-t", str(templates)]
818
+ if args.templates_id:
819
+ argv += ["-id", args.templates_id]
820
+ proc = core.run_tool("nuclei", argv, ctx.lock, timeout=args.max_minutes * 60)
821
+ lines = jsonl.read_text(encoding="utf-8").splitlines() if jsonl.is_file() else []
822
+ if proc.returncode not in (0, 1) and not lines:
823
+ ctx.degrade("nuclei", core.clean_excerpt(proc.stderr.decode(errors="replace"), 200))
824
+ findings = nuclei_to_findings(ctx, lines)
825
+ ctx.run["tools"].append({"name": "nuclei", "version": ctx.lock["tools"]["nuclei"]["version"], "sha256": None})
826
+ ctx.run["inputs"].update({"url": url, "tags": tags, "fingerprint": {k: fingerprint.get(k) for k in ("tech", "webserver", "cdn_name", "status_code")}})
827
+ finally:
828
+ lock.unlink(missing_ok=True)
829
+ return ctx.finish(findings, covered=[f"url:{url}"] if not ctx.run["degraded"] else [])
830
+
831
+
832
+ # --------------------------------------------------------------------------- ZAP baseline (passive)
833
+
834
+
835
+ ZAP_IMAGE = "ghcr.io/zaproxy/zaproxy:stable"
836
+ ZAP_IGNORE = {"10020", "10021", "10035", "10038", "10063"} # headers already graded by `headers`
837
+
838
+
839
+ def zap_to_findings(ctx: core.Context, report: dict) -> list[dict]:
840
+ findings = []
841
+ for site in report.get("site") or []:
842
+ for alert in site.get("alerts") or []:
843
+ plugin = str(alert.get("pluginid"))
844
+ if plugin in ZAP_IGNORE:
845
+ continue
846
+ risk = {"3": "high", "2": "medium", "1": "low", "0": "info"}.get(str(alert.get("riskcode")), "info")
847
+ confidence = {"3": "confirmed", "4": "confirmed", "2": "likely", "1": "possible"}.get(str(alert.get("confidence")), "possible")
848
+ instances = alert.get("instances") or []
849
+ first = instances[0] if instances else {}
850
+ cwe = str(alert.get("cweid") or "")
851
+ findings.append(core.make_finding(
852
+ kind="misconfiguration", command="zap-baseline", primary_id=f"zap-{plugin}", site=ctx.site,
853
+ title=core.clean_excerpt(re.sub(r"<[^>]+>", "", alert.get("alert") or alert.get("name") or plugin), 200),
854
+ severity=risk, severity_source="tool", confidence=confidence, cwe_ids=[f"CWE-{cwe}"] if cwe.isdigit() and cwe != "0" else [],
855
+ target={"url": first.get("uri") or site.get("@name"), "host": host_of(site.get("@name") or "")},
856
+ evidence=[{"path": i.get("uri", ""), "excerpt": core.clean_excerpt(i.get("evidence") or "", 200)} for i in instances[:5]],
857
+ tool="zap-baseline", rule_id=plugin,
858
+ remediation={"summary": core.clean_excerpt(re.sub(r"<[^>]+>", " ", alert.get("solution") or ""), 300), "steps": [], "snippets": [],
859
+ "references": re.findall(r"https?://[^\s<]+", alert.get("reference") or "")[:4], "cheat_sheet": None}))
860
+ return findings
861
+
862
+
863
+ def zap_baseline(ctx: core.Context):
864
+ args = ctx.args
865
+ url = args.url if "://" in args.url else f"https://{args.url}"
866
+ if require_ownership(ctx, host_of(url), False) != "verified":
867
+ raise core.PolicyError("zap-baseline needs a verified domain")
868
+ docker = shutil.which("docker")
869
+ if not docker:
870
+ raise core.ToolError("zap-baseline needs Docker (it runs the official ZAP image, passive rules only)")
871
+ with tempfile.TemporaryDirectory() as tmp:
872
+ conf = Path(tmp) / "maggie-zap-rules.conf"
873
+ conf.write_text("".join(f"{rule}\tIGNORE\t(maggie headers covers this)\n" for rule in sorted(ZAP_IGNORE)))
874
+ os.chmod(tmp, 0o777)
875
+ argv = [docker, "run", "--rm", "-v", f"{tmp}:/zap/wrk:rw", args.image or ZAP_IMAGE, "zap-baseline.py", "-t", url, "-m", str(args.minutes),
876
+ "-J", "zap.json", "-I", "-c", "maggie-zap-rules.conf", "-z", "-silent"]
877
+ proc = subprocess.run(argv, capture_output=True, timeout=(args.minutes + 10) * 60, check=False)
878
+ report_path = Path(tmp) / "zap.json"
879
+ if not report_path.is_file():
880
+ ctx.degrade("zap", core.clean_excerpt(proc.stderr.decode(errors="replace"), 200))
881
+ return ctx.finish([])
882
+ findings = zap_to_findings(ctx, json.loads(report_path.read_text()))
883
+ ctx.run["tools"].append({"name": "zap-baseline", "version": args.image or ZAP_IMAGE, "sha256": None})
884
+ return ctx.finish(findings, covered=[f"url:{url}"])
885
+
886
+
887
+ # --------------------------------------------------------------------------- public-check (passive, browser-equivalent)
888
+
889
+ # A public check only looks at what any visitor already sees: one GET of the
890
+ # home page, a TLS handshake, and public DNS. It never probes paths, never
891
+ # submits anything and never runs an active scanner (that is `web-scan` /
892
+ # `zap-baseline`, which stay verified-only). The point is to spot a problem on
893
+ # a site you do NOT control so you can tell its owner.
894
+
895
+ # <meta name="generator"> product -> how to record it in a stack.
896
+ GENERATORS = [
897
+ (re.compile(r"WordPress\s+([0-9]+(?:\.[0-9]+){1,2})", re.I), "wordpress-core"),
898
+ (re.compile(r"Drupal\s+([0-9]+(?:\.[0-9]+){0,2})", re.I), ("npm", "drupal")),
899
+ (re.compile(r"Joomla!?\s*-?\s*([0-9]+(?:\.[0-9]+){1,2})", re.I), ("npm", "joomla")),
900
+ ]
901
+ # Server / X-Powered-By token -> runtime or service name used by the matchers.
902
+ SERVER_RUNTIMES = [
903
+ (re.compile(r"nginx/([0-9]+(?:\.[0-9]+){1,2})", re.I), "nginx"),
904
+ (re.compile(r"Apache/([0-9]+(?:\.[0-9]+){1,2})", re.I), "apache"),
905
+ (re.compile(r"PHP/([0-9]+(?:\.[0-9]+){1,2})", re.I), "php"),
906
+ (re.compile(r"OpenSSL/([0-9]+(?:\.[0-9]+){1,2}[a-z]?)", re.I), "openssl"),
907
+ ]
908
+ ASSET_RE = re.compile(r"""(?:src|href)\s*=\s*['"]([^'"]+?\.(?:js|css))(?:\?[^'"]*)?['"]""", re.I)
909
+ GENERATOR_RE = re.compile(r"""<meta[^>]+name\s*=\s*['"]generator['"][^>]+content\s*=\s*['"]([^'"]+)['"]""", re.I)
910
+
911
+
912
+ def resolve_txt(ctx: core.Context, name: str) -> list[str]:
913
+ """Best-effort TXT lookup: the system resolver, then public DoH. Never raises."""
914
+ values: list[str] = []
915
+ try:
916
+ servers = system_resolvers()
917
+ if servers:
918
+ values = dns_txt_udp(name, servers[0])
919
+ except OSError:
920
+ values = []
921
+ if not values:
922
+ try:
923
+ values = dns_txt_doh(ctx, name) or []
924
+ except (core.DegradedError, OSError, ValueError):
925
+ values = []
926
+ return values
927
+
928
+
929
+ def remote_fingerprint(host: str, response: dict, site: str) -> dict:
930
+ """A maggie-security-stack.v1 built only from the home page a visitor receives."""
931
+ body = response.get("body") or b""
932
+ text = body.decode("utf-8", errors="replace") if isinstance(body, (bytes, bytearray)) else str(body)
933
+ headers = response.get("headers") or {}
934
+ components: list[dict] = []
935
+ runtimes: list[dict] = []
936
+ wordpress: dict = {}
937
+ seen: set[tuple] = set()
938
+
939
+ generator = ""
940
+ match = GENERATOR_RE.search(text)
941
+ if match:
942
+ generator = match.group(1)
943
+ for rx, target in GENERATORS:
944
+ got = rx.search(generator)
945
+ if not got:
946
+ continue
947
+ if target == "wordpress-core":
948
+ wordpress = {"core": got.group(1), "plugins": [], "themes": []}
949
+ else:
950
+ eco, cname = target
951
+ key = (eco, cname, got.group(1))
952
+ if key not in seen:
953
+ seen.add(key)
954
+ components.append({"purl": core.make_purl(eco, cname, got.group(1)), "ecosystem": eco, "name": cname,
955
+ "version": got.group(1), "scope": "runtime", "direct": True, "location": f"https://{host}/ (generator)"})
956
+ break
957
+
958
+ for header in ("server", "x-powered-by"):
959
+ value = " ".join(headers.get(header) or [])
960
+ for rx, name in SERVER_RUNTIMES:
961
+ got = rx.search(value)
962
+ if got and ("runtime", name) not in seen:
963
+ seen.add(("runtime", name))
964
+ runtimes.append({"name": name, "version": got.group(1), "source": f"{header} header", "host": None})
965
+
966
+ import maggie_security_inventory as inventory
967
+
968
+ extractors = inventory._retire_extractors()
969
+ assets = 0
970
+ for raw in ASSET_RE.findall(text):
971
+ if assets >= 40:
972
+ break
973
+ assets += 1
974
+ basename = raw.rstrip("/").rsplit("/", 1)[-1]
975
+ for lib, (name_res, _content_res, npm) in extractors.items():
976
+ version = None
977
+ for rx in name_res:
978
+ got = rx.search(basename)
979
+ if got:
980
+ version = got.group(1)
981
+ break
982
+ if not version:
983
+ continue
984
+ version = re.sub(r"([.-](min|slim|pack|prod|production|umd|esm|bundle|js))+$", "", version).rstrip(".-_")
985
+ key = ("npm", npm, version)
986
+ if key in seen:
987
+ break
988
+ seen.add(key)
989
+ components.append({"purl": core.make_purl("npm", npm, version), "ecosystem": "npm", "name": npm, "version": version,
990
+ "scope": "runtime", "direct": None, "location": f"https://{host}/{basename}"})
991
+ break
992
+
993
+ site_type = "wordpress" if wordpress else "custom"
994
+ digest = core.sha256_bytes(core.canonical_json([components, runtimes, wordpress]).encode("utf-8"))
995
+ return {"schema": "maggie-security-stack.v1", "site": {"id": site, "type": site_type, "domains": [host], "hosting": None},
996
+ "collected_at": core.iso(), "hash": f"sha256:{digest}", "sources": [{"kind": "homepage", "path": f"https://{host}/"}],
997
+ "components": components, "runtimes": runtimes, "wordpress": wordpress, "images": [], "hosts": [],
998
+ "watch": {"products": []}, "generator": generator}
999
+
1000
+
1001
+ def email_dns_findings(ctx: core.Context, host: str, site: str) -> list[dict]:
1002
+ """Passive email-spoofing hygiene from public DNS: SPF and DMARC on the registrable domain."""
1003
+ domain = registrable(host)
1004
+ findings: list[dict] = []
1005
+ spf = [v for v in resolve_txt(ctx, domain) if v.lower().startswith("v=spf1")]
1006
+ if not spf:
1007
+ findings.append(core.make_finding(
1008
+ kind="misconfiguration", command="public-check", primary_id="email-spf-missing", site=site, title=f"{domain} has no SPF record",
1009
+ severity="low", severity_source="maggie", confidence="confirmed", target={"host": domain}, asvs_ids=[],
1010
+ remediation={"summary": "Publish an SPF TXT record so receivers can reject mail forged from this domain.", "steps": [],
1011
+ "snippets": [{"kind": "dns", "text": f'{domain}. TXT "v=spf1 -all" # tighten to your real senders'}],
1012
+ "references": ["https://datatracker.ietf.org/doc/html/rfc7208"], "cheat_sheet": None}))
1013
+ elif any("+all" in v.lower() for v in spf):
1014
+ findings.append(core.make_finding(
1015
+ kind="misconfiguration", command="public-check", primary_id="email-spf-permissive", site=site,
1016
+ title=f"{domain} SPF ends in +all (anyone may send as this domain)", severity="medium", severity_source="maggie",
1017
+ confidence="confirmed", target={"host": domain}, evidence=[{"path": "SPF", "excerpt": core.clean_excerpt(spf[0], 200)}],
1018
+ remediation={"summary": "Replace +all with -all (or ~all) so forged senders are rejected.", "steps": [], "snippets": [],
1019
+ "references": [], "cheat_sheet": None}))
1020
+ dmarc = [v for v in resolve_txt(ctx, f"_dmarc.{domain}") if v.lower().startswith("v=dmarc1")]
1021
+ if not dmarc:
1022
+ findings.append(core.make_finding(
1023
+ kind="misconfiguration", command="public-check", primary_id="email-dmarc-missing", site=site, title=f"{domain} has no DMARC record",
1024
+ severity="low", severity_source="maggie", confidence="confirmed", target={"host": f"_dmarc.{domain}"}, asvs_ids=[],
1025
+ remediation={"summary": "Publish a DMARC record so spoofed mail is reported and (later) rejected.", "steps": [],
1026
+ "snippets": [{"kind": "dns", "text": f'_dmarc.{domain}. TXT "v=DMARC1; p=none; rua=mailto:dmarc@{domain}"'}],
1027
+ "references": ["https://datatracker.ietf.org/doc/html/rfc7489"], "cheat_sheet": None}))
1028
+ elif any(re.search(r"\bp\s*=\s*none\b", v, re.I) for v in dmarc):
1029
+ findings.append(core.make_finding(
1030
+ kind="misconfiguration", command="public-check", primary_id="email-dmarc-none", site=site,
1031
+ title=f"{domain} DMARC policy is p=none (monitoring only, no enforcement)", severity="info", severity_source="maggie",
1032
+ confidence="confirmed", target={"host": f"_dmarc.{domain}"}, evidence=[{"path": "DMARC", "excerpt": core.clean_excerpt(dmarc[0], 200)}],
1033
+ remediation={"summary": "Once reports look clean, move the policy to quarantine then reject.", "steps": [], "snippets": [],
1034
+ "references": [], "cheat_sheet": None}))
1035
+ return findings
1036
+
1037
+
1038
+ def securitytxt_finding(ctx: core.Context, base: str, host: str, site: str) -> dict | None:
1039
+ """One GET of /.well-known/security.txt; a missing file is an info nudge, not a risk."""
1040
+ try:
1041
+ response = fetch(urllib.parse.urljoin(base + "/", "/.well-known/security.txt"), follow=False, timeout=10)
1042
+ except (OSError, ssl.SSLError):
1043
+ return None
1044
+ if response.get("status") == 200 and b"contact" in (response.get("body") or b"").lower():
1045
+ return None
1046
+ return core.make_finding(
1047
+ kind="misconfiguration", command="public-check", primary_id="no-security-txt", site=site,
1048
+ title="No security.txt: no published way to report a vulnerability", severity="info", severity_source="maggie",
1049
+ confidence="confirmed", target={"url": f"https://{host}/.well-known/security.txt", "host": host}, asvs_ids=[],
1050
+ remediation={"summary": "Publish /.well-known/security.txt with a Contact: line so researchers can reach you.", "steps": [],
1051
+ "snippets": [{"kind": "text", "text": f"Contact: mailto:security@{registrable(host)}\nExpires: <date>"}],
1052
+ "references": ["https://www.rfc-editor.org/rfc/rfc9116"], "cheat_sheet": None})
1053
+
1054
+
1055
+ def advisory_findings_remote(ctx: core.Context, stack: dict) -> tuple[list[dict], list[str]]:
1056
+ """Run the same advisory engine as vuln-watch over the passively-built remote stack."""
1057
+ import maggie_security_intel as intel
1058
+
1059
+ findings: list[dict] = []
1060
+ notes: list[str] = []
1061
+ try:
1062
+ osv, ok = intel.match_osv(ctx, stack, "public-check")
1063
+ findings += osv
1064
+ if not ok:
1065
+ notes.append("OSV lookup was degraded; version advisories may be incomplete")
1066
+ soft, _cov = intel.software_findings(ctx, stack, "public-check")
1067
+ findings += soft
1068
+ if stack.get("wordpress", {}).get("core"):
1069
+ wp, wp_ok = intel.wordpress_findings(ctx, stack, "public-check", budget=1)
1070
+ findings += wp
1071
+ if not wp_ok:
1072
+ notes.append("WordPress advisory lookup was degraded")
1073
+ kev = intel.load_kev(ctx)
1074
+ intel.apply_kev(findings, kev, stack)
1075
+ intel.join_epss(ctx, findings)
1076
+ except core.DegradedError as error:
1077
+ notes.append(f"advisory lookup degraded: {core.clean_excerpt(str(error), 160)}")
1078
+ return findings, notes
1079
+
1080
+
1081
+ SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
1082
+
1083
+
1084
+ def outreach_markdown(host: str, findings: list[dict]) -> str:
1085
+ """A disclosure-ready note for the site owner: what is visible, not how to exploit it."""
1086
+ by_sev: dict[str, list[dict]] = {}
1087
+ for f in findings:
1088
+ by_sev.setdefault(f["severity"], []).append(f)
1089
+ lines = [f"# Security notes for {host}", "",
1090
+ "We ran only passive checks — the same requests any visitor's browser makes "
1091
+ "(your home page, a TLS handshake, and public DNS). We did not probe, log in, "
1092
+ "or scan. Here is what is publicly visible, so you can decide what to fix.", ""]
1093
+ actionable = [s for s in ("critical", "high", "medium", "low") if by_sev.get(s)]
1094
+ if not actionable:
1095
+ lines += ["Nothing notable was visible from the outside. ", ""]
1096
+ for sev in ("critical", "high", "medium", "low", "info"):
1097
+ items = sorted(by_sev.get(sev) or [], key=lambda f: f["title"])
1098
+ if not items:
1099
+ continue
1100
+ lines.append(f"## {sev.title()}")
1101
+ for f in items:
1102
+ fix = (f.get("remediation") or {}).get("summary") or ""
1103
+ lines.append(f"- **{f['title']}**" + (f" — {fix}" if fix else ""))
1104
+ lines.append("")
1105
+ lines += ["---",
1106
+ "_Sent by <your name / company>. We have no affiliation with this site and "
1107
+ "hold no further details beyond the public observations above. If you would "
1108
+ "rather not hear from us again, just reply and we will stop._"]
1109
+ return "\n".join(lines)
1110
+
1111
+
1112
+ def _public_targets(args) -> list[str]:
1113
+ """Base URLs to check, de-duplicated; a bare domain becomes https://domain/."""
1114
+ raw: list[str] = []
1115
+ if getattr(args, "url", None):
1116
+ raw.append(args.url)
1117
+ raw += list(getattr(args, "domain", None) or [])
1118
+ if getattr(args, "domains", None):
1119
+ for line in Path(args.domains).read_text(encoding="utf-8").splitlines():
1120
+ line = line.strip()
1121
+ if line and not line.startswith("#"):
1122
+ raw.append(line)
1123
+ bases = [(item if "://" in item else f"https://{item}") for item in raw]
1124
+ return list(dict.fromkeys(b for b in bases if host_of(b)))
1125
+
1126
+
1127
+ def public_check(ctx: core.Context):
1128
+ args = ctx.args
1129
+ ctx.args.no_ledger = True # these are other people's sites: never touch the local ledger
1130
+ bases = _public_targets(args)
1131
+ if not bases:
1132
+ raise core.UsageError("give --url, one or more --domain, or --domains FILE")
1133
+ if len(bases) > args.max_sites:
1134
+ raise core.PolicyError(f"{len(bases)} domains exceeds --max-sites {args.max_sites}; raise it deliberately")
1135
+ outreach_dir = Path(args.outreach) if args.outreach else None
1136
+ if outreach_dir:
1137
+ outreach_dir.mkdir(parents=True, exist_ok=True)
1138
+ all_findings: list[dict] = []
1139
+ sites: list[dict] = []
1140
+ for index, base in enumerate(bases):
1141
+ host = host_of(base)
1142
+ if index:
1143
+ time.sleep(max(0.0, 1.0 / max(args.rate, 0.2))) # be a polite visitor between sites
1144
+ require_ownership(ctx, host, public_check=True) # interactive-only; refuses in CI/routines
1145
+ entry = {"host": host, "notes": []}
1146
+ try:
1147
+ response = fetch(urllib.parse.urljoin(base + "/", "/"), timeout=15)
1148
+ except (OSError, ssl.SSLError) as error:
1149
+ entry["notes"].append(f"home page not reachable: {core.clean_excerpt(str(error), 160)}")
1150
+ sites.append(entry)
1151
+ continue
1152
+ if response.get("left_domain"):
1153
+ entry["notes"].append(f"redirects off-domain to {response['left_domain']}; checked only the first response")
1154
+ # The remote site is unrelated to the local project, so detect the serving
1155
+ # layer from its response headers alone, never from files on this machine.
1156
+ layer = detect_serving(Path("/nonexistent-maggie-prospect"), response["headers"], "auto")
1157
+ findings = grade_response(ctx, response["url"], response, layer, response["url"].startswith("https://"))
1158
+ findings += native_tls(ctx, host, 443, None)
1159
+ stack = remote_fingerprint(host, response, ctx.site)
1160
+ entry["detected"] = {"generator": stack.get("generator") or None,
1161
+ "components": [f"{c['name']} {c['version']}" for c in stack["components"]],
1162
+ "runtimes": [f"{r['name']} {r['version']}" for r in stack["runtimes"]]}
1163
+ advisories, notes = advisory_findings_remote(ctx, stack)
1164
+ findings += advisories
1165
+ entry["notes"] += notes
1166
+ findings += email_dns_findings(ctx, host, ctx.site)
1167
+ sec = securitytxt_finding(ctx, base, host, ctx.site)
1168
+ if sec:
1169
+ findings.append(sec)
1170
+ for f in findings:
1171
+ f.setdefault("target", {}).setdefault("host", host)
1172
+ entry["counts"] = {s: sum(1 for f in findings if f["severity"] == s) for s in SEVERITY_ORDER if any(x["severity"] == s for x in findings)}
1173
+ all_findings += findings
1174
+ if outreach_dir:
1175
+ report = outreach_dir / f"{registrable(host)}.md"
1176
+ report.write_text(outreach_markdown(host, findings), encoding="utf-8")
1177
+ entry["outreach"] = str(report)
1178
+ sites.append(entry)
1179
+ all_findings.sort(key=lambda f: (SEVERITY_ORDER.get(f["severity"], 9), f["title"]))
1180
+ ctx.run["inputs"].update({"mode": "public-check (passive, unverified)", "sites": sites, "label": "unverified domains (public check)"})
1181
+ return ctx.finish(all_findings, covered=[f"url:{host_of(b)}" for b in bases])
1182
+
1183
+
1184
+ # --------------------------------------------------------------------------- registration
1185
+
1186
+
1187
+ def _dv(p: argparse.ArgumentParser) -> None:
1188
+ p.add_argument("action", choices=["start", "check", "status", "revoke"])
1189
+ p.add_argument("--domain")
1190
+ p.add_argument("--method", choices=["dns", "file"], default="dns")
1191
+ p.add_argument("--rotate", action="store_true")
1192
+
1193
+
1194
+ def _headers(p: argparse.ArgumentParser) -> None:
1195
+ p.add_argument("--url", required=True)
1196
+ p.add_argument("--paths", nargs="*", help="paths to check (default / plus config headers.paths; at most 5)")
1197
+ p.add_argument("--serving", choices=["auto", "nginx", "apache", "cloudflare", "headers-file", "astro", "next", "php"], default="auto")
1198
+ p.add_argument("--public-check", action="store_true", help="one GET on an unverified domain, labelled unverified (interactive only)")
1199
+
1200
+
1201
+ def _tls(p: argparse.ArgumentParser) -> None:
1202
+ p.add_argument("--host", required=True)
1203
+ p.add_argument("--port", type=int, default=443)
1204
+ p.add_argument("--ip", help="origin IP to test behind a CDN (verified domain only)")
1205
+ p.add_argument("--mode", choices=["quick", "full"], default="quick")
1206
+ p.add_argument("--engine", choices=["auto", "testssl", "native"], default="auto")
1207
+ p.add_argument("--public-check", action="store_true")
1208
+
1209
+
1210
+ def _scan(p: argparse.ArgumentParser) -> None:
1211
+ p.add_argument("--url", required=True)
1212
+ p.add_argument("--profile", choices=["safe", "full-safe"], default="safe")
1213
+ p.add_argument("--templates-id", help="run one template id (e.g. after a new CVE template)")
1214
+ p.add_argument("--rate", type=int, default=10, help="requests per second (max 10)")
1215
+ p.add_argument("--max-minutes", type=int, default=15)
1216
+
1217
+
1218
+ def _zap(p: argparse.ArgumentParser) -> None:
1219
+ p.add_argument("--url", required=True)
1220
+ p.add_argument("--minutes", type=int, default=5)
1221
+ p.add_argument("--image", help="pinned ZAP image reference (default ghcr.io/zaproxy/zaproxy:stable)")
1222
+
1223
+
1224
+ def _public(p: argparse.ArgumentParser) -> None:
1225
+ p.add_argument("--url", help="one site to check")
1226
+ p.add_argument("--domain", action="append", help="a domain to check (repeatable)")
1227
+ p.add_argument("--domains", help="file with one domain per line")
1228
+ p.add_argument("--outreach", help="write a disclosure-ready note per domain into this directory")
1229
+ p.add_argument("--rate", type=float, default=0.5, help="sites per second between domains (default 0.5)")
1230
+ p.add_argument("--max-sites", type=int, default=50, help="refuse a run with more domains than this")
1231
+
1232
+
1233
+ COMMANDS = [
1234
+ core.Command("domain-verify", "prove you control a domain (DNS TXT or a token file) before any active check", ["doh", "dns", "target-site"], _dv,
1235
+ domain_verify, findings=False),
1236
+ core.Command("headers", "grade HTTP security headers and cookies per path, with fix snippets for your server", ["target-site", "doh", "dns"],
1237
+ _headers, headers_cmd),
1238
+ core.Command("tls", "TLS protocols, certificate expiry and trust (testssl.sh when installed)", ["target-site", "doh", "dns"], _tls, tls_cmd),
1239
+ core.Command("web-scan", "safe nuclei scan of your verified site (no fuzzing, brute force or OAST; 10 req/s)", ["target-site", "doh", "dns"],
1240
+ _scan, web_scan),
1241
+ core.Command("zap-baseline", "weekly passive ZAP baseline scan of your verified site (Docker)", ["target-site", "doh", "dns"], _zap, zap_baseline,
1242
+ routine_safe=False),
1243
+ core.Command("public-check",
1244
+ "passive, browser-equivalent checks of sites you do not control (headers, TLS, visible versions and their known CVEs, SPF/DMARC, "
1245
+ "security.txt) so you can tell the owner; never probes or scans",
1246
+ ["target-site", "doh", "dns", "osv-api", "wpvulnerability", "kev", "epss"], _public, public_check, routine_safe=False),
1247
+ ]