Skip to content

Repository files navigation

Sentinel

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).

Why a plain scanner is not enough

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=xmlsitemap19 or /sitemap19.xml feeds thousands of spam URLs straight to Google, often referenced from robots.txt.

A keyword check on the homepage catches none of these. Sentinel goes after each one specifically.

What it actually does

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)

The part I care about most: not crying wolf

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:

  1. post_modified is 0000-00-00 (malware that wrote straight to the database and bypassed the normal save flow), or
  2. a known spam domain appears in the body, or
  3. 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).

Design decisions worth knowing

  • 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.xml to its real sitemap. If the scanner followed that redirect it would read a clean page and mis-report a live hit. Sentinel only counts a 200 that 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.

Configuration

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 line

For Gmail or Google Workspace, turn on 2FA and create an App Password for SMTP_PASSWORD. Do not use the account's normal password.

Running it

# 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.com

Scheduling it

The 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.timer

Persistent=true means a run missed while the machine was asleep is caught up on the next boot.

Layout

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)

A note on the threat intelligence in here

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.

License

MIT. See LICENSE.

About

Daily outside-only malware watch for a fleet of WordPress sites. Catches casino/betting spam injections (rogue sitemaps, cloaking, hidden injected links, IOC domains) and verifies hits to avoid false alarms. No dependencies.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages