@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,511 @@
1
+ #!/usr/bin/env python3
2
+ """Host checks (docs/maggie-security/07-host-audit-prd.md):
3
+ ``host-quick``, ``host-audit``, ``host-nginx-config``.
4
+
5
+ - ``host-quick``: a read-only evidence collector runs on the host (printed by
6
+ ``--print-collector`` for review; over SSH only with ``ssh-read`` consent)
7
+ and the checks run here. The check list and their logic are ported from
8
+ nuver-labs/vps-audit (MIT, Copyright (c) 2024 Israel Abebe Kokiso) with the
9
+ egress (``api.ipify.org``) and the report file removed, plus Maggie checks
10
+ (databases listening publicly, Docker bypassing UFW, time sync).
11
+ - ``host-audit``: parses a lynis ``report.dat`` (lynis is GPL and runs on the
12
+ host as a separate program); over SSH it needs ``ssh-temp-write`` consent
13
+ and the host lock, uploads the pinned tarball to a private /tmp folder,
14
+ and removes it afterwards, verifying the removal.
15
+ - ``host-nginx-config``: rules over ``nginx -T`` and ``php -i`` output
16
+ (ideas from the CIS NGINX benchmark; no third-party rule text).
17
+
18
+ Nothing here changes the host's configuration.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import re
26
+ import shlex
27
+ import subprocess
28
+ from pathlib import Path
29
+
30
+ import maggie_security_core as core
31
+ from maggie_host_runtime import COLLECTOR as RUNTIME_COLLECTOR, parse_sections
32
+
33
+ QUICK_COLLECTOR = r"""set +e
34
+ s() { printf '\n### %s\n' "$1"; }
35
+ SUDO=""; [ "$(id -u)" != 0 ] && sudo -n true 2>/dev/null && SUDO="sudo -n"
36
+ s os-release; cat /etc/os-release 2>/dev/null
37
+ s sshd-T; $SUDO sshd -T 2>/dev/null | grep -Ei '^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication) '
38
+ s sshd-config; cat /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf 2>/dev/null | grep -Ei '^\s*(port|permitrootlogin|passwordauthentication|include)\b'
39
+ s reboot-required; [ -f /var/run/reboot-required ] && echo yes || echo no
40
+ s ufw; $SUDO ufw status 2>/dev/null | head -3
41
+ s firewalld; firewall-cmd --state 2>/dev/null
42
+ s iptables; $SUDO iptables -S 2>/dev/null | grep -c -- '-A'
43
+ s nftables; $SUDO nft list ruleset 2>/dev/null | grep -c 'chain'
44
+ s unattended; dpkg-query -W -f='${Status}' unattended-upgrades 2>/dev/null; echo; cat /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null; rpm -q dnf-automatic 2>/dev/null
45
+ s ips; for u in fail2ban crowdsec; do printf '%s %s\n' "$u" "$(systemctl is-active $u 2>/dev/null)"; done; $SUDO docker ps --format '{{.Image}}' 2>/dev/null | grep -Ei 'fail2ban|crowdsec'
46
+ s f2b-jail; $SUDO fail2ban-client get sshd port 2>/dev/null; $SUDO cat /etc/fail2ban/jail.local /etc/fail2ban/jail.d/*.conf 2>/dev/null | grep -Ei '^\s*(\[|enabled|port|banaction)'
47
+ s updates; apt-get -s upgrade 2>/dev/null | grep -E '^[0-9]+ upgraded'; apt-get -s upgrade 2>/dev/null | grep -c '^Inst .*-security'
48
+ s listen; ss -H -tlnp 2>/dev/null || netstat -tln 2>/dev/null
49
+ s docker-ports; $SUDO docker ps --format '{{.Names}}\t{{.Ports}}' 2>/dev/null
50
+ s sudo-log; $SUDO grep -rhE '^Defaults.*(logfile|log_output)' /etc/sudoers /etc/sudoers.d 2>/dev/null | head -3
51
+ s pwquality; grep -E '^\s*minlen\s*=' /etc/security/pwquality.conf 2>/dev/null | tail -1
52
+ s disk; df -P / | awk 'NR==2 {print int($5)}'
53
+ s mem; free | awk '/^Mem:/ {printf "%.0f\n", $3/$2*100}'
54
+ s load; nproc; cut -d' ' -f1 /proc/loadavg
55
+ s failed-logins; $SUDO grep -c 'Failed password' /var/log/auth.log 2>/dev/null || $SUDO grep -c 'Failed password' /var/log/secure 2>/dev/null
56
+ s time-sync; timedatectl show -p NTPSynchronized --value 2>/dev/null || chronyc tracking 2>/dev/null | head -1
57
+ s suid; timeout 45 find / -xdev -type f -perm -4000 2>/dev/null | grep -Ev '^(/usr/bin/|/bin/|/sbin/|/usr/sbin/|/usr/lib|/usr/libexec)' | grep -Ev '(ping|sudo|mount|umount|su|passwd|chsh|newgrp|gpasswd|chfn)$' | head -20
58
+ """
59
+
60
+ DB_PORTS = {"3306": "MySQL/MariaDB", "5432": "PostgreSQL", "6379": "Redis", "11211": "Memcached", "27017": "MongoDB", "9200": "Elasticsearch",
61
+ "5984": "CouchDB", "9042": "Cassandra", "2375": "Docker API (no TLS)", "8086": "InfluxDB"}
62
+ VPS_AUDIT = "nuver-labs/vps-audit (MIT)"
63
+
64
+
65
+ def check(fid: str, title: str, severity: str, summary: str, *, kind: str = "host-hardening", host: str, ctx: core.Context,
66
+ evidence: str | None = None, steps: list[str] | None = None, asvs: list[str] | None = None) -> dict:
67
+ return core.make_finding(kind=kind, command=ctx.command, primary_id=fid, site=ctx.site, title=title, severity=severity, severity_source="maggie",
68
+ confidence="confirmed", target={"host": host}, asvs_ids=asvs or [],
69
+ evidence=[{"path": host, "excerpt": evidence}] if evidence else [], rule_id=fid,
70
+ remediation={"summary": summary, "steps": steps or [], "snippets": [], "references": [], "cheat_sheet": None})
71
+
72
+
73
+ def quick_checks(ctx: core.Context, sections: dict[str, str], host: str) -> list[dict]:
74
+ f: list[dict] = []
75
+ sec = lambda name: sections.get(name, "").strip() # noqa: E731
76
+ effective = {}
77
+ for line in sec("sshd-T").splitlines():
78
+ parts = line.split(None, 1)
79
+ if len(parts) == 2:
80
+ effective[parts[0].lower()] = parts[1].strip().lower()
81
+ for line in sec("sshd-config").splitlines():
82
+ parts = line.strip().split(None, 1)
83
+ if len(parts) == 2 and parts[0].lower() not in effective and parts[0].lower() != "include":
84
+ effective.setdefault(parts[0].lower(), parts[1].strip().lower()) # first match wins, as sshd does
85
+ if sec("sshd-T") or sec("sshd-config"):
86
+ root = effective.get("permitrootlogin", "prohibit-password")
87
+ if root not in ("no", "prohibit-password", "without-password", "forced-commands-only"):
88
+ f.append(check("hq-ssh-root", "SSH allows root login with a password", "high", "Set `PermitRootLogin no` (or prohibit-password).", host=host, ctx=ctx,
89
+ evidence=f"PermitRootLogin {root}", steps=["sudo sshd -t && sudo systemctl reload ssh"]))
90
+ if effective.get("passwordauthentication", "yes") != "no":
91
+ f.append(check("hq-ssh-password", "SSH accepts passwords (brute-force target)", "high",
92
+ "Use SSH keys only: `PasswordAuthentication no` in /etc/ssh/sshd_config.d/, after confirming your key works.",
93
+ host=host, ctx=ctx, evidence="PasswordAuthentication yes",
94
+ steps=["Check you can log in with your key in a second session first", "sudo sshd -t && sudo systemctl reload ssh"]))
95
+ if effective.get("port", "22") == "22":
96
+ f.append(check("hq-ssh-port", "SSH listens on the default port 22", "info", "Optional: a non-default port only reduces log noise.", host=host, ctx=ctx))
97
+ else:
98
+ f.append(check("hq-ssh-unread", "SSH configuration could not be read", "info", "Run the collector with sudo for a complete check.", host=host, ctx=ctx))
99
+ ufw = sec("ufw").lower()
100
+ firewall = "status: active" in ufw or sec("firewalld") == "running" or _int(sec("iptables")) > 3 or _int(sec("nftables")) > 0
101
+ if not firewall:
102
+ f.append(check("hq-firewall", "No active firewall", "high", "Enable a firewall that allows only SSH, HTTP and HTTPS (e.g. ufw).", host=host, ctx=ctx,
103
+ steps=["sudo ufw allow OpenSSH && sudo ufw allow 80,443/tcp && sudo ufw enable"]))
104
+ unattended = sec("unattended")
105
+ if "install ok installed" not in unattended and "dnf-automatic" not in unattended.replace("not installed", ""):
106
+ f.append(check("hq-unattended", "Automatic security updates are not set up", "medium", "Install and enable unattended-upgrades (Debian/Ubuntu) or dnf-automatic.",
107
+ host=host, ctx=ctx, steps=["sudo apt install unattended-upgrades && sudo dpkg-reconfigure -plow unattended-upgrades"]))
108
+ updates = sec("updates").splitlines()
109
+ pending = _int(updates[0].split()[0]) if updates and updates[0].split() else 0
110
+ security = _int(updates[1]) if len(updates) > 1 else 0
111
+ if pending:
112
+ f.append(check("hq-updates", f"{pending} package updates pending ({security} security)", "medium" if security else "low",
113
+ "Apply updates (sudo apt upgrade) in a maintenance window.", host=host, ctx=ctx))
114
+ if sec("reboot-required") == "yes":
115
+ f.append(check("hq-restart", "A restart is needed to finish updates (e.g. a new kernel)", "low", "Reboot in a quiet hour.", host=host, ctx=ctx))
116
+ ips_lines = [line.split() for line in sec("ips").lower().splitlines() if line.strip()]
117
+ unit_active = any(len(parts) == 2 and parts[0] in ("fail2ban", "crowdsec") and parts[1] == "active" for parts in ips_lines)
118
+ container = any(len(parts) == 1 and re.search(r"fail2ban|crowdsec", parts[0]) for parts in ips_lines)
119
+ if not (unit_active or container):
120
+ f.append(check("hq-ips", "No intrusion prevention (fail2ban or CrowdSec) is running", "medium",
121
+ "Run `maggie security protect-plan` for a reviewed fail2ban/CrowdSec setup.", host=host, ctx=ctx))
122
+ jail = sec("f2b-jail")
123
+ ssh_port = effective.get("port", "22")
124
+ if jail and "allports" not in jail:
125
+ ports = re.findall(r"(?im)^\s*port\s*=\s*(\S+)", jail)
126
+ first = jail.splitlines()[0].strip() if jail else ""
127
+ jail_ports = set(re.split(r"[,\s]+", first if re.fullmatch(r"[\w,\s:]+", first) and not first.startswith("[") else (ports[0] if ports else "ssh")))
128
+ if ssh_port not in jail_ports and not ("ssh" in jail_ports and ssh_port == "22") and not any(":" in p for p in jail_ports):
129
+ f.append(check("hq-f2b-port", f"fail2ban guards port {', '.join(sorted(jail_ports))} but SSH listens on {ssh_port}: its bans do nothing",
130
+ "medium", f"Set `port = {ssh_port}` in the [sshd] jail (jail.local) or use banaction = nftables[type=allports].",
131
+ host=host, ctx=ctx, evidence=first[:120]))
132
+ public_db = []
133
+ for line in sec("listen").splitlines():
134
+ match = re.search(r"(\S+):(\d+)\s", line + " ")
135
+ if not match:
136
+ continue
137
+ address, port = match.group(1), match.group(2)
138
+ if port in DB_PORTS and address in ("0.0.0.0", "*", "[::]", "::"):
139
+ public_db.append(f"{DB_PORTS[port]} ({port})")
140
+ if public_db:
141
+ f.append(check("hq-ports", f"Database or admin port listens on every interface: {', '.join(sorted(set(public_db)))}", "high",
142
+ "Bind it to 127.0.0.1 (or a private network) and keep it closed in the firewall.", host=host, ctx=ctx))
143
+ docker_ports = sec("docker-ports")
144
+ if "status: active" in ufw and re.search(r"0\.0\.0\.0:\d+->", docker_ports):
145
+ f.append(check("hq-docker-ufw", "Docker publishes container ports that bypass UFW rules", "high",
146
+ "Publish on 127.0.0.1 (-p 127.0.0.1:8080:80) and put nginx in front, or use DOCKER-USER chain rules.", host=host, ctx=ctx,
147
+ evidence=docker_ports.splitlines()[0][:150]))
148
+ suid = [line for line in sec("suid").splitlines() if line.strip()]
149
+ if suid:
150
+ f.append(check("hq-suid", f"{len(suid)} SUID programs outside the usual system folders", "low", "Check that each is expected.", host=host, ctx=ctx,
151
+ evidence=", ".join(suid[:5])))
152
+ if not sec("sudo-log"):
153
+ f.append(check("hq-sudo-log", "sudo commands are not logged to a file", "low", "Add `Defaults logfile=/var/log/sudo.log` via visudo.", host=host, ctx=ctx))
154
+ minlen = re.search(r"minlen\s*=\s*(\d+)", sec("pwquality"))
155
+ if not minlen or int(minlen.group(1)) < 12:
156
+ f.append(check("hq-password-policy", "Weak or no password length policy", "low", "Set minlen = 12 in /etc/security/pwquality.conf.", host=host, ctx=ctx))
157
+ for fid, name, value in (("hq-disk", "Disk", _int(sec("disk"))), ("hq-mem", "Memory", _int(sec("mem")))):
158
+ if value >= 90:
159
+ f.append(check(fid, f"{name} use is {value}%", "low", "Free space or add capacity before it fills.", kind="availability", host=host, ctx=ctx))
160
+ load = sec("load").split()
161
+ if len(load) == 2 and _int(load[0]) and float(load[1]) > 2 * _int(load[0]):
162
+ f.append(check("hq-cpu", f"Load average {load[1]} on {load[0]} cores", "low", "Check what is using the CPU.", kind="availability", host=host, ctx=ctx))
163
+ failed = _int(sec("failed-logins"))
164
+ if failed > 100:
165
+ f.append(check("hq-failed-logins", f"{failed} failed SSH logins in the current auth log (attempts, not break-ins)", "info",
166
+ "Expected on public servers; keys-only SSH and fail2ban make them harmless.", kind="attack-signal", host=host, ctx=ctx))
167
+ if sec("time-sync") and sec("time-sync").lower() not in ("yes",) and "leap status : normal" not in sec("time-sync").lower():
168
+ f.append(check("hq-time-sync", "The clock is not synchronised (NTP)", "low", "Enable systemd-timesyncd or chrony; logs and TLS depend on time.",
169
+ host=host, ctx=ctx))
170
+ return f
171
+
172
+
173
+ def _int(text: str) -> int:
174
+ match = re.search(r"-?\d+", text or "")
175
+ return int(match.group(0)) if match else 0
176
+
177
+
178
+ def ssh_run(target: str, script: str, timeout: int = 300) -> str:
179
+ proc = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", target, "sh -s"], input=script, capture_output=True, text=True,
180
+ timeout=timeout, check=False)
181
+ if proc.returncode not in (0, 1) and not proc.stdout:
182
+ raise core.DegradedError(f"ssh {target}: {core.clean_excerpt(proc.stderr, 200)}")
183
+ return proc.stdout
184
+
185
+
186
+ def evidence_text(ctx: core.Context, collector: str) -> tuple[str, str]:
187
+ args = ctx.args
188
+ if args.evidence:
189
+ path = Path(args.evidence)
190
+ if not path.is_file():
191
+ raise core.UsageError(f"file not found: {path}")
192
+ return path.read_text(encoding="utf-8", errors="replace"), args.host or path.stem
193
+ if args.ssh:
194
+ alias, _, target = args.ssh.partition("=")
195
+ if not target:
196
+ raise core.UsageError("--ssh takes ALIAS=user@host")
197
+ core.require_consent(ctx.state, alias, "ssh-read")
198
+ text = ssh_run(target, collector)
199
+ out = ctx.state.path("inventory", f"host-{alias}-{ctx.command}.txt")
200
+ out.parent.mkdir(parents=True, exist_ok=True)
201
+ out.write_text(text, encoding="utf-8")
202
+ return text, alias
203
+ raise core.UsageError("give --evidence FILE (collector output) or --ssh ALIAS=user@host; see --print-collector")
204
+
205
+
206
+ def host_quick(ctx: core.Context):
207
+ if ctx.args.print_collector:
208
+ return {"_raw": QUICK_COLLECTOR}
209
+ text, host = evidence_text(ctx, QUICK_COLLECTOR)
210
+ sections = parse_sections(text)
211
+ if not sections:
212
+ raise core.UsageError("the evidence has no '### section' blocks; run the collector from --print-collector")
213
+ findings = quick_checks(ctx, sections, host)
214
+ ctx.run["inputs"].update({"host": host, "checks_from": VPS_AUDIT})
215
+ return ctx.finish(findings, covered=[f"host:{host}", "*"])
216
+
217
+
218
+ # --------------------------------------------------------------------------- host-audit (lynis report.dat)
219
+
220
+
221
+ def parse_lynis(text: str) -> dict:
222
+ data: dict = {"warning": [], "suggestion": [], "vulnerable_package": [], "firewall_software": [], "malware_scanner": []}
223
+ for line in text.splitlines():
224
+ if "=" not in line or line.startswith("#"):
225
+ continue
226
+ key, _, value = line.partition("=")
227
+ if key.endswith("[]"):
228
+ data.setdefault(key[:-2], []).append(value)
229
+ else:
230
+ data[key] = value
231
+ return data
232
+
233
+
234
+ LYNIS_HIGH_PREFIX = ("SSH-", "FIRE-", "AUTH-")
235
+ LYNIS_COSMETIC = {"BANN-7126", "BANN-7130", "LYNIS"}
236
+
237
+
238
+ def lynis_findings(ctx: core.Context, data: dict, host: str) -> list[dict]:
239
+ findings = []
240
+ for kind_key, base in (("warning", "medium"), ("suggestion", "low")):
241
+ for entry in data.get(kind_key, []):
242
+ parts = entry.split("|")
243
+ test = parts[0] if parts else "LYNIS"
244
+ message = parts[1] if len(parts) > 1 else entry
245
+ details = parts[2] if len(parts) > 2 and parts[2] not in ("-", "") else ""
246
+ solution = parts[3] if len(parts) > 3 and parts[3] not in ("-", "") else ""
247
+ severity = "high" if kind_key == "warning" and test.startswith(LYNIS_HIGH_PREFIX) else base
248
+ if test in LYNIS_COSMETIC:
249
+ severity = "info"
250
+ findings.append(core.make_finding(
251
+ kind="host-hardening", command="host-audit", primary_id=f"lynis-{test}-{core.sha256_bytes(message.encode())[:8]}", site=ctx.site,
252
+ title=f"{test}: {core.clean_excerpt(message, 200)}", severity=severity, severity_source="tool", confidence="likely",
253
+ target={"host": host}, tool="lynis", rule_id=test,
254
+ evidence=[{"path": host, "excerpt": core.clean_excerpt(details, 200)}] if details else [],
255
+ remediation={"summary": f"lynis suggests: {core.clean_excerpt(solution, 300)}" if solution else "See the lynis control for this test.",
256
+ "steps": [], "snippets": [], "references": [f"https://cisofy.com/lynis/controls/{test}/"], "cheat_sheet": None}))
257
+ for package in data.get("vulnerable_package", []):
258
+ findings.append(core.make_finding(
259
+ kind="vulnerability", command="host-audit", primary_id=f"lynis-vulnerable-package-{package}", site=ctx.site,
260
+ title=f"lynis reports a vulnerable package: {package}", severity="medium", severity_source="tool", confidence="likely",
261
+ affected={"purl": None, "ecosystem": None, "name": package, "version": None, "component_type": "os", "direct": None, "scope": "os"},
262
+ target={"host": host}, tool="lynis", rule_id="PKGS-7392",
263
+ remediation={"summary": f"Update {package}; `maggie security vuln-watch` with this host's evidence shows the CVEs.", "steps": [], "snippets": [],
264
+ "references": [], "cheat_sheet": None}))
265
+ return findings
266
+
267
+
268
+ LYNIS_PROFILE = """# Maggie lynis profile: no upload, skip tests that do not apply to web servers
269
+ upload=no
270
+ skip-test=PRNT-2307
271
+ skip-test=PRNT-2308
272
+ skip-test=STRG-1840
273
+ skip-test=STRG-1842
274
+ skip-test=NAME-4402
275
+ skip-test=NAME-4404
276
+ skip-test=KRB-5108
277
+ skip-test=LDAP-2219
278
+ skip-test=STRG-1902
279
+ """
280
+
281
+
282
+ def host_audit(ctx: core.Context):
283
+ args = ctx.args
284
+ if args.evidence:
285
+ path = Path(args.evidence)
286
+ if not path.is_file():
287
+ raise core.UsageError(f"file not found: {path}")
288
+ data = parse_lynis(path.read_text(encoding="utf-8", errors="replace"))
289
+ host = args.host or "host"
290
+ else:
291
+ if not args.ssh:
292
+ raise core.UsageError("give --evidence report.dat, or --ssh ALIAS=user@host (needs ssh-temp-write consent)")
293
+ alias, _, target = args.ssh.partition("=")
294
+ core.require_consent(ctx.state, alias, "ssh-temp-write")
295
+ data = remote_lynis(ctx, alias, target)
296
+ host = alias
297
+ findings = lynis_findings(ctx, data, host)
298
+ index = data.get("hardening_index")
299
+ ctx.run["inputs"].update({"host": host, "lynis_version": data.get("lynis_version"), "hardening_index": int(index) if str(index).isdigit() else None,
300
+ "firewall_software": data.get("firewall_software"), "malware_scanner": data.get("malware_scanner")})
301
+ ctx.run["tools"].append({"name": "lynis", "version": data.get("lynis_version"), "sha256": None})
302
+ return ctx.finish(findings, covered=[f"host:{host}", "*"])
303
+
304
+
305
+ def remote_lynis(ctx: core.Context, alias: str, target: str) -> dict:
306
+ """Upload the pinned lynis tarball to a private /tmp folder, run it, copy report.dat back, remove the folder and verify."""
307
+ import maggie_host_runtime as runtime
308
+
309
+ spec = ctx.lock["tools"]["lynis"]
310
+ asset = (spec.get("assets") or {}).get("any")
311
+ if not asset or asset.get("sha256") in (None, "UNPINNED"):
312
+ raise core.ToolError("lynis has no pinned tarball in the tools lock yet; run lynis yourself and pass --evidence report.dat")
313
+ tarball = core.tools_cache_dir() / "lynis" / spec["version"] / "lynis.tar.gz"
314
+ if not tarball.is_file():
315
+ status, _h, body = core.http_request(asset["url"], timeout=300)
316
+ if status != 200 or core.sha256_bytes(body) != asset["sha256"]:
317
+ raise core.ToolError("lynis tarball download failed or its sha256 does not match the lock")
318
+ tarball.parent.mkdir(parents=True, exist_ok=True)
319
+ tarball.write_bytes(body)
320
+ elif core.sha256_file(tarball) != asset["sha256"]:
321
+ raise core.ToolError("cached lynis tarball does not match the lock")
322
+ session = f"maggie-security-{ctx.run['id'][-4:]}"
323
+ lock_path = "/run/lock/maggie-agent-host.lock" # the same lock `maggie deployment host-lock` uses
324
+ lock = runtime.host_lock("acquire", target, session, 1800, "security host-audit", False, 60, lock_path, runtime.WATCH if hasattr(runtime, "WATCH") else [], [])
325
+ if lock.get("result") not in ("acquired", "mine"):
326
+ raise core.PolicyError(f"host lock is held by another session: {json.dumps(lock)[:300]}")
327
+ remote_dir = f"/tmp/maggie-lynis-{ctx.run['id'][-4:]}"
328
+ try:
329
+ subprocess.run(["ssh", "-o", "BatchMode=yes", target, f"umask 077 && mkdir -p {remote_dir}"], check=True, timeout=60)
330
+ subprocess.run(["scp", "-q", "-o", "BatchMode=yes", str(tarball), f"{target}:{remote_dir}/lynis.tar.gz"], check=True, timeout=300)
331
+ 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)
336
+ report = proc.stdout
337
+ finally:
338
+ subprocess.run(["ssh", "-o", "BatchMode=yes", target, f"rm -rf {remote_dir}"], timeout=60, check=False)
339
+ gone = subprocess.run(["ssh", "-o", "BatchMode=yes", target, f"test ! -e {remote_dir}"], timeout=60, check=False).returncode == 0
340
+ ctx.run["inputs"]["remote_cleanup"] = "verified" if gone else "failed"
341
+ runtime.host_lock("release", target, session, 0, "", False, 60, lock_path, [], [])
342
+ if not report.strip():
343
+ raise core.DegradedError("lynis produced no report")
344
+ return parse_lynis(report)
345
+
346
+
347
+ # --------------------------------------------------------------------------- host-nginx-config
348
+
349
+
350
+ NGINX_EXTRA = RUNTIME_COLLECTOR + r"""
351
+ s php-i; php -i 2>/dev/null | grep -Ei '^(expose_php|allow_url_include|allow_url_fopen|display_errors|disable_functions|session.cookie_secure|session.cookie_httponly|session.use_strict_mode) '
352
+ s key-perms; for k in $($SUDO nginx -T 2>/dev/null | awk '/ssl_certificate_key/ {gsub(";",""); print $2}' | sort -u); do $SUDO stat -c '%a %U %n' "$k" 2>/dev/null; done
353
+ """
354
+
355
+
356
+ def server_blocks(text: str) -> list[dict]:
357
+ """Split `nginx -T` output (or a config file) into server blocks with their locations."""
358
+ blocks = []
359
+ current_file = ""
360
+ clean = []
361
+ for line in text.splitlines():
362
+ if line.startswith("# configuration file "):
363
+ current_file = line[len("# configuration file "):].rstrip(":")
364
+ clean.append(f"__FILE__ {current_file};")
365
+ continue
366
+ clean.append(line.split("#", 1)[0] if not line.strip().startswith("#") else "")
367
+ body = "\n".join(clean)
368
+ for match in re.finditer(r"\bserver\s*\{", body):
369
+ depth, i = 1, match.end()
370
+ while i < len(body) and depth:
371
+ depth += {"{": 1, "}": -1}.get(body[i], 0)
372
+ i += 1
373
+ content = body[match.end():i - 1]
374
+ before = body[:match.start()]
375
+ file_match = re.findall(r"__FILE__ ([^;]+);", before)
376
+ top = re.sub(r"\blocation\b[^{]*\{(?:[^{}]|\{[^{}]*\})*\}", "", content)
377
+ blocks.append({"file": file_match[-1] if file_match else "", "text": content, "top": top,
378
+ "names": re.findall(r"server_name\s+([^;]+);", content)[:1],
379
+ "listen": re.findall(r"listen\s+([^;]+);", top),
380
+ "locations": re.findall(r"\blocation\s+([^{]*)\{((?:[^{}]|\{[^{}]*\})*)\}", content)})
381
+ return blocks
382
+
383
+
384
+ def nginx_findings(ctx: core.Context, nginx: str, php: str, key_perms: str, host: str) -> list[dict]:
385
+ findings = []
386
+ seen = set()
387
+
388
+ def add(fid: str, title: str, severity: str, summary: str, snippet: str | None = None, where: str = "", asvs: list[str] | None = None) -> None:
389
+ key = (fid, where)
390
+ if key in seen:
391
+ return
392
+ seen.add(key)
393
+ findings.append(core.make_finding(kind="misconfiguration", command="host-nginx-config", primary_id=fid, site=ctx.site,
394
+ title=title + (f" ({where})" if where else ""), severity=severity, severity_source="maggie",
395
+ 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"],
397
+ "snippets": [{"kind": "nginx", "text": snippet}] if snippet else [],
398
+ "references": ["https://nginx.org/en/docs/"], "cheat_sheet": None}))
399
+
400
+ http_level = re.sub(r"\bserver\s*\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}", "", nginx)
401
+ if not re.search(r"(?m)^\s*server_tokens\s+off\s*;", nginx):
402
+ add("ngx-server-tokens", "nginx shows its version (server_tokens is not off)", "low", "Add `server_tokens off;` in the http block.", "server_tokens off;", asvs=["V13.4.6"])
403
+ if re.search(r"(?m)^\s*autoindex\s+on\s*;", nginx):
404
+ add("ngx-autoindex", "Directory listing is on (autoindex on)", "medium", "Remove `autoindex on;`.", asvs=["V13.4.3"])
405
+ for block in server_blocks(nginx):
406
+ name = (block["names"][0].split()[0] if block["names"] else "_")
407
+ where = f"{name} in {block['file']}" if block["file"] else name
408
+ text, top = block["text"], block["top"]
409
+ tls = any("ssl" in listen or "443" in listen for listen in block["listen"])
410
+ plain = any(re.match(r"^(\[::\]:|0\.0\.0\.0:)?80\b", listen.strip()) for listen in block["listen"])
411
+ has_root = re.search(r"(?m)^\s*root\s+\S+;", text)
412
+ locations = block["locations"]
413
+ if plain and not tls and not re.search(r"return\s+30[18]\s+https://", text) and has_root:
414
+ add("ngx-https-redirect", "Plain HTTP is served instead of redirected to HTTPS", "medium",
415
+ "Redirect port 80 to HTTPS (keep /.well-known/acme-challenge/ if you use HTTP-01).", "return 301 https://$host$request_uri;", where, ["V12.2.1"])
416
+ if has_root and not any(re.search(r"~\*?\s*/\\\.", loc[0]) or "/\\." in loc[0] for loc in locations):
417
+ add("ngx-dotfiles", "Hidden files (.git, .env) can be served", "high", "Deny hidden files except .well-known.",
418
+ "location ~ /\\.(?!well-known) { deny all; }", where, ["V13.4.1"])
419
+ php_site = "fastcgi_pass" in text
420
+ if php_site and not any(re.search(r"(uploads|files)", loc[0]) and "deny" in loc[1] for loc in locations):
421
+ add("ngx-php-uploads", "PHP files in upload folders can be executed", "high", "Deny .php under upload folders.",
422
+ "location ~* /(?:uploads|files)/.*\\.php$ { deny all; }", where, ["V5.3.2"])
423
+ if php_site and "fastcgi_split_path_info" in text and not re.search(r"try_files\s+\$uri\s*=404|try_files\s+\$fastcgi_script_name\s*=404", text):
424
+ add("ngx-fastcgi-split", "PATH_INFO handling without a file check can run uploaded files as PHP", "high",
425
+ "Add `try_files $fastcgi_script_name =404;` in the PHP location.", "try_files $fastcgi_script_name =404;", where, ["V5.3.2"])
426
+ if php_site and not re.search(r"(env|git|bak|old|sql|swp|ini|log)", " ".join(loc[0] for loc in locations)):
427
+ add("ngx-sensitive-ext", "Backup and config files (.sql, .bak, .env, .log) are not blocked", "medium", "Deny sensitive extensions.",
428
+ "location ~* \\.(?:bak|old|sql|swp|ini|log|env)$ { deny all; }", where, ["V13.4.1"])
429
+ if tls:
430
+ protocols = re.findall(r"ssl_protocols\s+([^;]+);", text + http_level)
431
+ if protocols and re.search(r"\b(SSLv3|TLSv1|TLSv1\.1)\b", protocols[0].replace("TLSv1.2", "").replace("TLSv1.3", "")):
432
+ add("ngx-tls-protocols", f"Old TLS versions allowed ({protocols[0].strip()})", "high", "Allow only TLS 1.2 and 1.3.",
433
+ "ssl_protocols TLSv1.2 TLSv1.3;", where, ["V12.1.1"])
434
+ ciphers = re.findall(r"ssl_ciphers\s+([^;]+);", text + http_level)
435
+ if ciphers and re.search(r"(?<![!-])\b(RC4|3DES|DES-|MD5|NULL|EXPORT|aNULL)", ciphers[0]):
436
+ add("ngx-tls-ciphers", "Weak TLS ciphers allowed", "high", "Use the Mozilla intermediate cipher list.", where=where, asvs=["V12.1.2"])
437
+ if "strict-transport-security" not in (text + http_level).lower():
438
+ add("ngx-hsts", "No HSTS header in this HTTPS server (unless your CDN adds it)", "medium", "Add HSTS.",
439
+ 'add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;', where, ["V3.4.1"])
440
+ server_headers = re.findall(r"add_header\s+(\S+)", top + http_level)
441
+ for loc, loc_body in locations:
442
+ if "add_header" in loc_body and server_headers:
443
+ add("ngx-add-header-inherit", f"location {loc.strip()} sets its own add_header, which drops the server's security headers there", "medium",
444
+ "Repeat the security headers inside that location (nginx does not merge add_header levels).", where=where, asvs=["V3.4.1"])
445
+ break
446
+ if re.search(r"wp-login\.php|wordpress|wp-content", text) and not re.search(r"limit_req\b", text):
447
+ add("ngx-limit-req", "WordPress login is not rate limited", "medium", "Rate limit wp-login.php and xmlrpc.php.",
448
+ "limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;\nlocation = /wp-login.php { limit_req zone=login burst=5 nodelay; include fastcgi_params; fastcgi_pass php; }",
449
+ where)
450
+ if not re.search(r"xmlrpc\.php", " ".join(loc[0] for loc in locations)):
451
+ add("ngx-xmlrpc", "WordPress xmlrpc.php is reachable (brute-force and pingback abuse)", "medium", "Deny it unless Jetpack or the app needs it.",
452
+ "location = /xmlrpc.php { deny all; }", where)
453
+ if re.search(r"(?m)^\s*access_log\s+off\s*;", top):
454
+ add("ngx-access-log", "Access logging is off for a public server", "medium", "Keep access logs (security checks and incident response need them).", where=where)
455
+ if "proxy_pass" in text and "proxy_hide_header" not in text + http_level and "x-powered-by" not in (text + http_level).lower():
456
+ add("ngx-proxy-hide", "The app's X-Powered-By header is passed through", "low", "Hide it.", "proxy_hide_header X-Powered-By;", where, ["V13.4.6"])
457
+ if re.search(r"(?m)^\s*error_log\s+\S+\s+debug\s*;", nginx):
458
+ add("ngx-error-log-level", "error_log is at debug level", "low", "Use `warn` or `error` in production.")
459
+ if "set_real_ip_from" not in nginx and re.search(r"cloudflare|cf-connecting-ip", nginx, re.I) is None and "proxy_protocol" not in nginx:
460
+ pass # cannot tell whether a CDN sits in front from config alone; `headers` reports the edge
461
+ php_values = dict(re.findall(r"(?m)^(\S+)\s*=>\s*(\S+)", php))
462
+ if php_values:
463
+ if php_values.get("expose_php", "").lower() == "on":
464
+ add("php-expose", "PHP announces its version (expose_php = On)", "low", "Set expose_php = Off in php.ini.", asvs=["V13.4.6"])
465
+ if php_values.get("allow_url_include", "").lower() == "on":
466
+ add("php-url-include", "PHP allows including remote URLs (allow_url_include = On)", "high", "Set allow_url_include = Off.")
467
+ if php_values.get("display_errors", "").lower() in ("on", "1", "stdout"):
468
+ add("php-display-errors", "PHP shows errors to visitors (display_errors = On)", "medium", "Set display_errors = Off and log errors instead.",
469
+ asvs=["V13.4.2"])
470
+ for line in key_perms.splitlines():
471
+ parts = line.split(None, 2)
472
+ if len(parts) == 3 and (int(parts[0][-1]) > 0 or int(parts[0][-2]) > 4 or parts[1] != "root"):
473
+ 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"])
475
+ return findings
476
+
477
+
478
+ def host_nginx_config(ctx: core.Context):
479
+ if ctx.args.print_collector:
480
+ return {"_raw": NGINX_EXTRA}
481
+ text, host = evidence_text(ctx, NGINX_EXTRA)
482
+ sections = parse_sections(text)
483
+ nginx = sections.get("nginx-T", "") if sections else text
484
+ if not nginx.strip():
485
+ raise core.UsageError("no nginx configuration in the evidence (need the `nginx -T` section or a config file)")
486
+ findings = nginx_findings(ctx, nginx, sections.get("php-i", ""), sections.get("key-perms", ""), host)
487
+ ctx.run["inputs"].update({"host": host, "server_blocks": len(server_blocks(nginx))})
488
+ return ctx.finish(findings, covered=[f"host:{host}", "*"])
489
+
490
+
491
+ def _quick(p: argparse.ArgumentParser) -> None:
492
+ p.add_argument("--ssh", help="ALIAS=user@host (needs ssh-read consent)")
493
+ p.add_argument("--evidence", help="saved collector output")
494
+ p.add_argument("--host", help="host alias for --evidence")
495
+ p.add_argument("--print-collector", action="store_true", help="print the read-only collector script to review")
496
+
497
+
498
+ def _audit(p: argparse.ArgumentParser) -> None:
499
+ p.add_argument("--ssh", help="ALIAS=user@host (needs ssh-temp-write consent and takes the host lock)")
500
+ p.add_argument("--evidence", help="a lynis report.dat")
501
+ p.add_argument("--host", help="host alias for --evidence")
502
+
503
+
504
+ COMMANDS = [
505
+ core.Command("host-quick", "quick server hardening check: SSH, firewall, updates, intrusion prevention, open database ports (read-only)", [],
506
+ _quick, host_quick),
507
+ core.Command("host-audit", "deep server audit with lynis (temporary upload, removed after; host lock)", ["github-releases"], _audit, host_audit,
508
+ routine_safe=False),
509
+ core.Command("host-nginx-config", "nginx and PHP configuration rules (hidden files, PHP in uploads, TLS, headers, rate limits)", [], _quick,
510
+ host_nginx_config),
511
+ ]
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env python3
2
+ """``maggie security hosted-job``: the CMO.so server job for the hosted daily watch
3
+ (docs/maggie-security/11-mcp-tools-prd.md section 9, 12-workflows-prd.md section 5).
4
+
5
+ The backend stores each workspace's ``maggie-security-stack-upload.v1`` and,
6
+ once a day per opted-in workspace (spread over 05:07–05:53 UTC), runs:
7
+
8
+ maggie security hosted-job --stack-upload STACK.json --state-dir DIR/<workspace> --workspace example.com
9
+
10
+ It needs no project files: it matches the uploaded names and versions against
11
+ OSV, Node security advisories, WPVulnerability, the CVE delta, KEV and EPSS,
12
+ keeps the ledger per workspace under ``--state-dir``, enforces the per-workspace
13
+ daily caps (PRD 11 section 9) and writes a ``report_save`` payload only when
14
+ something is new (otherwise a one-line "nothing new" result). The backend sends
15
+ that payload with its own report inbox code; this command never holds
16
+ credentials and never sends anything itself.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ from pathlib import Path
24
+
25
+ import maggie_security_core as core
26
+
27
+ DAILY_CAPS = {"vuln_watch": 4, "headers_check": 20, "tls_check": 10, "osv_query": 200, "wp_check": 50}
28
+
29
+
30
+ def take_cap(state: core.State, workspace: str, tool: str) -> int:
31
+ """Count one use of a capped tool for today; raises PolicyError over the cap."""
32
+ path = state.path("caps.json")
33
+ data = core.read_json(path) if path.is_file() else {}
34
+ today = core.iso()[:10]
35
+ if data.get("date") != today:
36
+ data = {"date": today, "workspace": workspace, "used": {}}
37
+ used = data["used"].get(tool, 0)
38
+ if used >= DAILY_CAPS[tool]:
39
+ raise core.PolicyError(f"daily cap reached for {tool} on {workspace} ({DAILY_CAPS[tool]}/day)")
40
+ data["used"][tool] = used + 1
41
+ core.write_json(path, data)
42
+ return used + 1
43
+
44
+
45
+ def stack_from_upload(upload: dict) -> dict:
46
+ components = [{"purl": core.make_purl(c["ecosystem"], c["name"], c["version"]), "ecosystem": c["ecosystem"], "name": c["name"], "version": c["version"],
47
+ "scope": c.get("scope") or "runtime", "direct": c.get("direct"), "location": "uploaded stack"} for c in upload["components"]]
48
+ import maggie_security_versions as versions
49
+
50
+ runtimes = [{"name": r["name"], "version": r["version"], "cycle": versions.cycle_of(r["version"]), "host": r.get("host"), "source": "uploaded stack"}
51
+ for r in upload["runtimes"]]
52
+ return {"schema": "maggie-security-stack.v1", "site": {"id": upload["workspace"], "type": upload.get("site_type") or "unknown", "domains": [upload["workspace"]],
53
+ "hosting": None},
54
+ "collected_at": upload["created_at"], "hash": upload["hash"], "sources": [{"kind": "manual", "path": "uploaded stack"}],
55
+ "components": components, "runtimes": runtimes, "wordpress": upload.get("wordpress"), "images": [],
56
+ "hosts": [{"id": h, "os": None, "public": True} for h in sorted({r["host"] for r in runtimes if r.get("host")})],
57
+ "watch": {"products": upload.get("watch") or []}}
58
+
59
+
60
+ def hosted_job(ctx: core.Context) -> dict:
61
+ args = ctx.args
62
+ upload = core.read_json(Path(args.stack_upload))
63
+ errors = core.validate_contract(upload, "stack-upload-v1")
64
+ if errors:
65
+ raise core.UsageError("stack upload failed its contract: " + "; ".join(errors[:5]))
66
+ if args.workspace and upload["workspace"] != args.workspace:
67
+ raise core.PolicyError("the upload belongs to another workspace")
68
+ state_dir = Path(args.state_dir)
69
+ state_dir.mkdir(parents=True, exist_ok=True)
70
+ # Run everything against the workspace's own state folder, never a project tree.
71
+ ctx.project = state_dir.resolve()
72
+ ctx.state = core.State(ctx.project)
73
+ ctx.site = upload["workspace"]
74
+ ctx.cache = core.FeedCache(ctx.state, ctx.guard)
75
+ ctx.run = core.new_run("vuln-watch", ctx.site, {"hosted": True, "stack_hash": upload["hash"]})
76
+ used = take_cap(ctx.state, upload["workspace"], "vuln_watch")
77
+ stack = stack_from_upload(upload)
78
+ core.write_json(ctx.state.path("inventory", "stack.json"), stack)
79
+ import maggie_security_intel as intel
80
+
81
+ ctx.args = argparse.Namespace(**{**vars(args), "rebuild_inventory": False, "sources": args.sources, "advisory_out": str(ctx.state.path("advisories")),
82
+ "cve_budget": 1500, "wp_budget": 300, "no_epss": False, "full_rematch": False, "since": args.since})
83
+ doc = intel.vuln_watch(ctx)
84
+ new = [f for f in doc["findings"] if f["status"] == "new"]
85
+ escalated = [f for f in new if f.get("change") == "escalated"]
86
+ result = {"schema": "maggie-security-hosted-job.v1", "workspace": upload["workspace"], "run": doc["run"]["id"], "cap_used": used,
87
+ "cap": DAILY_CAPS["vuln_watch"], "new": len(new), "open": doc["summary"]["open"], "degraded": doc["run"]["degraded"]}
88
+ if not new:
89
+ result["report_save"] = None
90
+ result["line"] = f"Nothing new for {upload['workspace']} today" + (f" (incomplete: {', '.join(doc['run']['degraded'])})" if doc["run"]["degraded"] else "") + "."
91
+ return result
92
+ import maggie_security_report as report
93
+
94
+ ctx.args = argparse.Namespace(**{**vars(ctx.args), "kind": "daily_ops", "audience": "owner", "save": True, "runs": [doc["run"]["id"]], "since": "48h",
95
+ "level": 1, "incident": None, "output": None, "fail_on": None, "ci": False})
96
+ built = report.build(ctx)
97
+ if not built["check"]["passed"]:
98
+ raise core.PolicyError("report failed report-check: " + "; ".join(built["check"]["errors"]))
99
+ payload = core.read_json(Path(built["report_save"]["payload"]))
100
+ payload["data"]["findings_set"] = {"schema": doc["schema"], "run": doc["run"]["id"], "new": [f["id"] for f in new]}
101
+ result.update({"report_save": payload, "escalated": len(escalated),
102
+ "notify": any(f["priority"] in ("p0", "p1") for f in new) or bool(escalated)})
103
+ return result
104
+
105
+
106
+ def configure(p: argparse.ArgumentParser) -> None:
107
+ p.add_argument("--stack-upload", required=True, help="maggie-security-stack-upload.v1 file for one workspace")
108
+ p.add_argument("--state-dir", required=True, help="the workspace's own state folder (ledger, feeds cache, caps)")
109
+ p.add_argument("--workspace", help="expected workspace; refuses an upload for another one")
110
+ p.add_argument("--sources", help="as vuln-watch --sources")
111
+
112
+
113
+ COMMANDS = [core.Command("hosted-job", "CMO.so server job: daily watch on an uploaded stack, with caps; returns a report_save payload when something is new",
114
+ ["osv-api", "kev", "node-core", "wpvulnerability", "cvelistv5-zip", "cvelistv5-delta", "cvelistv5-record", "epss", "eol"],
115
+ configure, hosted_job, findings=False, routine_safe=False)]