@topy-ai/maggie 0.7.57 → 0.7.59

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 CHANGED
@@ -29,7 +29,9 @@ npx @topy-ai/maggie init --scope project --agent all
29
29
  npx @topy-ai/maggie doctor --scope project --project .
30
30
  ```
31
31
 
32
- 目前 npm 版本為 `0.7.57`:新增 `maggie security public-check`,對你**無法控制**的站點做被動、等同瀏覽器的檢查(安全 headers、TLS、首頁洩漏的版本與其已知 CVE、SPF/DMARC、security.txt),讓你能通知站主;只限真人互動執行,絕不探測、提交或掃描,主動掃描(web-scan、zap-baseline)與主機檢查仍須先驗證域名。
32
+ 目前 npm 版本為 `0.7.58`:新增 `maggie security fix-plan`(由發現整理出的修復 runbook:檔案權限、防火牆、套件/apt 更新、入侵防護、Web server 設定、EOL;產生一份你自己在 host lock 下、備份後執行的 shell script,Maggie 從不代為套用),並把 lynis tarball 以 sha256 pin 住,讓 `host-audit` 能透過 SSH 做深度主機稽核。
33
+
34
+ 0.7.57 新增 `maggie security public-check`,對你**無法控制**的站點做被動、等同瀏覽器的檢查(安全 headers、TLS、首頁洩漏的版本與其已知 CVE、SPF/DMARC、security.txt),讓你能通知站主;只限真人互動執行,絕不探測、提交或掃描,主動掃描(web-scan、zap-baseline)與主機檢查仍須先驗證域名。
33
35
 
34
36
  0.7.56 新增 Maggie Security(`maggie-security` skill 與 `maggie security` 指令,共 46 個指令、29 個 MCP 工具),涵蓋套件、runtime、資料庫、Web server、Linux 發行版與 WordPress 的漏洞公告與 end-of-life 日期,以及 secrets、程式碼、網站與主機檢查、報告、CI gate 與每日監控。
35
37
 
@@ -141,7 +143,7 @@ structure drift 或重複 treatment product 時 fail closed。
141
143
  deployment、memory、feedback 和 MaggieDash。內容先 draft/review,外部寫入、
142
144
  publish 與 production deployment 需要明確確認。
143
145
 
144
- 目前 npm 版本為 `0.7.57`,包含 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
146
+ 目前 npm 版本為 `0.7.59`,包含 fix-plan、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
145
147
  dispatch、`maggie --version`、feedback batch review 聚合與重複偵測、marketplace enrichment evidence、booking
146
148
  worker/resolver evidence,以及 read-only migration preflight。它也包含 host-owned mobile app surface contract、signed-in
147
149
  camera-state QA、直接 Astro route resolution、correlated feedback batch,以及
package/README.md CHANGED
@@ -89,7 +89,19 @@ 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.57`. It adds `maggie security public-check`: passive,
92
+ This package is `0.7.59`. It makes `maggie security host-audit` (the deep
93
+ lynis audit over SSH) robust on hosts where a storage test can stall: lynis runs
94
+ in its own process group under a watchdog, and the bundled profile skips the ZFS
95
+ storage/filesystem tests that hang, so the audit completes instead of timing out.
96
+
97
+ The 0.7.58 release added `maggie security fix-plan` — a
98
+ reviewed remediation runbook built from the open findings (file permissions,
99
+ firewall, package/apt updates, intrusion prevention, web-server config, end of
100
+ life). It writes a shell script you run yourself, under a host lock, after a
101
+ backup; Maggie never applies it. This release also pins the `lynis` tarball
102
+ (sha256) so `host-audit` can run the deep host audit over SSH.
103
+
104
+ The 0.7.57 release added `maggie security public-check`: passive,
93
105
  browser-equivalent checks of sites you do **not** control (security headers,
94
106
  TLS, versions disclosed on the home page and their known CVEs, SPF/DMARC,
95
107
  `security.txt`), so you can tell an owner about a visible problem. It is
@@ -97,7 +109,7 @@ interactive-only and never probes, submits or scans; the active scanners
97
109
  (`web-scan`, `zap-baseline`) and host checks stay verified-only.
98
110
 
99
111
  The 0.7.56 release added Maggie Security, a new
100
- `maggie-security` skill and `maggie security` command family (47 commands, 30
112
+ `maggie-security` skill and `maggie security` command family (48 commands, 30
101
113
  MCP tools, 7 contracts under `contracts/maggie-security/`).
102
114
 
103
115
  - **Advisories for the whole stack.** Packages (npm, pnpm, Yarn, Bun,
@@ -844,8 +856,8 @@ artifact schemas.
844
856
  Recommended upgrade sequence for the current release:
845
857
 
846
858
  ```bash
847
- npx @topy-ai/maggie@0.7.57 update --scope project --project . --force
848
- npx @topy-ai/maggie@0.7.57 cleanup --scope project --project .
859
+ npx @topy-ai/maggie@0.7.59 update --scope project --project . --force
860
+ npx @topy-ai/maggie@0.7.59 cleanup --scope project --project .
849
861
  ```
850
862
 
851
863
  Maintainers should pass npm credentials through the repository helper, never
@@ -50,6 +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
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` |
54
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` |
55
56
  | "is this npm package safe to add?" | `maggie security preinstall PKG[@VERSION]` before installing (W5); never install on `block` | `security_package_vet` |
@@ -122,4 +122,5 @@ Formats: nginx/Apache combined and vcombined, Caddy JSON, Cloudflare Logpush JSO
122
122
  | Command | What it does |
123
123
  |---|---|
124
124
  | `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. |
125
126
  | `maggie security mcp [--tools p0\|all] [--print-config claude\|codex]` | local stdio MCP server |
@@ -266,18 +266,38 @@ def lynis_findings(ctx: core.Context, data: dict, host: str) -> list[dict]:
266
266
 
267
267
 
268
268
  LYNIS_PROFILE = """# Maggie lynis profile: no upload, skip tests that do not apply to web servers
269
+ # and skip the storage/filesystem enumeration that hangs on ZFS hosts
270
+ # (the remote watchdog below is the backstop if anything else stalls).
269
271
  upload=no
270
272
  skip-test=PRNT-2307
271
273
  skip-test=PRNT-2308
272
274
  skip-test=STRG-1840
273
275
  skip-test=STRG-1842
276
+ skip-test=STRG-1846
277
+ skip-test=STRG-1902
274
278
  skip-test=NAME-4402
275
279
  skip-test=NAME-4404
276
280
  skip-test=KRB-5108
277
281
  skip-test=LDAP-2219
278
- skip-test=STRG-1902
282
+ skip-test=FILE-6310
283
+ skip-test=FILE-6311
284
+ skip-test=FILE-6329
285
+ skip-test=FILE-6330
286
+ skip-test=FILE-6332
287
+ skip-test=FILE-6336
288
+ skip-test=FILE-6354
289
+ skip-test=FILE-6362
290
+ skip-test=FILE-6368
291
+ skip-test=FILE-6374
292
+ skip-test=FILE-6376
279
293
  """
280
294
 
295
+ # A ZFS host can wedge a lynis storage test for a very long time, and killing the
296
+ # ssh client leaves the stuck test running and holding the channel. So we run
297
+ # lynis in its own process group under a remote watchdog that SIGKILLs the whole
298
+ # group; the command always returns and flushes, finished or not.
299
+ LYNIS_WATCHDOG_SECONDS = 600
300
+
281
301
 
282
302
  def host_audit(ctx: core.Context):
283
303
  args = ctx.args
@@ -329,10 +349,21 @@ def remote_lynis(ctx: core.Context, alias: str, target: str) -> dict:
329
349
  subprocess.run(["ssh", "-o", "BatchMode=yes", target, f"umask 077 && mkdir -p {remote_dir}"], check=True, timeout=60)
330
350
  subprocess.run(["scp", "-q", "-o", "BatchMode=yes", str(tarball), f"{target}:{remote_dir}/lynis.tar.gz"], check=True, timeout=300)
331
351
  profile = shlex.quote(LYNIS_PROFILE)
332
- script = (f"cd {remote_dir} && tar xzf lynis.tar.gz && printf %s {profile} > maggie.prf && cd lynis && "
333
- f"$( [ $(id -u) != 0 ] && sudo -n true 2>/dev/null && echo sudo -n ) ./lynis audit system --quick --no-colors --no-log "
334
- f"--profile {remote_dir}/maggie.prf --report-file {remote_dir}/report.dat --logfile /dev/null >/dev/null 2>&1; cat {remote_dir}/report.dat")
335
- proc = subprocess.run(["ssh", "-o", "BatchMode=yes", target, script], capture_output=True, text=True, timeout=1800, check=False)
352
+ # Run lynis with sudo when available (a full audit needs root). Run it in
353
+ # its own session (setsid) under a watchdog that SIGKILLs the whole process
354
+ # group if it overruns, so a stuck storage test cannot hang the session or
355
+ # leave an orphan holding the ssh channel. Read report.dat back with the
356
+ # same sudo (a root run writes it 0600, so a plain cat reads nothing).
357
+ inner = (f"setsid ./lynis audit system --quick --no-colors --no-log "
358
+ f"--profile {remote_dir}/maggie.prf --report-file {remote_dir}/report.dat --logfile /dev/null >/dev/null 2>&1 & "
359
+ f"LP=$!; ( sleep {LYNIS_WATCHDOG_SECONDS}; kill -9 -$LP 2>/dev/null ) & WD=$!; "
360
+ f"wait $LP 2>/dev/null; kill $WD 2>/dev/null")
361
+ script = (f"SUDO=$( [ $(id -u) != 0 ] && sudo -n true 2>/dev/null && echo 'sudo -n' ); "
362
+ f"cd {remote_dir} && tar xzf lynis.tar.gz && printf %s {profile} > maggie.prf && cd lynis && "
363
+ f"$SUDO sh -c {shlex.quote(inner)} </dev/null >/dev/null 2>&1; "
364
+ f"$SUDO cat {remote_dir}/report.dat 2>/dev/null")
365
+ proc = subprocess.run(["ssh", "-o", "BatchMode=yes", target, script], input="", capture_output=True, text=True,
366
+ timeout=LYNIS_WATCHDOG_SECONDS + 180, check=False)
336
367
  report = proc.stdout
337
368
  finally:
338
369
  subprocess.run(["ssh", "-o", "BatchMode=yes", target, f"rm -rf {remote_dir}"], timeout=60, check=False)
@@ -385,7 +416,8 @@ def nginx_findings(ctx: core.Context, nginx: str, php: str, key_perms: str, host
385
416
  findings = []
386
417
  seen = set()
387
418
 
388
- def add(fid: str, title: str, severity: str, summary: str, snippet: str | None = None, where: str = "", asvs: list[str] | None = None) -> None:
419
+ def add(fid: str, title: str, severity: str, summary: str, snippet: str | None = None, where: str = "", asvs: list[str] | None = None,
420
+ steps: list[str] | None = None) -> None:
389
421
  key = (fid, where)
390
422
  if key in seen:
391
423
  return
@@ -393,7 +425,7 @@ def nginx_findings(ctx: core.Context, nginx: str, php: str, key_perms: str, host
393
425
  findings.append(core.make_finding(kind="misconfiguration", command="host-nginx-config", primary_id=fid, site=ctx.site,
394
426
  title=title + (f" ({where})" if where else ""), severity=severity, severity_source="maggie",
395
427
  confidence="likely", target={"host": host, "path": where or None}, asvs_ids=asvs or [], rule_id=fid,
396
- remediation={"summary": summary, "steps": ["sudo nginx -t && sudo systemctl reload nginx"],
428
+ remediation={"summary": summary, "steps": steps or ["sudo nginx -t && sudo systemctl reload nginx"],
397
429
  "snippets": [{"kind": "nginx", "text": snippet}] if snippet else [],
398
430
  "references": ["https://nginx.org/en/docs/"], "cheat_sheet": None}))
399
431
 
@@ -471,7 +503,7 @@ def nginx_findings(ctx: core.Context, nginx: str, php: str, key_perms: str, host
471
503
  parts = line.split(None, 2)
472
504
  if len(parts) == 3 and (int(parts[0][-1]) > 0 or int(parts[0][-2]) > 4 or parts[1] != "root"):
473
505
  add("ngx-tls-key-perms", f"TLS private key is readable by others ({parts[0]} {parts[1]})", "high", "chmod 600 the key and chown root.",
474
- where=parts[2], asvs=["V13.3.1"])
506
+ where=parts[2], asvs=["V13.3.1"], steps=[f"sudo chmod 600 {shlex.quote(parts[2])} && sudo chown root:root {shlex.quote(parts[2])}"])
475
507
  return findings
476
508
 
477
509
 
@@ -153,6 +153,9 @@ def why(finding: dict) -> str:
153
153
  return "Anyone who sees the file can use this key."
154
154
  if kev:
155
155
  return "Attackers are already using this flaw (on the KEV list)."
156
+ if kind in ("misconfiguration", "header", "tls", "exposure", "attack-signal"):
157
+ sev = {"critical": "A serious", "high": "A serious", "medium": "A moderate", "low": "A minor", "info": "A minor"}[finding["severity"]]
158
+ return f"{sev} configuration weakness an attacker could use."
156
159
  severity = {"critical": "A serious", "high": "A serious", "medium": "A moderate", "low": "A minor", "info": "A minor"}[finding["severity"]]
157
160
  return f"{severity} known flaw (CVE) affects the version you run."
158
161
 
@@ -184,8 +187,10 @@ def group_actions(findings: list[dict]) -> list[dict]:
184
187
  items = core.sort_findings(groups[key])
185
188
  lead = items[0]
186
189
  if key[0] == "single" or len(items) == 1:
190
+ la = lead.get("affected") or {}
187
191
  actions.append({"priority": lead["priority"], "title": action_title(lead), "why": why(lead), "how": how(lead),
188
- "ids": [lead["id"]], "count": 1, "lead": lead})
192
+ "ids": [lead["id"]], "count": 1, "lead": lead,
193
+ "package": la.get("name"), "ecosystem": la.get("ecosystem"), "fix_version": (lead.get("fix") or {}).get("recommended")})
189
194
  continue
190
195
  affected = lead["affected"]
191
196
  eco = affected.get("ecosystem")
@@ -205,7 +210,8 @@ def group_actions(findings: list[dict]) -> list[dict]:
205
210
  + ("; attackers are using at least one (on the KEV list)." if exploited else "."))
206
211
  step_from = next((f for f in items if (f.get("fix") or {}).get("recommended") == target_version), lead)
207
212
  actions.append({"priority": lead["priority"], "title": title, "why": reason, "how": how(step_from),
208
- "ids": [f["id"] for f in items], "count": len(items), "lead": lead})
213
+ "ids": [f["id"] for f in items], "count": len(items), "lead": lead,
214
+ "package": affected.get("name"), "ecosystem": eco, "fix_version": target_version})
209
215
  return sorted(actions, key=lambda a: (core.PRIORITIES.index(a["priority"]), -a["count"]))
210
216
 
211
217
 
@@ -219,19 +225,25 @@ def explain_jargon(text: str, used: set[str]) -> str:
219
225
  return re.sub(r"\b(" + "|".join(JARGON) + r")\b", sub, text)
220
226
 
221
227
 
222
- def build(ctx: core.Context) -> dict:
223
- args = ctx.args
224
- since = args.since
225
- if args.kind == "gate" and since in (None, "last-report"):
226
- since = "6h" # the gate reads this deploy's runs only
228
+ def resolve_since(ctx: core.Context, since: str | None, kind: str = "weekly") -> str:
229
+ if kind == "gate" and since in (None, "last-report"):
230
+ return "6h" # the gate reads this deploy's runs only
227
231
  if since in (None, "last-report"):
228
232
  last = last_report_time(ctx.state)
229
233
  if last:
230
234
  hours = max(1, int((core.utcnow() - core.parse_time(last)).total_seconds() // 3600) + 1)
231
- since = f"{hours}h"
232
- else:
233
- since = "7d"
234
- runs = latest_runs(ctx.state, since, args.runs)
235
+ return f"{hours}h"
236
+ return "7d"
237
+ return since
238
+
239
+
240
+ def collect_findings(ctx: core.Context, since: str, run_ids: list[str] | None) -> tuple[list[dict], list[dict], set[str]]:
241
+ """The current findings for a site: the latest run per command merged with the ledger.
242
+
243
+ Returns (findings with their derived status, the runs used, the set of fixed ids).
244
+ Shared by `report` and `fix-plan` so both see exactly the same state.
245
+ """
246
+ runs = latest_runs(ctx.state, since, run_ids)
235
247
  if not runs:
236
248
  raise core.UsageError(f"no security runs in the last {since}; run e.g. `maggie security vuln-watch` first")
237
249
  ledger = core.Ledger(ctx.state.path("ledger.jsonl"))
@@ -254,7 +266,13 @@ def build(ctx: core.Context) -> dict:
254
266
  first_seen = (state or {}).get("first_seen") or finding.get("first_seen")
255
267
  finding = dict(finding, status="new" if first_seen and core.parse_time(first_seen) >= floor and status in ("new", "open") else status)
256
268
  findings.append(finding)
257
- findings = core.sort_findings(findings)
269
+ return core.sort_findings(findings), runs, fixed_ids
270
+
271
+
272
+ def build(ctx: core.Context) -> dict:
273
+ args = ctx.args
274
+ since = resolve_since(ctx, args.since, args.kind)
275
+ findings, runs, fixed_ids = collect_findings(ctx, since, args.runs)
258
276
  actionable = [f for f in findings if f["status"] in ("new", "open")]
259
277
  grouped = group_actions([f for f in actionable if f["priority"] in ("p0", "p1")])
260
278
  p0 = sum(1 for a in grouped if a["priority"] == "p0")
@@ -271,7 +289,7 @@ def build(ctx: core.Context) -> dict:
271
289
 
272
290
  built = set(maggie_security.area_commands())
273
291
  missing = [c for c in EXPECTED.get(site_type, EXPECTED["unknown"]) if c not in ran and c in built]
274
- period = {"from": core.iso(floor), "to": core.iso()}
292
+ period = {"from": core.iso(core.utcnow() - core.parse_duration(since)), "to": core.iso()}
275
293
  actions = [f for f in actionable if f["priority"] in ("p0", "p1")]
276
294
  report_id = f"{core.iso()[:10]}-{args.kind}-{core.sha256_bytes(json.dumps(sorted(f['id'] for f in findings)).encode())[:6]}"
277
295
  data = {
@@ -490,6 +508,160 @@ def incident_cmd(ctx: core.Context) -> dict:
490
508
  return data
491
509
 
492
510
 
511
+ # --------------------------------------------------------------------------- fix-plan (a reviewed remediation runbook)
512
+
513
+ # A fix-plan turns the open findings into an ordered runbook of the fixes the
514
+ # findings themselves already carry. It never connects to a host and never runs
515
+ # anything — the operator reviews it and runs it under a host lock. It only
516
+ # emits remediation text the checks authored; it does not invent commands.
517
+
518
+ FIX_CATEGORIES = [
519
+ ("secrets-permissions", "Secrets and file permissions", "Quick, high-value fixes — do these first."),
520
+ ("firewall", "Firewall", "Close everything except the ports you actually serve."),
521
+ ("packages", "Package updates", "Apply in a maintenance window; reboot if the kernel or libc changed."),
522
+ ("intrusion-prevention", "Intrusion prevention", "Use `maggie security protect-plan` for a reviewed fail2ban/CrowdSec setup."),
523
+ ("web-server", "Web server configuration", "Edit the server/vhost files, then `sudo nginx -t && sudo systemctl reload nginx`."),
524
+ ("end-of-life", "End-of-life upgrades", "Plan a move to a supported release line."),
525
+ ("other", "Other", "Review and apply as appropriate."),
526
+ ]
527
+
528
+
529
+ def fix_category(finding: dict) -> str:
530
+ kind = finding.get("kind")
531
+ rule = (finding.get("source") or {}).get("rule_id") or ""
532
+ command = (finding.get("source") or {}).get("command") or ""
533
+ title = finding.get("title", "").lower()
534
+ if kind == "secret" or "readable by others" in title or "world-readable" in title or "permission" in title:
535
+ return "secrets-permissions"
536
+ if "firewall" in title or rule.startswith("hq-firewall"):
537
+ return "firewall"
538
+ if "fail2ban" in title or "crowdsec" in title or "intrusion" in title:
539
+ return "intrusion-prevention"
540
+ if kind == "vulnerability":
541
+ return "packages"
542
+ if kind == "eol":
543
+ return "end-of-life"
544
+ if command in ("host-nginx-config", "headers", "tls") or kind in ("header", "tls"):
545
+ return "web-server"
546
+ return "other"
547
+
548
+
549
+ def fix_commands(action: dict) -> tuple[list[str], list[tuple[str, str]], list[str]]:
550
+ """(shell commands, (kind, snippet) paste-ins, TODO notes) faithfully from the lead finding."""
551
+ lead = action["lead"]
552
+ rem = lead.get("remediation") or {}
553
+ steps = [s for s in (rem.get("steps") or []) if not s.startswith(("Listed by CISA", "Check you can log in"))]
554
+ snippets = [(s.get("kind") or "text", s.get("text") or "") for s in (rem.get("snippets") or []) if s.get("text")]
555
+ commands = [s for s in steps if re.match(r"^\s*(sudo |apt|chmod|chown|ufw|systemctl|nginx |visudo|sed |install )", s)]
556
+ todos = [] if (commands or snippets) else [rem.get("summary") or action["title"]]
557
+ return commands, snippets, todos
558
+
559
+
560
+ def fix_plan(ctx: core.Context) -> dict:
561
+ args = ctx.args
562
+ since = resolve_since(ctx, args.since)
563
+ findings, _runs, _fixed = collect_findings(ctx, since, args.runs)
564
+ actionable = [f for f in findings if f["status"] in ("new", "open")]
565
+ if args.priority:
566
+ floor = core.PRIORITIES.index(args.priority)
567
+ actionable = [f for f in actionable if core.PRIORITIES.index(f["priority"]) <= floor]
568
+ actions = group_actions(actionable)
569
+ buckets: dict[str, list[dict]] = {key: [] for key, _, _ in FIX_CATEGORIES}
570
+ for action in actions:
571
+ buckets[fix_category(action["lead"])].append(action)
572
+
573
+ host = next((f.get("target", {}).get("host") for f in actionable if (f.get("target") or {}).get("host")), None)
574
+ lock_target = host or "<user@host>"
575
+ sh: list[str] = [
576
+ "#!/usr/bin/env bash",
577
+ f"# Remediation runbook for {ctx.site}" + (f" ({host})" if host else "") + f" — generated {core.iso()[:10]} by maggie security fix-plan.",
578
+ "#",
579
+ "# REVIEW EVERY LINE BEFORE RUNNING. Maggie did not and will not run any of this.",
580
+ "# Run it yourself, as the operator, after:",
581
+ "# 1. taking a snapshot/backup of the server,",
582
+ "# 2. opening a SECOND SSH session (so a bad sshd/firewall change cannot lock you out),",
583
+ f"# 3. taking the change lock: maggie deployment host-lock acquire --host {lock_target} --purpose fix-plan",
584
+ "# Package upgrades belong in a maintenance window. Test nginx with `nginx -t` before every reload.",
585
+ "set -euo pipefail",
586
+ "",
587
+ ]
588
+ md: list[str] = [f"# Remediation runbook — {ctx.site}" + (f" ({host})" if host else ""), "",
589
+ "Generated by `maggie security fix-plan`. **Review before running — Maggie never runs these.** "
590
+ "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`.", ""]
592
+ apt_packages: list[str] = []
593
+ total = 0
594
+ for key, label, note in FIX_CATEGORIES:
595
+ items = buckets.get(key) or []
596
+ if not items:
597
+ continue
598
+ sh += [f"# =================== {label} ===================", f"# {note}", ""]
599
+ md += [f"## {label}", f"_{note}_", ""]
600
+ for action in items:
601
+ total += 1
602
+ sh.append(f"# [{action['priority']}] {action['title']} — {action['why']}")
603
+ md.append(f"- **{action['title']}** — {action['why']}")
604
+ if key == "packages":
605
+ # Build the fix command ourselves; never copy the finding's step text
606
+ # (it reads "apt-get install --only-upgrade X (fixed in Y)" — not valid shell).
607
+ name = action.get("package")
608
+ osfam = (action.get("ecosystem") or "").split(":")[0]
609
+ if name and osfam in ("Ubuntu", "Debian") and action.get("fix_version"):
610
+ apt_packages.append(name)
611
+ sh.append(f"# {name} -> {action['fix_version']} (in the apt batch above)")
612
+ else:
613
+ sh.append(f"# TODO (manual): {action['how']}")
614
+ md.append(f" - {action['how']}")
615
+ sh.append("")
616
+ continue
617
+ commands, snippets, todos = fix_commands(action)
618
+ for cmd in commands:
619
+ sh.append(cmd)
620
+ for kind, text in snippets:
621
+ sh.append(f"# {kind} config — paste into the right block:")
622
+ sh += [f"# {line}" for line in text.splitlines()]
623
+ md.append(f" - `{kind}`: `{text}`")
624
+ for todo in todos:
625
+ sh.append(f"# TODO (manual): {todo}")
626
+ md.append(f" - {todo}")
627
+ if commands:
628
+ md.append(" - run: " + "; ".join(f"`{c}`" for c in commands))
629
+ sh.append("")
630
+ md.append("")
631
+ if apt_packages:
632
+ pkgs = " ".join(sorted(set(apt_packages)))
633
+ insert = ["# Apply available Ubuntu/Debian security updates for the affected packages:",
634
+ "sudo apt-get update",
635
+ f"sudo apt-get install --only-upgrade {pkgs}",
636
+ "# Or apply all pending security updates at once:",
637
+ "# sudo unattended-upgrade -d # or: sudo apt-get upgrade", ""]
638
+ anchor = sh.index("# =================== Package updates ===================") + 3
639
+ sh[anchor:anchor] = insert
640
+ md.append("### apt batch")
641
+ md.append(f"```\nsudo apt-get update && sudo apt-get install --only-upgrade {pkgs}\n```\n")
642
+ runbook = "\n".join(md).rstrip() + "\n"
643
+ script = "\n".join(sh).rstrip() + "\n"
644
+ if args.script:
645
+ path = Path(args.script)
646
+ path.parent.mkdir(parents=True, exist_ok=True)
647
+ path.write_text(script, encoding="utf-8")
648
+ path.chmod(0o755)
649
+ if getattr(args, "output", None):
650
+ out = Path(args.output)
651
+ out.parent.mkdir(parents=True, exist_ok=True)
652
+ out.write_text(runbook, encoding="utf-8")
653
+ return {"schema": "maggie-security-fix-plan.v1", "site": ctx.site, "host": host, "generated_at": core.iso(),
654
+ "actions": total, "apt_packages": sorted(set(apt_packages)),
655
+ "script_path": str(args.script) if args.script else None,
656
+ "note": "Review before running. Maggie never applies these changes.", "markdown": runbook}
657
+
658
+
659
+ def _fix(p: argparse.ArgumentParser) -> None:
660
+ p.add_argument("--priority", choices=["p0", "p1", "p2", "p3", "p4"], help="only include findings at or above this priority")
661
+ p.add_argument("--script", help="write the runnable shell runbook to this path (chmod 755)")
662
+ p.add_argument("--runs", action="append", help="use these run ids instead of the latest per command")
663
+
664
+
493
665
  def _incident(p: argparse.ArgumentParser) -> None:
494
666
  p.add_argument("action", choices=["start", "log", "close", "show", "list"])
495
667
  p.add_argument("--id")
@@ -503,4 +675,6 @@ def _incident(p: argparse.ArgumentParser) -> None:
503
675
  COMMANDS = [core.Command("report", "one per-site report from the latest runs and the ledger (Markdown + JSON, report_save payload)", [],
504
676
  configure, run, findings=False),
505
677
  core.Command("incident", "timeline for a CVE response or other incident (local records; used by report --kind incident)", [], _incident,
506
- incident_cmd, findings=False)]
678
+ incident_cmd, findings=False),
679
+ core.Command("fix-plan", "a reviewed remediation runbook from the open findings (Markdown + a shell script); never applied by Maggie",
680
+ [], _fix, fix_plan, findings=False, routine_safe=False)]
@@ -202,7 +202,13 @@
202
202
  "forbidden_flags": [
203
203
  "--upload"
204
204
  ],
205
- "notes": "GPL: run from the upstream tarball on the host; never vendored."
205
+ "notes": "GPL: downloaded on use, sha256-verified, uploaded to a private /tmp dir on the host and removed after; never vendored into the package.",
206
+ "assets": {
207
+ "any": {
208
+ "url": "https://downloads.cisofy.com/lynis/lynis-3.1.5.tar.gz",
209
+ "sha256": "8d2c6652ba60116a82514522b666ca77293f4bfc69f1e581028769f7ebb52ba4"
210
+ }
211
+ }
206
212
  },
207
213
  "goaccess": {
208
214
  "version": "1.9.4",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.57",
3
+ "version": "0.7.59",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",