The fake WordPress site behind nginx writes every request it gets as one JSON object per line, append-only. The raw file is hard to skim. This runbook is how I read it day to day, and these are the parts I want to remember.
Run it on the real host
Everything here runs on the real VPS. The log lives under /var/log/wp-honeypot/, and that path does not exist inside Cowrie. If a command can't find the file, check which shell I'm in before debugging anything else.
The fields I lean on are ts (a UTC timestamp), ip, method, path, body and headers. The ip field is the real client, taken from nginx's X-Real-IP header.
Read POSTs, not GETs
The core tool is wp-posts, a short Python 3 script installed in /usr/local/bin. It reads the log, skips blank lines, keeps only POST requests, URL-decodes the body with unquote_plus, and prints an aligned table: time, IP, path, and what the body was.
The reasoning behind POST-only: GETs are mostly scanners pulling static pages. The interesting behaviour, such as login attempts and xmlrpc probes, shows up in POST bodies. Decoding matters because form data arrives URL-encoded, and a login attempt is only legible once it is turned back into plain text.
It goes in with a heredoc and a quoted delimiter. The general shape, as an example:
cat > /usr/local/bin/wp-posts << 'EOF'
...script...
EOF
chmod +x /usr/local/bin/wp-posts
Quoting 'EOF' is the important part. It stops the shell expanding anything, so $, backticks and Python's {} format braces land in the file exactly as written. /usr/local/bin is on the default PATH, so after chmod +x the script runs as a bare wp-posts from anywhere.
There's also a version that never touches disk: the same script fed to python3 - on stdin with a quoted 'PY' heredoc. It suits a box where I don't want the script left behind, or trying a tweak before saving it. Same quoting rule applies.
Subtract yourself before trusting the view
The script drops one hard-coded IP, which is my own test traffic. In the real file it's a constant. Here's the shape, with a placeholder standing in for the real value:
SKIP_IP = "<my-test-ip>" # example placeholder only
It's the same discipline as when ranking Cowrie IPs. If my own testing is in the data, it clutters the table and can outrank actual attackers. Take myself out first, then read.
Turn known noise into one word
If the User-Agent contains "Nmap", the script doesn't print the payload. It prints a tag instead:
Nmap xmlrpc listMethodswhen the body asks forlistMethodsNmap VMware/SOAP /sdkwhen the body hasRetrieveServiceContentor the path is/sdkNmap empty POSTwhen there is no body- plain
Nmapotherwise
Everything else shows the first 80 characters of the decoded body, or (empty). Recognisable scanner noise collapses to a label, and anything that isn't Nmap stands out.
Day to day that means three commands: wp-posts for every POST except mine, wp-posts | grep -v Nmap to drop the tagged rows, and tail -f on the raw log to watch requests arrive. The runbook's note on that last one is that a bare tail -f doesn't need --line-buffered; it's the filtering pipes that buffer.
One-off questions straight off the raw log
When I don't want the table, plain pipelines do the job:
wc -lacross the log files gives a total request count, because one line is one request.- A
sed -ncapture on"path": "...", thensort | uniq -c | sort -rn, ranks the most-requested paths. The runbook expects that to show what scanners hunt for, things likewp-login.php,xmlrpc.php,.envand/sdk. - The same capture on
iplists the top talkers, and onmethodgives the GET/POST/PUT/HEAD breakdown. The runbook's note: a spike in PUTs suggests upload probing and is worth a closer look. grep -Eacrosswp-login,xmlrpc,wp-json,wp-content,wlwmanifestandadmin-ajaxis a quick "is this actually a WordPress scanner" filter.-Eis what makes the|alternation work.- For the full raw JSON of each interesting hit: grep for the POST method,
grep -vmy own IP,grep -v 'Nmap Scripting Engine'. That's the manual version ofwp-posts | grep -v Nmap.
Mind this next time
The runbook has a few small traps of its own.
The one-off section says everything uses the http.jsonl* glob so rotated days are included. That's true for wc, the sed rankings and the grep -E filter. It isn't true for the raw POST pipeline, which reads only the current http.jsonl. wp-posts and the inline printer also read only the current file. So the table shows today's file and the rankings show every day. Those two views won't agree, and that's expected.
The code comment on wp-posts | grep -v Nmap says "humans / real attackers only". That's overstated. It only drops rows tagged Nmap, and any other scanner stays in. The runbook's prose has it right: genuine login attempts and anything not obviously a scanner.
grep -v with my IP as a plain string drops any line containing that string anywhere, not only in the ip field. The Python version compares the field exactly. They are close equivalents, not identical ones.
The sed captures assume the logger writes "key": "value" with a space after the colon. If that format ever changes, they'll quietly match nothing rather than error.
wp-posts reads the whole file into memory in one go. That's fine for a small honeypot log.
And the skip IP is hard-coded. Whether my own address stays the same isn't written down in the runbook, so that line is worth checking before I trust the table.
How the log gets produced in the first place lives in the earlier WordPress and nginx build notes, not here. This one is just for reading it.