@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.
Files changed (104) hide show
  1. package/README-zh-TW.md +25 -23
  2. package/README.md +93 -71
  3. package/bin/maggie.js +14 -2
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
  5. package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
  6. package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
  7. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
  8. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +2 -1
  9. package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
  10. package/bundled-contracts/maggie-security/finding-example-v1.json +89 -0
  11. package/bundled-contracts/maggie-security/finding-v1.schema.json +63 -0
  12. package/bundled-contracts/maggie-security/findings-set-example-v1.json +148 -0
  13. package/bundled-contracts/maggie-security/findings-set-v1.schema.json +32 -0
  14. package/bundled-contracts/maggie-security/protect-plan-v1.schema.json +28 -0
  15. package/bundled-contracts/maggie-security/report-v1.schema.json +29 -0
  16. package/bundled-contracts/maggie-security/stack-example-v1.json +66 -0
  17. package/bundled-contracts/maggie-security/stack-upload-v1.schema.json +22 -0
  18. package/bundled-contracts/maggie-security/stack-v1.schema.json +37 -0
  19. package/bundled-contracts/maggie-security/tools-lock-v1.schema.json +36 -0
  20. package/bundled-skills/catalog.json +5 -1
  21. package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
  22. package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
  23. package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
  24. package/bundled-skills/maggie-blog/SKILL.md +9 -2
  25. package/bundled-skills/maggie-booking/SKILL.md +1 -1
  26. package/bundled-skills/maggie-dash/SKILL.md +9 -0
  27. package/bundled-skills/maggie-deployment/SKILL.md +77 -1
  28. package/bundled-skills/maggie-deployment/references/vps.md +5 -0
  29. package/bundled-skills/maggie-marketplace/SKILL.md +21 -1
  30. package/bundled-skills/maggie-ops/SKILL.md +28 -0
  31. package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
  32. package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
  33. package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
  34. package/bundled-skills/maggie-security/SKILL.md +172 -0
  35. package/bundled-skills/maggie-security/agents/openai.yaml +4 -0
  36. package/bundled-skills/maggie-security/data/LICENSE-NOTICE.md +24 -0
  37. package/bundled-skills/maggie-security/data/asvs-map.json +209 -0
  38. package/bundled-skills/maggie-security/data/fail2ban/filter.d/maggie-wordpress.conf +6 -0
  39. package/bundled-skills/maggie-security/data/semgrep/LICENSE-NOTICE.md +5 -0
  40. package/bundled-skills/maggie-security/data/semgrep/VERSION +1 -0
  41. package/bundled-skills/maggie-security/data/semgrep/rules/dockerfile.yaml +14 -0
  42. package/bundled-skills/maggie-security/data/semgrep/rules/generic.yaml +26 -0
  43. package/bundled-skills/maggie-security/data/semgrep/rules/js.yaml +216 -0
  44. package/bundled-skills/maggie-security/data/semgrep/rules/php-debug-ok.yaml +17 -0
  45. package/bundled-skills/maggie-security/data/semgrep/rules/php-debug.yaml +17 -0
  46. package/bundled-skills/maggie-security/data/semgrep/rules/php.yaml +101 -0
  47. package/bundled-skills/maggie-security/data/semgrep/tests/dockerfile.dockerfile +10 -0
  48. package/bundled-skills/maggie-security/data/semgrep/tests/generic.astro +11 -0
  49. package/bundled-skills/maggie-security/data/semgrep/tests/generic.conf +11 -0
  50. package/bundled-skills/maggie-security/data/semgrep/tests/js.jsx +95 -0
  51. package/bundled-skills/maggie-security/data/semgrep/tests/php-debug-ok.php +5 -0
  52. package/bundled-skills/maggie-security/data/semgrep/tests/php-debug.php +3 -0
  53. package/bundled-skills/maggie-security/data/semgrep/tests/php.php +55 -0
  54. package/bundled-skills/maggie-security/evals/evals.json +97 -0
  55. package/bundled-skills/maggie-security/references/commands.md +111 -0
  56. package/bundled-skills/maggie-security/references/cve-response.md +24 -0
  57. package/bundled-skills/maggie-security/references/explaining-findings.md +57 -0
  58. package/bundled-skills/maggie-security/references/findings.md +39 -0
  59. package/bundled-skills/maggie-security/references/ownership-and-consent.md +29 -0
  60. package/bundled-skills/maggie-security/references/site-types.md +22 -0
  61. package/bundled-skills/maggie-security/references/tools-and-licences.md +16 -0
  62. package/bundled-skills/maggie-security/references/workflows.md +23 -0
  63. package/bundled-skills/maggie-security/registry/security-tools.registry.json +628 -0
  64. package/bundled-skills/maggie-security/routines.example.json +27 -0
  65. package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
  66. package/bundled-skills/maggie-social-share/SKILL.md +20 -0
  67. package/bundled-tools/clis/maggie.py +63 -3
  68. package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
  69. package/bundled-tools/clis/maggie_deployment_readiness.py +17 -2
  70. package/bundled-tools/clis/maggie_host_runtime.py +458 -0
  71. package/bundled-tools/clis/maggie_live_urls.py +11 -2
  72. package/bundled-tools/clis/maggie_ops.py +2 -0
  73. package/bundled-tools/clis/maggie_ops_routines.py +352 -0
  74. package/bundled-tools/clis/maggie_query_ideas.py +154 -0
  75. package/bundled-tools/clis/maggie_security.py +342 -0
  76. package/bundled-tools/clis/maggie_security_agent.py +326 -0
  77. package/bundled-tools/clis/maggie_security_code.py +650 -0
  78. package/bundled-tools/clis/maggie_security_core.py +1512 -0
  79. package/bundled-tools/clis/maggie_security_deps.py +560 -0
  80. package/bundled-tools/clis/maggie_security_health.py +415 -0
  81. package/bundled-tools/clis/maggie_security_host.py +511 -0
  82. package/bundled-tools/clis/maggie_security_hosted.py +115 -0
  83. package/bundled-tools/clis/maggie_security_intel.py +1294 -0
  84. package/bundled-tools/clis/maggie_security_inventory.py +1023 -0
  85. package/bundled-tools/clis/maggie_security_logs.py +777 -0
  86. package/bundled-tools/clis/maggie_security_mcp.py +473 -0
  87. package/bundled-tools/clis/maggie_security_mirror.py +122 -0
  88. package/bundled-tools/clis/maggie_security_protect.py +519 -0
  89. package/bundled-tools/clis/maggie_security_report.py +506 -0
  90. package/bundled-tools/clis/maggie_security_standards.py +214 -0
  91. package/bundled-tools/clis/maggie_security_versions.py +381 -0
  92. package/bundled-tools/clis/maggie_security_web.py +936 -0
  93. package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
  94. package/bundled-tools/clis/maggie_social_share.py +101 -1
  95. package/bundled-tools/clis/maggie_version_sources.py +93 -0
  96. package/bundled-tools/clis/security_data/eol-snapshot.json +1 -0
  97. package/bundled-tools/clis/security_data/gitleaks-rules.json +3812 -0
  98. package/bundled-tools/clis/security_data/maltrail-lists.json +1 -0
  99. package/bundled-tools/clis/security_data/public-suffix-icann.txt +7374 -0
  100. package/bundled-tools/clis/security_data/retire-extractors.json +695 -0
  101. package/bundled-tools/clis/security_data/sigma-web.json +6514 -0
  102. package/bundled-tools/clis/security_data/tools.lock.json +358 -0
  103. package/bundled-tools/integrations/maggie-api-pull.md +28 -1
  104. 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
+ ]