# logwatch

The logwatch plugin tails nginx or Apache access logs and looks for attacks on WordPress sites. It catches the WP2Shell `batch/v1` exploit, webshells that actually execute, and xmlrpc brute-force bursts. A periodic snapshot can't see these, because they only show up in the per-request log. Turn it on for any host that serves WordPress.

The rules run on the agent, because they need the raw lines. The server turns each finding into its own alert.

## Enable it

Add `logwatch` to the `plugins` array and list the logs to watch.

```json
{
  "plugins": ["logwatch"],
  "logwatch": {
    "refreshInterval": 60000,
    "xmlrpcBurstThreshold": 20,
    "sources": [
      { "name": "shop access", "path": "/var/log/nginx/shop.example.com.access.log" },
      { "name": "blog access", "path": "/var/log/nginx/blog.example.com.access.log", "fromStart": false }
    ]
  }
}
```

| Option | Default | What it does |
|---|---|---|
| `refreshInterval` | `60000` | Milliseconds between reads. This is also the window the xmlrpc burst count covers. |
| `xmlrpcBurstThreshold` | `20` | `POST /xmlrpc.php` requests from one IP, within one window, that count as a burst. |
| `sources` | `[]` | Logs to watch. Each entry takes `path` (required), `name`, and `fromStart`. |
| `sources[].name` | file name of `path` | Label shown in alerts and on the card. |
| `sources[].fromStart` | `false` | Read the whole file on first sight instead of starting at the end. |
| `path`, `name`, `fromStart` | none | Shorthand for a single source, used when `sources` is empty. |

## What it reports

One payload per source, per interval, with unique id `log-<name or path>`.

- `name`, `source`, `path`, and `window` in seconds.
- `counts` for the window. `total` parsed lines, `batchV1` (exploit hits that returned 207), `batchAttempts`, `batchBlocked` (403, 406 or 429), `xmlrpcPosts`, `webshellHits`, `loginSuccess`, and `loginRedirectsAlways`.
- `findings`, the list of rule hits below. Each has `rule`, `key`, `severity`, `ip`, `path`, `title` and `detail`.
- `error`, when reading the log failed.

## When it alerts

| Rule | Fires when | Severity |
|---|---|---|
| `wp-batch-207` | `POST /wp-json/batch/v1` (or `?rest_route=/batch/v1`) returns 207 | error |
| `batch-then-login` | the same IP also got a 302 from `POST /wp-login.php` and then a 2xx from a logged-in wp-admin page | error |
| `webshell-exec` | a `.php` under `wp-content/uploads/` returns 2xx, or a `.php` under a plugin directory returns 2xx with a command-style or base64 query | error |
| `xmlrpc-burst` | one IP sends `xmlrpcBurstThreshold` or more `POST /xmlrpc.php` in a window | warning, or error at 5 times the threshold |

`batch-then-login` replaces `wp-batch-207` for that IP. Only a 207 raises the batch alert. Blocked attempts count toward `batchAttempts` and `batchBlocked` but don't alert. Webshell probes that return 403 or 404 don't alert either.

Each finding becomes its own notification, keyed on the IP or path. Two different attackers produce two alerts. There is no one-report debounce for logwatch. A finding alerts on the report that contains it.

Each window starts fresh, so a finding that stops appearing in the next window is resolved. If the same finding comes back within 30 minutes of being resolved, the server records it but does not push it or post it to Slack.

## On the dashboard

The dashboard has no dedicated logwatch card. The service shows as a plain card with its name and status. The `counts` are stored but not drawn.

A source that has not reported for 300 seconds shows `OFFLINE`.

## Requirements

- Read access to each log file for the agent user.
- The log must record the real client IP. Behind Cloudflare, set nginx `real_ip` with `set_real_ip_from` and `CF-Connecting-IP`. Otherwise every finding names a Cloudflare edge address.

The parser takes the last IP before the `[timestamp]` as the client, and a non-IP first field as the vhost. That handles the standard combined format, vhost-first formats, and formats that log both the edge IP and `$http_cf_connecting_ip`.

## Gotchas

- By default the plugin starts at the end of each file, so old attacks in the log don't alert. Set `fromStart` to replay history.
- Each read is capped at the last 5 MB of new data. On a very busy log, lines beyond that in one window are skipped.
- A file that shrinks is treated as rotated, and reading restarts at the top.
- Some sites hide or block `wp-login.php`, so every login request returns a redirect. Once the plugin sees a plain `GET /wp-login.php` answered with a 3xx, it stops trusting login 302s for that file. `loginRedirectsAlways` turns true and `batch-then-login` can no longer fire.
- A missing file is skipped silently each cycle, and the source reports zero lines. A read error is sent as `error`, but the server's logwatch rules only look at `findings`. Either way the source shows as online.
