phpwarden 2.0.0__py3-none-any.whl
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.
- phpwarden/__init__.py +8 -0
- phpwarden/cli.py +448 -0
- phpwarden/data/php-cve-feed.json +427709 -0
- phpwarden/deps.py +283 -0
- phpwarden/detect.py +194 -0
- phpwarden/engine.py +137 -0
- phpwarden/env.py +82 -0
- phpwarden/feed.py +213 -0
- phpwarden/guard.py +792 -0
- phpwarden/report.py +206 -0
- phpwarden/rules.py +152 -0
- phpwarden/version.py +110 -0
- phpwarden/vulndb.py +186 -0
- phpwarden/wordpress.py +155 -0
- phpwarden-2.0.0.dist-info/METADATA +177 -0
- phpwarden-2.0.0.dist-info/RECORD +20 -0
- phpwarden-2.0.0.dist-info/WHEEL +5 -0
- phpwarden-2.0.0.dist-info/entry_points.txt +3 -0
- phpwarden-2.0.0.dist-info/licenses/LICENSE +202 -0
- phpwarden-2.0.0.dist-info/top_level.txt +1 -0
phpwarden/guard.py
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""php2shell-guard — autonomous-defense daemon.
|
|
3
|
+
|
|
4
|
+
Implements the Claude<->Grok build contract in DESIGN.md.
|
|
5
|
+
|
|
6
|
+
Build order honored: status + self-probe + census + killswitch, THEN the
|
|
7
|
+
actuation half (pid-join parser, worker-kill / pool-recycle, breaker counters).
|
|
8
|
+
|
|
9
|
+
Safety model (do not weaken without re-reading DESIGN.md invariants):
|
|
10
|
+
* Boot mode is DRYRUN. Reaching ENFORCE requires an operator ack file AND the
|
|
11
|
+
P1-P7 promotion guards AND the --allow-enforce flag. Absent any of these the
|
|
12
|
+
destructive os.kill / recycle paths are unreachable.
|
|
13
|
+
* "daemon up" != "protected": detection_claim is LIVE only when pid-join is
|
|
14
|
+
honestly armed; missing plumbing => REFUSED.
|
|
15
|
+
* Killswitch ACTIVE forces DRYRUN (fail-open on kills) but census, canary,
|
|
16
|
+
alerting, would_have recording and the deploy gate keep running.
|
|
17
|
+
* Breakers cap actuation so the daemon cannot self-DoS the fleet.
|
|
18
|
+
* v1 never edits FPM config; it only reads and recommends.
|
|
19
|
+
|
|
20
|
+
Zero dependencies (Python 3.8+, stdlib only). Self-contained version census.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import datetime as _dt
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import signal
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
from collections import deque
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
# --------------------------------------------------------------------------- #
|
|
37
|
+
# Version census (self-contained): parse a PHP version and classify it against
|
|
38
|
+
# the php2shell-class fix releases. Status one of PATCHED / VULNERABLE / EOL /
|
|
39
|
+
# UNKNOWN — the guard only actuates on a non-PATCHED engine (invariant 7).
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
_PRERELEASE = re.compile(r"(?i)(?:-|~)?(alpha|beta|rc|dev|snapshot)")
|
|
42
|
+
# First fixed release per branch for the php2shell worker-poisoning CVEs.
|
|
43
|
+
PATCHED_MIN = {
|
|
44
|
+
(8, 5): (8, 5, 12),
|
|
45
|
+
(8, 4): (8, 4, 27),
|
|
46
|
+
(8, 3): (8, 3, 36),
|
|
47
|
+
(8, 2): (8, 2, 35),
|
|
48
|
+
}
|
|
49
|
+
NEWEST_KNOWN_BRANCH = (8, 5)
|
|
50
|
+
CVES = "CVE-2026-109618, CVE-2026-109619"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_version(text: str):
|
|
54
|
+
"""Extract (major, minor, patch, is_prerelease) from a PHP version string,
|
|
55
|
+
or None. A prerelease sorts below the final release of the same X.Y.Z."""
|
|
56
|
+
if not text:
|
|
57
|
+
return None
|
|
58
|
+
m = re.search(r"(\d+)\.(\d+)\.(\d+)", text)
|
|
59
|
+
if not m:
|
|
60
|
+
return None
|
|
61
|
+
major, minor, patch = (int(m.group(i)) for i in (1, 2, 3))
|
|
62
|
+
return major, minor, patch, bool(_PRERELEASE.search(text[m.end():]))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def classify(version_tuple):
|
|
66
|
+
"""Return (status, message). status: PATCHED / VULNERABLE / EOL / UNKNOWN."""
|
|
67
|
+
if version_tuple is None:
|
|
68
|
+
return "UNKNOWN", "Could not parse a PHP version (X.Y.Z)."
|
|
69
|
+
major, minor, patch, is_pre = version_tuple
|
|
70
|
+
branch, ver = (major, minor), (major, minor, patch)
|
|
71
|
+
label = f"{major}.{minor}.{patch}" + (" (prerelease)" if is_pre else "")
|
|
72
|
+
if branch > NEWEST_KNOWN_BRANCH:
|
|
73
|
+
if is_pre:
|
|
74
|
+
return "UNKNOWN", f"{label}: prerelease on a branch newer than known; confirm it includes {CVES}."
|
|
75
|
+
return "PATCHED", f"{label}: branch newer than the vulnerable set; fixes included upstream."
|
|
76
|
+
if branch in PATCHED_MIN:
|
|
77
|
+
min_safe = PATCHED_MIN[branch]
|
|
78
|
+
safe = ".".join(map(str, min_safe))
|
|
79
|
+
if ver > min_safe or (ver == min_safe and not is_pre):
|
|
80
|
+
return "PATCHED", f"{label}: at or above the fixed release {safe} for {major}.{minor}."
|
|
81
|
+
return "VULNERABLE", f"{label}: below the fixed release {safe} for {major}.{minor}. Update now. ({CVES})"
|
|
82
|
+
return "EOL", f"{label}: end-of-life branch, no fix available. ({CVES})"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
UTC = _dt.timezone.utc
|
|
86
|
+
STATE_DIR_DEFAULT = "data/php2shell-guard"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def now_iso() -> str:
|
|
90
|
+
return _dt.datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _epoch() -> float:
|
|
94
|
+
return time.time()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# --------------------------------------------------------------------------- #
|
|
98
|
+
# Killswitch
|
|
99
|
+
# --------------------------------------------------------------------------- #
|
|
100
|
+
KILLSWITCH_SEARCH = [
|
|
101
|
+
"/etc/php2shell-guard/killswitch.json",
|
|
102
|
+
"data/php2shell-guard/killswitch.json",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_killswitch():
|
|
107
|
+
paths = list(KILLSWITCH_SEARCH)
|
|
108
|
+
env = os.environ.get("PHP2SHELL_KILLSWITCH_PATH")
|
|
109
|
+
if env:
|
|
110
|
+
paths.append(env)
|
|
111
|
+
for p in paths:
|
|
112
|
+
fp = Path(p)
|
|
113
|
+
if not fp.exists():
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
data = json.loads(fp.read_text())
|
|
117
|
+
except (OSError, ValueError):
|
|
118
|
+
return "ACTIVE", {"reason": "killswitch_corrupt_failsafe", "path": p}
|
|
119
|
+
if data.get("tripped"):
|
|
120
|
+
return "ACTIVE", {"reason": data.get("reason", ""), "path": p,
|
|
121
|
+
"scopes": data.get("scopes", {})}
|
|
122
|
+
if data.get("hold"):
|
|
123
|
+
return "HOLD", {"reason": data.get("reason", ""), "path": p}
|
|
124
|
+
return "OFF", {"path": p}
|
|
125
|
+
return "OFF", {"path": None}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --------------------------------------------------------------------------- #
|
|
129
|
+
# Version census (deterministic)
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
def census(pool: dict):
|
|
132
|
+
ver_text = pool.get("php_version")
|
|
133
|
+
if not ver_text:
|
|
134
|
+
binary = pool.get("php_binary", "php-fpm")
|
|
135
|
+
try:
|
|
136
|
+
out = subprocess.run([binary, "-v"], capture_output=True, text=True,
|
|
137
|
+
timeout=10, check=False)
|
|
138
|
+
lines = (out.stdout or out.stderr).splitlines()
|
|
139
|
+
ver_text = lines[0] if lines else None
|
|
140
|
+
except (OSError, subprocess.SubprocessError):
|
|
141
|
+
ver_text = None
|
|
142
|
+
parsed = parse_version(ver_text) if ver_text else None
|
|
143
|
+
status, _msg = classify(parsed)
|
|
144
|
+
ver = ".".join(map(str, parsed[:3])) if parsed else None
|
|
145
|
+
return status, ver
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# --------------------------------------------------------------------------- #
|
|
149
|
+
# Log cursors: read only new bytes each tick, survive restarts
|
|
150
|
+
# --------------------------------------------------------------------------- #
|
|
151
|
+
class LogCursor:
|
|
152
|
+
"""Tails a file by remembering a byte offset. Handles truncation/rotation."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, path: str, offset: int = 0, inode: int = 0):
|
|
155
|
+
self.path = path
|
|
156
|
+
self.offset = offset
|
|
157
|
+
self.inode = inode
|
|
158
|
+
|
|
159
|
+
def to_dict(self):
|
|
160
|
+
return {"path": self.path, "offset": self.offset, "inode": self.inode}
|
|
161
|
+
|
|
162
|
+
def read_new(self):
|
|
163
|
+
fp = Path(self.path)
|
|
164
|
+
if not fp.exists() or not os.access(self.path, os.R_OK):
|
|
165
|
+
return []
|
|
166
|
+
st = fp.stat()
|
|
167
|
+
# rotation (new inode) or truncation (shrunk) -> restart from 0
|
|
168
|
+
if st.st_ino != self.inode or st.st_size < self.offset:
|
|
169
|
+
self.offset = 0
|
|
170
|
+
self.inode = st.st_ino
|
|
171
|
+
try:
|
|
172
|
+
with fp.open("r", errors="replace") as fh:
|
|
173
|
+
fh.seek(self.offset)
|
|
174
|
+
data = fh.read()
|
|
175
|
+
self.offset = fh.tell()
|
|
176
|
+
except OSError:
|
|
177
|
+
return []
|
|
178
|
+
return data.splitlines()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# --------------------------------------------------------------------------- #
|
|
182
|
+
# pid-join parser + correlator
|
|
183
|
+
# --------------------------------------------------------------------------- #
|
|
184
|
+
# Access-log parsing. A pool may override with "access_log_regex" (named groups:
|
|
185
|
+
# pid, status). Otherwise we find the worker pid and the response status with two
|
|
186
|
+
# independent searches, which is robust to field order and log format. Multipart
|
|
187
|
+
# is sniffed from a logged Content-Type.
|
|
188
|
+
_ACCESS_PID_RE = re.compile(r'(?:upstream_pid|\bpid)[=:\s]+(\d+)|\bchild\s+(\d+)', re.I)
|
|
189
|
+
_ACCESS_STATUS_RE = re.compile(r'"\s*[A-Z]+[^"]*"\s+(\d{3})')
|
|
190
|
+
# FPM error/catch_workers_output: child pid + OOM/fatal markers.
|
|
191
|
+
_FATAL_PID_RE = re.compile(r"child\s+(\d+)", re.I)
|
|
192
|
+
_OOM_RE = re.compile(
|
|
193
|
+
r"allowed memory size|memory_limit|exhausted|exited on signal\s*(?:9|11)|SIGKILL|SIGSEGV|"
|
|
194
|
+
r"out of memory|oom",
|
|
195
|
+
re.I,
|
|
196
|
+
)
|
|
197
|
+
_MULTIPART_RE = re.compile(r"multipart/form-data", re.I)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_access_line(line: str, custom_re: re.Pattern = None):
|
|
201
|
+
"""Return dict(pid, status, multipart) or None."""
|
|
202
|
+
if custom_re is not None:
|
|
203
|
+
m = custom_re.search(line)
|
|
204
|
+
if not m:
|
|
205
|
+
return None
|
|
206
|
+
gd = m.groupdict()
|
|
207
|
+
pid, status = gd.get("pid"), gd.get("status")
|
|
208
|
+
if status is None:
|
|
209
|
+
return None
|
|
210
|
+
return {
|
|
211
|
+
"pid": int(pid) if pid and str(pid).isdigit() else None,
|
|
212
|
+
"status": int(status),
|
|
213
|
+
"multipart": bool(_MULTIPART_RE.search(line)),
|
|
214
|
+
}
|
|
215
|
+
sm = _ACCESS_STATUS_RE.search(line)
|
|
216
|
+
if not sm:
|
|
217
|
+
return None
|
|
218
|
+
pm = _ACCESS_PID_RE.search(line)
|
|
219
|
+
pid = (pm.group(1) or pm.group(2)) if pm else None
|
|
220
|
+
return {
|
|
221
|
+
"pid": int(pid) if pid else None,
|
|
222
|
+
"status": int(sm.group(1)),
|
|
223
|
+
"multipart": bool(_MULTIPART_RE.search(line)),
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def parse_fatal_line(line: str):
|
|
228
|
+
"""Return (pid, kind) when the line is an OOM/fatal with a worker pid."""
|
|
229
|
+
if not _OOM_RE.search(line):
|
|
230
|
+
return None
|
|
231
|
+
m = _FATAL_PID_RE.search(line)
|
|
232
|
+
if not m:
|
|
233
|
+
return None
|
|
234
|
+
return int(m.group(1)), "oom_or_fatal"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class JoinCorrelator:
|
|
238
|
+
"""Core detector: fatal/OOM on worker P, then anomalous reuse of P.
|
|
239
|
+
|
|
240
|
+
Emits a suspicion event when, within `window_s` of a fatal on pid P, that
|
|
241
|
+
same pid P serves requests showing the aftermath (500s, or a multipart tie),
|
|
242
|
+
i.e. the worker did not die clean and is being reused.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(self, window_s: int = 120, min_anomalies: int = 2):
|
|
246
|
+
self.window_s = window_s
|
|
247
|
+
self.min_anomalies = min_anomalies
|
|
248
|
+
self.last_fatal = {} # pid -> epoch of last fatal
|
|
249
|
+
self.anomaly_count = {} # pid -> count since fatal
|
|
250
|
+
|
|
251
|
+
def feed_fatal(self, pid: int, ts: float):
|
|
252
|
+
self.last_fatal[pid] = ts
|
|
253
|
+
self.anomaly_count[pid] = 0
|
|
254
|
+
|
|
255
|
+
def feed_access(self, rec: dict, ts: float):
|
|
256
|
+
pid = rec.get("pid")
|
|
257
|
+
if pid is None or pid not in self.last_fatal:
|
|
258
|
+
return None
|
|
259
|
+
if ts - self.last_fatal[pid] > self.window_s:
|
|
260
|
+
# stale; forget it
|
|
261
|
+
self.last_fatal.pop(pid, None)
|
|
262
|
+
self.anomaly_count.pop(pid, None)
|
|
263
|
+
return None
|
|
264
|
+
anomalous = rec["status"] >= 500 or rec["multipart"]
|
|
265
|
+
if anomalous:
|
|
266
|
+
self.anomaly_count[pid] = self.anomaly_count.get(pid, 0) + 1
|
|
267
|
+
if self.anomaly_count[pid] >= self.min_anomalies:
|
|
268
|
+
evidence = {
|
|
269
|
+
"pid": pid,
|
|
270
|
+
"fatal_age_s": round(ts - self.last_fatal[pid], 1),
|
|
271
|
+
"anomalies": self.anomaly_count[pid],
|
|
272
|
+
}
|
|
273
|
+
# one event per fatal episode
|
|
274
|
+
self.last_fatal.pop(pid, None)
|
|
275
|
+
self.anomaly_count.pop(pid, None)
|
|
276
|
+
return {"pid": pid, "reason": "oom_then_reuse", "evidence": evidence}
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def run_pid_join(pool: dict, cursors: dict, correlator: JoinCorrelator):
|
|
281
|
+
"""Read new access + fatal lines, return list of suspicion events."""
|
|
282
|
+
events = []
|
|
283
|
+
custom = pool.get("access_log_regex")
|
|
284
|
+
custom_re = re.compile(custom) if custom else None
|
|
285
|
+
|
|
286
|
+
fatal_path = pool.get("fatal_log") or pool.get("error_log")
|
|
287
|
+
if fatal_path:
|
|
288
|
+
fc = cursors.setdefault("fatal:" + fatal_path, LogCursor(fatal_path))
|
|
289
|
+
for line in fc.read_new():
|
|
290
|
+
parsed = parse_fatal_line(line)
|
|
291
|
+
if parsed:
|
|
292
|
+
correlator.feed_fatal(parsed[0], _epoch())
|
|
293
|
+
|
|
294
|
+
acc_path = pool.get("access_log")
|
|
295
|
+
if acc_path:
|
|
296
|
+
ac = cursors.setdefault("access:" + acc_path, LogCursor(acc_path))
|
|
297
|
+
for line in ac.read_new():
|
|
298
|
+
rec = parse_access_line(line, custom_re)
|
|
299
|
+
if rec:
|
|
300
|
+
ev = correlator.feed_access(rec, _epoch())
|
|
301
|
+
if ev:
|
|
302
|
+
ev["pool"] = pool.get("pool")
|
|
303
|
+
events.append(ev)
|
|
304
|
+
return events
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --------------------------------------------------------------------------- #
|
|
308
|
+
# Circuit breakers
|
|
309
|
+
# --------------------------------------------------------------------------- #
|
|
310
|
+
class Breakers:
|
|
311
|
+
"""Rolling-window caps so actuation cannot self-DoS the fleet."""
|
|
312
|
+
|
|
313
|
+
WINDOW = 600 # 10 minutes
|
|
314
|
+
RECYCLE_PER_POOL = 1
|
|
315
|
+
RECYCLE_PER_HOST = 3
|
|
316
|
+
KILL_PER_POOL = 2
|
|
317
|
+
KILL_FRACTION_PER_TICK = 0.25
|
|
318
|
+
COOLDOWN_AFTER_RECYCLE = 120
|
|
319
|
+
|
|
320
|
+
def __init__(self, state: dict):
|
|
321
|
+
self.actions = deque(state.get("actions", [])) # list of {ts,scope,type}
|
|
322
|
+
self.frozen_until = state.get("frozen_until")
|
|
323
|
+
self.frozen_reason = state.get("frozen_reason")
|
|
324
|
+
|
|
325
|
+
def to_dict(self):
|
|
326
|
+
self._evict()
|
|
327
|
+
return {
|
|
328
|
+
"actions": list(self.actions),
|
|
329
|
+
"frozen_until": self.frozen_until,
|
|
330
|
+
"frozen_reason": self.frozen_reason,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
def _evict(self):
|
|
334
|
+
cut = _epoch() - self.WINDOW
|
|
335
|
+
while self.actions and self.actions[0]["ts"] < cut:
|
|
336
|
+
self.actions.popleft()
|
|
337
|
+
|
|
338
|
+
def _count(self, type_=None, scope=None):
|
|
339
|
+
self._evict()
|
|
340
|
+
return sum(1 for a in self.actions
|
|
341
|
+
if (type_ is None or a["type"] == type_)
|
|
342
|
+
and (scope is None or a["scope"] == scope))
|
|
343
|
+
|
|
344
|
+
def status(self):
|
|
345
|
+
if self.frozen_until and _epoch() < self.frozen_until:
|
|
346
|
+
return f"FROZEN:{self.frozen_reason}"
|
|
347
|
+
return "OK"
|
|
348
|
+
|
|
349
|
+
def freeze(self, reason: str, seconds: int = WINDOW):
|
|
350
|
+
self.frozen_until = _epoch() + seconds
|
|
351
|
+
self.frozen_reason = reason
|
|
352
|
+
|
|
353
|
+
def allow_recycle(self, pool: str, deploy_guarded: bool):
|
|
354
|
+
if self.status() != "OK":
|
|
355
|
+
return False, self.status()
|
|
356
|
+
if deploy_guarded:
|
|
357
|
+
return False, "deploy_or_load_guard"
|
|
358
|
+
# cooldown: no actuation on a pool within 120s of its last recycle
|
|
359
|
+
last = [a for a in self.actions
|
|
360
|
+
if a["type"] == "POOL_RECYCLE" and a["scope"] == f"pool:{pool}"]
|
|
361
|
+
if last and _epoch() - last[-1]["ts"] < self.COOLDOWN_AFTER_RECYCLE:
|
|
362
|
+
return False, "cooldown"
|
|
363
|
+
if self._count("POOL_RECYCLE", f"pool:{pool}") >= self.RECYCLE_PER_POOL:
|
|
364
|
+
return False, "pool_recycle_cap"
|
|
365
|
+
if self._count("POOL_RECYCLE", "host") >= self.RECYCLE_PER_HOST:
|
|
366
|
+
self.freeze("fleet_recycle_cap")
|
|
367
|
+
return False, "fleet_frozen"
|
|
368
|
+
return True, "ok"
|
|
369
|
+
|
|
370
|
+
def allow_kill(self, pool: str, workers: int, killed_this_tick: int, deploy_guarded: bool):
|
|
371
|
+
if self.status() != "OK":
|
|
372
|
+
return False, self.status()
|
|
373
|
+
if deploy_guarded:
|
|
374
|
+
return False, "deploy_or_load_guard"
|
|
375
|
+
if self._count("WORKER_KILL", f"pool:{pool}") >= self.KILL_PER_POOL:
|
|
376
|
+
return False, "pool_kill_cap"
|
|
377
|
+
max_this_tick = max(1, int(workers * self.KILL_FRACTION_PER_TICK))
|
|
378
|
+
if killed_this_tick >= max_this_tick:
|
|
379
|
+
return False, "tick_fraction_cap"
|
|
380
|
+
return True, "ok"
|
|
381
|
+
|
|
382
|
+
def record(self, type_: str, pool: str):
|
|
383
|
+
ts = _epoch()
|
|
384
|
+
self.actions.append({"ts": ts, "scope": f"pool:{pool}", "type": type_})
|
|
385
|
+
self.actions.append({"ts": ts, "scope": "host", "type": type_})
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def deploy_guard_active(pool: dict) -> bool:
|
|
389
|
+
"""Suppress actuation during deploys / load spikes (contract: deploy/load guard)."""
|
|
390
|
+
lock = pool.get("deploy_lock", "data/php2shell-guard/deploy.lock")
|
|
391
|
+
fresh = 300
|
|
392
|
+
if lock and Path(lock).exists():
|
|
393
|
+
try:
|
|
394
|
+
if _epoch() - Path(lock).stat().st_mtime < fresh:
|
|
395
|
+
return True
|
|
396
|
+
except OSError:
|
|
397
|
+
pass
|
|
398
|
+
# conf mtime burst: a just-reloaded pool is churning for benign reasons
|
|
399
|
+
conf = pool.get("fpm_conf")
|
|
400
|
+
if conf and Path(conf).exists():
|
|
401
|
+
try:
|
|
402
|
+
if _epoch() - Path(conf).stat().st_mtime < fresh:
|
|
403
|
+
return True
|
|
404
|
+
except OSError:
|
|
405
|
+
pass
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# --------------------------------------------------------------------------- #
|
|
410
|
+
# Actuators (reachable only in ENFORCE with all guards satisfied)
|
|
411
|
+
# --------------------------------------------------------------------------- #
|
|
412
|
+
def actuate_worker_kill(pid: int) -> dict:
|
|
413
|
+
"""Signal a single poisoned worker; the FPM master respawns it clean."""
|
|
414
|
+
try:
|
|
415
|
+
os.kill(pid, signal.SIGTERM)
|
|
416
|
+
return {"ok": True}
|
|
417
|
+
except OSError as e:
|
|
418
|
+
return {"ok": False, "error": str(e)}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def actuate_pool_recycle(pool: dict) -> dict:
|
|
422
|
+
"""Graceful pool recycle. Prefers a configured command, else SIGUSR2 master."""
|
|
423
|
+
cmd = pool.get("recycle_command")
|
|
424
|
+
if cmd:
|
|
425
|
+
try:
|
|
426
|
+
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
|
|
427
|
+
return {"ok": r.returncode == 0, "rc": r.returncode, "err": r.stderr[:200]}
|
|
428
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
429
|
+
return {"ok": False, "error": str(e)}
|
|
430
|
+
master = pool.get("master_pid")
|
|
431
|
+
if master:
|
|
432
|
+
try:
|
|
433
|
+
os.kill(int(master), signal.SIGUSR2) # FPM graceful reload
|
|
434
|
+
return {"ok": True, "method": "SIGUSR2"}
|
|
435
|
+
except (OSError, ValueError) as e:
|
|
436
|
+
return {"ok": False, "error": str(e)}
|
|
437
|
+
return {"ok": False, "error": "no_recycle_command_or_master_pid"}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# --------------------------------------------------------------------------- #
|
|
441
|
+
# Promotion guards P1-P7 (never auto-promote; human + ack + flag required)
|
|
442
|
+
# --------------------------------------------------------------------------- #
|
|
443
|
+
def promotion_guards(pool_status: dict, killswitch_state: str, ack: dict):
|
|
444
|
+
"""Return (ok, failed[]). P6 (72h clean or ack) is operator-driven."""
|
|
445
|
+
failed = []
|
|
446
|
+
if pool_status["census"] in ("EOL", "UNKNOWN"): # P1
|
|
447
|
+
failed.append("P1_census")
|
|
448
|
+
if not pool_status["max_requests_ok"]: # P2
|
|
449
|
+
failed.append("P2_max_requests")
|
|
450
|
+
if pool_status["pid_join"] != "ARMED": # P3
|
|
451
|
+
failed.append("P3_pid_join")
|
|
452
|
+
if pool_status["canary"] not in ("OK",): # P4
|
|
453
|
+
failed.append("P4_canary_baseline")
|
|
454
|
+
if killswitch_state != "OFF": # P5
|
|
455
|
+
failed.append("P5_killswitch")
|
|
456
|
+
if not ack.get(pool_status["pool"]): # P6
|
|
457
|
+
failed.append("P6_burnin_or_ack")
|
|
458
|
+
if not pool_status.get("audit_ok", True): # P7
|
|
459
|
+
failed.append("P7_audit")
|
|
460
|
+
return (not failed), failed
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# --------------------------------------------------------------------------- #
|
|
464
|
+
# Self-probe
|
|
465
|
+
# --------------------------------------------------------------------------- #
|
|
466
|
+
_PID_FIELD = re.compile(r"(?:\$?upstream_pid|%p|\bpid[=:]\s*\d+|child\s+\d+)", re.I)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def probe_max_requests(pool: dict):
|
|
470
|
+
conf = pool.get("fpm_conf")
|
|
471
|
+
if not conf or not Path(conf).exists():
|
|
472
|
+
return None, False
|
|
473
|
+
try:
|
|
474
|
+
text = Path(conf).read_text(errors="replace")
|
|
475
|
+
except OSError:
|
|
476
|
+
return None, False
|
|
477
|
+
m = re.search(r"^\s*pm\.max_requests\s*=\s*(\d+)", text, re.M)
|
|
478
|
+
if not m:
|
|
479
|
+
return None, False
|
|
480
|
+
val = int(m.group(1))
|
|
481
|
+
return val, (0 < val <= 500)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def probe_pid_join(pool: dict):
|
|
485
|
+
access_log = pool.get("access_log")
|
|
486
|
+
if not access_log:
|
|
487
|
+
return False, "no_access_log_configured"
|
|
488
|
+
fp = Path(access_log)
|
|
489
|
+
if not fp.exists() or not os.access(access_log, os.R_OK):
|
|
490
|
+
return False, "access_log_unreadable"
|
|
491
|
+
try:
|
|
492
|
+
with fp.open(errors="replace") as fh:
|
|
493
|
+
tail = fh.readlines()[-100:]
|
|
494
|
+
except OSError:
|
|
495
|
+
return False, "access_log_unreadable"
|
|
496
|
+
if not any(_PID_FIELD.search(line) for line in tail):
|
|
497
|
+
return False, "no_upstream_pid_field"
|
|
498
|
+
return True, None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def probe_signal_permission(pool: dict):
|
|
502
|
+
pid = pool.get("sample_worker_pid") or pool.get("master_pid")
|
|
503
|
+
if not pid:
|
|
504
|
+
return False, "no_sample_worker_pid"
|
|
505
|
+
try:
|
|
506
|
+
os.kill(int(pid), 0)
|
|
507
|
+
return True, None
|
|
508
|
+
except (OSError, ValueError) as e:
|
|
509
|
+
return False, f"no_signal_permission:{e.__class__.__name__}"
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def probe_audit_writable(audit_path: str):
|
|
513
|
+
try:
|
|
514
|
+
Path(audit_path).parent.mkdir(parents=True, exist_ok=True)
|
|
515
|
+
with open(audit_path, "a"):
|
|
516
|
+
pass
|
|
517
|
+
return True, None
|
|
518
|
+
except OSError:
|
|
519
|
+
return False, "audit_log_unwritable"
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def probe_canary(pool: dict):
|
|
523
|
+
url = pool.get("canary_url")
|
|
524
|
+
if not url:
|
|
525
|
+
return "DISABLED", {"reason": "no_canary_url"}
|
|
526
|
+
import urllib.request
|
|
527
|
+
try:
|
|
528
|
+
req = urllib.request.Request(url, headers={"User-Agent": "php2shell-guard-canary"})
|
|
529
|
+
with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
|
|
530
|
+
set_cookie = resp.headers.get("Set-Cookie", "")
|
|
531
|
+
except Exception as e: # noqa: BLE001
|
|
532
|
+
return "TRIPPED", {"reason": f"canary_request_failed:{e.__class__.__name__}"}
|
|
533
|
+
m = re.search(r"PHPSESSID=([^;]+)", set_cookie)
|
|
534
|
+
if not m:
|
|
535
|
+
return "OK", {"note": "no_phpsessid_in_response", "ts": now_iso()}
|
|
536
|
+
sid = m.group(1)
|
|
537
|
+
if len(sid) > 128 or not re.fullmatch(r"[A-Za-z0-9,\-]{8,96}", sid):
|
|
538
|
+
return "TRIPPED", {"reason": "phpsessid_grammar_violation", "len": len(sid)}
|
|
539
|
+
return "OK", {"ts": now_iso()}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# --------------------------------------------------------------------------- #
|
|
543
|
+
# Per-pool evaluation + actuation decision
|
|
544
|
+
# --------------------------------------------------------------------------- #
|
|
545
|
+
def evaluate_pool(pool, killswitch_state, audit_path, mode, cursors,
|
|
546
|
+
correlators, breakers, ack, enforce_allowed):
|
|
547
|
+
name = pool.get("pool", "default")
|
|
548
|
+
workers = int(pool.get("workers", 1))
|
|
549
|
+
census_status, php_version = census(pool)
|
|
550
|
+
mr_val, mr_ok = probe_max_requests(pool)
|
|
551
|
+
|
|
552
|
+
pid_partial, pid_reason = probe_pid_join(pool)
|
|
553
|
+
sig_ok, sig_reason = (True, None)
|
|
554
|
+
audit_ok, audit_reason = probe_audit_writable(audit_path)
|
|
555
|
+
if pid_partial:
|
|
556
|
+
sig_ok, sig_reason = probe_signal_permission(pool)
|
|
557
|
+
|
|
558
|
+
disarm = None
|
|
559
|
+
if killswitch_state != "OFF":
|
|
560
|
+
disarm = f"killswitch_{killswitch_state.lower()}"
|
|
561
|
+
elif not pid_partial:
|
|
562
|
+
disarm = pid_reason
|
|
563
|
+
elif not sig_ok:
|
|
564
|
+
disarm = sig_reason
|
|
565
|
+
elif not audit_ok:
|
|
566
|
+
disarm = audit_reason
|
|
567
|
+
elif not mr_ok:
|
|
568
|
+
disarm = "max_requests_not_ok"
|
|
569
|
+
pid_join = "ARMED" if disarm is None else f"DISARMED:{disarm}"
|
|
570
|
+
|
|
571
|
+
canary_state, canary_detail = probe_canary(pool)
|
|
572
|
+
canary_field = canary_state if canary_state in ("TRIPPED", "DISABLED") else "OK"
|
|
573
|
+
|
|
574
|
+
if pid_join == "ARMED" and canary_field == "OK":
|
|
575
|
+
claim = "LIVE"
|
|
576
|
+
elif pid_join == "ARMED" and canary_field == "DISABLED":
|
|
577
|
+
claim = "DEGRADED"
|
|
578
|
+
else:
|
|
579
|
+
claim = "REFUSED"
|
|
580
|
+
|
|
581
|
+
pstat = {
|
|
582
|
+
"pool": name, "php_version": php_version, "census": census_status,
|
|
583
|
+
"max_requests": mr_val, "max_requests_ok": mr_ok,
|
|
584
|
+
"pid_join": pid_join, "canary": canary_field, "canary_detail": canary_detail,
|
|
585
|
+
"detection_claim": claim, "audit_ok": audit_ok,
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
# --- detection: run the pid-join parser over new log lines ----------------
|
|
589
|
+
corr = correlators.setdefault(name, JoinCorrelator())
|
|
590
|
+
events = []
|
|
591
|
+
if pid_partial: # only meaningful if we can read logs at all
|
|
592
|
+
events = run_pid_join(pool, cursors, corr)
|
|
593
|
+
|
|
594
|
+
# canary TRIPPED demotes enforce + is itself an event (no kill from canary)
|
|
595
|
+
deploy_guarded = deploy_guard_active(pool)
|
|
596
|
+
would = compute_base_would_have(pool, census_status, claim, mr_ok)
|
|
597
|
+
killed_this_tick = 0
|
|
598
|
+
|
|
599
|
+
# effective mode for this pool: requires the P1-P7 promotion guards to pass,
|
|
600
|
+
# which includes the operator ack (P6). Automation can never self-promote.
|
|
601
|
+
guards_ok, failed_guards = promotion_guards(pstat, killswitch_state, ack)
|
|
602
|
+
pool_enforce = (mode == "ENFORCE" and enforce_allowed and guards_ok
|
|
603
|
+
and pid_join == "ARMED" and killswitch_state == "OFF"
|
|
604
|
+
and canary_field != "TRIPPED")
|
|
605
|
+
pstat["promotion_failed"] = failed_guards
|
|
606
|
+
|
|
607
|
+
for ev in events:
|
|
608
|
+
decision = decide_and_act(ev, pool, pstat, breakers, workers,
|
|
609
|
+
killed_this_tick, deploy_guarded, pool_enforce,
|
|
610
|
+
audit_path)
|
|
611
|
+
would.append(decision["ledger"])
|
|
612
|
+
if decision.get("killed"):
|
|
613
|
+
killed_this_tick += 1
|
|
614
|
+
|
|
615
|
+
if canary_field == "TRIPPED":
|
|
616
|
+
would.append({"action": "demote_to_dryrun", "detail": "canary tripped",
|
|
617
|
+
"executed": pool_enforce, "suppressed_by": None})
|
|
618
|
+
|
|
619
|
+
pstat.update({
|
|
620
|
+
"enforce_mode": "ENFORCE" if pool_enforce else "DRYRUN",
|
|
621
|
+
"killswitch": killswitch_state,
|
|
622
|
+
"breakers": breakers.status(),
|
|
623
|
+
"last_action": next((w for w in reversed(would) if w.get("executed")), None),
|
|
624
|
+
"would_have_actions": would,
|
|
625
|
+
"events": events,
|
|
626
|
+
})
|
|
627
|
+
return pstat
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def compute_base_would_have(pool, census_status, claim, mr_ok):
|
|
631
|
+
out = []
|
|
632
|
+
if census_status in ("VULNERABLE", "EOL", "UNKNOWN"):
|
|
633
|
+
out.append({"action": "alert", "detail": f"census={census_status}; patch + contain",
|
|
634
|
+
"executed": False, "suppressed_by": None})
|
|
635
|
+
if census_status in ("VULNERABLE", "EOL") and not mr_ok:
|
|
636
|
+
out.append({"action": "recommend_pm_max_requests",
|
|
637
|
+
"detail": "set pm.max_requests (e.g. 128 exposed); not auto-edited",
|
|
638
|
+
"executed": False, "suppressed_by": None})
|
|
639
|
+
if claim == "REFUSED":
|
|
640
|
+
out.append({"action": "note", "detail": "no pid-join; behavioral detectors cannot actuate",
|
|
641
|
+
"executed": False, "suppressed_by": "no_pid_join"})
|
|
642
|
+
return out
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def decide_and_act(ev, pool, pstat, breakers, workers, killed_this_tick,
|
|
646
|
+
deploy_guarded, pool_enforce, audit_path):
|
|
647
|
+
"""Choose WORKER_KILL (preferred) or escalate to POOL_RECYCLE, honoring breakers."""
|
|
648
|
+
pid = ev["pid"]
|
|
649
|
+
name = pstat["pool"]
|
|
650
|
+
# PATCHED census => behavioral detectors never kill (invariant 7)
|
|
651
|
+
if pstat["census"] == "PATCHED":
|
|
652
|
+
return {"ledger": {"action": "noop", "detail": "census PATCHED; no kill",
|
|
653
|
+
"event": ev, "executed": False, "suppressed_by": "census_patched"}}
|
|
654
|
+
|
|
655
|
+
allowed, why = breakers.allow_kill(name, workers, killed_this_tick, deploy_guarded)
|
|
656
|
+
if not allowed:
|
|
657
|
+
return {"ledger": {"action": "worker_kill", "pid": pid, "event": ev,
|
|
658
|
+
"executed": False, "suppressed_by": why}}
|
|
659
|
+
|
|
660
|
+
if not pool_enforce:
|
|
661
|
+
return {"ledger": {"action": "worker_kill", "pid": pid, "event": ev,
|
|
662
|
+
"executed": False, "suppressed_by": "dryrun"}}
|
|
663
|
+
|
|
664
|
+
# ENFORCE path (reachable only after promotion)
|
|
665
|
+
result = actuate_worker_kill(pid)
|
|
666
|
+
breakers.record("WORKER_KILL", name)
|
|
667
|
+
append_audit(audit_path, {"ts": now_iso(), "action": "WORKER_KILL",
|
|
668
|
+
"pool": name, "pid": pid, "event": ev, "result": result})
|
|
669
|
+
return {"killed": result.get("ok", False),
|
|
670
|
+
"ledger": {"action": "worker_kill", "pid": pid, "event": ev,
|
|
671
|
+
"executed": True, "result": result, "suppressed_by": None}}
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def append_audit(audit_path, record):
|
|
675
|
+
try:
|
|
676
|
+
with open(audit_path, "a") as fh:
|
|
677
|
+
fh.write(json.dumps(record) + "\n")
|
|
678
|
+
except OSError:
|
|
679
|
+
pass
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
# --------------------------------------------------------------------------- #
|
|
683
|
+
# State persistence (cursors + breakers)
|
|
684
|
+
# --------------------------------------------------------------------------- #
|
|
685
|
+
def load_state(state_dir):
|
|
686
|
+
fp = Path(state_dir) / "guard_state.json"
|
|
687
|
+
if fp.exists():
|
|
688
|
+
try:
|
|
689
|
+
return json.loads(fp.read_text())
|
|
690
|
+
except (OSError, ValueError):
|
|
691
|
+
return {}
|
|
692
|
+
return {}
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def save_state(state_dir, cursors, breakers):
|
|
696
|
+
fp = Path(state_dir) / "guard_state.json"
|
|
697
|
+
try:
|
|
698
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
699
|
+
fp.write_text(json.dumps({
|
|
700
|
+
"cursors": {k: c.to_dict() for k, c in cursors.items()},
|
|
701
|
+
"breakers": breakers.to_dict(),
|
|
702
|
+
}, indent=2))
|
|
703
|
+
except OSError:
|
|
704
|
+
pass
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# --------------------------------------------------------------------------- #
|
|
708
|
+
# Tick / loop
|
|
709
|
+
# --------------------------------------------------------------------------- #
|
|
710
|
+
def tick(config, mode, correlators, enforce_allowed):
|
|
711
|
+
state_dir = config.get("state_dir", STATE_DIR_DEFAULT)
|
|
712
|
+
audit_path = config.get("audit_log", str(Path(state_dir) / "actions.jsonl"))
|
|
713
|
+
ks_state, ks_detail = load_killswitch()
|
|
714
|
+
|
|
715
|
+
raw = load_state(state_dir)
|
|
716
|
+
cursors = {k: LogCursor(**v) for k, v in raw.get("cursors", {}).items()}
|
|
717
|
+
breakers = Breakers(raw.get("breakers", {}))
|
|
718
|
+
ack = config.get("enforce_ack", {}) # {pool: true} set by operator only
|
|
719
|
+
|
|
720
|
+
pools = []
|
|
721
|
+
for p in config.get("pools", []):
|
|
722
|
+
pstat = evaluate_pool(p, ks_state, audit_path, mode, cursors,
|
|
723
|
+
correlators, breakers, ack, enforce_allowed)
|
|
724
|
+
pools.append(pstat)
|
|
725
|
+
|
|
726
|
+
save_state(state_dir, cursors, breakers)
|
|
727
|
+
status = {
|
|
728
|
+
"ts": now_iso(),
|
|
729
|
+
"requested_mode": mode,
|
|
730
|
+
"enforce_allowed": enforce_allowed,
|
|
731
|
+
"killswitch": {"state": ks_state, **ks_detail},
|
|
732
|
+
"breakers": breakers.status(),
|
|
733
|
+
"pools": pools,
|
|
734
|
+
}
|
|
735
|
+
out = config.get("status_file", str(Path(state_dir) / "status.json"))
|
|
736
|
+
try:
|
|
737
|
+
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
|
738
|
+
Path(out).write_text(json.dumps(status, indent=2))
|
|
739
|
+
except OSError:
|
|
740
|
+
pass
|
|
741
|
+
return status
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def render(status):
|
|
745
|
+
print(f"php2shell-guard requested={status['requested_mode']} "
|
|
746
|
+
f"enforce_allowed={status['enforce_allowed']} "
|
|
747
|
+
f"killswitch={status['killswitch']['state']} breakers={status['breakers']} {status['ts']}")
|
|
748
|
+
for p in status["pools"]:
|
|
749
|
+
print(f" pool {p['pool']}: census={p['census']} php={p['php_version']} "
|
|
750
|
+
f"max_requests={p['max_requests']}({'ok' if p['max_requests_ok'] else 'NO'}) "
|
|
751
|
+
f"pid_join={p['pid_join']} canary={p['canary']} "
|
|
752
|
+
f"claim={p['detection_claim']} mode={p['enforce_mode']}")
|
|
753
|
+
for w in p["would_have_actions"]:
|
|
754
|
+
sup = f" [suppressed_by={w['suppressed_by']}]" if w.get("suppressed_by") else ""
|
|
755
|
+
ex = "EXECUTED " if w.get("executed") else "would: "
|
|
756
|
+
print(f" {ex}{w['action']} — {w.get('detail','')}{sup}")
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def main(argv=None):
|
|
760
|
+
ap = argparse.ArgumentParser(description="php2shell autonomous-defense daemon")
|
|
761
|
+
ap.add_argument("--config", required=True)
|
|
762
|
+
ap.add_argument("--json", action="store_true")
|
|
763
|
+
ap.add_argument("--loop", type=int, metavar="SECONDS")
|
|
764
|
+
ap.add_argument("--mode", choices=["DRYRUN", "ENFORCE"], default="DRYRUN",
|
|
765
|
+
help="requested mode; ENFORCE also needs --allow-enforce and passing P1-P7")
|
|
766
|
+
ap.add_argument("--allow-enforce", action="store_true",
|
|
767
|
+
help="operator safety latch; without it ENFORCE is downgraded to DRYRUN")
|
|
768
|
+
args = ap.parse_args(argv)
|
|
769
|
+
|
|
770
|
+
try:
|
|
771
|
+
config = json.loads(Path(args.config).read_text())
|
|
772
|
+
except (OSError, ValueError) as e:
|
|
773
|
+
print(f"config error: {e}", file=sys.stderr)
|
|
774
|
+
return 2
|
|
775
|
+
|
|
776
|
+
mode = args.mode
|
|
777
|
+
enforce_allowed = bool(args.allow_enforce) and mode == "ENFORCE"
|
|
778
|
+
if mode == "ENFORCE" and not args.allow_enforce:
|
|
779
|
+
print("NOTE: --mode ENFORCE ignored without --allow-enforce; staying DRYRUN", file=sys.stderr)
|
|
780
|
+
mode = "DRYRUN"
|
|
781
|
+
|
|
782
|
+
correlators = {} # persists across ticks within one process
|
|
783
|
+
while True:
|
|
784
|
+
status = tick(config, mode, correlators, enforce_allowed)
|
|
785
|
+
print(json.dumps(status, indent=2)) if args.json else render(status)
|
|
786
|
+
if not args.loop:
|
|
787
|
+
return 0
|
|
788
|
+
time.sleep(max(5, args.loop))
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
if __name__ == "__main__":
|
|
792
|
+
sys.exit(main())
|