| Server IP : 138.197.107.151 / Your IP : 216.73.217.10 Web Server : Apache/2.4.58 (Ubuntu) System : Linux BloxBy-Builder 6.8.0-71-generic #71-Ubuntu SMP PREEMPT_DYNAMIC Tue Jul 22 16:52:38 UTC 2025 x86_64 User : wpbetasites_mrakzqskir ( 1022) PHP Version : 8.3.6 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /var/www/bsd-crawler-parser/sweep/ |
Upload File : |
"""Moves WP Engine logs out of the public bsd-wpe-logs bucket ASAP.
bsd-wpe-logs is deliberately public (WP Engine's log delivery requires it), so
every file sitting there is exposed. The job here is to shorten that window.
Two ways in, one code path:
S3 ObjectCreated event -> moves that one object within seconds of landing.
This is the fast path and does ~all the work.
EventBridge schedule -> lists the bucket and moves whatever is left.
This is the BACKSTOP, not the main mechanism.
The backstop matters. S3 -> Lambda is an asynchronous invoke: Lambda retries a
failed event twice and then DISCARDS it. With no scheduled sweep, one dropped
event strands a log file in a public bucket indefinitely. With it, the worst
case is bounded by the schedule interval -- i.e. no worse than the behaviour
this replaced.
"""
import urllib.parse
import boto3
from botocore.exceptions import ClientError
s3 = boto3.client("s3")
SRC = "bsd-wpe-logs"
DST = "bsd-wpe-logs-private"
# WP Engine has delivered under BOTH of these. It used `logs/` up to
# 2026-07-16; when the .largefs validation files were restored on 2026-07-17 it
# resumed under `wpe_logs/` instead. Watching only one prefix is what let ~331MB
# of visitor logs sit in the public bucket for 45 minutes while the sweep
# cheerfully reported `moved=0` -- so watch both, and treat any *new* prefix as
# something to shout about rather than ignore (see unswept_prefixes()).
PREFIXES = ("logs/", "wpe_logs/")
# Everything downstream -- the parser's SOURCE_PREFIX, its IAM policy, the
# viewer -- is built around `logs/`. Normalising on the way out means the
# delivery prefix can change again without any of that having to care.
CANONICAL = "logs/"
def _skip(key):
"""Preserves the original sweep's skip rules."""
return key.endswith(".largefs") or key.endswith("/")
def dest_key(key):
"""Where `key` lands in DST. Any known delivery prefix maps to `logs/`."""
for prefix in PREFIXES:
if key.startswith(prefix):
return CANONICAL + key[len(prefix):]
return key
def move(key):
"""Copy one object to DST and delete it from SRC.
Returns "moved", "skipped", or "gone". Idempotent: S3 event delivery is
at-least-once, so a duplicate event finds the object already gone. That is
success, not an error -- raising would burn retries and eventually DLQ a
file that was actually moved correctly.
"""
if _skip(key):
return "skipped"
try:
s3.copy_object(Bucket=DST, Key=dest_key(key),
CopySource={"Bucket": SRC, "Key": key})
except ClientError as exc:
code = exc.response.get("Error", {}).get("Code")
if code in ("NoSuchKey", "404", "NoSuchObject"):
return "gone"
raise
s3.delete_object(Bucket=SRC, Key=key)
return "moved"
def handle_events(records):
"""Fast path: move just the objects named in this S3 notification."""
counts = {"moved": 0, "skipped": 0, "gone": 0}
failures = []
for rec in records:
bucket = rec.get("s3", {}).get("bucket", {}).get("name")
raw = rec.get("s3", {}).get("object", {}).get("key")
if not bucket or not raw:
continue
# Defensive: only ever move out of the public bucket. Guarantees we can
# never be pointed at DST and copy a bucket onto itself.
if bucket != SRC:
print("ignoring event for unexpected bucket: %s" % bucket)
continue
# S3 event keys are URL-encoded and spaces arrive as "+".
key = urllib.parse.unquote_plus(raw)
try:
counts[move(key)] += 1
except Exception as exc:
print("ERROR moving %s: %s" % (key, exc))
failures.append(key)
print("event: moved=%(moved)d skipped=%(skipped)d gone=%(gone)d"
% counts, "failures=%d" % len(failures))
if failures:
# Raise so Lambda retries. Anything that still fails is caught by the
# scheduled backstop rather than being lost.
raise RuntimeError("failed to move: %s" % ", ".join(failures))
return counts
def unswept_prefixes():
"""Top-level prefixes in the public bucket that no PREFIX covers.
This is the alarm that did not exist on 2026-07-17. WP Engine began
delivering to `wpe_logs/` while the sweep watched only `logs/`, so it found
nothing to do and logged `moved=0` -- which this file documents as the
healthy steady state -- every 15 minutes, for 45 minutes, while ~331MB of
visitor logs sat world-readable beside it. `moved=0` only means "nothing to
move where I looked". This says whether we looked in the right place.
Objects at the root (the .largefs validation files) are not a prefix and are
not reported: they are meant to stay there.
"""
resp = s3.list_objects_v2(Bucket=SRC, Delimiter="/")
stray = []
for cp in resp.get("CommonPrefixes", []):
prefix = cp["Prefix"]
if not any(prefix.startswith(known) for known in PREFIXES):
n = s3.list_objects_v2(Bucket=SRC, Prefix=prefix).get("KeyCount", 0)
stray.append((prefix, n))
return stray
def full_sweep():
"""Backstop path: move anything still sitting in the public bucket."""
counts = {"moved": 0, "skipped": 0, "gone": 0}
failures = []
paginator = s3.get_paginator("list_objects_v2")
for prefix in PREFIXES:
for page in paginator.paginate(Bucket=SRC, Prefix=prefix):
for obj in page.get("Contents", []):
key = obj["Key"]
try:
counts[move(key)] += 1
except Exception as exc:
print("ERROR moving %s: %s" % (key, exc))
failures.append(key)
# A healthy steady state is moved=0: the event trigger already got them.
# A consistently non-zero count here means events are not firing.
print("sweep: moved=%(moved)d skipped=%(skipped)d gone=%(gone)d"
% counts, "failures=%d" % len(failures))
# ...but moved=0 is only good news if we swept where the logs actually land.
for prefix, n in unswept_prefixes():
print("WARNING: %s holds %d object(s) and no prefix in %s covers it. "
"If WP Engine is delivering there, those files are sitting in a "
"PUBLIC bucket. Add it to PREFIXES and to the bucket "
"notification." % (prefix, n, list(PREFIXES)))
if failures:
raise RuntimeError("failed to move: %s" % ", ".join(failures))
return counts
def lambda_handler(event, context):
records = (event or {}).get("Records") or []
if records:
return handle_events(records)
return full_sweep()