@topy-ai/maggie 0.7.55 → 0.7.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README-zh-TW.md +7 -3
  2. package/README.md +42 -8
  3. package/bin/maggie.js +4 -0
  4. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +2 -1
  5. package/bundled-contracts/maggie-security/finding-example-v1.json +89 -0
  6. package/bundled-contracts/maggie-security/finding-v1.schema.json +63 -0
  7. package/bundled-contracts/maggie-security/findings-set-example-v1.json +148 -0
  8. package/bundled-contracts/maggie-security/findings-set-v1.schema.json +32 -0
  9. package/bundled-contracts/maggie-security/protect-plan-v1.schema.json +28 -0
  10. package/bundled-contracts/maggie-security/report-v1.schema.json +29 -0
  11. package/bundled-contracts/maggie-security/stack-example-v1.json +66 -0
  12. package/bundled-contracts/maggie-security/stack-upload-v1.schema.json +22 -0
  13. package/bundled-contracts/maggie-security/stack-v1.schema.json +37 -0
  14. package/bundled-contracts/maggie-security/tools-lock-v1.schema.json +36 -0
  15. package/bundled-skills/catalog.json +4 -0
  16. package/bundled-skills/maggie-security/SKILL.md +173 -0
  17. package/bundled-skills/maggie-security/agents/openai.yaml +4 -0
  18. package/bundled-skills/maggie-security/data/LICENSE-NOTICE.md +24 -0
  19. package/bundled-skills/maggie-security/data/asvs-map.json +209 -0
  20. package/bundled-skills/maggie-security/data/fail2ban/filter.d/maggie-wordpress.conf +6 -0
  21. package/bundled-skills/maggie-security/data/semgrep/LICENSE-NOTICE.md +5 -0
  22. package/bundled-skills/maggie-security/data/semgrep/VERSION +1 -0
  23. package/bundled-skills/maggie-security/data/semgrep/rules/dockerfile.yaml +14 -0
  24. package/bundled-skills/maggie-security/data/semgrep/rules/generic.yaml +26 -0
  25. package/bundled-skills/maggie-security/data/semgrep/rules/js.yaml +216 -0
  26. package/bundled-skills/maggie-security/data/semgrep/rules/php-debug-ok.yaml +17 -0
  27. package/bundled-skills/maggie-security/data/semgrep/rules/php-debug.yaml +17 -0
  28. package/bundled-skills/maggie-security/data/semgrep/rules/php.yaml +101 -0
  29. package/bundled-skills/maggie-security/data/semgrep/tests/dockerfile.dockerfile +10 -0
  30. package/bundled-skills/maggie-security/data/semgrep/tests/generic.astro +11 -0
  31. package/bundled-skills/maggie-security/data/semgrep/tests/generic.conf +11 -0
  32. package/bundled-skills/maggie-security/data/semgrep/tests/js.jsx +95 -0
  33. package/bundled-skills/maggie-security/data/semgrep/tests/php-debug-ok.php +5 -0
  34. package/bundled-skills/maggie-security/data/semgrep/tests/php-debug.php +3 -0
  35. package/bundled-skills/maggie-security/data/semgrep/tests/php.php +55 -0
  36. package/bundled-skills/maggie-security/evals/evals.json +97 -0
  37. package/bundled-skills/maggie-security/references/commands.md +125 -0
  38. package/bundled-skills/maggie-security/references/cve-response.md +24 -0
  39. package/bundled-skills/maggie-security/references/explaining-findings.md +57 -0
  40. package/bundled-skills/maggie-security/references/findings.md +39 -0
  41. package/bundled-skills/maggie-security/references/ownership-and-consent.md +29 -0
  42. package/bundled-skills/maggie-security/references/site-types.md +22 -0
  43. package/bundled-skills/maggie-security/references/tools-and-licences.md +16 -0
  44. package/bundled-skills/maggie-security/references/workflows.md +23 -0
  45. package/bundled-skills/maggie-security/registry/security-tools.registry.json +654 -0
  46. package/bundled-skills/maggie-security/routines.example.json +27 -0
  47. package/bundled-tools/clis/maggie_deployment_readiness.py +17 -2
  48. package/bundled-tools/clis/maggie_security.py +342 -0
  49. package/bundled-tools/clis/maggie_security_agent.py +326 -0
  50. package/bundled-tools/clis/maggie_security_code.py +650 -0
  51. package/bundled-tools/clis/maggie_security_core.py +1512 -0
  52. package/bundled-tools/clis/maggie_security_deps.py +560 -0
  53. package/bundled-tools/clis/maggie_security_health.py +415 -0
  54. package/bundled-tools/clis/maggie_security_host.py +511 -0
  55. package/bundled-tools/clis/maggie_security_hosted.py +115 -0
  56. package/bundled-tools/clis/maggie_security_intel.py +1294 -0
  57. package/bundled-tools/clis/maggie_security_inventory.py +1023 -0
  58. package/bundled-tools/clis/maggie_security_logs.py +777 -0
  59. package/bundled-tools/clis/maggie_security_mcp.py +484 -0
  60. package/bundled-tools/clis/maggie_security_mirror.py +122 -0
  61. package/bundled-tools/clis/maggie_security_protect.py +519 -0
  62. package/bundled-tools/clis/maggie_security_report.py +506 -0
  63. package/bundled-tools/clis/maggie_security_standards.py +214 -0
  64. package/bundled-tools/clis/maggie_security_versions.py +381 -0
  65. package/bundled-tools/clis/maggie_security_web.py +1247 -0
  66. package/bundled-tools/clis/security_data/eol-snapshot.json +1 -0
  67. package/bundled-tools/clis/security_data/gitleaks-rules.json +3812 -0
  68. package/bundled-tools/clis/security_data/maltrail-lists.json +1 -0
  69. package/bundled-tools/clis/security_data/public-suffix-icann.txt +7374 -0
  70. package/bundled-tools/clis/security_data/retire-extractors.json +695 -0
  71. package/bundled-tools/clis/security_data/sigma-web.json +6514 -0
  72. package/bundled-tools/clis/security_data/tools.lock.json +358 -0
  73. package/package.json +1 -1
@@ -0,0 +1,506 @@
1
+ #!/usr/bin/env python3
2
+ """``maggie security report`` (docs/maggie-security/09-risk-report-standards-prd.md section 6.1).
3
+
4
+ Combines the latest run per command and the ledger into one per-site report:
5
+ Markdown for the owner (plain language) or a technical audience, plus a
6
+ ``maggie-security-report.v1`` JSON. Both are written to
7
+ ``.maggie/security/reports/``. The report passes the same checks as
8
+ ``maggie assistant report-check`` before anything is saved; ``--save`` writes
9
+ the exact ``report_save`` payload for the agent or bridge to send (this CLI
10
+ never holds CMO.so credentials).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import re
18
+ from pathlib import Path
19
+
20
+ import maggie_security_core as core
21
+ import maggie_security_versions as versions
22
+ from maggie_assistant_tools import report_check
23
+
24
+ BODY_LIMIT = 50000
25
+ KIND_INBOX = {"weekly": "weekly", "daily_ops": "daily_ops", "incident": "agent", "gate": "agent"}
26
+ JARGON = {
27
+ "CVE": "a public ID for a known security flaw",
28
+ "KEV": "CISA's list of flaws attackers are using right now",
29
+ "EOL": "end of life: no more security fixes",
30
+ "HSTS": "a header that makes browsers always use HTTPS",
31
+ "CSP": "a header that limits which scripts a page may run",
32
+ "WAF": "a filter that blocks common attacks before they reach the site",
33
+ "SRI": "a check that a script from another site was not changed",
34
+ }
35
+ FEAR_WORDS = re.compile(r"(?i)\b(critical breach|hacked|compromised|catastrophic)\b")
36
+ # Which command families a profile expects to have run (PRD 09 "What we checked").
37
+ EXPECTED = {
38
+ "wordpress": ["vuln-watch", "wp-check", "eol-watch", "secrets", "headers", "tls", "host-quick", "logs-traffic"],
39
+ "node-app": ["vuln-watch", "deps", "eol-watch", "secrets", "headers", "tls"],
40
+ "php-app": ["vuln-watch", "deps", "eol-watch", "secrets", "headers", "tls", "host-quick"],
41
+ "astro": ["vuln-watch", "deps", "secrets", "headers", "tls"], "workers": ["vuln-watch", "deps", "secrets", "headers", "tls"],
42
+ "python-app": ["vuln-watch", "deps", "eol-watch", "secrets", "headers", "tls"], "java-app": ["vuln-watch", "deps", "eol-watch", "secrets", "headers", "tls"],
43
+ "go-app": ["vuln-watch", "deps", "eol-watch", "secrets", "headers", "tls"], "ruby-app": ["vuln-watch", "deps", "eol-watch", "secrets", "headers", "tls"],
44
+ "dotnet-app": ["vuln-watch", "deps", "eol-watch", "secrets", "headers", "tls"],
45
+ "static": ["vuln-watch", "secrets", "headers", "tls"], "unknown": ["vuln-watch", "deps", "secrets"],
46
+ }
47
+ AREA_LABEL = {"vuln-watch": "Vulnerabilities in your software", "wp-check": "WordPress core, plugins and themes", "eol-watch": "Software past end of life",
48
+ "deps": "Code dependencies", "secrets": "Passwords and keys in code", "headers": "Website security headers", "tls": "HTTPS setup",
49
+ "host-quick": "Server", "logs-traffic": "Traffic and attacks", "code-scan": "Code patterns", "web-scan": "Known website exposures",
50
+ "osv-query": "Package lookups", "node-check": "Node.js", "logs-sigma": "Attack patterns in logs", "host-audit": "Server hardening"}
51
+
52
+
53
+ def latest_runs(state: core.State, since: str | None, run_ids: list[str] | None) -> list[dict]:
54
+ runs_dir = state.path("runs")
55
+ if not runs_dir.is_dir():
56
+ return []
57
+ docs = []
58
+ for path in sorted(runs_dir.glob("*.json")):
59
+ try:
60
+ docs.append(json.loads(path.read_text(encoding="utf-8")))
61
+ except (ValueError, OSError):
62
+ continue
63
+ if run_ids:
64
+ return [d for d in docs if d["run"]["id"] in run_ids]
65
+ floor = core.utcnow() - core.parse_duration(since) if since else None
66
+ latest: dict[str, dict] = {}
67
+ for doc in docs:
68
+ finished = core.parse_time(doc["run"]["finished_at"])
69
+ if floor and finished < floor:
70
+ continue
71
+ command = doc["run"]["command"]
72
+ if command in ("osv-query",):
73
+ continue
74
+ if command not in latest or core.parse_time(latest[command]["run"]["finished_at"]) <= finished:
75
+ latest[command] = doc
76
+ return list(latest.values())
77
+
78
+
79
+ GATE_COMMANDS = ("deps", "secrets")
80
+
81
+
82
+ def gate_decision(findings: list[dict], runs: list[dict]) -> dict:
83
+ """Pre-deploy gate (docs/maggie-security/12-workflows-prd.md section 7.3)."""
84
+ block, warn = [], []
85
+ ran = {d["run"]["command"]: d for d in runs}
86
+ for command in GATE_COMMANDS:
87
+ if command not in ran:
88
+ block.append({"reason": f"`maggie security {command}` did not run for this gate", "ids": []})
89
+ elif ran[command]["run"].get("degraded"):
90
+ block.append({"reason": f"`maggie security {command}` was incomplete ({', '.join(ran[command]['run']['degraded'])}); "
91
+ "a gate that silently skipped is worse than none", "ids": []})
92
+ active = [f for f in findings if f["status"] in ("new", "open")]
93
+ secrets = [f for f in active if f["kind"] == "secret" and f["severity"] in ("critical", "high")
94
+ and any(not e.get("commit") for e in f.get("evidence") or [{}])]
95
+ if secrets:
96
+ block.append({"reason": "secrets in tracked files or build output: rotate and remove them (no override)", "ids": [f["id"] for f in secrets]})
97
+ serious = [f for f in active if f["kind"] in ("vulnerability", "malicious-package") and f["severity"] in ("critical", "high")]
98
+ runtime_fixable = [f for f in serious if (f.get("affected") or {}).get("scope") != "dev" and ((f.get("fix") or {}).get("available") or f["kind"] == "malicious-package")]
99
+ if runtime_fixable:
100
+ block.append({"reason": "critical/high vulnerabilities with a fix available: upgrade, or record an accepted risk with "
101
+ "`maggie security ledger accept --id … --reason …`", "ids": [f["id"] for f in runtime_fixable]})
102
+ no_fix = [f for f in serious if (f.get("affected") or {}).get("scope") != "dev" and not (f.get("fix") or {}).get("available") and f not in runtime_fixable]
103
+ if no_fix:
104
+ warn.append({"reason": "critical/high vulnerabilities without a fix: give a reason in the release notes", "ids": [f["id"] for f in no_fix]})
105
+ dev = [f for f in serious if (f.get("affected") or {}).get("scope") == "dev"]
106
+ if dev:
107
+ warn.append({"reason": "dev-only dependencies with known flaws", "ids": [f["id"] for f in dev]})
108
+ decision = "block" if block else "warn" if warn else "pass"
109
+ return {"decision": decision, "block": block, "warn": warn, "checked": sorted(ran)}
110
+
111
+
112
+ def last_report_time(state: core.State) -> str | None:
113
+ index = state.path("reports", "index.jsonl")
114
+ if not index.is_file():
115
+ return None
116
+ lines = [json.loads(l) for l in index.read_text(encoding="utf-8").splitlines() if l.strip()]
117
+ return lines[-1]["at"] if lines else None
118
+
119
+
120
+ def verdict(p0: int, p1: int, compromise: bool) -> dict:
121
+ if compromise:
122
+ return {"code": "possible-break-in", "text": "Something in your logs looks like a possible break-in. Check it now (first section)."}
123
+ if p0:
124
+ return {"code": "act-today", "text": f"{p0 + p1} thing{'s' if p0 + p1 != 1 else ''} to do, {p0} of them today. No sign that anyone got in."}
125
+ if p1:
126
+ return {"code": "act-this-week", "text": f"{p1} thing{'s' if p1 != 1 else ''} to do this week. No sign that anyone got in."}
127
+ return {"code": "nothing-urgent", "text": "Nothing needs doing this week."}
128
+
129
+
130
+ def action_title(finding: dict) -> str:
131
+ affected = finding.get("affected") or {}
132
+ fix = finding.get("fix") or {}
133
+ name = affected.get("name") or ""
134
+ if finding["kind"] in ("vulnerability", "malicious-package") and fix.get("recommended"):
135
+ return f"Update {name} to {fix['recommended']}"
136
+ if finding["kind"] == "malicious-package":
137
+ return f"Remove {name} now"
138
+ if finding["kind"] == "eol":
139
+ return f"Plan the move off {name} {affected.get('version') or ''}".strip()
140
+ if finding["kind"] == "secret":
141
+ return "Revoke and replace the exposed key"
142
+ return finding["title"]
143
+
144
+
145
+ def why(finding: dict) -> str:
146
+ kev = (finding.get("kev") or {}).get("listed")
147
+ kind = finding["kind"]
148
+ if kind == "malicious-package":
149
+ return "This package is known to be malicious."
150
+ if kind == "eol":
151
+ return "It no longer gets security fixes (EOL)."
152
+ if kind == "secret":
153
+ return "Anyone who sees the file can use this key."
154
+ if kev:
155
+ return "Attackers are already using this flaw (on the KEV list)."
156
+ severity = {"critical": "A serious", "high": "A serious", "medium": "A moderate", "low": "A minor", "info": "A minor"}[finding["severity"]]
157
+ return f"{severity} known flaw (CVE) affects the version you run."
158
+
159
+
160
+ def how(finding: dict) -> str:
161
+ steps = (finding.get("remediation") or {}).get("steps") or []
162
+ step = next((s for s in steps if not s.startswith("Listed by CISA")), None)
163
+ return f"`{step}`" if step and len(step) < 120 else ((finding.get("remediation") or {}).get("summary") or "See details.")
164
+
165
+
166
+ def group_actions(findings: list[dict]) -> list[dict]:
167
+ """One action per component: every vulnerability on the same package/runtime and host
168
+ becomes "Update X to <the version that fixes all of them>"."""
169
+ groups: dict[tuple, list[dict]] = {}
170
+ order: list[tuple] = []
171
+ for finding in findings:
172
+ affected = finding.get("affected") or {}
173
+ target = finding.get("target") or {}
174
+ if finding["kind"] in ("vulnerability",) and affected.get("name"):
175
+ key = ("vuln", affected.get("ecosystem"), affected["name"], target.get("host"), target.get("image"))
176
+ else:
177
+ key = ("single", finding["id"])
178
+ if key not in groups:
179
+ groups[key] = []
180
+ order.append(key)
181
+ groups[key].append(finding)
182
+ actions = []
183
+ for key in order:
184
+ items = core.sort_findings(groups[key])
185
+ lead = items[0]
186
+ if key[0] == "single" or len(items) == 1:
187
+ actions.append({"priority": lead["priority"], "title": action_title(lead), "why": why(lead), "how": how(lead),
188
+ "ids": [lead["id"]], "count": 1, "lead": lead})
189
+ continue
190
+ affected = lead["affected"]
191
+ eco = affected.get("ecosystem")
192
+ fixes = [f["fix"]["recommended"] for f in items if (f.get("fix") or {}).get("recommended")]
193
+ unfixed = sum(1 for f in items if not (f.get("fix") or {}).get("recommended"))
194
+ target_version = max(fixes, key=versions.sort_key(eco)) if fixes else None
195
+ serious = sum(1 for f in items if f["severity"] in ("critical", "high"))
196
+ exploited = any((f.get("kev") or {}).get("listed") for f in items)
197
+ where = f" on {lead['target']['host']}" if (lead.get("target") or {}).get("host") else ""
198
+ if target_version and not unfixed:
199
+ title = f"Update {affected['name']}{where} to {target_version}"
200
+ elif target_version:
201
+ title = f"Update {affected['name']}{where} to {target_version} and plan a supported line ({unfixed} {'flaws have' if unfixed != 1 else 'flaw has'} no fix on this line)"
202
+ else:
203
+ title = f"Move {affected['name']} {affected.get('version') or ''}{where} to a supported line".replace(" ", " ")
204
+ reason = (f"{len(items)} known flaws (CVE) in the version you run, {serious} serious"
205
+ + ("; attackers are using at least one (on the KEV list)." if exploited else "."))
206
+ step_from = next((f for f in items if (f.get("fix") or {}).get("recommended") == target_version), lead)
207
+ 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})
209
+ return sorted(actions, key=lambda a: (core.PRIORITIES.index(a["priority"]), -a["count"]))
210
+
211
+
212
+ def explain_jargon(text: str, used: set[str]) -> str:
213
+ def sub(match: re.Match) -> str:
214
+ word = match.group(0)
215
+ if word in used:
216
+ return word
217
+ used.add(word)
218
+ return f"{word} ({JARGON[word]})"
219
+ return re.sub(r"\b(" + "|".join(JARGON) + r")\b", sub, text)
220
+
221
+
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
227
+ if since in (None, "last-report"):
228
+ last = last_report_time(ctx.state)
229
+ if last:
230
+ 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
+ if not runs:
236
+ raise core.UsageError(f"no security runs in the last {since}; run e.g. `maggie security vuln-watch` first")
237
+ ledger = core.Ledger(ctx.state.path("ledger.jsonl"))
238
+ history = ledger.history()
239
+ by_id: dict[str, dict] = {}
240
+ fixed_ids: set[str] = set()
241
+ for doc in sorted(runs, key=lambda d: d["run"]["finished_at"]):
242
+ for finding in doc.get("findings") or []:
243
+ by_id[finding["id"]] = finding
244
+ fixed_ids |= set(doc.get("fixed") or [])
245
+ merged = core.merge_findings(by_id.values())
246
+ floor = core.utcnow() - core.parse_duration(since)
247
+ findings = []
248
+ for finding in merged:
249
+ state = core.Ledger.derive(history.get(finding["id"], [])) if finding["id"] in history else None
250
+ status = (state or {}).get("status") or finding["status"]
251
+ if status == "fixed":
252
+ fixed_ids.add(finding["id"])
253
+ continue
254
+ first_seen = (state or {}).get("first_seen") or finding.get("first_seen")
255
+ finding = dict(finding, status="new" if first_seen and core.parse_time(first_seen) >= floor and status in ("new", "open") else status)
256
+ findings.append(finding)
257
+ findings = core.sort_findings(findings)
258
+ actionable = [f for f in findings if f["status"] in ("new", "open")]
259
+ grouped = group_actions([f for f in actionable if f["priority"] in ("p0", "p1")])
260
+ p0 = sum(1 for a in grouped if a["priority"] == "p0")
261
+ p1 = sum(1 for a in grouped if a["priority"] == "p1")
262
+ compromise = any((f.get("signal") or {}).get("type") == "compromise-indicator" for f in actionable)
263
+ verdict_info = verdict(p0, p1, compromise)
264
+ degraded = sorted({s for d in runs for s in d["run"].get("degraded") or []})
265
+ stack_path = ctx.state.path("inventory", "stack.json")
266
+ site_type = core.read_json(stack_path)["site"].get("type", "unknown") if stack_path.is_file() else core.detect_site_type(ctx.project)
267
+ checked = [{"command": d["run"]["command"], "last_run": d["run"]["finished_at"], "degraded": bool(d["run"].get("degraded"))}
268
+ for d in sorted(runs, key=lambda d: d["run"]["command"])]
269
+ ran = {c["command"] for c in checked}
270
+ import maggie_security
271
+
272
+ built = set(maggie_security.area_commands())
273
+ 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()}
275
+ actions = [f for f in actionable if f["priority"] in ("p0", "p1")]
276
+ report_id = f"{core.iso()[:10]}-{args.kind}-{core.sha256_bytes(json.dumps(sorted(f['id'] for f in findings)).encode())[:6]}"
277
+ data = {
278
+ "schema": "maggie-security-report.v1", "id": report_id, "site": ctx.site, "kind": args.kind, "audience": args.audience, "period": period,
279
+ "verdict": verdict_info,
280
+ "counts": {"new": sum(1 for f in findings if f["status"] == "new"), "open": sum(1 for f in findings if f["status"] == "open"),
281
+ "fixed": len(fixed_ids), "by_priority": {p: sum(1 for f in actionable if f["priority"] == p) for p in core.PRIORITIES}},
282
+ "actions": [{"finding_id": a["ids"][0], "finding_ids": a["ids"], "priority": a["priority"], "title": a["title"]}
283
+ for a in group_actions(actions)[:5]],
284
+ "coverage": None,
285
+ "checked": checked, "not_checked": missing, "degraded_sources": degraded,
286
+ "runs": [d["run"]["id"] for d in runs], "findings": [f["id"] for f in findings], "fixed": sorted(fixed_ids),
287
+ "maggie_version": core.maggie_version(),
288
+ }
289
+ if args.kind == "gate":
290
+ data["gate"] = gate_decision(findings, runs)
291
+ if args.kind == "incident":
292
+ directory = ctx.state.path("incidents")
293
+ items = sorted(directory.glob("*.json"), key=lambda p: p.stat().st_mtime) if directory.is_dir() else []
294
+ chosen = next((p for p in items if p.stem == args.incident), None) if args.incident else (items[-1] if items else None)
295
+ if chosen:
296
+ data["incident"] = {k: v for k, v in core.read_json(chosen).items() if k in ("id", "title", "cve", "status", "timeline")}
297
+ markdown = render(data, findings, args.audience, ctx.site)
298
+ if len(markdown) > BODY_LIMIT:
299
+ markdown = render(data, findings, args.audience, ctx.site, clip=True)
300
+ title = f"Security: {verdict_info['text'].split('.')[0]} — {ctx.site} — {core.iso()[:10]}"[:120]
301
+ summary = (verdict_info["text"] + (f" First: {data['actions'][0]['title']}." if data["actions"] else ""))[:500]
302
+ payload = {"title": title, "summary": summary, "body_md": markdown, "kind": KIND_INBOX[args.kind],
303
+ "data": {k: v for k, v in data.items() if k != "findings"}}
304
+ check = report_check(payload)
305
+ texts = [markdown, json.dumps(data)]
306
+ leaked = core.assert_no_secret(texts, _secret_values(ctx))
307
+ if leaked:
308
+ check["passed"] = False
309
+ check["errors"].append("a secret value from the project appears in the report")
310
+ out_dir = ctx.state.path("reports")
311
+ out_dir.mkdir(parents=True, exist_ok=True)
312
+ base = out_dir / f"{core.iso()[:10]}-{args.kind}"
313
+ base.with_suffix(".md").write_text(markdown, encoding="utf-8")
314
+ core.write_json(base.with_suffix(".json"), data)
315
+ if args.output:
316
+ core.write_json(Path(args.output) if Path(args.output).is_absolute() else ctx.project / args.output, data)
317
+ result = {"schema": "maggie-security-report-result.v1", "report": data, "markdown": markdown, "check": check,
318
+ "paths": {"md": str(base.with_suffix(".md")), "json": str(base.with_suffix(".json"))}}
319
+ if not check["passed"]:
320
+ result["exit_code"] = core.EXIT_POLICY
321
+ return result
322
+ if args.save:
323
+ save_path = base.with_name(base.name + ".report_save.json")
324
+ core.write_json(save_path, payload)
325
+ with (out_dir / "index.jsonl").open("a", encoding="utf-8") as handle:
326
+ handle.write(core.canonical_json({"at": core.iso(), "id": report_id, "kind": args.kind, "payload": str(save_path)}) + "\n")
327
+ result["report_save"] = {"payload": str(save_path),
328
+ "next": "send this payload with the report_save MCP tool (routines: report_save is the only write allowed)"}
329
+ if args.kind == "gate":
330
+ result["exit_code"] = core.EXIT_FINDINGS if data["gate"]["decision"] == "block" else core.EXIT_OK
331
+ return result
332
+ threshold = core.fail_threshold(args)
333
+ if threshold and any(core.SEVERITIES.index(f["severity"]) <= core.SEVERITIES.index(threshold) for f in actionable):
334
+ result["exit_code"] = core.EXIT_FINDINGS
335
+ elif degraded:
336
+ result["exit_code"] = core.EXIT_DEGRADED
337
+ return result
338
+
339
+
340
+ def _secret_values(ctx: core.Context) -> list[str]:
341
+ """Hashes are all we keep; a report-time check can only test known fixture/test values passed by env."""
342
+ import os
343
+
344
+ value = os.environ.get("MAGGIE_SECURITY_TEST_SECRET")
345
+ return [value] if value else []
346
+
347
+
348
+ def render(data: dict, findings: list[dict], audience: str, site: str, clip: bool = False) -> str:
349
+ used: set[str] = set()
350
+ owner = audience == "owner"
351
+ lines = [f"# Security report — {site} — {data['period']['to'][:10]}", "", f"**Verdict:** {data['verdict']['text']}", ""]
352
+ incident = data.get("incident")
353
+ if incident:
354
+ lines += [f"## Incident: {incident['title']} ({incident['status']})", ""]
355
+ for item in incident["timeline"]:
356
+ lines.append(f"- {item['at']} — {item['event']}" + (f": {item['note']}" if item.get("note") else ""))
357
+ lines.append("")
358
+ gate = data.get("gate")
359
+ if gate:
360
+ lines += [f"## Deploy gate: {gate['decision'].upper()}", ""]
361
+ for item in gate["block"]:
362
+ lines.append(f"- Blocks: {item['reason']}" + (f" ({len(item['ids'])})" if item["ids"] else ""))
363
+ for item in gate["warn"]:
364
+ lines.append(f"- Warning: {item['reason']}" + (f" ({len(item['ids'])})" if item["ids"] else ""))
365
+ lines.append("")
366
+ compromise = [f for f in findings if (f.get("signal") or {}).get("type") == "compromise-indicator" and f["status"] in ("new", "open")]
367
+ if compromise:
368
+ lines += ["## Possible break-in — check now", ""]
369
+ for finding in compromise:
370
+ lines.append(f"- {finding['title']} ({(finding.get('target') or {}).get('host') or ''})")
371
+ lines.append("")
372
+ actionable = [f for f in findings if f["status"] in ("new", "open")]
373
+ urgent = [f for f in actionable if f["priority"] in ("p0", "p1")]
374
+ urgent_actions = group_actions(urgent)
375
+ lines.append(f"## Do this week ({len(urgent_actions)})")
376
+ lines.append("")
377
+ if not urgent_actions:
378
+ lines += ["Nothing urgent.", ""]
379
+ for index, action in enumerate(urgent_actions[:5], 1):
380
+ text = f"{index}. **{action['title']}** — {action['why']}"
381
+ lines.append(explain_jargon(text, used) if owner else text)
382
+ lines.append(f" How: {action['how']}")
383
+ if not owner:
384
+ lead = action["lead"]
385
+ lines.append(f" {action['count']} finding(s): " + ", ".join(f"`{i}`" for i in action["ids"][:6])
386
+ + (" …" if len(action["ids"]) > 6 else "") + f" · {', '.join((lead.get('aliases') or [])[:3])}"
387
+ + (f" · CVSS {lead['cvss']['score']}" if lead.get("cvss") else "")
388
+ + (f" · EPSS {lead['epss']['score']:.2f}" if lead.get("epss") else ""))
389
+ if len(urgent_actions) > 5:
390
+ lines.append(f"\n…and {len(urgent_actions) - 5} more below.")
391
+ lines.append("")
392
+ attacks = [f for f in actionable if f["kind"] == "attack-signal" and (f.get("signal") or {}).get("type") == "attempt"]
393
+ for title, status in (("New since last report", "new"), ("Still open", "open")):
394
+ items = [f for f in findings if f["status"] == status and f["priority"] not in ("p0", "p1") and f["kind"] != "attack-signal"]
395
+ rows = group_actions(items) if owner else [{"priority": f["priority"], "title": f["title"], "ids": [f["id"]], "count": 1, "lead": f}
396
+ for f in core.sort_findings(items)]
397
+ lines.append(f"## {title} ({len(rows)})")
398
+ lines.append("")
399
+ shown = rows[:20] if clip else rows
400
+ for row in shown:
401
+ lead = row["lead"]
402
+ since = f" (since {lead['first_seen'][:10]})" if status == "open" and lead.get("first_seen") else ""
403
+ extra = "" if owner else f" — `{lead['id']}`"
404
+ count = f" ({row['count']} issues)" if row["count"] > 1 else ""
405
+ lines.append(f"- [{row['priority']}] {row['title']}{count}{since}{extra}")
406
+ if len(shown) < len(rows):
407
+ lines.append(f"- {len(rows) - len(shown)} more in the full report (`maggie security report --format json`)")
408
+ lines.append("")
409
+ lines.append(f"## Fixed since last report ({len(data['fixed'])})")
410
+ lines.append("")
411
+ if data["fixed"]:
412
+ lines.append(f"{len(data['fixed'])} earlier finding(s) no longer appear in a complete check.")
413
+ lines.append("")
414
+ if attacks:
415
+ lines += ["## Attacks seen (not break-ins)", ""]
416
+ for finding in attacks:
417
+ signal = finding["signal"]
418
+ lines.append(f"- {signal.get('count')} attempts: {finding['title']} in {signal.get('window')}. Attempts are not break-ins.")
419
+ lines.append("")
420
+ lines += ["## What we checked", "", "| Area | Last checked | Result |", "|---|---|---|"]
421
+ for item in data["checked"]:
422
+ count = sum(1 for f in findings if f["source"]["command"] == item["command"] and f["status"] in ("new", "open"))
423
+ result = "incomplete" if item["degraded"] else (f"{count} open" if count else "no issues")
424
+ lines.append(f"| {AREA_LABEL.get(item['command'], item['command'])} | {item['last_run'][:10]} | {result} |")
425
+ for command in data["not_checked"]:
426
+ lines.append(f"| {AREA_LABEL.get(command, command)} | not checked yet | run `maggie security {command}` |")
427
+ lines.append("")
428
+ if data["degraded_sources"]:
429
+ lines += ["## Sources that did not answer", "",
430
+ f"{', '.join(data['degraded_sources'])} did not answer; those checks may be incomplete, and nothing was marked fixed from them.", ""]
431
+ lines += ["---", "", core.PRIORITY_FOOTER, "",
432
+ "Data: OSV (CC-BY-4.0 / Apache-2.0 sources), CISA KEV (CC0), FIRST EPSS, endoflife.date (MIT), WPVulnerability, CVE List (CVE Terms of Use), "
433
+ f"Node.js security-wg (MIT). Generated by Maggie {data['maggie_version']} · report `{data['id']}`.", ""]
434
+ text = "\n".join(lines)
435
+ if owner and FEAR_WORDS.search(text) and not compromise:
436
+ text = FEAR_WORDS.sub("affected", text)
437
+ return text
438
+
439
+
440
+ def configure(parser: argparse.ArgumentParser) -> None:
441
+ parser.add_argument("--kind", choices=["weekly", "daily_ops", "incident", "gate"], default="weekly")
442
+ parser.add_argument("--runs", action="append", help="use these run ids instead of the latest per command")
443
+ parser.add_argument("--audience", choices=["owner", "technical"], default="owner")
444
+ parser.add_argument("--save", action="store_true", help="write the report_save payload and record it in reports/index.jsonl")
445
+ parser.add_argument("--level", type=int, choices=[1, 2], default=1)
446
+ parser.add_argument("--incident", help="incident id for --kind incident (default: the latest)")
447
+
448
+
449
+ def run(ctx: core.Context) -> dict:
450
+ return build(ctx)
451
+
452
+
453
+ # --------------------------------------------------------------------------- incidents (W2 timeline)
454
+
455
+ INCIDENT_EVENTS = ["detected", "advisory-written", "inventory-checked", "plan-proposed", "plan-approved", "lock-acquired", "changed", "verified",
456
+ "lock-released", "mitigated", "closed", "note"]
457
+
458
+
459
+ def incident_cmd(ctx: core.Context) -> dict:
460
+ """A timeline for one CVE response (docs/maggie-security/12-workflows-prd.md section 6). Local records only."""
461
+ args = ctx.args
462
+ directory = ctx.state.path("incidents")
463
+ if args.action == "start":
464
+ if not args.title:
465
+ raise core.UsageError("incident start needs --title")
466
+ iid = f"inc-{core.iso()[:10]}-{core.sha256_bytes((args.title + core.iso()).encode())[:6]}"
467
+ data = {"schema": "maggie-security-incident.v1", "id": iid, "title": core.clean_excerpt(args.title, 200), "cve": args.cve,
468
+ "finding": args.finding, "status": "open", "opened_at": core.iso(), "closed_at": None,
469
+ "timeline": [{"at": core.iso(), "event": "detected", "note": core.clean_excerpt(args.note or "", 300)}]}
470
+ core.write_json(directory / f"{iid}.json", data)
471
+ return data
472
+ if args.action == "list":
473
+ items = [core.read_json(p) for p in sorted(directory.glob("*.json"))] if directory.is_dir() else []
474
+ return {"schema": "maggie-security-incidents.v1", "incidents": [{k: i[k] for k in ("id", "title", "cve", "status", "opened_at", "closed_at")} for i in items]}
475
+ path = directory / f"{args.id}.json"
476
+ if not args.id or not path.is_file():
477
+ raise core.UsageError("give --id of an incident (see `maggie security incident list`)")
478
+ data = core.read_json(path)
479
+ if args.action == "show":
480
+ return data
481
+ if data["status"] == "closed" and args.action != "show":
482
+ raise core.UsageError("incident is closed")
483
+ event = "closed" if args.action == "close" else args.event
484
+ if event not in INCIDENT_EVENTS:
485
+ raise core.UsageError(f"--event must be one of {', '.join(INCIDENT_EVENTS)}")
486
+ data["timeline"].append({"at": core.iso(), "event": event, "note": core.clean_excerpt(args.note or "", 300)})
487
+ if event == "closed":
488
+ data["status"], data["closed_at"] = "closed", core.iso()
489
+ core.write_json(path, data)
490
+ return data
491
+
492
+
493
+ def _incident(p: argparse.ArgumentParser) -> None:
494
+ p.add_argument("action", choices=["start", "log", "close", "show", "list"])
495
+ p.add_argument("--id")
496
+ p.add_argument("--title")
497
+ p.add_argument("--cve")
498
+ p.add_argument("--finding")
499
+ p.add_argument("--event", help=", ".join(INCIDENT_EVENTS))
500
+ p.add_argument("--note")
501
+
502
+
503
+ COMMANDS = [core.Command("report", "one per-site report from the latest runs and the ledger (Markdown + JSON, report_save payload)", [],
504
+ configure, run, findings=False),
505
+ core.Command("incident", "timeline for a CVE response or other incident (local records; used by report --kind incident)", [], _incident,
506
+ incident_cmd, findings=False)]