@topy-ai/maggie 0.7.59 → 0.7.61
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 +1 -1
- package/README.md +6 -4
- package/bundled-skills/maggie-security/SKILL.md +1 -1
- package/bundled-skills/maggie-security/references/commands.md +2 -1
- package/bundled-tools/clis/maggie_security_host.py +121 -0
- package/bundled-tools/clis/maggie_security_mcp.py +2 -2
- package/bundled-tools/clis/maggie_security_report.py +211 -5
- package/package.json +1 -1
package/README-zh-TW.md
CHANGED
|
@@ -143,7 +143,7 @@ structure drift 或重複 treatment product 時 fail closed。
|
|
|
143
143
|
deployment、memory、feedback 和 MaggieDash。內容先 draft/review,外部寫入、
|
|
144
144
|
publish 與 production deployment 需要明確確認。
|
|
145
145
|
|
|
146
|
-
目前 npm 版本為 `0.7.
|
|
146
|
+
目前 npm 版本為 `0.7.61`,包含 fix-plan 修補風險分級 T1–T4(判斷補丁是否安全套用)與 host-impact 補丁影響面量測(哪些服務需重啟、是否需重開機)、public-check、Maggie Security、batch 31 排程 agent routines、報告收件匣與無人值守 agent 限制、PHP runtime 盤點與主機鎖、pull 網站改寫、query 點子與社群佇列檢查,batch 30 MaggieDash 先裝核心、release privacy gate、feedback 備援、社群連結與圖片檢查、大量頁面產生與 app 檢查,batch 29 預約規則、部署、app QA、助理與社群功能,以及 batch 28 產品 app、assistant tools、mobile、product release、skills catalog 與 bridge host 功能、batch 26、27 feedback 修正與 idempotent `maggie update`與 Booking email-template 合約/CLI validator 同步。本版完成 2026-09-24 booking feedback batch:Calendar grid/free-slot entry 與 host modal focus-restoration evidence。本版新增 Starter marketplace provisioning plan;本版完成 feedback batches 22、23:加入 declarative browser interaction evidence、design markup/style/content-key gate、compiler source anchoring、entity double-escape protection、`update --help` read-only、noindex hypothetical indexability、可過期 baseline acknowledgement、DOM-only link extraction regression,以及 MaggieDash 0.2.10 的 content override、短期 browser session、CDN-safe public-session cache contracts。authentication 和 isolated dev server 仍由 host 負責。`maggie-booking` workflow、傳統 email/password、Stripe、Resend、public booking flow 及既有安全邊界維持不變;provider-bound video generation 與 transactional delivery 仍記錄在 issue #39,前一批延後功能仍在 issue #38,不會直接成為 active memory。0.7.33 記錄並審查一筆更正回饋;確認 0.7.32 已正確處理 batch index,沒有修改已驗證的 shared behavior。0.7.32 加入 feedback 一基索引支援、MaggieDash panel 的 source/freshness/error evidence、Consent Mode 與實際 write/no-op reconciliation、SEO baseline recapture、sitemap origin rebasing、content-diff scope、migration ledger reconciliation、deployment credential preflight、release provenance/runtime preflight、icon release gate、opt-in blog auto-publish gate,以及 least-privilege VPS deployer。也修復 top-level `maggie feedback` dispatcher parity。0.7.31 加入 update 差異摘要、atomic installer copy、subset install manifest merge,以及 inventory drift 時 doctor non-zero。0.7.30 補上 feedback tracking issue closeout gate 與 batch 14/15 audit ledger。0.7.29 修正 npm package landing page 使用英文 README;0.7.28 加入 versioned Gemini model policy、明確 fallback 與 provenance、bundled-first CLI
|
|
147
147
|
dispatch、`maggie --version`、feedback batch review 聚合與重複偵測、marketplace enrichment evidence、booking
|
|
148
148
|
worker/resolver evidence,以及 read-only migration preflight。它也包含 host-owned mobile app surface contract、signed-in
|
|
149
149
|
camera-state QA、直接 Astro route resolution、correlated feedback batch,以及
|
package/README.md
CHANGED
|
@@ -89,7 +89,9 @@ Maggie keeps the existing project foundation and asks for decisions before
|
|
|
89
89
|
shared routes, analytics, or publishing boundaries change. The current
|
|
90
90
|
package ships 23 installable skills and a local-first MaggieDash foundation.
|
|
91
91
|
|
|
92
|
-
This package is `0.7.
|
|
92
|
+
This package is `0.7.61` — safe patching. `maggie security fix-plan` tags every action with a **fix-risk tier** T1–T4 — whether a patch is safe to apply, not just what to patch: T1 batch-safe distro/permission fixes, T2 restart+smoke-test, T3 config hardening that validates and auto-rolls-back, T4 major/EOL moves emitted as accepted-risk drafts to stage first. New `maggie security host-impact` (read-only) measures each patch's **blast radius** — which pending updates are security-only, which running services must restart to make a fix live (`needrestart`), and whether a reboot is pending — and feeds it back into the plan.
|
|
93
|
+
|
|
94
|
+
The 0.7.59 release made `maggie security host-audit` (the deep
|
|
93
95
|
lynis audit over SSH) robust on hosts where a storage test can stall: lynis runs
|
|
94
96
|
in its own process group under a watchdog, and the bundled profile skips the ZFS
|
|
95
97
|
storage/filesystem tests that hang, so the audit completes instead of timing out.
|
|
@@ -109,7 +111,7 @@ interactive-only and never probes, submits or scans; the active scanners
|
|
|
109
111
|
(`web-scan`, `zap-baseline`) and host checks stay verified-only.
|
|
110
112
|
|
|
111
113
|
The 0.7.56 release added Maggie Security, a new
|
|
112
|
-
`maggie-security` skill and `maggie security` command family (
|
|
114
|
+
`maggie-security` skill and `maggie security` command family (49 commands, 30
|
|
113
115
|
MCP tools, 7 contracts under `contracts/maggie-security/`).
|
|
114
116
|
|
|
115
117
|
- **Advisories for the whole stack.** Packages (npm, pnpm, Yarn, Bun,
|
|
@@ -856,8 +858,8 @@ artifact schemas.
|
|
|
856
858
|
Recommended upgrade sequence for the current release:
|
|
857
859
|
|
|
858
860
|
```bash
|
|
859
|
-
npx @topy-ai/maggie@0.7.
|
|
860
|
-
npx @topy-ai/maggie@0.7.
|
|
861
|
+
npx @topy-ai/maggie@0.7.61 update --scope project --project . --force
|
|
862
|
+
npx @topy-ai/maggie@0.7.61 cleanup --scope project --project .
|
|
861
863
|
```
|
|
862
864
|
|
|
863
865
|
Maintainers should pass npm credentials through the repository helper, never
|
|
@@ -50,7 +50,7 @@ Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and c
|
|
|
50
50
|
| "are we being attacked / were we hacked?" | `maggie security logs-traffic --file access.log`, `maggie security logs-sigma --file access.log`, `maggie security logs-ioc --file access.log`; servers: `maggie security logs-remote --ssh web1=user@host` | `security_logs_check` |
|
|
51
51
|
| "is my server hardened?" | `maggie security host-quick --ssh web1=user@host` (or `--print-collector` for the person to run), `maggie security host-nginx-config --ssh web1=user@host`, deep: `maggie security host-audit --ssh web1=user@host` | `security_host_check` |
|
|
52
52
|
| "protect my server" (CrowdSec / fail2ban / Cloudflare) | `maggie security protect-plan --host web1 [--engine crowdsec\|fail2ban]` → the person reviews and runs it under the host lock; afterwards `maggie security protect-plan verify --plan FILE --ssh web1=user@host` | `security_protect_plan` |
|
|
53
|
-
| "give me the fixes to run" | after the checks: `maggie security fix-plan --script fix.sh --output RUNBOOK.md` — a reviewed runbook (file permissions, firewall, apt updates, intrusion prevention, nginx, EOL) built from the findings; the person reviews it and runs it themselves under the host lock. Maggie never applies it. | — |
|
|
53
|
+
| "give me the fixes to run" | after the checks: `maggie security fix-plan --script fix.sh --output RUNBOOK.md` — a reviewed runbook (file permissions, firewall, apt updates, intrusion prevention, nginx, EOL) built from the findings, each action tagged with a fix-risk tier T1–T4 (T1 batch-safe → T3 validate+auto-rollback → T4 stage-first/accepted-risk) so the operator knows what is safe to batch vs what must be tested first; the person reviews it and runs it themselves under the host lock. Maggie never applies it. Run `maggie security host-impact` first so the plan is blast-radius-aware (which services a patch affects, what must restart, whether a reboot is pending). | — |
|
|
54
54
|
| "monitor uptime / tell me when the site is down" | `maggie security uptime-init` (verified domains) → `maggie security uptime-deploy --hostname status.example.com` (plan) → `maggie security uptime-status --gatus URL`; jobs report with `maggie security uptime-push` | `security_uptime_status` |
|
|
55
55
|
| "how do we compare to OWASP?" / "threat model" | `maggie security checklist --level 1`, `maggie security threat-model --include-findings` | `security_checklist_get`, `security_threat_model` |
|
|
56
56
|
| "is this npm package safe to add?" | `maggie security preinstall PKG[@VERSION]` before installing (W5); never install on `block` | `security_package_vet` |
|
|
@@ -91,6 +91,7 @@ Formats: nginx/Apache combined and vcombined, Caddy JSON, Cloudflare Logpush JSO
|
|
|
91
91
|
| `maggie security host-quick --ssh web1=user@host \| --evidence FILE \| --print-collector` | SSH, firewall, automatic updates, fail2ban/CrowdSec and port alignment, databases listening publicly, Docker bypassing UFW, SUID, sudo logging, disk/memory; checks ported from vps-audit (MIT) | ssh-read |
|
|
92
92
|
| `maggie security host-nginx-config --ssh web1=user@host \| --evidence FILE` | nginx -T and php -i rules: hidden files, PHP in uploads, PATH_INFO, old TLS/weak ciphers, HSTS, add_header inheritance, WordPress login limits, xmlrpc, key permissions | ssh-read |
|
|
93
93
|
| `maggie security host-audit --ssh web1=user@host \| --evidence report.dat` | lynis (GPL, external) uploaded to a private /tmp folder under the host lock, removed afterwards (verified); hardening index and warnings | ssh-temp-write |
|
|
94
|
+
| `maggie security host-impact --ssh web1=user@host \| --evidence FILE \| --print-collector` | patch **blast radius** (read-only): which pending updates are security-only (`apt-get -s upgrade` origin parsing) vs not, which running services still use pre-update libraries and must restart (`needrestart -b` — a patched library in memory is still the old one until restart), whether a reboot is pending (`/var/run/reboot-required` + kernel comparison), and the port→process map. Writes `inventory/impact.json`, which `fix-plan` reads to sharpen its risk tiers and restart steps. Techniques ported from PatchMon (MIT). | ssh-read |
|
|
94
95
|
| `maggie security incident start\|log\|close\|show\|list` | timeline for a CVE response; `report --kind incident` prints it | none |
|
|
95
96
|
|
|
96
97
|
## Protect and assure
|
|
@@ -122,5 +123,5 @@ Formats: nginx/Apache combined and vcombined, Caddy JSON, Cloudflare Logpush JSO
|
|
|
122
123
|
| Command | What it does |
|
|
123
124
|
|---|---|
|
|
124
125
|
| `maggie security report --kind weekly\|daily_ops\|incident\|gate [--audience owner\|technical] [--save]` | Markdown + JSON report from the latest runs; `--save` writes the `report_save` payload |
|
|
125
|
-
| `maggie security fix-plan [--priority p0..p4] [--script fix.sh] [--output RUNBOOK.md]` | a reviewed remediation runbook from the open findings: grouped by category (file permissions, firewall, package/apt updates, intrusion prevention, web-server config, EOL), each step the commands the findings themselves carry (never invented). Writes a shell script you run yourself, under `maggie deployment host-lock`, after a backup and with a second SSH session open. Maggie never applies it. |
|
|
126
|
+
| `maggie security fix-plan [--priority p0..p4] [--script fix.sh] [--output RUNBOOK.md]` | a reviewed remediation runbook from the open findings: grouped by category (file permissions, firewall, package/apt updates, intrusion prevention, web-server config, EOL), each step the commands the findings themselves carry (never invented). Every action is tagged with a **fix-risk tier** (T1 batch-safe distro/permission fixes → T2 restart+smoke-test → T3 config hardening that validates and auto-rolls-back → T4 major/EOL moves that are NOT auto-applied and are emitted as accepted-risk drafts to stage first); the apt batch backs up package state and verifies afterwards. If `host-impact` has run, the plan also shows the **blast radius** (which services each patch affects, what must restart, whether a reboot is pending) and its apt verify step restarts the services still running pre-update libraries. Writes a shell script you run yourself, under `maggie deployment host-lock`, after a backup and with a second SSH session open. Maggie never applies it. |
|
|
126
127
|
| `maggie security mcp [--tools p0\|all] [--print-config claude\|codex]` | local stdio MCP server |
|
|
@@ -215,6 +215,125 @@ def host_quick(ctx: core.Context):
|
|
|
215
215
|
return ctx.finish(findings, covered=[f"host:{host}", "*"])
|
|
216
216
|
|
|
217
217
|
|
|
218
|
+
# --------------------------------------------------------------------------- host-impact (patch blast-radius)
|
|
219
|
+
|
|
220
|
+
# The blast-radius collector answers "how big is the impact of patching X on this
|
|
221
|
+
# host?" — the input the fix-risk tiers need to judge a patch against what is
|
|
222
|
+
# actually running. Read-only. Techniques ported from PatchMon (apt security
|
|
223
|
+
# simulate + origin parsing; /var/run/reboot-required + kernel comparison;
|
|
224
|
+
# needrestart for the patched-but-not-restarted gap).
|
|
225
|
+
IMPACT_COLLECTOR = r"""set +e
|
|
226
|
+
s() { printf '\n### %s\n' "$1"; }
|
|
227
|
+
SUDO=""; [ "$(id -u)" != 0 ] && sudo -n true 2>/dev/null && SUDO="sudo -n"
|
|
228
|
+
s os-release; cat /etc/os-release 2>/dev/null
|
|
229
|
+
s security-updates; LANG=C apt-get -s -o Debug::NoLocking=1 upgrade 2>/dev/null | grep '^Inst ' | head -300
|
|
230
|
+
s reboot-required; [ -f /var/run/reboot-required ] && echo yes || echo no
|
|
231
|
+
s reboot-pkgs; cat /var/run/reboot-required.pkgs 2>/dev/null | head -50
|
|
232
|
+
s kernel; uname -r; ls -1 /boot/vmlinuz-* 2>/dev/null | sed 's#.*/vmlinuz-##' | sort -V | tail -1
|
|
233
|
+
s needrestart; $SUDO needrestart -b 2>/dev/null | grep -E '^NEEDRESTART-(KCUR|KEXP|KSTA|SVC):' | head -200
|
|
234
|
+
s listen; ss -H -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null
|
|
235
|
+
s running-services; systemctl list-units --type=service --state=running --no-legend 2>/dev/null | awk '{print $1}' | head -200
|
|
236
|
+
s timers; systemctl list-timers --all --no-legend 2>/dev/null | awk '{print $NF}' | grep -v '^$' | head -60
|
|
237
|
+
s cron; ls -1 /etc/cron.d /etc/cron.daily /etc/cron.hourly 2>/dev/null | grep -v ':$' | sort -u | head -40; $SUDO ls -1 /var/spool/cron/crontabs 2>/dev/null | head -20
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
LISTEN_PROC_RE = re.compile(r'users:\(\("([^"]+)"')
|
|
241
|
+
APT_INST_RE = re.compile(r"^Inst\s+(\S+)\s+(?:\[[^\]]*\]\s+)?\(\S+\s*([^)]*)\)") # name, then the origin tail
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def parse_impact(sections: dict[str, str]) -> dict:
|
|
245
|
+
"""Turn the collector sections into a patch blast-radius snapshot."""
|
|
246
|
+
def lines(name: str) -> list[str]:
|
|
247
|
+
return [l.strip() for l in sections.get(name, "").splitlines() if l.strip()]
|
|
248
|
+
|
|
249
|
+
security, pending = [], []
|
|
250
|
+
for line in lines("security-updates"):
|
|
251
|
+
m = APT_INST_RE.match(line)
|
|
252
|
+
if not m:
|
|
253
|
+
continue
|
|
254
|
+
name, origins = m.group(1), m.group(2) or ""
|
|
255
|
+
pending.append(name)
|
|
256
|
+
if "security" in origins.lower(): # only the origin tail — package names can contain "security"
|
|
257
|
+
security.append(name)
|
|
258
|
+
|
|
259
|
+
reboot = (sections.get("reboot-required", "").strip().lower().startswith("yes"))
|
|
260
|
+
reboot_pkgs = lines("reboot-pkgs")
|
|
261
|
+
kl = lines("kernel")
|
|
262
|
+
running_k = kl[0] if kl else None
|
|
263
|
+
latest_k = kl[1] if len(kl) > 1 else None
|
|
264
|
+
|
|
265
|
+
svc_restart, kcur, kexp, ksta = [], running_k, None, None
|
|
266
|
+
for line in lines("needrestart"):
|
|
267
|
+
tag, _, val = line.partition(":")
|
|
268
|
+
val = val.strip()
|
|
269
|
+
if tag == "NEEDRESTART-SVC":
|
|
270
|
+
svc_restart.append(val)
|
|
271
|
+
elif tag == "NEEDRESTART-KCUR":
|
|
272
|
+
kcur = val
|
|
273
|
+
elif tag == "NEEDRESTART-KEXP":
|
|
274
|
+
kexp = val
|
|
275
|
+
elif tag == "NEEDRESTART-KSTA":
|
|
276
|
+
ksta = val
|
|
277
|
+
if kexp and not latest_k:
|
|
278
|
+
latest_k = kexp
|
|
279
|
+
kernel_stale = bool(ksta == "3") or bool(running_k and latest_k and running_k != latest_k)
|
|
280
|
+
|
|
281
|
+
listening = []
|
|
282
|
+
for line in lines("listen"):
|
|
283
|
+
proc = LISTEN_PROC_RE.search(line)
|
|
284
|
+
addr = line.split()[3] if len(line.split()) > 3 else ""
|
|
285
|
+
port = addr.rsplit(":", 1)[-1] if ":" in addr else addr
|
|
286
|
+
if proc and port:
|
|
287
|
+
listening.append({"port": port, "process": proc.group(1)})
|
|
288
|
+
|
|
289
|
+
return {"schema": "maggie-security-impact.v1",
|
|
290
|
+
"reboot_required": reboot or kernel_stale, "reboot_pkgs": reboot_pkgs,
|
|
291
|
+
"kernel": {"running": kcur, "latest": latest_k, "stale": kernel_stale},
|
|
292
|
+
"services_need_restart": sorted(set(svc_restart)),
|
|
293
|
+
"security_updates": sorted(set(security)), "pending_updates": sorted(set(pending)),
|
|
294
|
+
"listening": listening, "running_services": lines("running-services"),
|
|
295
|
+
"timers": lines("timers"), "cron": lines("cron"),
|
|
296
|
+
"needrestart_available": bool(sections.get("needrestart", "").strip())}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def impact_findings(ctx: core.Context, snapshot: dict, host: str) -> list[dict]:
|
|
300
|
+
findings: list[dict] = []
|
|
301
|
+
svcs = snapshot["services_need_restart"]
|
|
302
|
+
if svcs:
|
|
303
|
+
shown = ", ".join(svcs[:8]) + (f" (+{len(svcs) - 8} more)" if len(svcs) > 8 else "")
|
|
304
|
+
findings.append(check(
|
|
305
|
+
"hq-needs-restart", f"{len(svcs)} running service(s) still use pre-update libraries — restart needed", "medium",
|
|
306
|
+
"A library was updated on disk but these services are still running the old copy in memory, so the fix is not live yet. "
|
|
307
|
+
"Restart them (or reboot if the kernel changed).", host=host, ctx=ctx,
|
|
308
|
+
evidence=shown, steps=["sudo needrestart -r a # review, then restart the services it lists"] +
|
|
309
|
+
[f"sudo systemctl restart {shlex.quote(s)}" for s in svcs[:8]]))
|
|
310
|
+
if snapshot["reboot_required"]:
|
|
311
|
+
why_pkgs = (": " + ", ".join(snapshot["reboot_pkgs"][:6])) if snapshot["reboot_pkgs"] else ""
|
|
312
|
+
kern = snapshot["kernel"]
|
|
313
|
+
detail = f"running {kern['running']}, installed {kern['latest']}" if kern.get("stale") and kern.get("latest") else why_pkgs
|
|
314
|
+
findings.append(check(
|
|
315
|
+
"hq-reboot-required", "The server needs a reboot to finish applying updates", "medium",
|
|
316
|
+
"A kernel or core library update is only fully applied after a reboot. Schedule one in a maintenance window.",
|
|
317
|
+
host=host, ctx=ctx, evidence=detail.strip(" :"),
|
|
318
|
+
steps=["# schedule a reboot in a maintenance window, then:", "sudo shutdown -r +1 'Maggie: applying security updates'"]))
|
|
319
|
+
return findings
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def host_impact(ctx: core.Context):
|
|
323
|
+
if ctx.args.print_collector:
|
|
324
|
+
return {"_raw": IMPACT_COLLECTOR}
|
|
325
|
+
text, host = evidence_text(ctx, IMPACT_COLLECTOR)
|
|
326
|
+
sections = parse_sections(text)
|
|
327
|
+
if not sections:
|
|
328
|
+
raise core.UsageError("the evidence has no '### section' blocks; run the collector from --print-collector")
|
|
329
|
+
snapshot = parse_impact(sections)
|
|
330
|
+
snapshot.update({"host": host, "collected_at": core.iso()})
|
|
331
|
+
core.write_json(ctx.state.path("inventory", "impact.json"), snapshot)
|
|
332
|
+
findings = impact_findings(ctx, snapshot, host)
|
|
333
|
+
ctx.run["inputs"].update({"host": host, "impact": "inventory/impact.json"})
|
|
334
|
+
return ctx.finish(findings, covered=[f"host:{host}", "*"])
|
|
335
|
+
|
|
336
|
+
|
|
218
337
|
# --------------------------------------------------------------------------- host-audit (lynis report.dat)
|
|
219
338
|
|
|
220
339
|
|
|
@@ -540,4 +659,6 @@ COMMANDS = [
|
|
|
540
659
|
routine_safe=False),
|
|
541
660
|
core.Command("host-nginx-config", "nginx and PHP configuration rules (hidden files, PHP in uploads, TLS, headers, rate limits)", [], _quick,
|
|
542
661
|
host_nginx_config),
|
|
662
|
+
core.Command("host-impact", "patch blast-radius: which pending updates are security-only, what needs restart/reboot, and what is running "
|
|
663
|
+
"(read-only; feeds fix-plan's risk tiers)", [], _quick, host_impact),
|
|
543
664
|
]
|
|
@@ -73,7 +73,7 @@ SCHEMAS: dict[str, dict] = {
|
|
|
73
73
|
"mode": {"enum": ["traffic", "attacks", "ioc"]}, "file": LOGFILE, "since": SINCE,
|
|
74
74
|
"rules": {"type": "string", "pattern": r"^(web|proxy|emerging)(,(web|proxy|emerging))*$"}}},
|
|
75
75
|
"security_host_check": {"type": "object", "required": ["host"], "additionalProperties": False, "properties": {
|
|
76
|
-
"host": ALIAS, "depth": {"enum": ["quick", "nginx"]}}},
|
|
76
|
+
"host": ALIAS, "depth": {"enum": ["quick", "nginx", "impact"]}}},
|
|
77
77
|
"security_protect_plan": {"type": "object", "required": ["host"], "additionalProperties": False, "properties": {
|
|
78
78
|
"host": ALIAS, "engine": {"enum": ["auto", "crowdsec", "fail2ban", "cloudflare"]}, "site_type": {"enum": ["wordpress", "php-app", "node-app", "static", "workers"]}}},
|
|
79
79
|
"security_uptime_status": {"type": "object", "additionalProperties": False, "properties": {"window": {"enum": ["1h", "24h", "7d", "30d"]}}},
|
|
@@ -229,7 +229,7 @@ class Server:
|
|
|
229
229
|
argv += ["--rules", args["rules"]]
|
|
230
230
|
return argv
|
|
231
231
|
if name == "security_host_check":
|
|
232
|
-
command = "host-nginx-config"
|
|
232
|
+
command = {"nginx": "host-nginx-config", "impact": "host-impact"}.get(args.get("depth"), "host-quick")
|
|
233
233
|
return [command] if dry else [command, "--ssh", f"{args['host']}={self.host_target(args['host'])}"]
|
|
234
234
|
if name == "security_protect_plan":
|
|
235
235
|
argv = ["protect-plan", "--host", args.get("host", "web1"), "--engine", args.get("engine", "auto")]
|
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
import argparse
|
|
16
16
|
import json
|
|
17
17
|
import re
|
|
18
|
+
import shlex
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
|
|
20
21
|
import maggie_security_core as core
|
|
@@ -546,6 +547,53 @@ def fix_category(finding: dict) -> str:
|
|
|
546
547
|
return "other"
|
|
547
548
|
|
|
548
549
|
|
|
550
|
+
# Fix-risk tiers answer "will this patch break something?", a separate axis from
|
|
551
|
+
# "how urgent is the flaw?" (severity/KEV/EPSS). The operator uses both: a KEV
|
|
552
|
+
# critical justifies a riskier tier sooner, but the tier decides HOW to apply it.
|
|
553
|
+
FIX_TIERS = [
|
|
554
|
+
("T1", "Safe to batch-apply",
|
|
555
|
+
"Distro security-only updates and file-permission fixes. ABI-stable within the release; very low breakage risk. Do these first."),
|
|
556
|
+
("T2", "Apply, then restart and smoke-test",
|
|
557
|
+
"App package upgrades (no major-version change) and plan-only setups. Low–moderate risk: restart the affected service and check it serves."),
|
|
558
|
+
("T3", "Apply one at a time — validate and auto-rollback",
|
|
559
|
+
"Config hardening (TLS, headers, firewall, vhosts). Changes behaviour; can break old clients or lock you out. Each step backs up, validates, and reverts on failure."),
|
|
560
|
+
("T4", "Do NOT auto-apply — stage first",
|
|
561
|
+
"Major-version jumps and end-of-life moves. High compatibility risk. Test in staging, schedule a change window; record as accepted-risk until then."),
|
|
562
|
+
]
|
|
563
|
+
TIER_LABEL = {t: f"{t} — {name}" for t, name, _ in FIX_TIERS}
|
|
564
|
+
TIER_ORDER = [t for t, _, _ in FIX_TIERS]
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _is_major_jump(current: str | None, fixed: str | None) -> bool:
|
|
568
|
+
"""A different leading integer between the installed and fixed version — the
|
|
569
|
+
semver signal for a breaking upgrade (nginx 1.x→2.x, OpenSSL 1.1→3.0, Node 18→22)."""
|
|
570
|
+
def major(v: str | None) -> str | None:
|
|
571
|
+
m = re.match(r"\D*(\d+)", v or "")
|
|
572
|
+
return m.group(1) if m else None
|
|
573
|
+
cm, fm = major(current), major(fixed)
|
|
574
|
+
return bool(cm and fm and cm != fm)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def fix_tier(action: dict) -> str:
|
|
578
|
+
"""Compatibility/reversibility tier T1–T4 for an action (see FIX_TIERS)."""
|
|
579
|
+
lead = action["lead"]
|
|
580
|
+
category = fix_category(lead)
|
|
581
|
+
if category == "end-of-life":
|
|
582
|
+
return "T4"
|
|
583
|
+
if category == "packages":
|
|
584
|
+
osfam = (action.get("ecosystem") or "").split(":")[0]
|
|
585
|
+
if osfam in ("Ubuntu", "Debian"):
|
|
586
|
+
return "T1" # distro security pocket, ABI-stable within the release
|
|
587
|
+
if _is_major_jump((lead.get("affected") or {}).get("version"), action.get("fix_version")):
|
|
588
|
+
return "T4" # app package with a breaking major-version jump
|
|
589
|
+
return "T2"
|
|
590
|
+
if category == "secrets-permissions":
|
|
591
|
+
return "T1"
|
|
592
|
+
if category in ("web-server", "firewall"):
|
|
593
|
+
return "T3" # config change that alters behaviour / can lock you out
|
|
594
|
+
return "T2"
|
|
595
|
+
|
|
596
|
+
|
|
549
597
|
def fix_commands(action: dict) -> tuple[list[str], list[tuple[str, str]], list[str]]:
|
|
550
598
|
"""(shell commands, (kind, snippet) paste-ins, TODO notes) faithfully from the lead finding."""
|
|
551
599
|
lead = action["lead"]
|
|
@@ -557,6 +605,98 @@ def fix_commands(action: dict) -> tuple[list[str], list[tuple[str, str]], list[s
|
|
|
557
605
|
return commands, snippets, todos
|
|
558
606
|
|
|
559
607
|
|
|
608
|
+
# nginx directives valid in the http{} context can be dropped into conf.d; the
|
|
609
|
+
# rest (location/proxy/add_header) must live in a server{} block.
|
|
610
|
+
NGINX_HTTP_DIRECTIVES = {"ssl_protocols", "ssl_ciphers", "ssl_prefer_server_ciphers", "ssl_ecdh_curve",
|
|
611
|
+
"ssl_session_tickets", "ssl_session_timeout", "ssl_dhparam", "server_tokens"}
|
|
612
|
+
NGINX_FILE_RE = re.compile(r"(/etc/nginx/[\w./-]+\.conf)")
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def nginx_fix_block(actions: list[dict]) -> tuple[list[str], list[str]]:
|
|
616
|
+
"""Runnable nginx fixes: a global drop-in for http-context directives, and a
|
|
617
|
+
snippet file + a guarded, reversible `include` for server-context ones."""
|
|
618
|
+
http_dirs: list[tuple[str, dict]] = []
|
|
619
|
+
server_by_file: dict[str, list[str]] = {}
|
|
620
|
+
manual: list[dict] = []
|
|
621
|
+
for action in actions:
|
|
622
|
+
rem = action["lead"].get("remediation") or {}
|
|
623
|
+
snippets = [s.get("text", "").strip() for s in (rem.get("snippets") or []) if s.get("text")]
|
|
624
|
+
if not snippets:
|
|
625
|
+
manual.append(action)
|
|
626
|
+
continue
|
|
627
|
+
tp = (action["lead"].get("target") or {}).get("path") or ""
|
|
628
|
+
match = NGINX_FILE_RE.search(tp) or NGINX_FILE_RE.search(action["lead"].get("title", ""))
|
|
629
|
+
vfile = match.group(1) if match else None
|
|
630
|
+
for text in snippets:
|
|
631
|
+
name = text.split()[0] if text.split() else ""
|
|
632
|
+
if name in NGINX_HTTP_DIRECTIVES:
|
|
633
|
+
http_dirs.append((text, action))
|
|
634
|
+
elif vfile:
|
|
635
|
+
server_by_file.setdefault(vfile, [])
|
|
636
|
+
if text not in server_by_file[vfile]:
|
|
637
|
+
server_by_file[vfile].append(text)
|
|
638
|
+
else:
|
|
639
|
+
manual.append(action)
|
|
640
|
+
sh: list[str] = []
|
|
641
|
+
md: list[str] = []
|
|
642
|
+
if http_dirs:
|
|
643
|
+
directives = list(dict.fromkeys(d for d, _ in http_dirs))
|
|
644
|
+
fixes = "; ".join(dict.fromkeys(a["title"] for _, a in http_dirs))
|
|
645
|
+
body = "".join(d + "\n" for d in directives)
|
|
646
|
+
sh += [f"# Global TLS/hardening — fixes: {fixes}",
|
|
647
|
+
f"printf %s {shlex.quote(body)} | sudo tee /etc/nginx/conf.d/00-maggie-hardening.conf >/dev/null",
|
|
648
|
+
"sudo nginx -t && sudo systemctl reload nginx", ""]
|
|
649
|
+
md.append("- **nginx global hardening** → drop-in `/etc/nginx/conf.d/00-maggie-hardening.conf`: "
|
|
650
|
+
+ ", ".join(f"`{d}`" for d in directives))
|
|
651
|
+
for vfile, directives in server_by_file.items():
|
|
652
|
+
stem = re.sub(r"[^\w.-]", "_", re.sub(r"\.conf$", "", vfile.rsplit("/", 1)[-1]))
|
|
653
|
+
snip = f"/etc/nginx/snippets/maggie-{stem}.conf"
|
|
654
|
+
heredoc = "\n".join(directives)
|
|
655
|
+
sh += [f"# Harden {vfile} (backs up, validates, auto-reverts on error)",
|
|
656
|
+
f"sudo tee {snip} >/dev/null <<'MAGGIE_SNIP'",
|
|
657
|
+
heredoc,
|
|
658
|
+
"MAGGIE_SNIP",
|
|
659
|
+
f"sudo cp -a {shlex.quote(vfile)} {shlex.quote(vfile + '.maggie.bak')}",
|
|
660
|
+
f"sudo sed -i -E '/^[[:space:]]*server[[:space:]]*\\{{[[:space:]]*$/a\\ include {snip};' {shlex.quote(vfile)}",
|
|
661
|
+
f"sudo nginx -t && sudo systemctl reload nginx || {{ echo 'nginx test failed — reverting'; "
|
|
662
|
+
f"sudo cp -a {shlex.quote(vfile + '.maggie.bak')} {shlex.quote(vfile)}; sudo systemctl reload nginx; }}", ""]
|
|
663
|
+
md.append(f"- **nginx vhost hardening** (`{vfile}` via `{snip}` + guarded include): "
|
|
664
|
+
+ ", ".join(f"`{d}`" for d in directives))
|
|
665
|
+
for action in manual:
|
|
666
|
+
rem = action["lead"].get("remediation") or {}
|
|
667
|
+
sh += [f"# [{action['priority']}] {action['title']} — {action['why']}",
|
|
668
|
+
f"# TODO (manual): {rem.get('summary') or action['how']}", ""]
|
|
669
|
+
md.append(f"- **{action['title']}** — {rem.get('summary') or action['how']} _(manual)_")
|
|
670
|
+
return sh, md
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def impact_summary_sh(impact: dict) -> list[str]:
|
|
674
|
+
"""A blast-radius header for the runbook, from `host-impact`'s snapshot."""
|
|
675
|
+
svcs = impact.get("services_need_restart") or []
|
|
676
|
+
listening = impact.get("listening") or []
|
|
677
|
+
out = ["# =================== Blast radius (from host-impact) ===================",
|
|
678
|
+
f"# Pending updates: {len(impact.get('pending_updates') or [])} "
|
|
679
|
+
f"({len(impact.get('security_updates') or [])} security-only — safe to batch as T1).",
|
|
680
|
+
f"# Public-facing services (port -> process): "
|
|
681
|
+
+ (", ".join(f"{l['port']}:{l['process']}" for l in listening[:8]) or "none detected") + ".",
|
|
682
|
+
f"# Needs restart after patching ({len(svcs)}): " + (", ".join(svcs[:8]) or "none") + ".",
|
|
683
|
+
f"# Reboot pending: {'YES — schedule a maintenance window' if impact.get('reboot_required') else 'no'}.",
|
|
684
|
+
"# A patch is not live in a running service until that service restarts (or the box reboots).", ""]
|
|
685
|
+
return out
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def impact_summary_md(impact: dict) -> list[str]:
|
|
689
|
+
svcs = impact.get("services_need_restart") or []
|
|
690
|
+
listening = impact.get("listening") or []
|
|
691
|
+
return ["## Blast radius (from `host-impact`)", "",
|
|
692
|
+
f"- **Pending updates:** {len(impact.get('pending_updates') or [])} "
|
|
693
|
+
f"({len(impact.get('security_updates') or [])} security-only → safe to batch as T1)",
|
|
694
|
+
"- **Public-facing services:** " + (", ".join(f"`{l['port']}:{l['process']}`" for l in listening[:8]) or "none detected"),
|
|
695
|
+
f"- **Needs restart after patching ({len(svcs)}):** " + (", ".join(f"`{s}`" for s in svcs[:8]) or "none"),
|
|
696
|
+
f"- **Reboot pending:** {'**YES** — schedule a maintenance window' if impact.get('reboot_required') else 'no'}",
|
|
697
|
+
"", "_A patch is not live in a running service until that service restarts (or the box reboots)._", ""]
|
|
698
|
+
|
|
699
|
+
|
|
560
700
|
def fix_plan(ctx: core.Context) -> dict:
|
|
561
701
|
args = ctx.args
|
|
562
702
|
since = resolve_since(ctx, args.since)
|
|
@@ -570,6 +710,9 @@ def fix_plan(ctx: core.Context) -> dict:
|
|
|
570
710
|
for action in actions:
|
|
571
711
|
buckets[fix_category(action["lead"])].append(action)
|
|
572
712
|
|
|
713
|
+
impact_path = ctx.state.path("inventory", "impact.json")
|
|
714
|
+
impact = core.read_json(impact_path) if impact_path.is_file() else None
|
|
715
|
+
|
|
573
716
|
host = next((f.get("target", {}).get("host") for f in actionable if (f.get("target") or {}).get("host")), None)
|
|
574
717
|
lock_target = host or "<user@host>"
|
|
575
718
|
sh: list[str] = [
|
|
@@ -582,25 +725,73 @@ def fix_plan(ctx: core.Context) -> dict:
|
|
|
582
725
|
"# 2. opening a SECOND SSH session (so a bad sshd/firewall change cannot lock you out),",
|
|
583
726
|
f"# 3. taking the change lock: maggie deployment host-lock acquire --host {lock_target} --purpose fix-plan",
|
|
584
727
|
"# Package upgrades belong in a maintenance window. Test nginx with `nginx -t` before every reload.",
|
|
728
|
+
"#",
|
|
729
|
+
"# FIX-RISK TIERS (will this patch break something? — separate from how urgent the flaw is):",
|
|
730
|
+
] + [f"# {TIER_LABEL[t]}: {note}" for t, _, note in FIX_TIERS] + [
|
|
731
|
+
"# Recommended order: T1 first, then T2, then T3 one at a time; schedule T4 separately.",
|
|
585
732
|
"set -euo pipefail",
|
|
586
733
|
"",
|
|
587
734
|
]
|
|
588
735
|
md: list[str] = [f"# Remediation runbook — {ctx.site}" + (f" ({host})" if host else ""), "",
|
|
589
736
|
"Generated by `maggie security fix-plan`. **Review before running — Maggie never runs these.** "
|
|
590
737
|
"Take a backup, keep a second SSH session open, and work under "
|
|
591
|
-
f"`maggie deployment host-lock acquire --host {lock_target} --purpose fix-plan`.", ""
|
|
738
|
+
f"`maggie deployment host-lock acquire --host {lock_target} --purpose fix-plan`.", "",
|
|
739
|
+
"**Fix-risk tiers** — how likely a patch is to break something (separate from how urgent the flaw is). "
|
|
740
|
+
"Apply T1 first, then T2, then T3 one at a time; schedule T4 separately.", ""]
|
|
741
|
+
md += [f"- **{TIER_LABEL[t]}** — {note}" for t, _, note in FIX_TIERS] + [""]
|
|
742
|
+
if impact:
|
|
743
|
+
sh += impact_summary_sh(impact)
|
|
744
|
+
md += impact_summary_md(impact)
|
|
745
|
+
else:
|
|
746
|
+
sh += ["# Blast radius not collected. For a sharper plan — which services a patch affects, what",
|
|
747
|
+
"# must restart (a patched library in memory is still the old one until restart), and whether",
|
|
748
|
+
"# a reboot is pending — run: maggie security host-impact --ssh "
|
|
749
|
+
+ (f"{host}=<user@host>" if host else "<alias>=<user@host>"), ""]
|
|
750
|
+
md += ["> Run `maggie security host-impact` first for a blast-radius-aware plan "
|
|
751
|
+
"(which services each patch affects, what must restart, whether a reboot is pending).", ""]
|
|
752
|
+
|
|
592
753
|
apt_packages: list[str] = []
|
|
593
754
|
total = 0
|
|
755
|
+
tier_counts: dict[str, int] = {t: 0 for t in TIER_ORDER}
|
|
756
|
+
for action in actions:
|
|
757
|
+
tier_counts[fix_tier(action)] += 1
|
|
594
758
|
for key, label, note in FIX_CATEGORIES:
|
|
595
759
|
items = buckets.get(key) or []
|
|
596
760
|
if not items:
|
|
597
761
|
continue
|
|
598
762
|
sh += [f"# =================== {label} ===================", f"# {note}", ""]
|
|
599
763
|
md += [f"## {label}", f"_{note}_", ""]
|
|
764
|
+
if key == "web-server":
|
|
765
|
+
sh.append("# Tier: T3 — each fix below backs up, validates with `nginx -t`, and auto-reverts on error.")
|
|
766
|
+
sh.append("")
|
|
767
|
+
md.append(f"_Risk tier: **{TIER_LABEL['T3']}** — each fix validates and auto-reverts._")
|
|
768
|
+
md.append("")
|
|
769
|
+
block_sh, block_md = nginx_fix_block(items)
|
|
770
|
+
sh += block_sh
|
|
771
|
+
md += block_md
|
|
772
|
+
total += len(items)
|
|
773
|
+
continue
|
|
774
|
+
if key == "end-of-life":
|
|
775
|
+
sh.append(f"# Tier: {TIER_LABEL['T4']}. DO NOT run these as part of this script.")
|
|
776
|
+
sh.append("# Test each move in staging and schedule a change window. Until then, record an accepted risk:")
|
|
777
|
+
md.append(f"_Risk tier: **{TIER_LABEL['T4']}** — do not auto-apply. Stage first; record as accepted-risk until scheduled._")
|
|
778
|
+
md.append("")
|
|
779
|
+
for action in items:
|
|
780
|
+
total += 1
|
|
781
|
+
sh.append(f"# [{action['priority']}] {action['title']} — {action['why']}")
|
|
782
|
+
for fid in action["ids"]:
|
|
783
|
+
sh.append(f"# maggie security ledger accept --id {fid} "
|
|
784
|
+
f"--reason {shlex.quote(action['title'] + ' — major/EOL upgrade, staging first')}")
|
|
785
|
+
sh.append("")
|
|
786
|
+
md.append(f"- **{action['title']}** — {action['why']}")
|
|
787
|
+
md.append(f" - accept until staged: `maggie security ledger accept --id {action['ids'][0]} --reason \"…\"`")
|
|
788
|
+
md.append("")
|
|
789
|
+
continue
|
|
600
790
|
for action in items:
|
|
601
791
|
total += 1
|
|
602
|
-
|
|
603
|
-
|
|
792
|
+
tier = fix_tier(action)
|
|
793
|
+
sh.append(f"# [{action['priority']}][{tier}] {action['title']} — {action['why']}")
|
|
794
|
+
md.append(f"- **[{tier}] {action['title']}** — {action['why']}")
|
|
604
795
|
if key == "packages":
|
|
605
796
|
# Build the fix command ourselves; never copy the finding's step text
|
|
606
797
|
# (it reads "apt-get install --only-upgrade X (fixed in Y)" — not valid shell).
|
|
@@ -630,11 +821,25 @@ def fix_plan(ctx: core.Context) -> dict:
|
|
|
630
821
|
md.append("")
|
|
631
822
|
if apt_packages:
|
|
632
823
|
pkgs = " ".join(sorted(set(apt_packages)))
|
|
633
|
-
insert = ["#
|
|
824
|
+
insert = ["# Tier: T1 — distro security updates are ABI-stable within the release; safe to batch.",
|
|
825
|
+
"# Back up the package state first so you can roll back a bad upgrade:",
|
|
826
|
+
'sudo dpkg --get-selections > "$HOME/maggie-pkg-state.$(date +%F).txt"',
|
|
634
827
|
"sudo apt-get update",
|
|
635
828
|
f"sudo apt-get install --only-upgrade {pkgs}",
|
|
636
829
|
"# Or apply all pending security updates at once:",
|
|
637
|
-
"# sudo unattended-upgrade -d # or: sudo apt-get upgrade",
|
|
830
|
+
"# sudo unattended-upgrade -d # or: sudo apt-get upgrade",
|
|
831
|
+
"# Verify nothing broke after upgrading (and restart services if the kernel/libc changed):",
|
|
832
|
+
"systemctl list-units --state=failed --no-legend || true",
|
|
833
|
+
"sudo nginx -t 2>/dev/null && echo 'nginx config ok' || true"]
|
|
834
|
+
# A patched library on disk is not live until the services using it restart.
|
|
835
|
+
svcs = (impact or {}).get("services_need_restart") or []
|
|
836
|
+
insert += ["# Make the fix live — a patched library in memory is still the old one until restart:",
|
|
837
|
+
"sudo needrestart -r l # list what is still running old code; -r a to act on it"]
|
|
838
|
+
insert += [f"sudo systemctl restart {s}" for s in svcs[:8]]
|
|
839
|
+
if (impact or {}).get("reboot_required"):
|
|
840
|
+
insert += ["# A reboot is pending (kernel/core library) — schedule it in a maintenance window:",
|
|
841
|
+
"# sudo shutdown -r +1 'applying security updates'"]
|
|
842
|
+
insert += ["# If a service failed: downgrade the package (apt-get install pkg=<old>) or restore from the snapshot.", ""]
|
|
638
843
|
anchor = sh.index("# =================== Package updates ===================") + 3
|
|
639
844
|
sh[anchor:anchor] = insert
|
|
640
845
|
md.append("### apt batch")
|
|
@@ -652,6 +857,7 @@ def fix_plan(ctx: core.Context) -> dict:
|
|
|
652
857
|
out.write_text(runbook, encoding="utf-8")
|
|
653
858
|
return {"schema": "maggie-security-fix-plan.v1", "site": ctx.site, "host": host, "generated_at": core.iso(),
|
|
654
859
|
"actions": total, "apt_packages": sorted(set(apt_packages)),
|
|
860
|
+
"tiers": {t: tier_counts[t] for t in TIER_ORDER if tier_counts[t]},
|
|
655
861
|
"script_path": str(args.script) if args.script else None,
|
|
656
862
|
"note": "Review before running. Maggie never applies these changes.", "markdown": runbook}
|
|
657
863
|
|