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/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())