@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,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)]
|