Scan Engine — WP Luminary
WP Luminary runs every file through a three-stage pipeline designed to minimise LLM calls (and therefore cost and scan time) while still catching subtle, low-entropy malware.
Pipeline overview
Section titled “Pipeline overview”File │ ├─ Stage 1: Fast local triage (prescan) │ ├─ SVG → XML-aware analyzer │ ├─ .htaccess → Apache directive check │ ├─ PHP tag in non-PHP file → instant flag │ ├─ Risk pattern match (regex, 14 patterns) │ ├─ Shannon entropy check │ └─ PHP call flow / taint analysis ← (new in 1.4.3) │ ├─ Stage 2: WP core checksum verification │ └─ Core files matched against wordpress.org checksums → skip LLM if clean │ └─ Stage 3: LLM analysis └─ Only files that pass stages 1–2 without a clean verdict are sent hereFiles that resolve locally (clean, plugin-manifest-verified, or checksum-matched) never reach the LLM. This is the main lever that controls scan duration and credit consumption.
Stage 1: Prescan
Section titled “Stage 1: Prescan”SVG analyzer
Section titled “SVG analyzer”SVGs are parsed as XML. The analyzer looks for embedded <script> tags, javascript: URIs in href/xlink:href, event-handler attributes (onload, onclick, …), and <use> elements that could chain in external content.
.htaccess check
Section titled “.htaccess check”Apache configs are checked for directives that enable arbitrary code execution:
| Pattern | Risk |
|---|---|
AddType application/x-httpd-php |
Executes non-PHP files as PHP |
AddHandler … php |
Same via handler |
php_value auto_prepend_file |
Prepends attacker file on every request |
php_value auto_append_file |
Same, appended |
Options … ExecCGI |
Enables CGI execution |
Files that match are escalated to LLM. Files that don’t match are also escalated — .htaccess configs are small and context-dependent enough that a reasoning pass is always worth it.
Risk pattern matching
Section titled “Risk pattern matching”Fourteen regex patterns detect canonical single-statement obfuscation:
| Pattern | Example |
|---|---|
eval_base64 |
eval(base64_decode(…)) |
eval_gzinflate |
eval(gzinflate(…)) |
eval_str_rot |
eval(str_rot13(…)) |
eval_var |
eval($x) |
chr_chain |
chr(101).chr(118).chr(97).chr(108) |
hex_chain |
\x65\x76\x61\x6c (4+ hex escapes) |
create_func |
create_function('', …) |
preg_e_mod |
preg_replace('/…/e', …) |
assert_var |
assert($x) |
shell_var |
shell_exec($x), passthru($x) |
var_var_call |
$$fn(…) |
call_user_func_var |
call_user_func($x, …) |
js_doc_write |
document.write(unescape(…)) |
hidden_iframe |
Zero-size hidden <iframe> |
xss_direct_echo |
echo $_GET['x'] — direct reflected XSS |
sqli_wpdb_concat |
$wpdb->query("SELECT..." . $var) — SQL injection |
header_injection |
header('Location: ' . $var) — open redirect |
ssrf_remote_var |
wp_remote_get($var) — potential SSRF |
xss_printf_unesc |
printf($_ GET[...]) — format-string XSS |
Shannon entropy
Section titled “Shannon entropy”Measures the information density of the first 8 KB. High entropy indicates compressed or encoded payload:
| File type | Threshold |
|---|---|
| PHP | 5.5 bits/byte |
| JS | 5.9 bits/byte |
| HTML/HTM | 5.3 bits/byte |
Normal WP core PHP ≈ 4.84 b/byte; minified jQuery ≈ 5.38; base64 payload ≈ 6.0; gzip+base64 eval blob ≈ 6.3.
PHP call flow analysis
Section titled “PHP call flow analysis”Added in v1.4.3. This stage runs on PHP files that pass the pattern and entropy checks — catching subtle, low-entropy malware that looks “clean” on a line-by-line basis but is dangerous when data flows are traced.
It uses PHP’s built-in tokenizer (token_get_all()) to build a simplified data flow graph across up to 64 KB of source.
What it tracks:
-
Taint sources — variables assigned from user-controlled input:
$_GET,$_POST,$_REQUEST,$_COOKIE,$_FILES,$_SERVERphp://input,php://stdinviafile_get_contents()orfopen()
-
Taint propagation — taint flowing through transform functions:
base64_decode,gzinflate,gzuncompress,gzdecodestr_rot13,hex2bin,rawurldecode,urldecodeconvert_uudecode,quoted_printable_decode,stripslashes, …
-
Dangerous sinks — where tainted data causes harm:
| Sink | Risk |
|---|---|
eval, assert |
Executes attacker-controlled PHP |
exec, system, shell_exec, passthru, proc_open, popen |
Runs attacker-controlled shell command |
include, include_once, require, require_once |
Loads attacker-controlled file (RFI/LFI) |
file_put_contents, fwrite |
Writes attacker-controlled content to disk |
file_get_contents, readfile, fopen |
Reads arbitrary file (path traversal) |
header, wp_redirect |
Redirects to attacker-controlled URL (open redirect) |
echo / print |
Outputs tainted data without escaping (XSS) |
$wpdb->query/get_results/… |
Raw DB query with user-controlled input (SQLi) |
What it catches that patterns miss:
// Two-statement command injection — no eval, no base64, entropy is low.$cmd = $_GET['cmd'];exec($cmd);
// Three-hop taint chain — each step looks innocent alone.$raw = $_POST['payload'];$dec = base64_decode($raw);eval($dec);
// Dynamic include with user-controlled path (RFI/LFI).$page = $_GET['page'];include $page;
// User-controlled function name (variable function call).$fn = $_POST['func'];$fn();It also catches nested decode→execute combinations not in the fixed pattern list:
exec(str_rot13(hex2bin($x)));system(gzdecode(convert_uudecode($payload)));Sanitizer awareness (v1.4.11): taint is cleared when a variable is passed through esc_html, esc_attr, esc_url, sanitize_text_field, intval, absint, wp_kses, or similar escaping/typing functions — preventing false positives on code that correctly escapes before output.
Tier: Available for all tiers (Bronze, Silver, Gold). Call flow runs locally — it uses no API calls and adds no credit cost.
Stage 2: WP core checksum verification
Section titled “Stage 2: WP core checksum verification”For files inside ABSPATH (outside wp-content/), WP Luminary fetches the official checksums from api.wordpress.org and caches them for 24 hours. If a file’s SHA-256 hash matches the official checksum, it is marked clean without any LLM call.
Stage 3: LLM analysis
Section titled “Stage 3: LLM analysis”Files that reach this stage have failed local triage — they either matched a risk pattern, had high entropy, or contained a detected call flow chain. The LLM (Claude) receives:
- File path and extension
- Full file content (truncated to the model’s context window if needed)
- A structured prompt asking for a risk level (0–10), status, summary, and per-finding breakdown
The response is parsed into a structured result and stored in the wpl_file_hashes table.
Prompt delivery (all tiers): Scan prompts are never hardcoded in the plugin. They are delivered via the license validation response (features.file_system_prompt / features.db_system_prompt) and cached in the wpl_license_status transient for 12 hours. This means prompts can be improved server-side without a plugin release, and the prompt text is not visible in the plugin source.
For Silver and Gold tiers, the full analysis request is routed through the LittleBig proxy (wp-luminary-proxy) — the Anthropic key is managed server-side and never exposed in the plugin. For Bronze, the plugin makes direct Anthropic API calls using the user’s own key, with the prompt sourced from the license server features.
Plugin manifest verification
Section titled “Plugin manifest verification”Files in WP Luminary’s own plugin directory are compared against the release manifest (SHA-256 hashes of all shipped files). This means:
- Verified files (unchanged from release) are immediately marked clean
- Foreign files (not in the manifest) are auto-flagged at risk level 10 — no LLM needed
- Modified files fall through to the normal scan pipeline
Since v1.4.9 the manifest is regenerated by CI at build time against the exact shipped files. (v1.4.8 shipped with a stale manifest, causing the scanner to flag its own two new files as FOREIGN — files added in a release must always be present in the manifest.)
Scan scope (v1.4.8)
Section titled “Scan scope (v1.4.8)”Scanned locations: WordPress core (ABSPATH), the active theme and child theme, active plugins only, mu-plugins, and uploads. Inactive plugins are excluded — they cannot execute, and scanning them would burn credits on dead code.
The WordPress Plugins list shows a compact Luminary column (since v1.4.9; v1.4.8 used a full-width row):
| Column value | Icon | Meaning |
|---|---|---|
| Clean + {date} | green checkmark | All files in the plugin passed the last scan |
| N finding(s) | red warning, links to dashboard | Flagged/suspicious/auto-flagged files exist |
| Scan in progress | orange update icon | Files from this plugin are still in the pending queue |
| Not scanned yet | orange clock | Active plugin, no scan data yet |
| Inactive — excluded | gray minus | Inactive plugin (excluded by design) |
Status data comes from a single aggregated query over wpl_file_hashes, cached for 15 minutes (wpl_plugin_scan_summary transient, invalidated on scan completion).
After a fully clean scan, the dashboard shows an all-clear banner: files scanned, no threats found, all active plugins clean, last scan time.
Behavioral feedback (v1.4.8)
Section titled “Behavioral feedback (v1.4.8)”WP Luminary has no feedback buttons — deliberately. Buttons (“report false positive”) can be gamed by malware authors probing the detection engine. Instead, the plugin observes what admins actually do with findings and reports two behavioral signals to the license server (POST /v1/feedback):
| Signal | Trigger |
|---|---|
disputed |
Admin marks a flagged/suspicious file as ignored — they reviewed it and disagreed |
remediated |
A flagged file is deleted, or its content changes, before the next crawl — the admin acted on the finding |
Privacy: only a SHA-256 hash of the site-relative file path is transmitted, plus verdict, scan status, risk level, and plugin version. Never raw paths, never file contents, never domains. Requests are fire-and-forget (non-blocking, 2 s timeout, silent failure) and rate-limited server-side to 500 events per license per day.
These signals feed verdict-quality metrics per prompt/model version — a rising dispute rate on a prompt revision indicates false-positive regression.
File watcher requirements
Section titled “File watcher requirements”The real-time file watcher is a background daemon built on inotifywait and therefore runs on Linux hosts only (the vast majority of production WordPress hosting). Requirements:
- Linux (
inotifykernel API — not available on macOS/Windows) inotify-toolsinstalled (apt install inotify-tools/dnf install inotify-tools)- PHP
exec/shell_execnot disabled indisable_functions
Since v1.4.8 the Start Watcher button runs a preflight check and reports exactly which requirement is missing, verifies the daemon actually survived startup (2 s liveness check), and surfaces the last lines of the watcher log on failure. Scheduled scans work everywhere regardless of watcher availability.
Since v1.4.10 the daemon is launched via setsid in its own session — without it, a web-spawned watcher stays in the PHP-FPM worker’s process group and is SIGTERMed when the request’s process group is torn down (nohup only shields SIGHUP). Watcher log lines now carry UTC timestamps, and shutdown logs the PID and received signal number, so log lifecycles are attributable.
Since v1.4.11 a spawn-attempt marker (site URL, PHP binary, setsid path) is written to the watcher log before the daemon process is forked — so even if the daemon exits silently (e.g. setsid permission error, missing PHP binary), the log always shows what was attempted and which site triggered it.
Since v1.4.12 both nohup and setsid are resolved to their absolute paths before the daemon is forked. PHP-FPM runs with a minimal PATH that may not include /usr/bin, so calling bare nohup silently fails — the daemon is never forked and the spawn log shows nothing. The resolved paths are now included in the spawn-attempt marker. The daemon itself calls set_time_limit(0) immediately on startup, guarding against servers with a non-zero max_execution_time in their CLI php.ini (which would kill the daemon after the configured timeout).
Since v1.4.13 the daemon uses proc_open() instead of popen() to read inotifywait’s output. popen() wraps the pipe fd in a stdio FILE* buffer: stream_select() polls the underlying fd, but data already sitting in the stdio read buffer makes the fd appear unreadable, so events were never delivered — the watcher appeared running but silently ignored all file changes. proc_open() with ['pipe', 'w'] gives a raw PHP pipe stream with no stdio layer that stream_select() sees correctly.