@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.
- package/README-zh-TW.md +5 -3
- package/README.md +35 -8
- package/bin/maggie.js +4 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +2 -1
- package/bundled-contracts/maggie-security/finding-example-v1.json +89 -0
- package/bundled-contracts/maggie-security/finding-v1.schema.json +63 -0
- package/bundled-contracts/maggie-security/findings-set-example-v1.json +148 -0
- package/bundled-contracts/maggie-security/findings-set-v1.schema.json +32 -0
- package/bundled-contracts/maggie-security/protect-plan-v1.schema.json +28 -0
- package/bundled-contracts/maggie-security/report-v1.schema.json +29 -0
- package/bundled-contracts/maggie-security/stack-example-v1.json +66 -0
- package/bundled-contracts/maggie-security/stack-upload-v1.schema.json +22 -0
- package/bundled-contracts/maggie-security/stack-v1.schema.json +37 -0
- package/bundled-contracts/maggie-security/tools-lock-v1.schema.json +36 -0
- package/bundled-skills/catalog.json +4 -0
- package/bundled-skills/maggie-security/SKILL.md +172 -0
- package/bundled-skills/maggie-security/agents/openai.yaml +4 -0
- package/bundled-skills/maggie-security/data/LICENSE-NOTICE.md +24 -0
- package/bundled-skills/maggie-security/data/asvs-map.json +209 -0
- package/bundled-skills/maggie-security/data/fail2ban/filter.d/maggie-wordpress.conf +6 -0
- package/bundled-skills/maggie-security/data/semgrep/LICENSE-NOTICE.md +5 -0
- package/bundled-skills/maggie-security/data/semgrep/VERSION +1 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/dockerfile.yaml +14 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/generic.yaml +26 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/js.yaml +216 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/php-debug-ok.yaml +17 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/php-debug.yaml +17 -0
- package/bundled-skills/maggie-security/data/semgrep/rules/php.yaml +101 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/dockerfile.dockerfile +10 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/generic.astro +11 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/generic.conf +11 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/js.jsx +95 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/php-debug-ok.php +5 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/php-debug.php +3 -0
- package/bundled-skills/maggie-security/data/semgrep/tests/php.php +55 -0
- package/bundled-skills/maggie-security/evals/evals.json +97 -0
- package/bundled-skills/maggie-security/references/commands.md +111 -0
- package/bundled-skills/maggie-security/references/cve-response.md +24 -0
- package/bundled-skills/maggie-security/references/explaining-findings.md +57 -0
- package/bundled-skills/maggie-security/references/findings.md +39 -0
- package/bundled-skills/maggie-security/references/ownership-and-consent.md +29 -0
- package/bundled-skills/maggie-security/references/site-types.md +22 -0
- package/bundled-skills/maggie-security/references/tools-and-licences.md +16 -0
- package/bundled-skills/maggie-security/references/workflows.md +23 -0
- package/bundled-skills/maggie-security/registry/security-tools.registry.json +628 -0
- package/bundled-skills/maggie-security/routines.example.json +27 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +17 -2
- package/bundled-tools/clis/maggie_security.py +342 -0
- package/bundled-tools/clis/maggie_security_agent.py +326 -0
- package/bundled-tools/clis/maggie_security_code.py +650 -0
- package/bundled-tools/clis/maggie_security_core.py +1512 -0
- package/bundled-tools/clis/maggie_security_deps.py +560 -0
- package/bundled-tools/clis/maggie_security_health.py +415 -0
- package/bundled-tools/clis/maggie_security_host.py +511 -0
- package/bundled-tools/clis/maggie_security_hosted.py +115 -0
- package/bundled-tools/clis/maggie_security_intel.py +1294 -0
- package/bundled-tools/clis/maggie_security_inventory.py +1023 -0
- package/bundled-tools/clis/maggie_security_logs.py +777 -0
- package/bundled-tools/clis/maggie_security_mcp.py +473 -0
- package/bundled-tools/clis/maggie_security_mirror.py +122 -0
- package/bundled-tools/clis/maggie_security_protect.py +519 -0
- package/bundled-tools/clis/maggie_security_report.py +506 -0
- package/bundled-tools/clis/maggie_security_standards.py +214 -0
- package/bundled-tools/clis/maggie_security_versions.py +381 -0
- package/bundled-tools/clis/maggie_security_web.py +936 -0
- package/bundled-tools/clis/security_data/eol-snapshot.json +1 -0
- package/bundled-tools/clis/security_data/gitleaks-rules.json +3812 -0
- package/bundled-tools/clis/security_data/maltrail-lists.json +1 -0
- package/bundled-tools/clis/security_data/public-suffix-icann.txt +7374 -0
- package/bundled-tools/clis/security_data/retire-extractors.json +695 -0
- package/bundled-tools/clis/security_data/sigma-web.json +6514 -0
- package/bundled-tools/clis/security_data/tools.lock.json +358 -0
- 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
|
+
]
|