@topy-ai/maggie 0.7.54 → 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 +25 -23
- package/README.md +93 -71
- package/bin/maggie.js +14 -2
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
- package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +2 -1
- package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
- 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 +5 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
- package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
- package/bundled-skills/maggie-blog/SKILL.md +9 -2
- package/bundled-skills/maggie-booking/SKILL.md +1 -1
- package/bundled-skills/maggie-dash/SKILL.md +9 -0
- package/bundled-skills/maggie-deployment/SKILL.md +77 -1
- package/bundled-skills/maggie-deployment/references/vps.md +5 -0
- package/bundled-skills/maggie-marketplace/SKILL.md +21 -1
- package/bundled-skills/maggie-ops/SKILL.md +28 -0
- package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
- package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
- package/bundled-skills/maggie-ops/references/routine-mode.md +92 -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-skills/maggie-seo-geo/SKILL.md +9 -1
- package/bundled-skills/maggie-social-share/SKILL.md +20 -0
- package/bundled-tools/clis/maggie.py +63 -3
- package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
- package/bundled-tools/clis/maggie_deployment_readiness.py +17 -2
- package/bundled-tools/clis/maggie_host_runtime.py +458 -0
- package/bundled-tools/clis/maggie_live_urls.py +11 -2
- package/bundled-tools/clis/maggie_ops.py +2 -0
- package/bundled-tools/clis/maggie_ops_routines.py +352 -0
- package/bundled-tools/clis/maggie_query_ideas.py +154 -0
- 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/maggie_skill_catalog.py +16 -2
- package/bundled-tools/clis/maggie_social_share.py +101 -1
- package/bundled-tools/clis/maggie_version_sources.py +93 -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/bundled-tools/integrations/maggie-api-pull.md +28 -1
- package/package.json +1 -1
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Logs and traffic (docs/maggie-security/06-logs-traffic-prd.md):
|
|
3
|
+
``logs-traffic``, ``logs-sigma``, ``logs-ioc``, ``logs-remote``.
|
|
4
|
+
|
|
5
|
+
Logs are read where they are (files, ``.gz``, stdin, or over SSH with an
|
|
6
|
+
``ssh-read`` consent) and never leave the machine. Every match is an
|
|
7
|
+
"attempt" unless the evidence in PRD 06 section 8 is there (a webshell or a
|
|
8
|
+
secret file answered 2xx with a body, an exploit for a CVE the site is
|
|
9
|
+
currently affected by answered 2xx, a successful admin login from a high-score
|
|
10
|
+
IP); only then is it a "compromise indicator".
|
|
11
|
+
|
|
12
|
+
Engines are the standard library: a combined/vcombined/JSON log parser, a
|
|
13
|
+
traffic summary, the SigmaHQ web rules (DRL 1.1, compiled by
|
|
14
|
+
``scripts/security/build_sigma_pack.py``) evaluated in Python, and maltrail's
|
|
15
|
+
MIT mass-scanner and user-agent lists. Third-party IP feeds stay off until
|
|
16
|
+
their terms are reviewed (``--feeds``).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import collections
|
|
23
|
+
import glob
|
|
24
|
+
import gzip
|
|
25
|
+
import ipaddress
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import shlex
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import urllib.parse
|
|
32
|
+
from datetime import datetime, timedelta, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import maggie_security_core as core
|
|
36
|
+
|
|
37
|
+
COMBINED = re.compile(r'^(?:(?P<vhost>[^\s:]+)(?::\d+)?\s+)?(?P<ip>[0-9a-fA-F.:]+)\s+\S+\s+(?P<user>\S+)\s+\[(?P<time>[^\]]+)\]\s+'
|
|
38
|
+
r'"(?P<method>[A-Z]+|-)\s*(?P<target>[^"\s]*)\s*(?P<version>[^"]*)"\s+(?P<status>\d{3})\s+(?P<bytes>\d+|-)'
|
|
39
|
+
r'(?:\s+"(?P<referer>[^"]*)"\s+"(?P<ua>[^"]*)")?')
|
|
40
|
+
CLF_TIME = "%d/%b/%Y:%H:%M:%S %z"
|
|
41
|
+
LEVEL_SEVERITY = {"informational": "info", "low": "low", "medium": "medium", "high": "high", "critical": "critical"}
|
|
42
|
+
WEBSHELL_HINT = re.compile(r"(?i)(webshell|web shell|regeorg|china chopper|cmd\.jsp|shell\.php)")
|
|
43
|
+
SECRET_FILE = re.compile(r"(?i)/(\.env(\.[\w-]+)?|\.git/(config|HEAD)|wp-config\.php\.(bak|old|save|orig|txt|~)|\.htpasswd|id_rsa|\.aws/credentials)$")
|
|
44
|
+
SENSITIVE_QUERY = core.SECRET_QUERY
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Record:
|
|
48
|
+
__slots__ = ("ip", "time", "method", "target", "path", "query", "version", "status", "bytes", "referer", "ua", "vhost", "raw")
|
|
49
|
+
|
|
50
|
+
def field(self, name: str) -> str:
|
|
51
|
+
return {
|
|
52
|
+
"c-ip": self.ip, "cs-method": self.method, "cs-uri-stem": self.path, "c-uri-stem": self.path, "cs-uri-path": self.path,
|
|
53
|
+
"cs-uri-query": self.query, "c-uri-query": self.query, "cs-uri": self.target, "c-uri": self.target,
|
|
54
|
+
"sc-status": str(self.status), "cs-user-agent": self.ua, "c-useragent": self.ua, "cs-referer": self.referer,
|
|
55
|
+
"cs-host": self.vhost, "r-dns": self.vhost, "cs-version": self.version,
|
|
56
|
+
"cs-uri-extension": Path(self.path).suffix.lstrip("."), "c-uri-extension": Path(self.path).suffix.lstrip("."), "cs-cookie": "",
|
|
57
|
+
}.get(name, "")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_line(line: str) -> Record | None:
|
|
61
|
+
line = line.rstrip("\n")
|
|
62
|
+
if not line:
|
|
63
|
+
return None
|
|
64
|
+
if line.lstrip().startswith("{"):
|
|
65
|
+
return parse_json_line(line)
|
|
66
|
+
match = COMBINED.match(line)
|
|
67
|
+
if not match:
|
|
68
|
+
return None
|
|
69
|
+
rec = Record()
|
|
70
|
+
rec.raw = line
|
|
71
|
+
rec.ip = match.group("ip")
|
|
72
|
+
try:
|
|
73
|
+
rec.time = datetime.strptime(match.group("time"), CLF_TIME).astimezone(timezone.utc)
|
|
74
|
+
except ValueError:
|
|
75
|
+
return None
|
|
76
|
+
rec.method = match.group("method")
|
|
77
|
+
rec.target = match.group("target") or ""
|
|
78
|
+
rec.path, _, rec.query = rec.target.partition("?")
|
|
79
|
+
rec.version = match.group("version") or ""
|
|
80
|
+
rec.status = int(match.group("status"))
|
|
81
|
+
rec.bytes = int(match.group("bytes")) if match.group("bytes") not in (None, "-") else 0
|
|
82
|
+
rec.referer = match.group("referer") or ""
|
|
83
|
+
rec.ua = match.group("ua") or ""
|
|
84
|
+
rec.vhost = match.group("vhost") or ""
|
|
85
|
+
return rec
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_json_line(line: str) -> Record | None:
|
|
89
|
+
try:
|
|
90
|
+
data = json.loads(line)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return None
|
|
93
|
+
rec = Record()
|
|
94
|
+
rec.raw = line
|
|
95
|
+
request = data.get("request") if isinstance(data.get("request"), dict) else {}
|
|
96
|
+
if request: # Caddy
|
|
97
|
+
headers = request.get("headers") or {}
|
|
98
|
+
rec.ip = request.get("client_ip") or request.get("remote_ip") or ""
|
|
99
|
+
stamp = data.get("ts")
|
|
100
|
+
rec.time = datetime.fromtimestamp(float(stamp), timezone.utc) if isinstance(stamp, (int, float)) else core.parse_time(str(stamp))
|
|
101
|
+
rec.method, rec.target, rec.vhost = request.get("method", ""), request.get("uri", ""), request.get("host", "")
|
|
102
|
+
rec.ua = (headers.get("User-Agent") or [""])[0]
|
|
103
|
+
rec.referer = (headers.get("Referer") or [""])[0]
|
|
104
|
+
rec.status = int(data.get("status") or 0)
|
|
105
|
+
rec.bytes = int(data.get("size") or 0)
|
|
106
|
+
rec.version = request.get("proto", "")
|
|
107
|
+
elif "ClientIP" in data: # Cloudflare Logpush (http_requests)
|
|
108
|
+
rec.ip = data.get("ClientIP", "")
|
|
109
|
+
stamp = data.get("EdgeStartTimestamp")
|
|
110
|
+
rec.time = datetime.fromtimestamp(int(stamp) / (1e9 if int(stamp) > 1e12 else 1), timezone.utc) if str(stamp).isdigit() else core.parse_time(str(stamp))
|
|
111
|
+
rec.method, rec.target, rec.vhost = data.get("ClientRequestMethod", ""), data.get("ClientRequestURI", ""), data.get("ClientRequestHost", "")
|
|
112
|
+
rec.ua, rec.referer = data.get("ClientRequestUserAgent", ""), data.get("ClientRequestReferer", "")
|
|
113
|
+
rec.status, rec.bytes, rec.version = int(data.get("EdgeResponseStatus") or 0), int(data.get("EdgeResponseBytes") or 0), ""
|
|
114
|
+
else:
|
|
115
|
+
return None
|
|
116
|
+
rec.path, _, rec.query = rec.target.partition("?")
|
|
117
|
+
return rec
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --------------------------------------------------------------------------- reading
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def read_lines(ctx: core.Context):
|
|
124
|
+
args = ctx.args
|
|
125
|
+
cfg = (ctx.state.config().get("logs") or {})
|
|
126
|
+
if args.stdin:
|
|
127
|
+
yield from sys.stdin
|
|
128
|
+
return
|
|
129
|
+
if args.ssh:
|
|
130
|
+
alias, _, target = args.ssh.partition("=")
|
|
131
|
+
if not target:
|
|
132
|
+
raise core.UsageError("--ssh takes ALIAS=user@host[:path-glob]")
|
|
133
|
+
core.require_consent(ctx.state, alias, "ssh-read")
|
|
134
|
+
host, _, remote_glob = target.partition(":")
|
|
135
|
+
remote_glob = remote_glob or cfg.get("access_path") or "/var/log/nginx/access.log*"
|
|
136
|
+
since = int((core.utcnow() - since_delta(ctx)).timestamp())
|
|
137
|
+
command = (f"find {' '.join(shlex.quote(p) for p in remote_glob.split())} -type f -newermt @{since} -print0 2>/dev/null"
|
|
138
|
+
f" | xargs -0 -r ls -tr | tr '\\n' '\\0' | xargs -0 -r zcat -f")
|
|
139
|
+
proc = subprocess.Popen(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", host, command], stdout=subprocess.PIPE,
|
|
140
|
+
stderr=subprocess.DEVNULL, text=True, errors="replace")
|
|
141
|
+
assert proc.stdout is not None
|
|
142
|
+
with proc.stdout:
|
|
143
|
+
yield from proc.stdout
|
|
144
|
+
if proc.wait() not in (0, 123):
|
|
145
|
+
ctx.degrade("ssh", f"ssh {alias} exited {proc.returncode}")
|
|
146
|
+
return
|
|
147
|
+
patterns = args.file or ([cfg["access_path"]] if cfg.get("access_path") else [])
|
|
148
|
+
if not patterns:
|
|
149
|
+
raise core.UsageError("no log input: pass --file PATH, --stdin or --ssh ALIAS=user@host (or set logs.access_path in config.json)")
|
|
150
|
+
files = []
|
|
151
|
+
for pattern in patterns:
|
|
152
|
+
matched = sorted(glob.glob(pattern)) if Path(pattern).is_absolute() else (sorted(glob.glob(pattern)) or sorted(glob.glob(str(ctx.project / pattern))))
|
|
153
|
+
files += [Path(m) for m in matched if Path(m).is_file()]
|
|
154
|
+
if not files:
|
|
155
|
+
raise core.UsageError(f"no readable log file matches {', '.join(patterns)}")
|
|
156
|
+
floor = core.utcnow() - since_delta(ctx)
|
|
157
|
+
for path in sorted(files, key=lambda p: p.stat().st_mtime):
|
|
158
|
+
if datetime.fromtimestamp(path.stat().st_mtime, timezone.utc) < floor - timedelta(days=1):
|
|
159
|
+
continue
|
|
160
|
+
opener = gzip.open if path.suffix == ".gz" else open
|
|
161
|
+
try:
|
|
162
|
+
with opener(path, "rt", encoding="utf-8", errors="replace") as handle:
|
|
163
|
+
yield from handle
|
|
164
|
+
except OSError as error:
|
|
165
|
+
ctx.degrade("logs", f"{path.name}: {error}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def since_delta(ctx: core.Context) -> timedelta:
|
|
169
|
+
return core.parse_duration(ctx.args.since or "24h")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def records(ctx: core.Context):
|
|
173
|
+
floor = core.utcnow() - since_delta(ctx)
|
|
174
|
+
excludes = [ipaddress.ip_network(c, strict=False) for c in (ctx.args.exclude_ip or []) + ((ctx.state.config().get("logs") or {}).get("exclude") or [])]
|
|
175
|
+
vhost = getattr(ctx.args, "vhost", None)
|
|
176
|
+
stats = {"lines": 0, "parsed": 0, "unparsed": 0, "in_window": 0, "excluded": 0}
|
|
177
|
+
ctx.run["inputs"]["log_lines"] = stats
|
|
178
|
+
for line in read_lines(ctx):
|
|
179
|
+
stats["lines"] += 1
|
|
180
|
+
rec = parse_line(line)
|
|
181
|
+
if rec is None:
|
|
182
|
+
stats["unparsed"] += 1
|
|
183
|
+
continue
|
|
184
|
+
stats["parsed"] += 1
|
|
185
|
+
if rec.time < floor or rec.time > core.utcnow() + timedelta(minutes=5):
|
|
186
|
+
continue
|
|
187
|
+
if vhost and rec.vhost and rec.vhost != vhost:
|
|
188
|
+
continue
|
|
189
|
+
if excludes:
|
|
190
|
+
try:
|
|
191
|
+
address = ipaddress.ip_address(rec.ip)
|
|
192
|
+
if any(address in net for net in excludes):
|
|
193
|
+
stats["excluded"] += 1
|
|
194
|
+
continue
|
|
195
|
+
except ValueError:
|
|
196
|
+
pass
|
|
197
|
+
stats["in_window"] += 1
|
|
198
|
+
yield rec
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def redact_line(rec: Record) -> str:
|
|
202
|
+
target = core.mask_query(rec.target)
|
|
203
|
+
return core.clean_excerpt(f'{rec.ip} [{rec.time.strftime("%Y-%m-%dT%H:%M:%SZ")}] "{rec.method} {target}" {rec.status} {rec.bytes} "{rec.ua[:80]}"')
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def edge_only(ctx: core.Context) -> bool:
|
|
207
|
+
if ctx.args.file or ctx.args.stdin or ctx.args.ssh or (ctx.state.config().get("logs") or {}).get("access_path"):
|
|
208
|
+
return False
|
|
209
|
+
stack = ctx.state.path("inventory", "stack.json")
|
|
210
|
+
site_type = core.read_json(stack)["site"].get("type") if stack.is_file() else core.detect_site_type(ctx.project)
|
|
211
|
+
return site_type == "workers"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def edge_only_finding(ctx: core.Context) -> dict:
|
|
215
|
+
return core.make_finding(kind="availability", command=ctx.command, primary_id="logs-unavailable-edge-only", site=ctx.site,
|
|
216
|
+
title="This site runs only on Cloudflare's edge: there are no origin access logs to read", severity="info",
|
|
217
|
+
severity_source="maggie", confidence="confirmed", target={"site": ctx.site, "host": "edge"},
|
|
218
|
+
remediation={"summary": "Use Cloudflare's Security Analytics, or Logpush to a file and pass it with --file.", "steps": [],
|
|
219
|
+
"snippets": [], "references": ["https://developers.cloudflare.com/logs/"], "cheat_sheet": None})
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --------------------------------------------------------------------------- logs-traffic
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def traffic(ctx: core.Context):
|
|
226
|
+
if edge_only(ctx):
|
|
227
|
+
return ctx.finish([edge_only_finding(ctx)])
|
|
228
|
+
counts = collections.Counter()
|
|
229
|
+
status_classes = collections.Counter()
|
|
230
|
+
paths, not_found, ips, uas = collections.Counter(), collections.Counter(), collections.Counter(), collections.Counter()
|
|
231
|
+
hourly: dict[str, list[int]] = collections.defaultdict(lambda: [0, 0])
|
|
232
|
+
crawler = re.compile(r"(?i)(bot|crawl|spider|slurp|bingpreview|facebookexternalhit|curl|wget|python-requests|go-http-client)")
|
|
233
|
+
first = last = None
|
|
234
|
+
bandwidth = 0
|
|
235
|
+
for rec in records(ctx):
|
|
236
|
+
counts["requests"] += 1
|
|
237
|
+
status_classes[f"{rec.status // 100}xx"] += 1
|
|
238
|
+
paths[rec.path] += 1
|
|
239
|
+
ips[rec.ip] += 1
|
|
240
|
+
bandwidth += rec.bytes
|
|
241
|
+
if rec.status == 404:
|
|
242
|
+
not_found[rec.path] += 1
|
|
243
|
+
if crawler.search(rec.ua or ""):
|
|
244
|
+
counts["crawler"] += 1
|
|
245
|
+
hour = rec.time.strftime("%Y-%m-%dT%H")
|
|
246
|
+
hourly[hour][0] += 1
|
|
247
|
+
hourly[hour][1] += rec.status >= 500
|
|
248
|
+
first = rec.time if first is None or rec.time < first else first
|
|
249
|
+
last = rec.time if last is None or rec.time > last else last
|
|
250
|
+
uas[rec.ua[:120]] += 1
|
|
251
|
+
total = counts["requests"]
|
|
252
|
+
window = {"from": core.iso(first) if first else None, "to": core.iso(last) if last else None, "since": ctx.args.since or "24h"}
|
|
253
|
+
share = lambda n: round(n / total, 4) if total else 0 # noqa: E731
|
|
254
|
+
summary = {"window": window, "requests": total, "unique_visitors": len(ips), "bandwidth_bytes": bandwidth,
|
|
255
|
+
"status": {k: share(v) for k, v in sorted(status_classes.items())},
|
|
256
|
+
"top_paths": [{"path": core.mask_query(p)[:200], "hits": h} for p, h in paths.most_common(10)],
|
|
257
|
+
"top_404": [{"path": core.mask_query(p)[:200], "hits": h} for p, h in not_found.most_common(10)],
|
|
258
|
+
"top_ips": [{"ip": ip, "hits": h, "share": share(h)} for ip, h in ips.most_common(10)],
|
|
259
|
+
"top_user_agents": [{"ua": ua, "hits": h} for ua, h in uas.most_common(5)],
|
|
260
|
+
"crawler_share": share(counts["crawler"])}
|
|
261
|
+
previous = previous_traffic(ctx)
|
|
262
|
+
if previous and previous.get("requests"):
|
|
263
|
+
summary["baseline_delta"] = {"requests": f"{(total - previous['requests']) / previous['requests']:+.0%}",
|
|
264
|
+
"5xx": f"{(summary['status'].get('5xx', 0) - previous['status'].get('5xx', 0)) * 100:+.1f}pp"}
|
|
265
|
+
ctx.run["inputs"]["traffic"] = summary
|
|
266
|
+
thresholds = {"5xx_share": 0.02, "5xx_hour": 0.10, "single_ip": 0.05, "share_404": 0.30, **((ctx.state.config().get("logs") or {}).get("thresholds") or {})}
|
|
267
|
+
findings = []
|
|
268
|
+
if total:
|
|
269
|
+
five = summary["status"].get("5xx", 0)
|
|
270
|
+
bad_hours = [h for h, (n, e) in hourly.items() if n >= 20 and e / n > thresholds["5xx_hour"]]
|
|
271
|
+
if five > thresholds["5xx_share"] or bad_hours:
|
|
272
|
+
findings.append(core.make_finding(
|
|
273
|
+
kind="availability", command="logs-traffic", primary_id="traffic-5xx-burst", site=ctx.site,
|
|
274
|
+
title=f"{five:.1%} of requests failed with a server error" + (f"; worst hours: {', '.join(sorted(bad_hours)[:3])}" if bad_hours else ""),
|
|
275
|
+
severity="high" if len(bad_hours) > 1 else "medium", severity_source="maggie", confidence="confirmed", target={"site": ctx.site, "host": "origin"},
|
|
276
|
+
remediation={"summary": "Check the application and server error logs for those hours.", "steps": [], "snippets": [], "references": [], "cheat_sheet": None}))
|
|
277
|
+
top_ip, top_hits = ips.most_common(1)[0]
|
|
278
|
+
if share(top_hits) > thresholds["single_ip"] and total >= 200:
|
|
279
|
+
findings.append(core.make_finding(
|
|
280
|
+
kind="attack-signal", command="logs-traffic", primary_id="traffic-single-source-dominant", site=ctx.site,
|
|
281
|
+
title=f"One address made {share(top_hits):.0%} of all requests", severity="low", severity_source="maggie", confidence="possible",
|
|
282
|
+
target={"site": ctx.site, "host": "origin"},
|
|
283
|
+
signal={"type": "attempt", "count": top_hits, "top_sources": [top_ip], "window": ctx.args.since or "24h"},
|
|
284
|
+
remediation={"summary": "If it is not your own monitor or office, consider rate limiting it (see protect-plan).", "steps": [], "snippets": [],
|
|
285
|
+
"references": [], "cheat_sheet": None}))
|
|
286
|
+
if share(sum(not_found.values())) > thresholds["share_404"]:
|
|
287
|
+
findings.append(core.make_finding(
|
|
288
|
+
kind="attack-signal", command="logs-traffic", primary_id="traffic-404-heavy", site=ctx.site,
|
|
289
|
+
title=f"{share(sum(not_found.values())):.0%} of requests were for pages that do not exist (scanner noise)", severity="info",
|
|
290
|
+
severity_source="maggie", confidence="possible", target={"site": ctx.site, "host": "origin"},
|
|
291
|
+
signal={"type": "attempt", "count": sum(not_found.values()), "top_sources": [ip for ip, _ in ips.most_common(3)], "window": ctx.args.since or "24h"},
|
|
292
|
+
remediation={"summary": "Normal for public sites; no action unless it slows the server.", "steps": [], "snippets": [], "references": [], "cheat_sheet": None}))
|
|
293
|
+
if previous and previous.get("requests") and total > 3 * previous["requests"] and total >= 1000:
|
|
294
|
+
findings.append(core.make_finding(
|
|
295
|
+
kind="availability", command="logs-traffic", primary_id="traffic-spike", site=ctx.site,
|
|
296
|
+
title=f"Traffic is {total / previous['requests']:.1f}× the previous window", severity="low", severity_source="maggie",
|
|
297
|
+
confidence="possible", target={"site": ctx.site, "host": "origin"},
|
|
298
|
+
remediation={"summary": "Check whether it is a campaign, a crawler or an attack.", "steps": [], "snippets": [], "references": [], "cheat_sheet": None}))
|
|
299
|
+
elif ctx.run["inputs"]["log_lines"]["lines"] and not ctx.run["inputs"]["log_lines"]["parsed"]:
|
|
300
|
+
raise core.UsageError("no line could be parsed; supported formats: nginx/Apache combined or vcombined, Caddy JSON, Cloudflare Logpush JSON")
|
|
301
|
+
ctx.run["tools"].append({"name": "maggie-logs-native", "version": core.maggie_version(), "sha256": None})
|
|
302
|
+
return ctx.finish(findings, covered=["*"])
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def previous_traffic(ctx: core.Context) -> dict | None:
|
|
306
|
+
runs = ctx.state.path("runs")
|
|
307
|
+
for path in sorted(runs.glob("*logs-traffic*.json"), reverse=True) if runs.is_dir() else []:
|
|
308
|
+
try:
|
|
309
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
310
|
+
except ValueError:
|
|
311
|
+
continue
|
|
312
|
+
summary = (data["run"].get("inputs") or {}).get("traffic")
|
|
313
|
+
if summary and summary.get("window", {}).get("since") == (ctx.args.since or "24h"):
|
|
314
|
+
return summary
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# --------------------------------------------------------------------------- Sigma
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class Rule:
|
|
322
|
+
def __init__(self, data: dict) -> None:
|
|
323
|
+
self.data = data
|
|
324
|
+
detection = dict(data["detection"])
|
|
325
|
+
self.condition = str(detection.pop("condition"))
|
|
326
|
+
detection.pop("timeframe", None)
|
|
327
|
+
self.blocks = {name: self._compile(block) for name, block in detection.items()}
|
|
328
|
+
self.tokens = re.findall(r"\(|\)|[A-Za-z0-9_*]+", self.condition.replace("1 of", "1_of").replace("all of", "all_of"))
|
|
329
|
+
|
|
330
|
+
@staticmethod
|
|
331
|
+
def _compile(block):
|
|
332
|
+
if isinstance(block, list) and all(not isinstance(x, dict) for x in block):
|
|
333
|
+
return ("keywords", [str(x).lower() for x in block])
|
|
334
|
+
alternatives = block if isinstance(block, list) else [block]
|
|
335
|
+
compiled = []
|
|
336
|
+
for alt in alternatives:
|
|
337
|
+
fields = []
|
|
338
|
+
for key, values in (alt or {}).items():
|
|
339
|
+
name, *mods = key.split("|")
|
|
340
|
+
values = values if isinstance(values, list) else [values]
|
|
341
|
+
if "re" in mods:
|
|
342
|
+
matchers = [re.compile(str(v), re.I) for v in values]
|
|
343
|
+
elif "cidr" in mods:
|
|
344
|
+
matchers = [ipaddress.ip_network(str(v), strict=False) for v in values]
|
|
345
|
+
else:
|
|
346
|
+
matchers = [("" if v is None else str(v)).lower() for v in values]
|
|
347
|
+
fields.append((name, set(mods), matchers))
|
|
348
|
+
compiled.append(fields)
|
|
349
|
+
return ("map", compiled)
|
|
350
|
+
|
|
351
|
+
@staticmethod
|
|
352
|
+
def _value_matches(value: str, mods: set, matcher) -> bool:
|
|
353
|
+
if "re" in mods:
|
|
354
|
+
return bool(matcher.search(value))
|
|
355
|
+
if "cidr" in mods:
|
|
356
|
+
try:
|
|
357
|
+
return ipaddress.ip_address(value) in matcher
|
|
358
|
+
except ValueError:
|
|
359
|
+
return False
|
|
360
|
+
value = value.lower()
|
|
361
|
+
if "contains" in mods:
|
|
362
|
+
return matcher in value
|
|
363
|
+
if "startswith" in mods:
|
|
364
|
+
return value.startswith(matcher)
|
|
365
|
+
if "endswith" in mods:
|
|
366
|
+
return value.endswith(matcher)
|
|
367
|
+
if "*" in matcher or "?" in matcher:
|
|
368
|
+
return re.fullmatch(re.escape(matcher).replace(r"\*", ".*").replace(r"\?", "."), value) is not None
|
|
369
|
+
return value == matcher
|
|
370
|
+
|
|
371
|
+
def _block(self, name: str, rec: Record, variants: list[str]) -> bool:
|
|
372
|
+
kind, payload = self.blocks[name]
|
|
373
|
+
if kind == "keywords":
|
|
374
|
+
return any(kw in v for v in variants for kw in payload)
|
|
375
|
+
for fields in payload:
|
|
376
|
+
ok = True
|
|
377
|
+
for field, mods, matchers in fields:
|
|
378
|
+
if field == "": # keywords with modifiers ("|all")
|
|
379
|
+
hits = [any(m in v for v in variants) for m in matchers]
|
|
380
|
+
else:
|
|
381
|
+
raw = rec.field(field)
|
|
382
|
+
candidates = [raw, urllib.parse.unquote_plus(raw)] if field in ("cs-uri", "c-uri", "cs-uri-query", "c-uri-query", "cs-uri-stem") else [raw]
|
|
383
|
+
hits = [any(self._value_matches(c, mods, m) for c in candidates) for m in matchers]
|
|
384
|
+
if not (all(hits) if "all" in mods else any(hits)):
|
|
385
|
+
ok = False
|
|
386
|
+
break
|
|
387
|
+
if ok:
|
|
388
|
+
return True
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
def matches(self, rec: Record) -> bool:
|
|
392
|
+
lowered = rec.raw.lower()
|
|
393
|
+
variants = [lowered, urllib.parse.unquote_plus(lowered)]
|
|
394
|
+
pos = 0
|
|
395
|
+
tokens = self.tokens
|
|
396
|
+
|
|
397
|
+
def expr() -> bool:
|
|
398
|
+
nonlocal pos
|
|
399
|
+
value = term()
|
|
400
|
+
while pos < len(tokens) and tokens[pos] == "or":
|
|
401
|
+
pos += 1
|
|
402
|
+
rhs = term()
|
|
403
|
+
value = value or rhs
|
|
404
|
+
return value
|
|
405
|
+
|
|
406
|
+
def term() -> bool:
|
|
407
|
+
nonlocal pos
|
|
408
|
+
value = factor()
|
|
409
|
+
while pos < len(tokens) and tokens[pos] == "and":
|
|
410
|
+
pos += 1
|
|
411
|
+
rhs = factor()
|
|
412
|
+
value = value and rhs
|
|
413
|
+
return value
|
|
414
|
+
|
|
415
|
+
def factor() -> bool:
|
|
416
|
+
nonlocal pos
|
|
417
|
+
token = tokens[pos]
|
|
418
|
+
pos += 1
|
|
419
|
+
if token == "not":
|
|
420
|
+
return not factor()
|
|
421
|
+
if token == "(":
|
|
422
|
+
value = expr()
|
|
423
|
+
pos += 1 # ')'
|
|
424
|
+
return value
|
|
425
|
+
if token in ("1_of", "all_of"):
|
|
426
|
+
pattern = tokens[pos]
|
|
427
|
+
pos += 1
|
|
428
|
+
names = list(self.blocks) if pattern == "them" else [n for n in self.blocks if re.fullmatch(pattern.replace("*", ".*"), n)]
|
|
429
|
+
results = (self._block(n, rec, variants) for n in names)
|
|
430
|
+
return any(results) if token == "1_of" else (bool(names) and all(results))
|
|
431
|
+
return self._block(token, rec, variants)
|
|
432
|
+
|
|
433
|
+
return expr()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def load_rules(groups: set[str], rule_ids: list[str] | None, min_level: str) -> tuple[list[Rule], dict]:
|
|
437
|
+
pack = core.read_json(core.DATA_DIR / "sigma-web.json")
|
|
438
|
+
order = ["informational", "low", "medium", "high", "critical"]
|
|
439
|
+
floor = order.index(min_level)
|
|
440
|
+
rules, skipped = [], {"unsupported": 0, "below_level": 0, "other_group": 0}
|
|
441
|
+
for data in pack["rules"]:
|
|
442
|
+
if rule_ids and data["id"] not in rule_ids:
|
|
443
|
+
continue
|
|
444
|
+
if not rule_ids and data["group"] not in groups:
|
|
445
|
+
skipped["other_group"] += 1
|
|
446
|
+
continue
|
|
447
|
+
if data["status"] == "unsupported":
|
|
448
|
+
skipped["unsupported"] += 1
|
|
449
|
+
continue
|
|
450
|
+
if order.index(data.get("level", "medium") if data.get("level") in order else "medium") < floor:
|
|
451
|
+
skipped["below_level"] += 1
|
|
452
|
+
continue
|
|
453
|
+
try:
|
|
454
|
+
rules.append(Rule(data))
|
|
455
|
+
except (KeyError, re.error, ValueError):
|
|
456
|
+
skipped["unsupported"] += 1
|
|
457
|
+
return rules, {"commit": pack["commit"], **skipped}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def open_cve_aliases(ctx: core.Context) -> set[str]:
|
|
461
|
+
"""CVE ids of vulnerabilities vuln-watch currently reports as open for this site."""
|
|
462
|
+
ledger = core.Ledger(ctx.state.path("ledger.jsonl"))
|
|
463
|
+
open_ids = {fid for fid, events in ledger.history().items() if core.Ledger.derive(events)["status"] in ("new", "open")}
|
|
464
|
+
aliases = set()
|
|
465
|
+
runs = ctx.state.path("runs")
|
|
466
|
+
for path in sorted(runs.glob("*vuln-watch*.json"))[-3:] if runs.is_dir() else []:
|
|
467
|
+
for finding in json.loads(path.read_text(encoding="utf-8")).get("findings") or []:
|
|
468
|
+
if finding["id"] in open_ids:
|
|
469
|
+
aliases |= {a.upper() for a in finding.get("aliases") or [] if a.upper().startswith("CVE-")}
|
|
470
|
+
return aliases
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def sigma(ctx: core.Context):
|
|
474
|
+
args = ctx.args
|
|
475
|
+
groups = set((args.rules or "web,proxy").split(","))
|
|
476
|
+
rules, info = load_rules(groups, args.rule_id, args.min_level)
|
|
477
|
+
if args.list_rules:
|
|
478
|
+
pack = core.read_json(core.DATA_DIR / "sigma-web.json")
|
|
479
|
+
return {"_raw": json.dumps({"commit": pack["commit"], "licence": pack["licence"], "counts": pack["counts"],
|
|
480
|
+
"rules": [{k: r[k] for k in ("id", "title", "group", "level", "status", "unsupported_reasons")} for r in pack["rules"]]},
|
|
481
|
+
indent=2)}
|
|
482
|
+
if edge_only(ctx):
|
|
483
|
+
return ctx.finish([edge_only_finding(ctx)])
|
|
484
|
+
allow = (ctx.state.config().get("logs") or {}).get("sigma_allow") or {}
|
|
485
|
+
allow_paths = [re.compile(p) for p in allow.get("paths") or []]
|
|
486
|
+
allow_ids = set(allow.get("rule_ids") or [])
|
|
487
|
+
hits: dict[str, dict] = {}
|
|
488
|
+
for rec in records(ctx):
|
|
489
|
+
if allow_paths and any(p.search(rec.path) for p in allow_paths):
|
|
490
|
+
continue
|
|
491
|
+
for rule in rules:
|
|
492
|
+
if rule.data["id"] in allow_ids or not rule.matches(rec):
|
|
493
|
+
continue
|
|
494
|
+
entry = hits.setdefault(rule.data["id"], {"rule": rule, "count": 0, "ips": collections.Counter(), "status": collections.Counter(),
|
|
495
|
+
"samples": [], "ok_samples": [], "first": rec.time, "last": rec.time, "ok_bodies": 0, "ok_paths": set()})
|
|
496
|
+
entry["count"] += 1
|
|
497
|
+
entry["ips"][rec.ip] += 1
|
|
498
|
+
entry["status"][str(rec.status)] += 1
|
|
499
|
+
entry["first"], entry["last"] = min(entry["first"], rec.time), max(entry["last"], rec.time)
|
|
500
|
+
if len(entry["samples"]) < args.samples:
|
|
501
|
+
entry["samples"].append(redact_line(rec))
|
|
502
|
+
if 200 <= rec.status < 300:
|
|
503
|
+
if rec.bytes > 0:
|
|
504
|
+
entry["ok_bodies"] += 1
|
|
505
|
+
entry["ok_paths"].add(rec.path)
|
|
506
|
+
if len(entry["ok_samples"]) < args.samples:
|
|
507
|
+
entry["ok_samples"].append(redact_line(rec))
|
|
508
|
+
affected_cves = open_cve_aliases(ctx)
|
|
509
|
+
findings = []
|
|
510
|
+
for rule_id, entry in hits.items():
|
|
511
|
+
data = entry["rule"].data
|
|
512
|
+
success = sum(v for k, v in entry["status"].items() if k.startswith("2"))
|
|
513
|
+
level = LEVEL_SEVERITY.get(data.get("level"), "medium")
|
|
514
|
+
title = data["title"]
|
|
515
|
+
rule_cves = {t.upper().replace("CVE.", "CVE-") for t in data.get("tags") or [] if "cve" in t.lower()} | \
|
|
516
|
+
{m.upper() for m in re.findall(r"(?i)cve-\d{4}-\d+", title + " " + " ".join(data.get("references") or []))}
|
|
517
|
+
compromise_reason = None
|
|
518
|
+
if success and entry["ok_bodies"]:
|
|
519
|
+
if WEBSHELL_HINT.search(title) and any(p.lower().endswith((".php", ".jsp", ".aspx", ".asp")) for p in entry["ok_paths"]):
|
|
520
|
+
compromise_reason = "a webshell path answered 200 with content"
|
|
521
|
+
elif any(SECRET_FILE.search(p) for p in entry["ok_paths"]):
|
|
522
|
+
compromise_reason = "a secret file (.env, .git/config, config backup) was served with content"
|
|
523
|
+
elif rule_cves & affected_cves:
|
|
524
|
+
compromise_reason = f"an exploit for {', '.join(sorted(rule_cves & affected_cves))}, which this site is affected by, got a normal response"
|
|
525
|
+
if compromise_reason:
|
|
526
|
+
severity, signal_type, confidence = "critical", "compromise-indicator", "likely"
|
|
527
|
+
elif success:
|
|
528
|
+
severity = {"info": "low", "low": "medium", "medium": "high", "high": "high", "critical": "critical"}[level] if level in ("high", "critical") else \
|
|
529
|
+
{"info": "low", "low": "medium", "medium": "medium"}.get(level, "medium")
|
|
530
|
+
signal_type, confidence = "attempt", "possible"
|
|
531
|
+
else:
|
|
532
|
+
severity = level if level in ("info", "low") else "medium"
|
|
533
|
+
signal_type, confidence = "attempt", "possible"
|
|
534
|
+
top = [ip for ip, _ in entry["ips"].most_common(10)]
|
|
535
|
+
wording = (f"Possible break-in: {compromise_reason}" if compromise_reason else
|
|
536
|
+
f"{entry['count']} attempt{'s' if entry['count'] != 1 else ''}: {title}"
|
|
537
|
+
+ (f" — {success} got a normal response; check {'them' if success != 1 else 'it'}" if success else ""))
|
|
538
|
+
findings.append(core.make_finding(
|
|
539
|
+
kind="attack-signal", command="logs-sigma", primary_id=rule_id, site=ctx.site, title=wording[:300], severity=severity,
|
|
540
|
+
severity_source="tool", confidence=confidence, target={"site": ctx.site, "host": "origin"},
|
|
541
|
+
signal={"type": signal_type, "count": entry["count"], "top_sources": top, "window": args.since or "24h"},
|
|
542
|
+
evidence=[{"path": "access log", "excerpt": s} for s in (entry["ok_samples"] if success else entry["samples"])[:args.samples]],
|
|
543
|
+
tool="sigma", rule_id=rule_id, asvs_ids=[],
|
|
544
|
+
remediation={"summary": ("Check the listed requests and the server now; treat the site as possibly compromised until checked."
|
|
545
|
+
if compromise_reason else "These are attempts; nothing indicates they worked. Rate limiting or a WAF reduces the noise (protect-plan)."),
|
|
546
|
+
"steps": [f"False positives Sigma lists: {'; '.join(map(str, data.get('falsepositives') or []))[:300]}"] if data.get("falsepositives") else [],
|
|
547
|
+
"snippets": [], "references": (data.get("references") or [])[:4], "cheat_sheet": None},
|
|
548
|
+
untrusted={"sigma": {"title": data["title"], "author": data.get("author"), "id": rule_id, "licence": "DRL-1.1",
|
|
549
|
+
"url": f"https://github.com/SigmaHQ/sigma/blob/{info['commit']}/{data.get('source_path', '')}"}}))
|
|
550
|
+
findings[-1]["signal"].update({"success_count": success, "status_breakdown": dict(entry["status"]), "first": core.iso(entry["first"]),
|
|
551
|
+
"last": core.iso(entry["last"])})
|
|
552
|
+
ctx.run["inputs"]["sigma"] = {**info, "rules_run": len(rules), "groups": sorted(groups)}
|
|
553
|
+
ctx.run["tools"].append({"name": "sigma-rules", "version": info["commit"][:12], "sha256": None})
|
|
554
|
+
doc = ctx.finish(findings, covered=["*"])
|
|
555
|
+
if any((f.get("signal") or {}).get("type") == "compromise-indicator" and f["status"] in ("new", "open") for f in doc["findings"]) and args.ci:
|
|
556
|
+
args.fail_on = "info"
|
|
557
|
+
return doc
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# --------------------------------------------------------------------------- IOC
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
class IocLists:
|
|
564
|
+
def __init__(self, ctx: core.Context, feeds: list[str]) -> None:
|
|
565
|
+
data = core.read_json(core.DATA_DIR / "maltrail-lists.json")
|
|
566
|
+
self.mass_ips = set(data["mass_scanner_ips"])
|
|
567
|
+
self.mass_nets = [ipaddress.ip_network(c, strict=False) for c in data["mass_scanner_cidrs"] if "/" in c]
|
|
568
|
+
self.ua = re.compile("|".join(f"(?:{p})" for p in data["suspicious_ua"]))
|
|
569
|
+
self.feeds: dict[str, set[str]] = {}
|
|
570
|
+
self.nets: dict[str, list] = {}
|
|
571
|
+
for feed_id in feeds:
|
|
572
|
+
spec = IOC_FEEDS.get(feed_id)
|
|
573
|
+
if not spec:
|
|
574
|
+
raise core.UsageError(f"unknown IOC feed {feed_id}; known: {', '.join(IOC_FEEDS)}")
|
|
575
|
+
body = ctx.cache.get(f"ioc-{feed_id}", spec["url"], ttl="12h")
|
|
576
|
+
if body is None:
|
|
577
|
+
continue
|
|
578
|
+
ips, nets = set(), []
|
|
579
|
+
for line in body.decode("utf-8", errors="replace").splitlines():
|
|
580
|
+
item = line.split("#", 1)[0].split(";", 1)[0].strip()
|
|
581
|
+
if not item:
|
|
582
|
+
continue
|
|
583
|
+
if "/" in item:
|
|
584
|
+
try:
|
|
585
|
+
nets.append(ipaddress.ip_network(item, strict=False))
|
|
586
|
+
except ValueError:
|
|
587
|
+
pass
|
|
588
|
+
else:
|
|
589
|
+
ips.add(item)
|
|
590
|
+
self.feeds[feed_id], self.nets[feed_id] = ips, nets
|
|
591
|
+
|
|
592
|
+
def mass_scanner(self, ip: str) -> bool:
|
|
593
|
+
if ip in self.mass_ips:
|
|
594
|
+
return True
|
|
595
|
+
try:
|
|
596
|
+
address = ipaddress.ip_address(ip)
|
|
597
|
+
except ValueError:
|
|
598
|
+
return False
|
|
599
|
+
return any(address in net for net in self.mass_nets)
|
|
600
|
+
|
|
601
|
+
def listed_in(self, ip: str) -> list[str]:
|
|
602
|
+
out = []
|
|
603
|
+
try:
|
|
604
|
+
address = ipaddress.ip_address(ip)
|
|
605
|
+
except ValueError:
|
|
606
|
+
return out
|
|
607
|
+
for feed_id, ips in self.feeds.items():
|
|
608
|
+
if ip in ips or any(address in net for net in self.nets[feed_id]):
|
|
609
|
+
out.append(feed_id)
|
|
610
|
+
return out
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# Feeds that need a terms review before default use (PRD 06 section 6.3.1); none is on by default.
|
|
614
|
+
IOC_FEEDS = {
|
|
615
|
+
"blocklist-de": {"url": "https://lists.blocklist.de/lists/all.txt", "host": "lists.blocklist.de"},
|
|
616
|
+
"greensnow": {"url": "https://blocklist.greensnow.co/greensnow.txt", "host": "blocklist.greensnow.co"},
|
|
617
|
+
"et-compromised": {"url": "https://rules.emergingthreats.net/open/suricata/rules/compromised-ips.txt", "host": "rules.emergingthreats.net"},
|
|
618
|
+
"firehol-dshield-1d": {"url": "https://iplists.firehol.org/files/dshield_1d.netset", "host": "iplists.firehol.org"},
|
|
619
|
+
}
|
|
620
|
+
for _id, _spec in IOC_FEEDS.items():
|
|
621
|
+
core.SOURCES.setdefault(f"ioc-{_id}", {"hosts": [_spec["host"]], "ttl": "12h", "url": _spec["url"]})
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def ioc(ctx: core.Context):
|
|
625
|
+
args = ctx.args
|
|
626
|
+
if edge_only(ctx):
|
|
627
|
+
return ctx.finish([edge_only_finding(ctx)])
|
|
628
|
+
feeds = [f for f in (args.feeds or "").split(",") if f]
|
|
629
|
+
lists = IocLists(ctx, feeds)
|
|
630
|
+
per_ip: dict[str, dict] = {}
|
|
631
|
+
ua_hits: dict[str, dict] = {}
|
|
632
|
+
for rec in records(ctx):
|
|
633
|
+
entry = per_ip.setdefault(rec.ip, {"count": 0, "samples": [], "ua": False})
|
|
634
|
+
entry["count"] += 1
|
|
635
|
+
if len(entry["samples"]) < 2:
|
|
636
|
+
entry["samples"].append(redact_line(rec))
|
|
637
|
+
if rec.ua and lists.ua.search(rec.ua):
|
|
638
|
+
entry["ua"] = True
|
|
639
|
+
group = ua_hits.setdefault(rec.ua[:60], {"count": 0, "ips": collections.Counter(), "sample": redact_line(rec)})
|
|
640
|
+
group["count"] += 1
|
|
641
|
+
group["ips"][rec.ip] += 1
|
|
642
|
+
sigma_ips = set()
|
|
643
|
+
runs = ctx.state.path("runs")
|
|
644
|
+
for path in sorted(runs.glob("*logs-sigma*.json"))[-1:] if runs.is_dir() else []:
|
|
645
|
+
for finding in json.loads(path.read_text(encoding="utf-8")).get("findings") or []:
|
|
646
|
+
sigma_ips |= set((finding.get("signal") or {}).get("top_sources") or [])
|
|
647
|
+
findings, aggregate = [], collections.Counter()
|
|
648
|
+
for ip, entry in per_ip.items():
|
|
649
|
+
listed = lists.listed_in(ip)
|
|
650
|
+
score, label = 0, None
|
|
651
|
+
if listed:
|
|
652
|
+
score = 40 + 15 * (len(listed) - 1)
|
|
653
|
+
if entry["ua"]:
|
|
654
|
+
score += 15
|
|
655
|
+
if ip in sigma_ips and score:
|
|
656
|
+
score += 15
|
|
657
|
+
if not listed and lists.mass_scanner(ip):
|
|
658
|
+
score, label = 30, "mass-scanner"
|
|
659
|
+
score = min(score, 100)
|
|
660
|
+
if score == 0:
|
|
661
|
+
continue
|
|
662
|
+
label = label or ("high-confidence" if score >= 70 else "medium-confidence" if score >= 55 else "low-confidence")
|
|
663
|
+
if score < args.min_confidence:
|
|
664
|
+
aggregate[label] += entry["count"]
|
|
665
|
+
continue
|
|
666
|
+
findings.append(core.make_finding(
|
|
667
|
+
kind="attack-signal", command="logs-ioc", primary_id=f"ioc-ip-{ip}", site=ctx.site,
|
|
668
|
+
title=f"{entry['count']} requests from {ip}, listed by {', '.join(listed) or 'no IP feed'} (score {score}, {label})",
|
|
669
|
+
severity="medium" if score >= 70 else "low", severity_source="maggie", confidence="likely" if score >= 70 else "possible",
|
|
670
|
+
target={"site": ctx.site, "host": "origin"}, signal={"type": "attempt", "count": entry["count"], "top_sources": [ip], "window": args.since or "24h"},
|
|
671
|
+
evidence=[{"path": "access log", "excerpt": s} for s in entry["samples"]], rule_id="ioc-ip",
|
|
672
|
+
remediation={"summary": "Block recommended (protect-plan can add it to CrowdSec or fail2ban)." if score >= 70 else "Watch; block if it repeats next week.",
|
|
673
|
+
"steps": [], "snippets": [], "references": [], "cheat_sheet": None}))
|
|
674
|
+
for index, (ua, group) in enumerate(sorted(ua_hits.items(), key=lambda kv: -kv[1]["count"])[:10], 1):
|
|
675
|
+
findings.append(core.make_finding(
|
|
676
|
+
kind="attack-signal", command="logs-ioc", primary_id=f"ioc-ua:{ua}", site=ctx.site,
|
|
677
|
+
title=f"{group['count']} requests with a scanner or exploit user agent ({ua[:50]})", severity="low", severity_source="maggie",
|
|
678
|
+
confidence="possible", target={"site": ctx.site, "host": "origin"}, rule_id=f"ioc-ua:{index}",
|
|
679
|
+
signal={"type": "attempt", "count": group["count"], "top_sources": [ip for ip, _ in group["ips"].most_common(5)], "window": args.since or "24h"},
|
|
680
|
+
evidence=[{"path": "access log", "excerpt": group["sample"]}],
|
|
681
|
+
remediation={"summary": "Automated scanning; no action unless it hurts performance.", "steps": [], "snippets": [], "references": [], "cheat_sheet": None},
|
|
682
|
+
untrusted={"user_agent": ua}))
|
|
683
|
+
if aggregate:
|
|
684
|
+
findings.append(core.make_finding(
|
|
685
|
+
kind="attack-signal", command="logs-ioc", primary_id="ioc-aggregate", site=ctx.site,
|
|
686
|
+
title="Low-confidence and mass-scanner traffic: " + ", ".join(f"{n} requests {label}" for label, n in aggregate.items()),
|
|
687
|
+
severity="info", severity_source="maggie", confidence="possible", target={"site": ctx.site, "host": "origin"},
|
|
688
|
+
signal={"type": "attempt", "count": sum(aggregate.values()), "top_sources": [], "window": args.since or "24h"},
|
|
689
|
+
remediation={"summary": "Background noise of the internet; informational.", "steps": [], "snippets": [], "references": [], "cheat_sheet": None}))
|
|
690
|
+
ctx.run["inputs"]["ioc"] = {"feeds": feeds, "lists": ["maltrail-mass-scanner", "maltrail-ua"], "ips_seen": len(per_ip)}
|
|
691
|
+
return ctx.finish(findings, covered=["*"])
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# --------------------------------------------------------------------------- logs-remote (summary only, nothing written on the host)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
REMOTE_AWK = r'''
|
|
698
|
+
{ ip=$1; n++; split($0, q, "\""); split(q[2], r, " "); path=r[2]; sub(/\?.*/, "", path); st=$(NF>=9?9:NF);
|
|
699
|
+
match($0, /" [0-9][0-9][0-9] /); if (RSTART) st=substr($0, RSTART+2, 3);
|
|
700
|
+
ips[ip]++; paths[path]++; status[substr(st,1,1) "xx"]++; if (st=="404") nf[path]++;
|
|
701
|
+
if (pat != "" && index(tolower($0), tolower(pat))) { ph++; phip[ip]++ } }
|
|
702
|
+
END { printf "{\"requests\":%d", n;
|
|
703
|
+
printf ",\"status\":{"; s=""; for (k in status) { printf "%s\"%s\":%d", s, k, status[k]; s="," } printf "}";
|
|
704
|
+
printf ",\"top_ips\":["; s=""; c=0; for (k in ips) if (ips[k] > n/200 || c < 10) { printf "%s[\"%s\",%d]", s, k, ips[k]; s=","; c++ } printf "]";
|
|
705
|
+
printf ",\"top_paths\":["; s=""; c=0; for (k in paths) if (paths[k] > n/200 || c < 10) { gsub(/["\\]/, "", k); printf "%s[\"%s\",%d]", s, k, paths[k]; s=","; c++ } printf "]";
|
|
706
|
+
printf ",\"pattern_hits\":%d}\n", ph }
|
|
707
|
+
'''
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def remote(ctx: core.Context):
|
|
711
|
+
args = ctx.args
|
|
712
|
+
alias, _, target = (args.ssh or "").partition("=")
|
|
713
|
+
if not target:
|
|
714
|
+
raise core.UsageError("--ssh ALIAS=user@host[:path-glob] is required")
|
|
715
|
+
core.require_consent(ctx.state, alias, "ssh-read")
|
|
716
|
+
host, _, remote_glob = target.partition(":")
|
|
717
|
+
remote_glob = remote_glob or (ctx.state.config().get("logs") or {}).get("access_path") or "/var/log/nginx/access.log*"
|
|
718
|
+
since = int((core.utcnow() - since_delta(ctx)).timestamp())
|
|
719
|
+
command = (f"find {' '.join(shlex.quote(p) for p in remote_glob.split())} -type f -newermt @{since} -print0 2>/dev/null | xargs -0 -r zcat -f"
|
|
720
|
+
f" | awk -v pat={shlex.quote(args.pattern or '')} {shlex.quote(REMOTE_AWK)}")
|
|
721
|
+
proc = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", host, command], capture_output=True, text=True, timeout=900, check=False)
|
|
722
|
+
try:
|
|
723
|
+
raw = json.loads(proc.stdout.strip().splitlines()[-1])
|
|
724
|
+
except (ValueError, IndexError):
|
|
725
|
+
raise core.DegradedError(f"ssh {alias}: no summary returned ({core.clean_excerpt(proc.stderr, 200)})")
|
|
726
|
+
total = raw.get("requests", 0)
|
|
727
|
+
summary = {"requests": total, "status": {k: round(v / total, 4) for k, v in raw.get("status", {}).items()} if total else {},
|
|
728
|
+
"top_ips": sorted(({"ip": ip, "hits": h} for ip, h in raw.get("top_ips", [])), key=lambda x: -x["hits"])[:10],
|
|
729
|
+
"top_paths": sorted(({"path": core.mask_query(p)[:200], "hits": h} for p, h in raw.get("top_paths", [])), key=lambda x: -x["hits"])[:10],
|
|
730
|
+
"pattern": args.pattern, "pattern_hits": raw.get("pattern_hits", 0), "note": "summary computed on the host; no log line left it"}
|
|
731
|
+
ctx.run["inputs"]["traffic"] = summary
|
|
732
|
+
ctx.run["inputs"]["remote_cleanup"] = "nothing written"
|
|
733
|
+
return ctx.finish([], covered=[])
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
# --------------------------------------------------------------------------- registration
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _common(p: argparse.ArgumentParser) -> None:
|
|
740
|
+
p.add_argument("--file", action="append", help="log file or glob (repeatable; .gz is fine)")
|
|
741
|
+
p.add_argument("--stdin", action="store_true")
|
|
742
|
+
p.add_argument("--ssh", help="ALIAS=user@host[:path-glob]; needs ssh-read consent")
|
|
743
|
+
p.add_argument("--vhost")
|
|
744
|
+
p.add_argument("--exclude-ip", action="append", help="CIDR to ignore (your office, uptime probes)")
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _traffic(p: argparse.ArgumentParser) -> None:
|
|
748
|
+
_common(p)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _sigma(p: argparse.ArgumentParser) -> None:
|
|
752
|
+
_common(p)
|
|
753
|
+
p.add_argument("--rules", help="groups: web,proxy,emerging (default web,proxy)")
|
|
754
|
+
p.add_argument("--rule-id", action="append")
|
|
755
|
+
p.add_argument("--min-level", choices=["informational", "low", "medium", "high", "critical"], default="medium")
|
|
756
|
+
p.add_argument("--samples", type=int, default=3)
|
|
757
|
+
p.add_argument("--list-rules", action="store_true")
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _ioc(p: argparse.ArgumentParser) -> None:
|
|
761
|
+
_common(p)
|
|
762
|
+
p.add_argument("--feeds", help=f"extra IP feeds (terms to be reviewed by you): {', '.join(IOC_FEEDS)}")
|
|
763
|
+
p.add_argument("--min-confidence", type=int, default=55)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _remote(p: argparse.ArgumentParser) -> None:
|
|
767
|
+
p.add_argument("--ssh", required=True)
|
|
768
|
+
p.add_argument("--pattern", help="count lines containing this text (e.g. /xmlrpc.php)")
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
COMMANDS = [
|
|
772
|
+
core.Command("logs-traffic", "traffic summary from access logs: requests, errors, top paths and addresses, spikes", ["dns"], _traffic, traffic),
|
|
773
|
+
core.Command("logs-sigma", "attack patterns in access logs (SigmaHQ web rules); attempts are never called break-ins without evidence", [], _sigma, sigma),
|
|
774
|
+
core.Command("logs-ioc", "known scanners, suspicious user agents and (opt-in) attacker IP lists in access logs",
|
|
775
|
+
[f"ioc-{i}" for i in IOC_FEEDS], _ioc, ioc),
|
|
776
|
+
core.Command("logs-remote", "summary of a server's access logs computed on the server (only the summary comes back)", [], _remote, remote),
|
|
777
|
+
]
|