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