Sentinel is a small, dependency-free watchdog that scans a fleet of WordPress sites from the outside, every day, and emails one report. It is built to catch the casino and betting spam injections that hit WordPress sites in 2024 to 2026, the kind that quietly turn a law firm's blog into a doorway page for online gambling.
I wrote it after cleaning more than fifty compromised WordPress sites by hand. Once the sites were clean, the real problem was staying clean. Reinfection is common, and nobody watches every site every day. Sentinel does.
This repository is the sanitized, public version of the tool. The real site list and the SMTP credentials live in local files that are never committed (see Configuration).
The obvious way to check a site is to look for spam words on the homepage. That misses almost every real infection, because modern WordPress spam hides on purpose:
- Cloaking. The page served to Googlebot is full of casino links. The page served to a normal visitor looks fine. The attacker wants Google's ranking, not your clicks, so they only show the spam to the crawler.
- Hidden from the dashboard. The injected posts do not show up in wp-admin or in the normal post list. They are stitched in below the database layer, or hidden with CSS.
- File droppers. A single malicious PHP file rewrites the page HTML on the fly, so the spam never appears in any post you can edit.
- Rogue sitemaps. A fake sitemap at
/?feed=xmlsitemap19or/sitemap19.xmlfeeds thousands of spam URLs straight to Google, often referenced fromrobots.txt.
A keyword check on the homepage catches none of these. Sentinel goes after each one specifically.
Every run, for every site, Sentinel makes a small number of outside-only requests and runs two passes.
Pass 1, signature scan (signature_scan.py). The cheap fingerprint check for the
classic injection:
| Signal | Meaning |
|---|---|
SITEMAP19 |
rogue spam sitemap served at /?feed=xmlsitemap19 or /sitemap19.xml |
ROBOTS |
robots.txt points crawlers at that rogue sitemap |
SITEMAP_KW |
casino/betting keyword URLs listed inside the real XML sitemap |
POST_SPAM |
a published REST post whose slug, title, or body carries spam keywords |
Pass 2, deep checks (deep_checks.py). The checks that catch the evasions above:
| Signal | Meaning |
|---|---|
CLOAK |
the Googlebot view carries spam tokens or links the visitor view does not |
HIDDEN_LINK |
anchors hidden with inline CSS (display:none, off-screen, zero-size) |
BAD_SCRIPT / BAD_IFRAME |
an external script or iframe from a host not on the allowlist |
REDIRECT |
a meta-refresh or JS redirect to an external host |
OBF_JS |
obfuscated inline JavaScript (eval(atob(...)), long base64 blobs) |
IOC |
a known spam or command-and-control domain anywhere in the page |
SPAM_LINK |
an outbound link whose host matches a betting/casino/malware pattern |
SUCURI |
Sucuri SiteCheck reports malware or a blacklist hit |
TLS? |
the TLS certificate expires within 14 days (informational) |
A scanner that emails a false alarm every day gets ignored within a week. The hardest part of this project was not detection, it was trust. Sentinel separates real alerts from noise so the daily email stays meaningful.
The clearest example is the word "casino". It appears in a legitimate tax article about
a 1031 exchange, and "slots" appears in a legitimate immigration article. A naive scan
flags both. Sentinel does not. When the signature pass flags a POST_SPAM hit, the
orchestrator fetches that exact post and verifies it before raising an alarm. It is
promoted to a real alert only if the post also shows a true incident signature:
post_modifiedis0000-00-00(malware that wrote straight to the database and bypassed the normal save flow), or- a known spam domain appears in the body, or
- the post links out to a betting or casino host.
Otherwise it is reported as a keyword collision in the informational section, not as an alert. The same idea runs through the deep checks: a Cloudflare or reCAPTCHA bot wall is detected and demoted, because it makes the crawler view unreadable and is not itself a sign of malware. External scripts and iframes from known SaaS and CDN providers sit on an allowlist, so only a genuinely new third-party host stands out, which is exactly what an injected script looks like.
Each daily email has three sections: real alerts (investigate now), informational (review, not confirmed), and transient errors (network blips, re-checked next run).
- Outside-only, no credentials needed for scanning. Sentinel never logs into the sites. It only fetches public URLs. That keeps it safe to point at a large fleet and means a leak of this tool leaks nothing about the sites.
- Low concurrency on purpose. It runs three workers. Managed hosts and web application firewalls will rate-limit or IP-ban an aggressive sweep, and the deep pass is several requests per site. Slow and quiet beats fast and banned.
- Redirects are blocked on the sitemap probes. A cleaned site often 301s
/sitemap19.xmlto its real sitemap. If the scanner followed that redirect it would read a clean page and mis-report a live hit. Sentinel only counts a200that is not a redirect. - Honest about its limits. An outside scan cannot see database-only cloaked posts that are hidden from every external view, nor a dormant server-side file backdoor. Those need an authenticated database or SFTP pass. Sentinel is the deepest the outside can go, and it says so rather than pretending to be complete.
- No dependencies. Python 3.8+ standard library only. Nothing to
pip install, nothing to keep patched, easy to drop onto any box.
Two local files hold everything site-specific. Both are gitignored and never published.
cp .env.example .env # SMTP host, port, user, app password, from, to
cp sites.example.txt sites.txt # one domain per lineFor Gmail or Google Workspace, turn on 2FA and create an App Password for SMTP_PASSWORD.
Do not use the account's normal password.
# scan the whole fleet and email the report
python3 sentinel.py
# re-send the latest saved report without re-scanning (useful after changing .env)
python3 sentinel.py --resend
# test a single site against a single engine, printed to the terminal
python3 signature_scan.py example.com
python3 deep_checks.py example.comThe deploy/ folder has a systemd user timer that runs the scan daily at 09:00 in the
machine's local timezone.
cp deploy/sentinel.service deploy/sentinel.timer ~/.config/systemd/user/
# edit the ExecStart path in sentinel.service to point at run.sh
systemctl --user daemon-reload
systemctl --user enable --now sentinel.timer
loginctl enable-linger "$USER" # so the timer runs without an open session
systemctl --user list-timers sentinel.timerPersistent=true means a run missed while the machine was asleep is caught up on the
next boot.
sentinel.py orchestrator: runs both passes, verifies, classifies, emails
signature_scan.py pass 1: signature fingerprints for the classic injection
deep_checks.py pass 2: cloaking, injected links/scripts, IOC, Sucuri, TLS
deploy/ systemd timer, service, and the run wrapper
.env.example SMTP config template (copy to .env)
sites.example.txt fleet list template (copy to sites.txt)
The spam keyword lists and the known indicator-of-compromise domains in
signature_scan.py and deep_checks.py come from real infections. They are attacker
infrastructure and detection patterns, shared so others can reuse them. No client data,
no real site list, and no credentials are in this repository.
MIT. See LICENSE.