@topy-ai/maggie 0.7.55 → 0.7.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README-zh-TW.md +5 -3
  2. package/README.md +35 -8
  3. package/bin/maggie.js +4 -0
  4. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +2 -1
  5. package/bundled-contracts/maggie-security/finding-example-v1.json +89 -0
  6. package/bundled-contracts/maggie-security/finding-v1.schema.json +63 -0
  7. package/bundled-contracts/maggie-security/findings-set-example-v1.json +148 -0
  8. package/bundled-contracts/maggie-security/findings-set-v1.schema.json +32 -0
  9. package/bundled-contracts/maggie-security/protect-plan-v1.schema.json +28 -0
  10. package/bundled-contracts/maggie-security/report-v1.schema.json +29 -0
  11. package/bundled-contracts/maggie-security/stack-example-v1.json +66 -0
  12. package/bundled-contracts/maggie-security/stack-upload-v1.schema.json +22 -0
  13. package/bundled-contracts/maggie-security/stack-v1.schema.json +37 -0
  14. package/bundled-contracts/maggie-security/tools-lock-v1.schema.json +36 -0
  15. package/bundled-skills/catalog.json +4 -0
  16. package/bundled-skills/maggie-security/SKILL.md +172 -0
  17. package/bundled-skills/maggie-security/agents/openai.yaml +4 -0
  18. package/bundled-skills/maggie-security/data/LICENSE-NOTICE.md +24 -0
  19. package/bundled-skills/maggie-security/data/asvs-map.json +209 -0
  20. package/bundled-skills/maggie-security/data/fail2ban/filter.d/maggie-wordpress.conf +6 -0
  21. package/bundled-skills/maggie-security/data/semgrep/LICENSE-NOTICE.md +5 -0
  22. package/bundled-skills/maggie-security/data/semgrep/VERSION +1 -0
  23. package/bundled-skills/maggie-security/data/semgrep/rules/dockerfile.yaml +14 -0
  24. package/bundled-skills/maggie-security/data/semgrep/rules/generic.yaml +26 -0
  25. package/bundled-skills/maggie-security/data/semgrep/rules/js.yaml +216 -0
  26. package/bundled-skills/maggie-security/data/semgrep/rules/php-debug-ok.yaml +17 -0
  27. package/bundled-skills/maggie-security/data/semgrep/rules/php-debug.yaml +17 -0
  28. package/bundled-skills/maggie-security/data/semgrep/rules/php.yaml +101 -0
  29. package/bundled-skills/maggie-security/data/semgrep/tests/dockerfile.dockerfile +10 -0
  30. package/bundled-skills/maggie-security/data/semgrep/tests/generic.astro +11 -0
  31. package/bundled-skills/maggie-security/data/semgrep/tests/generic.conf +11 -0
  32. package/bundled-skills/maggie-security/data/semgrep/tests/js.jsx +95 -0
  33. package/bundled-skills/maggie-security/data/semgrep/tests/php-debug-ok.php +5 -0
  34. package/bundled-skills/maggie-security/data/semgrep/tests/php-debug.php +3 -0
  35. package/bundled-skills/maggie-security/data/semgrep/tests/php.php +55 -0
  36. package/bundled-skills/maggie-security/evals/evals.json +97 -0
  37. package/bundled-skills/maggie-security/references/commands.md +111 -0
  38. package/bundled-skills/maggie-security/references/cve-response.md +24 -0
  39. package/bundled-skills/maggie-security/references/explaining-findings.md +57 -0
  40. package/bundled-skills/maggie-security/references/findings.md +39 -0
  41. package/bundled-skills/maggie-security/references/ownership-and-consent.md +29 -0
  42. package/bundled-skills/maggie-security/references/site-types.md +22 -0
  43. package/bundled-skills/maggie-security/references/tools-and-licences.md +16 -0
  44. package/bundled-skills/maggie-security/references/workflows.md +23 -0
  45. package/bundled-skills/maggie-security/registry/security-tools.registry.json +628 -0
  46. package/bundled-skills/maggie-security/routines.example.json +27 -0
  47. package/bundled-tools/clis/maggie_deployment_readiness.py +17 -2
  48. package/bundled-tools/clis/maggie_security.py +342 -0
  49. package/bundled-tools/clis/maggie_security_agent.py +326 -0
  50. package/bundled-tools/clis/maggie_security_code.py +650 -0
  51. package/bundled-tools/clis/maggie_security_core.py +1512 -0
  52. package/bundled-tools/clis/maggie_security_deps.py +560 -0
  53. package/bundled-tools/clis/maggie_security_health.py +415 -0
  54. package/bundled-tools/clis/maggie_security_host.py +511 -0
  55. package/bundled-tools/clis/maggie_security_hosted.py +115 -0
  56. package/bundled-tools/clis/maggie_security_intel.py +1294 -0
  57. package/bundled-tools/clis/maggie_security_inventory.py +1023 -0
  58. package/bundled-tools/clis/maggie_security_logs.py +777 -0
  59. package/bundled-tools/clis/maggie_security_mcp.py +473 -0
  60. package/bundled-tools/clis/maggie_security_mirror.py +122 -0
  61. package/bundled-tools/clis/maggie_security_protect.py +519 -0
  62. package/bundled-tools/clis/maggie_security_report.py +506 -0
  63. package/bundled-tools/clis/maggie_security_standards.py +214 -0
  64. package/bundled-tools/clis/maggie_security_versions.py +381 -0
  65. package/bundled-tools/clis/maggie_security_web.py +936 -0
  66. package/bundled-tools/clis/security_data/eol-snapshot.json +1 -0
  67. package/bundled-tools/clis/security_data/gitleaks-rules.json +3812 -0
  68. package/bundled-tools/clis/security_data/maltrail-lists.json +1 -0
  69. package/bundled-tools/clis/security_data/public-suffix-icann.txt +7374 -0
  70. package/bundled-tools/clis/security_data/retire-extractors.json +695 -0
  71. package/bundled-tools/clis/security_data/sigma-web.json +6514 -0
  72. package/bundled-tools/clis/security_data/tools.lock.json +358 -0
  73. package/package.json +1 -1
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env python3
2
+ """Health and uptime with Gatus (docs/maggie-security/08-health-uptime-prd.md):
3
+ ``uptime-init``, ``uptime-deploy``, ``uptime-status``, ``uptime-push``.
4
+
5
+ - ``uptime-init`` writes a Gatus configuration folder for the site's verified
6
+ domains (homepage status/latency/certificate/domain expiry/brand text,
7
+ DNS, HTTP→HTTPS, site-type extras) plus heartbeat endpoints for the
8
+ security jobs. Secrets are ``${ENV}`` references, never values.
9
+ - ``uptime-deploy`` writes a plan (Docker on 127.0.0.1 behind nginx, image
10
+ pinned by digest, basic auth in Gatus); it never runs it.
11
+ - ``uptime-status`` reads a running Gatus (and optionally an Uptime Kuma
12
+ status page) and turns failures, low uptime, slow responses, certificate
13
+ and domain expiry, DNS failures and missed heartbeats into findings.
14
+ - ``uptime-push`` reports a job's result to its Gatus heartbeat endpoint.
15
+
16
+ Gatus facts used here were checked in the TwiN/gatus source: the endpoint key
17
+ format (``config/key/key.go``), the custom alert placeholders, the external
18
+ endpoint route and its Bearer token, and the raw uptime route (a float).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import base64
25
+ import json
26
+ import os
27
+ import re
28
+ import urllib.parse
29
+ from pathlib import Path
30
+
31
+ import maggie_security_core as core
32
+ import maggie_security_web as web
33
+
34
+ GATUS_IMAGE = "ghcr.io/twin/gatus:v5.37.0@sha256:094eb186e55235db367e90e9d56140e5897b7044c41de23cde4cc18e6da1242e"
35
+ HEARTBEATS = {"vuln-watch": "26h", "eol-watch": "26h", "report-daily": "26h", "report-weekly": "8d", "logs-sigma": "26h", "host-quick": "8d"}
36
+
37
+
38
+ def gatus_key(group: str, name: str) -> str:
39
+ """Port of Gatus config/key ConvertGroupAndNameToKey."""
40
+ def sanitize(text: str) -> str:
41
+ text = text.strip().lower()
42
+ for char in "/_., #+&":
43
+ text = text.replace(char, "-")
44
+ return text
45
+ return f"{sanitize(group)}_{sanitize(name)}"
46
+
47
+
48
+ def yaml_str(value: str) -> str:
49
+ return json.dumps(value) # a JSON string is a valid YAML double-quoted scalar
50
+
51
+
52
+ # --------------------------------------------------------------------------- uptime-init
53
+
54
+
55
+ def site_list(ctx: core.Context) -> list[dict]:
56
+ args = ctx.args
57
+ if args.sites:
58
+ return core.read_json(Path(args.sites))
59
+ domains = (ctx.state.config().get("domains") or [])
60
+ stack_path = ctx.state.path("inventory", "stack.json")
61
+ site_type = "unknown"
62
+ if stack_path.is_file():
63
+ stack = core.read_json(stack_path)
64
+ domains = domains or stack["site"].get("domains") or []
65
+ site_type = stack["site"].get("type") or site_type
66
+ if not domains:
67
+ raise core.UsageError("no domains: set domains in .maggie/security/config.json or pass --sites FILE")
68
+ return [{"id": re.sub(r"[^a-z0-9-]+", "-", d.lower()), "url": f"https://{d}/", "type": site_type, "brand_text": args.brand_text} for d in domains]
69
+
70
+
71
+ def endpoint_yaml(entry: dict) -> list[str]:
72
+ lines = [f" - name: {yaml_str(entry['name'])}", f" group: {yaml_str(entry['group'])}", f" url: {yaml_str(entry['url'])}",
73
+ f" interval: {entry.get('interval', '5m')}"]
74
+ if entry.get("dns"):
75
+ lines += [" dns:", f" query-name: {yaml_str(entry['dns']['query-name'])}", f" query-type: {yaml_str(entry['dns']['query-type'])}"]
76
+ if entry.get("ignore_redirect"):
77
+ lines += [" client:", " ignore-redirect: true"]
78
+ lines.append(" conditions:")
79
+ lines += [f" - {yaml_str(c)}" for c in entry["conditions"]]
80
+ if entry.get("alert", True):
81
+ lines += [" alerts:", f" - type: {entry.get('alert_type', 'custom')}", " failure-threshold: 3", " success-threshold: 2",
82
+ " send-on-resolved: true", f" description: {yaml_str(entry['group'] + ' ' + entry['name'])}"]
83
+ return lines
84
+
85
+
86
+ def site_endpoints(site: dict, interval: str, alert_type: str) -> list[dict]:
87
+ url = site["url"].rstrip("/") + "/"
88
+ host = web.host_of(url)
89
+ group = site["id"]
90
+ home = ["[STATUS] == 200", "[RESPONSE_TIME] < 1000", "[CERTIFICATE_EXPIRATION] > 720h"]
91
+ if site.get("brand_text"):
92
+ home.append(f"[BODY] == pat(*{site['brand_text']}*)")
93
+ endpoints = [{"name": "homepage", "group": group, "url": url, "interval": interval, "conditions": home},
94
+ {"name": "domain-expiry", "group": group, "url": url, "interval": "1h", "conditions": ["[DOMAIN_EXPIRATION] > 720h"]},
95
+ {"name": "dns-a", "group": group, "url": "1.1.1.1", "interval": "15m", "dns": {"query-name": host, "query-type": "A"},
96
+ "conditions": ["[DNS_RCODE] == NOERROR"]},
97
+ {"name": "https-redirect", "group": group, "url": f"http://{host}/", "interval": "30m", "ignore_redirect": True,
98
+ "conditions": ["[STATUS] == 301"]}]
99
+ site_type = site.get("type")
100
+ if site_type == "wordpress":
101
+ endpoints += [{"name": "wp-login", "group": group, "url": url + "wp-login.php", "interval": "15m", "conditions": ["[STATUS] < 500"]},
102
+ {"name": "wp-json", "group": group, "url": url + "wp-json/", "interval": "15m", "conditions": ["[STATUS] < 500"]},
103
+ {"name": "xmlrpc-blocked", "group": group, "url": url + "xmlrpc.php", "interval": "1h",
104
+ "conditions": [f"[STATUS] == {site.get('xmlrpc_status', 403)}"]}]
105
+ elif site_type in ("static", "workers", "astro"):
106
+ endpoints.append({"name": "sitemap", "group": group, "url": url + "sitemap.xml", "interval": "30m", "conditions": ["[STATUS] == 200"]})
107
+ if site.get("health_path"):
108
+ endpoints.append({"name": "health", "group": group, "url": url + site["health_path"].lstrip("/"), "interval": interval,
109
+ "conditions": ["[STATUS] == 200"]})
110
+ for entry in endpoints:
111
+ entry["alert_type"] = alert_type
112
+ entry["alert"] = alert_type != "none"
113
+ return endpoints
114
+
115
+
116
+ def uptime_init(ctx: core.Context) -> dict:
117
+ args = ctx.args
118
+ sites = site_list(ctx)
119
+ unverified = []
120
+ for site in sites:
121
+ try:
122
+ web.require_ownership(ctx, web.host_of(site["url"]), False)
123
+ except core.PolicyError:
124
+ unverified.append(web.registrable(web.host_of(site["url"])))
125
+ if unverified:
126
+ raise core.PolicyError(f"Gatus would poll these every few minutes, so they must be verified first: {', '.join(sorted(set(unverified)))}")
127
+ if args.alert == "ntfy" and args.ntfy_url and "ntfy.sh" in args.ntfy_url and not args.allow_public_ntfy:
128
+ raise core.PolicyError("public ntfy.sh topics can be read by anyone who guesses the name; use a self-hosted ntfy or pass --allow-public-ntfy")
129
+ for site in sites:
130
+ if site.get("brand_text"):
131
+ response = web.fetch(site["url"], timeout=15)
132
+ if site["brand_text"].lower() not in response["body"].decode("utf-8", errors="replace").lower():
133
+ raise core.UsageError(f"brand text {site['brand_text']!r} is not on {site['url']}; choose stable text from the page")
134
+ out = Path(args.out) if Path(args.out).is_absolute() else ctx.project / args.out
135
+ out.mkdir(parents=True, exist_ok=True)
136
+ alert_type = {"webhook": "custom", "ntfy": "ntfy", "email": "email", "none": "none"}[args.alert]
137
+ global_lines = ["# Maggie Gatus settings (generated by maggie security uptime-init; secrets are environment variables)",
138
+ "storage:", " type: sqlite", " path: /data/gatus.db", " maximum-number-of-results: 200", " maximum-number-of-events: 50",
139
+ "security:", " basic:", ' username: "${GATUS_BASIC_USER}"', ' password-bcrypt-base64: "${GATUS_BASIC_PASSWORD_BCRYPT_B64}"']
140
+ if alert_type == "custom":
141
+ global_lines += ["alerting:", " custom:", ' url: "${MAGGIE_ALERT_WEBHOOK_URL}"', ' method: "POST"', " body: |",
142
+ ' {"source":"gatus","endpoint":"[ENDPOINT_GROUP]/[ENDPOINT_NAME]","status":"[ALERT_TRIGGERED_OR_RESOLVED]","description":"[ALERT_DESCRIPTION]"}']
143
+ elif alert_type == "ntfy":
144
+ global_lines += ["alerting:", " ntfy:", f" url: {yaml_str(args.ntfy_url or 'https://ntfy.example.com')}", ' topic: "${MAGGIE_NTFY_TOPIC}"',
145
+ ' token: "${MAGGIE_NTFY_TOKEN}"', " priority: 4"]
146
+ elif alert_type == "email":
147
+ global_lines += ["alerting:", " email:", ' from: "${MAGGIE_ALERT_FROM}"', ' host: "${MAGGIE_SMTP_HOST}"', " port: 587",
148
+ ' username: "${MAGGIE_SMTP_USER}"', ' password: "${MAGGIE_SMTP_PASSWORD}"', ' to: "${MAGGIE_ALERT_TO}"']
149
+ files = {"00-maggie-global.yaml": "\n".join(global_lines) + "\n"}
150
+ for site in sites:
151
+ lines = [f"# {site['url']} ({site.get('type') or 'site'})", "endpoints:"]
152
+ for entry in site_endpoints(site, args.interval, alert_type):
153
+ lines += endpoint_yaml(entry)
154
+ files[f"10-site-{site['id']}.yaml"] = "\n".join(lines) + "\n"
155
+ if args.with_heartbeats:
156
+ lines = ["# Heartbeats for Maggie security jobs: each job reports with `maggie security uptime-push`; silence raises an alert.",
157
+ "external-endpoints:"]
158
+ for job, interval in HEARTBEATS.items():
159
+ lines += [f" - name: {yaml_str(job)}", ' group: "maggie-security"', f' token: "${{MAGGIE_HEARTBEAT_TOKEN}}"',
160
+ " heartbeat:", f" interval: {interval}"]
161
+ if alert_type != "none":
162
+ lines += [" alerts:", f" - type: {alert_type}", f" description: {yaml_str('security job ' + job + ' did not report')}"]
163
+ files["90-maggie-heartbeats.yaml"] = "\n".join(lines) + "\n"
164
+ files["README.md"] = ("# Gatus configuration (Maggie)\n\nGenerated by `maggie security uptime-init`. Each site file checks the homepage "
165
+ "(status 200, under 1 s, certificate valid for 30+ days" + (", brand text present" if any(s.get("brand_text") for s in sites) else "")
166
+ + "), domain expiry, DNS and the HTTP→HTTPS redirect.\nSet the environment variables named in `00-maggie-global.yaml` "
167
+ "in the container's env file (mode 0600). Deploy with the plan from `maggie security uptime-deploy`.\n")
168
+ for name, text in files.items():
169
+ (out / name).write_text(text, encoding="utf-8")
170
+ return {"schema": "maggie-security-uptime-init.v1", "dir": str(out), "files": sorted(files), "sites": [s["url"] for s in sites],
171
+ "endpoints": sum(len(site_endpoints(s, args.interval, alert_type)) for s in sites), "heartbeats": sorted(HEARTBEATS) if args.with_heartbeats else []}
172
+
173
+
174
+ # --------------------------------------------------------------------------- uptime-deploy (plan only)
175
+
176
+
177
+ def uptime_deploy(ctx: core.Context) -> dict:
178
+ args = ctx.args
179
+ host_alias = args.host_alias or "ops1"
180
+ target = ((ctx.state.config().get("hosts") or {}).get(host_alias)) or f"deploy@{host_alias}"
181
+ plan_id = f"up_{core.iso()[:10]}_{host_alias}"
182
+ hostname = args.hostname or "status.example.com"
183
+ compose = "\n".join([
184
+ "services:", " gatus:", f" image: {GATUS_IMAGE}", " restart: unless-stopped", " ports:", ' - "127.0.0.1:8080:8080"',
185
+ " environment:", " GATUS_CONFIG_PATH: /config", " env_file: /etc/gatus/gatus.env", " volumes:",
186
+ " - /srv/gatus/config:/config:ro", " - /srv/gatus/data:/data", " read_only: true", " security_opt:", ' - "no-new-privileges:true"',
187
+ " cap_drop:", ' - "ALL"'])
188
+ nginx = "\n".join([
189
+ "server {", " listen 443 ssl http2;", f" server_name {hostname};", f" ssl_certificate /etc/letsencrypt/live/{hostname}/fullchain.pem;",
190
+ f" ssl_certificate_key /etc/letsencrypt/live/{hostname}/privkey.pem;", ' add_header Strict-Transport-Security "max-age=31536000" always;',
191
+ " location ~ ^/api/v1/endpoints/[^/]+/(uptimes|response-times|health)/ { deny all; } # badges stay private unless you want them public",
192
+ " location ~ ^/api/v1/endpoints/[^/]+/external$ { proxy_pass http://127.0.0.1:8080; } # heartbeats (Bearer token)",
193
+ " location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; }", "}"])
194
+ steps = [
195
+ {"n": 1, "title": "Take the host lock", "kind": "local", "run": f"maggie deployment host-lock acquire --host {target} --session {plan_id} --purpose 'deploy gatus'"},
196
+ {"n": 2, "title": "Create folders owned by an unprivileged user", "kind": "host",
197
+ "run": "sudo useradd --system --no-create-home gatus 2>/dev/null; sudo install -d -o gatus -g gatus -m 750 /srv/gatus/config /srv/gatus/data && sudo install -d -m 700 /etc/gatus",
198
+ "rollback": "sudo rm -rf /srv/gatus /etc/gatus"},
199
+ {"n": 3, "title": "Copy the generated configuration", "kind": "local", "run": f"scp {args.config}/*.yaml {target}:/srv/gatus/config/"},
200
+ {"n": 4, "title": "Create the env file yourself (mode 0600): GATUS_BASIC_USER, GATUS_BASIC_PASSWORD_BCRYPT_B64 (htpasswd -nbBC 9 user pass | cut -d: -f2 | base64 -w0), MAGGIE_ALERT_WEBHOOK_URL, MAGGIE_HEARTBEAT_TOKEN",
201
+ "kind": "host", "run": "sudoedit /etc/gatus/gatus.env && sudo chmod 600 /etc/gatus/gatus.env", "rollback": "sudo rm -f /etc/gatus/gatus.env"},
202
+ {"n": 5, "title": "Write docker-compose.yml (image pinned by digest, bound to 127.0.0.1 only: Docker ports bypass UFW)", "kind": "host",
203
+ "run": "sudo tee /srv/gatus/docker-compose.yml <<'EOF'\n" + compose + "\nEOF", "rollback": "sudo rm -f /srv/gatus/docker-compose.yml"},
204
+ {"n": 6, "title": "Start Gatus", "kind": "host", "run": "cd /srv/gatus && sudo docker compose up -d", "rollback": "cd /srv/gatus && sudo docker compose down"},
205
+ {"n": 7, "title": f"Add the nginx site for {hostname} (TLS via your usual certbot)", "kind": "host",
206
+ "run": f"sudo tee /etc/nginx/sites-available/gatus <<'EOF'\n{nginx}\nEOF\nsudo ln -sf /etc/nginx/sites-available/gatus /etc/nginx/sites-enabled/gatus && sudo nginx -t && sudo systemctl reload nginx",
207
+ "rollback": "sudo rm -f /etc/nginx/sites-enabled/gatus && sudo systemctl reload nginx"},
208
+ {"n": 8, "title": "Release the host lock", "kind": "local", "run": f"maggie deployment host-lock release --host {target} --session {plan_id}"},
209
+ ]
210
+ verify = [f"curl -fsS -u USER:PASS https://{hostname}/api/v1/endpoints/statuses | head -c 300",
211
+ "sudo ls -l /srv/gatus/data/gatus.db # grows after a few minutes",
212
+ "cd /srv/gatus && sudo docker compose restart && curl … again: history is kept"]
213
+ markdown = "\n".join([f"# Uptime monitor plan {plan_id} — {host_alias}", "", "**Nothing has been changed.** Review, then run each step yourself under the host lock.",
214
+ "", "> A monitor on the same server as the site goes silent when that server does. Prefer a second small server; at least keep the heartbeats.", ""]
215
+ + [f"{s['n']}. **{s['title']}**\n ```bash\n {s['run']}\n ```" + (f"\n Undo: `{s['rollback']}`" if s.get("rollback") else "") for s in steps]
216
+ + ["", "## Check afterwards", ""] + [f"- `{v}`" for v in verify])
217
+ out = ctx.state.path("plans", f"{plan_id}.md")
218
+ out.parent.mkdir(parents=True, exist_ok=True)
219
+ out.write_text(markdown + "\n", encoding="utf-8")
220
+ core.write_json(out.with_suffix(".json"), {"schema": "maggie-security-uptime-plan.v1", "id": plan_id, "image": GATUS_IMAGE, "steps": steps, "verify": verify})
221
+ return {"schema": "maggie-security-uptime-plan.v1", "id": plan_id, "path": str(out), "image": GATUS_IMAGE, "markdown": markdown}
222
+
223
+
224
+ # --------------------------------------------------------------------------- uptime-status
225
+
226
+
227
+ def gatus_get(ctx: core.Context, base: str, path: str, auth: bool) -> tuple[int, bytes]:
228
+ url = base.rstrip("/") + path
229
+ ctx.guard.extra_hosts.add(web.host_of(base))
230
+ ctx.guard.check("target-site", url)
231
+ headers = {}
232
+ user, password = os.environ.get("GATUS_BASIC_USER"), os.environ.get("GATUS_BASIC_PASSWORD")
233
+ if auth and user and password:
234
+ headers["Authorization"] = "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
235
+ status, _h, body = core.http_request(url, headers=headers, timeout=20, retries=1)
236
+ return status, body
237
+
238
+
239
+ def go_duration_hours(text: str) -> float | None:
240
+ match = re.search(r"\((?:(\d+)h)?(?:(\d+)m)?(?:[\d.]+s)?\)", text)
241
+ if not match or not (match.group(1) or match.group(2)):
242
+ return None
243
+ return int(match.group(1) or 0) + int(match.group(2) or 0) / 60
244
+
245
+
246
+ def gatus_findings(ctx: core.Context, statuses: list[dict], uptimes: dict[str, float]) -> list[dict]:
247
+ findings = []
248
+
249
+ def add(rule: str, key: str, title: str, severity: str, kind: str = "availability", summary: str = "", url: str | None = None) -> None:
250
+ findings.append(core.make_finding(kind=kind, command="uptime-status", primary_id=f"{rule}:{key}", site=ctx.site, title=title, severity=severity,
251
+ severity_source="maggie", confidence="confirmed", target={"url": url or f"gatus:{key}", "host": "monitor"},
252
+ rule_id=rule, remediation={"summary": summary, "steps": [], "snippets": [], "references": [], "cheat_sheet": None}))
253
+
254
+ for status in statuses:
255
+ key, name, group = status.get("key"), status.get("name", ""), status.get("group", "")
256
+ results = status.get("results") or []
257
+ label = f"{group}/{name}"
258
+ if group == "maggie-security":
259
+ if results and not results[-1].get("success"):
260
+ add("job-missed", key, f"Security job {name} did not report or failed", "medium", summary="Check the routine or cron that runs it.")
261
+ continue
262
+ recent = results[-3:]
263
+ if recent and not any(r.get("success") for r in recent):
264
+ errors = "; ".join(e for r in recent for e in (r.get("errors") or []))[:150]
265
+ body_failed = all(any("[BODY]" in c.get("condition", "") and not c.get("success") for c in r.get("conditionResults") or []) for r in recent)
266
+ status_ok = all(r.get("status") == 200 for r in recent)
267
+ if body_failed and status_ok:
268
+ add("content-mismatch", key, f"{label}: the page loads but the expected text is missing (possible defacement or a wrong deploy)", "high",
269
+ kind="exposure", summary="Open the page and compare it with the last deploy.")
270
+ elif name.startswith("dns"):
271
+ add("dns-failure", key, f"{label}: DNS lookups fail", "high", summary="Check the DNS records and the registrar.")
272
+ else:
273
+ add("uptime-down", key, f"{label} is down" + (f" ({errors})" if errors else ""), "high" if name == "homepage" else "medium",
274
+ summary="Check the server and the last deploy.")
275
+ last = results[-1] if results else {}
276
+ for condition in last.get("conditionResults") or []:
277
+ text = condition.get("condition", "")
278
+ hours = go_duration_hours(text)
279
+ if hours is None:
280
+ continue
281
+ days = hours / 24
282
+ if "[CERTIFICATE_EXPIRATION]" in text and days <= 30:
283
+ add("cert-expiry", key, f"{label}: the certificate expires in {days:.0f} days", "high" if days <= 7 else "medium" if days <= 14 else "low",
284
+ kind="tls", summary="Renew it and check automatic renewal.")
285
+ if "[DOMAIN_EXPIRATION]" in text and days <= 30:
286
+ add("domain-expiry", key, f"{label}: the domain expires in {days:.0f} days", "critical" if days <= 7 else "high" if days <= 14 else "medium",
287
+ summary="Renew the domain at the registrar and turn on auto-renew.")
288
+ durations = [r.get("duration", 0) / 1e9 for r in results[-20:] if r.get("duration")]
289
+ if durations and name == "homepage" and sum(durations) / len(durations) > 1.0:
290
+ add("uptime-slow", key, f"{label} averages {sum(durations) / len(durations):.1f}s to respond", "low", summary="Check caching and the server load.")
291
+ ratio = uptimes.get(key)
292
+ if ratio is not None and ratio < 0.995:
293
+ add("uptime-below-target", key, f"{label} was up {ratio:.2%} of the window", "high" if ratio < 0.99 else "medium",
294
+ summary="Look at the outage times in Gatus.")
295
+ return findings
296
+
297
+
298
+ def uptime_status(ctx: core.Context):
299
+ args = ctx.args
300
+ findings: list[dict] = []
301
+ if args.gatus:
302
+ if args.statuses_file:
303
+ statuses = core.read_json(Path(args.statuses_file))
304
+ else:
305
+ status, body = gatus_get(ctx, args.gatus, "/api/v1/endpoints/statuses", True)
306
+ if status == 401:
307
+ ctx.degrade("gatus", "401: set GATUS_BASIC_USER and GATUS_BASIC_PASSWORD")
308
+ return ctx.finish([])
309
+ if status != 200:
310
+ ctx.degrade("gatus", f"HTTP {status}")
311
+ return ctx.finish([])
312
+ statuses = json.loads(body.decode() or "[]")
313
+ if args.group:
314
+ statuses = [s for s in statuses if s.get("group") == args.group]
315
+ uptimes = {}
316
+ if not args.statuses_file:
317
+ for item in statuses:
318
+ code, body = gatus_get(ctx, args.gatus, f"/api/v1/endpoints/{urllib.parse.quote(item['key'])}/uptimes/{args.window}", False)
319
+ if code == 200:
320
+ try:
321
+ uptimes[item["key"]] = float(body.decode().strip())
322
+ except ValueError:
323
+ pass
324
+ findings += gatus_findings(ctx, statuses, uptimes)
325
+ ctx.run["inputs"].update({"gatus": web.host_of(args.gatus), "endpoints": len(statuses), "window": args.window})
326
+ if args.kuma and args.kuma_slug:
327
+ status, body = gatus_get(ctx, args.kuma, f"/api/status-page/heartbeat/{urllib.parse.quote(args.kuma_slug)}", False)
328
+ if status == 200:
329
+ data = json.loads(body.decode() or "{}")
330
+ for monitor_id, beats in (data.get("heartbeatList") or {}).items():
331
+ if beats and beats[-1].get("status") == 0:
332
+ findings.append(core.make_finding(kind="availability", command="uptime-status", primary_id=f"uptime-down:kuma-{monitor_id}", site=ctx.site,
333
+ title=f"Uptime Kuma monitor {monitor_id} is down", severity="medium", severity_source="tool",
334
+ confidence="confirmed", target={"url": f"kuma:{monitor_id}", "host": "monitor"}))
335
+ else:
336
+ ctx.degrade("kuma", f"HTTP {status}")
337
+ if not args.gatus and not args.kuma:
338
+ raise core.UsageError("give --gatus URL (and/or --kuma URL --kuma-slug SLUG)")
339
+ return ctx.finish(findings, covered=["*"] if not ctx.run["degraded"] else [])
340
+
341
+
342
+ # --------------------------------------------------------------------------- uptime-push
343
+
344
+
345
+ def uptime_push(ctx: core.Context) -> dict:
346
+ args = ctx.args
347
+ config = ctx.state.config().get("uptime") or {}
348
+ token = os.environ.get("MAGGIE_HEARTBEAT_TOKEN")
349
+ if not token and config.get("heartbeat_token_file"):
350
+ token = Path(config["heartbeat_token_file"]).expanduser().read_text(encoding="utf-8").strip()
351
+ if not token:
352
+ raise core.UsageError("no heartbeat token: set MAGGIE_HEARTBEAT_TOKEN or uptime.heartbeat_token_file in config.json")
353
+ base = args.gatus or config.get("gatus_url")
354
+ if not base:
355
+ raise core.UsageError("give --gatus URL or set uptime.gatus_url in config.json")
356
+ key = gatus_key("maggie-security", args.job)
357
+ query = {"success": "true" if args.success == "true" else "false"}
358
+ if args.duration:
359
+ query["duration"] = args.duration
360
+ if args.error and args.success != "true":
361
+ query["error"] = re.sub(r"[^a-z0-9:_.-]", "", args.error.lower())[:60] # a short code, never finding details
362
+ url = f"{base.rstrip('/')}/api/v1/endpoints/{key}/external?{urllib.parse.urlencode(query)}"
363
+ ctx.guard.extra_hosts.add(web.host_of(base))
364
+ ctx.guard.check("target-site", url)
365
+ try:
366
+ status, _h, _b = core.http_request(url, method="POST", data=b"", headers={"Authorization": f"Bearer {token}"}, timeout=15, retries=1)
367
+ except core.DegradedError as error:
368
+ return {"schema": "maggie-security-uptime-push.v1", "key": key, "pushed": False, "error": str(error), "exit_code": core.EXIT_DEGRADED}
369
+ return {"schema": "maggie-security-uptime-push.v1", "key": key, "pushed": status == 200, "status": status,
370
+ "exit_code": core.EXIT_OK if status == 200 else core.EXIT_DEGRADED}
371
+
372
+
373
+ def _init(p: argparse.ArgumentParser) -> None:
374
+ p.add_argument("--out", default="ops/gatus")
375
+ p.add_argument("--sites", help="JSON list of {id, url, type, brand_text, health_path, xmlrpc_status}")
376
+ p.add_argument("--interval", default="5m")
377
+ p.add_argument("--alert", choices=["webhook", "ntfy", "email", "none"], default="webhook")
378
+ p.add_argument("--ntfy-url")
379
+ p.add_argument("--allow-public-ntfy", action="store_true")
380
+ p.add_argument("--brand-text", help="stable text on the homepage (defacement check)")
381
+ p.add_argument("--no-heartbeats", dest="with_heartbeats", action="store_false")
382
+
383
+
384
+ def _deploy(p: argparse.ArgumentParser) -> None:
385
+ p.add_argument("--host-alias")
386
+ p.add_argument("--config", default="ops/gatus")
387
+ p.add_argument("--hostname", help="public name for the status page, e.g. status.example.com")
388
+
389
+
390
+ def _status(p: argparse.ArgumentParser) -> None:
391
+ p.add_argument("--gatus", help="Gatus base URL")
392
+ p.add_argument("--statuses-file", help="saved /api/v1/endpoints/statuses JSON (offline)")
393
+ p.add_argument("--window", choices=["1h", "24h", "7d", "30d"], default="7d")
394
+ p.add_argument("--group")
395
+ p.add_argument("--kuma")
396
+ p.add_argument("--kuma-slug")
397
+
398
+
399
+ def _push(p: argparse.ArgumentParser) -> None:
400
+ p.add_argument("--gatus")
401
+ p.add_argument("--job", required=True, choices=sorted(HEARTBEATS))
402
+ p.add_argument("--success", choices=["true", "false"], default="true")
403
+ p.add_argument("--duration", help="Go duration, e.g. 38s")
404
+ p.add_argument("--error", help="short code such as degraded:wpvulnerability")
405
+
406
+
407
+ COMMANDS = [
408
+ core.Command("uptime-init", "generate a Gatus configuration for your verified sites and security-job heartbeats", ["target-site", "doh", "dns"],
409
+ _init, uptime_init, findings=False),
410
+ core.Command("uptime-deploy", "a reviewed plan to run Gatus (Docker on 127.0.0.1 behind nginx); never applied", [], _deploy, uptime_deploy,
411
+ findings=False, routine_safe=False),
412
+ core.Command("uptime-status", "read Gatus (or an Uptime Kuma status page) and report downtime, slowness, certificate and domain expiry",
413
+ ["target-site"], _status, uptime_status),
414
+ core.Command("uptime-push", "report a security job's result to its Gatus heartbeat", ["target-site"], _push, uptime_push, findings=False),
415
+ ]