Capture Ninjafirewall log file events to trigger actions.

OVERVIEW

The NinjaFirewall WP plugin is a very fast, reliable Web Application Firewall for WordPress. In this article, we create shutdown function in a Must-Use Plugin for WordPress that looks for new security events in the NinjaFirewall log file, then allows you to take any action you wish on those events. The included example simply copies some of the security event data to a separate text file. But you could just as easily change the action so that it reports the security events directly to an IP blocklist service, or tell CloudFlare to block that IP address from your web site. The possibilities are endless.

BACKGROUND

Ninjafirewall stores firewall event data in your WordPress installation’s /wp-content/nfwlog/ directory in a series of PHP files with a naming convention of firewall_<date>.php (e.g. firewall_20240301.php). Events in these files appear in the following form…

[1592563704] [0.01517] [my-site.com] [#1937399] [1515] [3] [10.254.85.237] [403] [GET] [/index.php] [Unauthorized action] [hex:4745543a712305f6175746f5fab2f67203d2074727565]
  • The first variable, [1592563704] is an Epoch timestamp. Fail2Ban recognizes this without having to add it to the regular expression in our Fail2Ban filter recipe.
  • The second variable, [0.01517]shows of how long it took NinjaFirewall to process the event.
  • The third variable, [my-site.com] is the web site host name.
  • The fourth variable, [#1937399] is Ninjafirewall’s Incident number for the event.
  • The fifth variable, [1515] shows the ID number of the violated “Rule”.
  • The sixth variable, [3] is the security level for this event. NinjaFirewall defines these as, 1=Medium, 2=High, 3=Critical, 4=Error, 5=Upload 6=Information, and 7=Debug. The example Fail2Ban filter recipe shown below will only act on High and Critical security events.
  • The seventh variable, [10.254.85.237] is the IP address of the attacker.
  • The remainder of the variables are HTTP response status code, HTTP method used, URL, rule violation description, and some hex encoded data used to display more event info in the log file viewer of the Ninjafirewall admin interface).

Unfortunately, NinjaFirewall does not have any hooks available to extend the capabilities of the plug-in. This may be due to a need-for-speed; hooks and APIs would only slow down the plugin. However, we can create a PHP shutdown function, which runs after WordPress has finished rendering a web page, and use it to see if anything of concern was added to the NinjaFirewall log file since the last time it was looked at.


PROCEDURE

A Must-Use Plugin in WordPress is very simple type of plugin that is stored in a special directory in your WordPress installation. It does not require any special activation code, and it automatically runs on every page request (as opposed to requests for images, javascripts, PDF files, etc.) to your web site. We will use this to register our shutdown function to run after any page request has been process. At that point, NinjaFirewall will have finished writing any new events to its log file. We can then examine those events, and act on anything of interest.

Note: On Apache2 web servers, selecting a log file name that starts with “.ht-” automatically prevents public access to the file.

Place the following example code in /wp-content/mu-plugins/ninjafirewall-actions.php in your web site’s home directory. This will copy all Medium, High and Critical security events into /wp-content/.ht-ninjafirewall.log. However, you may alter the nfa_actions( ) function to perform any action on the event data.

<?php
/*
 Plugin Name: NinjaFirewall Logfile Actions
 Plugin URI: https://kazimer.com/
 Description: Take actions based on NinjaFirewall Log file content.
 Version: 1.0
 Author: Kazimer Corp.
 Author URI: https://kazimer.com/
 License: GPLv3 or later
 Network: true
 */

/**
 * Make sure this script isn't run directly.
 */ 
defined( 'ABSPATH' ) || exit; 

/**
 * Setup some definitions.
 */ 
define( 'NFA_LOGFILE', '.ht-ninjafirewall.log' ); // File name where NF events will be copied to.

define( 'NFA_TAIL', -8192 ); // How many bytes from end of NF log file to examine.

/**
 * Shutdown function for NinjaFirewall log file reading.
 */
register_shutdown_function( function () {
	/**
	 * If NinjaFirewall plugin isn't enabled, then return.
	 */
	if ( !defined( 'NFW_STATUS' ) || true === get_transient( 'NFA_LOCK' ) ) {
		return;
	}
	/* Set a lock with a 30 second time out. */
	set_transient( 'NFA_LOCK', true, 30 );

	/**
	 * Check if a current log file exists.
	 * @var string $log_file
	 */
	$log_file = WP_CONTENT_DIR . '/nfwlog/firewall_' . date( 'Y-m' ) . '.php';
	if ( false === is_readable( $log_file ) ) {
		delete_transient( 'NFA_LOCK' );
		return;
	}

	/**
	 * Get last modified time from current log file.
	 * @var string $mod_time
	 */
	if ( false === ($mod_time = filemtime( $log_file )) ) {
		delete_transient( 'NFA_LOCK' );
		return;
	}
	$mod_time = $log_file . $mod_time;

	/**
	 * Check if log file was modified since last seen.
	 * @var string $last_mod
	 */
	$last_mod = get_transient( 'NFA_MODTIME' );
	if ( $mod_time === $last_mod ) {
		delete_transient( 'NFA_LOCK' );
		return;
	}

	/* Save file modified time. */
	set_transient( 'NFA_MODTIME', $mod_time );

	/**
	 * Get last part of file.
	 * @var string $log_data
	 */
	$log_data = file_get_contents( $log_file, false, null, NFA_TAIL );
	if ( false === $log_data ) {
		/* That didn't work, try to get the whole file */
		$log_data = file_get_contents( $log_file, false, null );
		if ( false === $log_data ) {
			delete_transient( 'NFA_LOCK' );
			return;
		}
	}

	/**
	 * Try to find the last seen incident number.
	 * @var string $last_incident
	 */
	$last_incident = get_transient( 'log_last_incident' );
	if ( !empty( $last_incident ) ) {
		$log_data = explode( $last_incident, $log_data );

		switch ( count( $log_data ) ) {
			case 2:
				$log_data = $log_data[ 1 ];
				break;

			case 1:
				$log_data = $log_data[ 0 ];
				break;

			default:
				delete_transient( 'NFA_LOCK' );
				return;
		}
	}

	$rows_copy = $log_rows  = explode( "\n", $log_data );

	/* Find first complete row of data */
	foreach ( $rows_copy as $row ) {
		if ( 1 === preg_match( '/^\[(?:[\d]{10})\] \[(?:[\d\.]{1,8})\] \[(?:[\w\.\-]{4,80})\] \[\#(?:\d{5,9})\] \[(?:\d{1,6})\] \[\d\]/', $row ) ) {
			break;
		}
		array_shift( $log_rows );
	}

	if ( empty( $log_rows ) ) {
		delete_transient( 'NFA_LOCK' );
		return;
	}

	/**
	 * Severity Level IDs...
	 *
	 * File: /wp-content/plugins/ninjafirewall/logs_firewall_log.php
	 * Line: ~111
	 * $levels = array( '', 'MEDIUM', 'HIGH', 'CRITICAL', 'ERROR', 'UPLOAD', 'INFO', 'DEBUG_ON' );
	 *
	 * MEDIUM = 1
	 * HIGH = 2
	 * CRITICAL = 3
	 *
	 *
	 * Process log entries.
	 *
	 * @var array $abuse_events
	 */
	$abuse_events  = [];
	$last_incident = null;
	foreach ( $log_rows as $row ) {
		$matches = null;
		if ( 1 === preg_match( '/^\[([\d]{10})\] \[(?:[\d\.]{1,8})\] \[([\w\.\-]{4,80})\] \[\#(\d{5,9})\] \[(?:\d{1,6})\] \[(\d)\] \[((?:(?:\d{1,3}\.){3}\d{1,3})|(?:(?:(?:[[:xdigit:]]{0,4}):){1,7}(?:[[:xdigit:]]{0,4})))\] \[(?:[\d]{3})\] \[(?:[a-zA-Z]{3,6})\] \[[^\]]{1,}\] \[([^\]]{1,})/', $row, $matches ) ) {
			list(, $timestamp, $hostname, $last_incident, $severity, $ip, $event) = $matches;
			if ( $severity >= 1 && $severity <= 3 ) {
				$abuse_events[] = [ $timestamp, $hostname, $ip, $severity, $event ];
			}
		}
	}

	if ( !empty( $last_incident ) ) {
		set_transient( 'log_last_incident', $last_incident );
	}

	if ( empty( $abuse_events ) ) {
		delete_transient( 'NFA_LOCK' );
		return;
	}

	/**
	 * Perform defined actions on log data.
	 */ 
	nfa_actions( $abuse_events );

	/* Delete lock before leaving. */
	delete_transient( 'NFA_LOCK' );
} ); 

/**
 * Process a NinjaFirewall security event.
 *
 * @param array $abuse_events
 */ 
function nfa_actions( $abuse_events ) { 
	/**
	 * This action copies some NF log data to a text log for use with Fail2Ban.
	 */ 
	$log_file = trailingslashit( WP_CONTENT_DIR ) . NFA_LOGFILE; 

	$max_len = ini_get( 'log_errors_max_len' ); 
	if ( false === $max_len ) { 
		$max_len = 1024; // Default value; 
	} 

	foreach ( $abuse_events as $event ) { 
		list($timestamp, $hostname, $ip, $severity, $event) = $event;

		$msg = "[{$timestamp}] [{$hostname}] [{$severity}] [{$ip}] [{$event}]\n"; 
		if ( strlen( $msg ) > $max_len ) {
			$msg = substr( $msg, 0, $max_len - 1 );  // trim string, leaving room for bracket.
			$msg = rtrim( $msg, ' ]' ) . ']';  // Make sure last character is a bracket.
		}
		error_log( $msg, 3, $log_file );
	}
}

LOGROTATE CONFIGURATION

If you use logrotate, adding the following to a file named /etc/logrotate.d/ninjafirewall-actions will automatically delete the log data after one week. This way we won’t fill up our content directory with huge log files.  Edit the log file path on the first line as needed…

/var/www/vhosts/my-site.com/htdocs/wp-content/.ht-ninjafirewall.log
{
        rotate 0
        weekly
        create 640 www-root www-root
        missingok
        notifempty
}

TAKE ACTION WITH FAIL2BAN

You could modify the example code above to immediately block the IP address in your system firewall, or maybe report it to a blocklist site. However, your web server will be happier if you avoid executing any additional code. Another option would be to point Fail2Ban at the new log file.  Then it can handle all the heavy lifting instead of making your web server do it. To do this, create a file named /etc/fail2ban/filter.d/ninjafirewall-action.conf and add the following filter recipe to it…

# Fail2Ban configuration file
#
# Author: Kazimer Corp
#

[INCLUDES]

# Read common prefixes. If any customizations available -- read them from
# common.local
before = common.conf


[Definition]

_daemon = ninjafirewall-action

# Option:  failregex
# Notes:   regex to match events marked Critical and High in ninjafirewall log files.
#          fail2ban automatically detects and translates the Epoch timestamp at the
#          start of each log file line even though it is not specified in failregex.
#
#          Samples:
#	   [1704033075] [mysite.com] [1] [10.168.232.107] [Blocked access to admin-ajax.php]
#	   [1704033075] [mysite.com] [2] [10.168.192.190] [WordPress: Blocked access to the WP REST API]
#
# Values:  TEXT
#
failregex = \[(?:[\w\.\-]{4,80})\] \[[23]\] \[<HOST>\] \[
            \[(?:[\w\.\-]{4,80})\] \[1\] \[<HOST>\] \[Blocked access to the login page

Notice that the filter recipe above acts on all High and Critical events. But it only responds to Medium events when NinjaFirewall blocks brute force login attempts by bots.  If you want it to act on all Medium events, then change \[[23]\] to \[[123]\] in the first failregex expression, and delete or comment out the second failregex expression.

Finally, add the following to your /etc/fail2ban/jail.local file. Alter logpath and add any additional parameters you may need, such as action and banaction

[ninjafirewall-action]
filter = ninjafirewall-action
enabled = true
maxretry = 1
logpath = /var/www/vhosts/my-site.com/htdocs/wp-content/.ht-ninjafirewall.log

CONCLUSION

A change in how Fail2Ban v0.11+ broke its ability to work with NinjaFirewall’s log files. Part of this is due to the unusual way that NinjaFirewall writes its log files. For instance, the log files are witten as a PHP file.  Also, NinjaFirewall it trims the head of the log file when it gets too long. Some changes the Fail2Ban developer made to deal with incorrect timezone settings on some servers triggered the incompatibility.

The example code above brings back some of the functionality of the original Kazimer Corp Fail2Ban filter for NinjaFirewall logs.

Note: NinjaFirewall uses auto_prepend_file to execute its firewall code before WordPress begins executing its code. The firewall code could end PHP execution before WordPress has a chance to initialize any Must-Use Plugins. This means register_shutdown_function( ) in this plugin won’t get a chance to run, and log events won’t be copied to .ht-ninjafirewall.log. Security events won’t be copied to .ht-ninjafirewall.log until an incoming page request is processed that does not get blocked by NinjaFirewall. Because of this, Fail2Ban will not be able to act on NinjaFirewall security events immediately. Unfortunately, this can’t be fixed until the incompatibility between NinjaFirewall’s current log file method and Fail2Ban are resolved by the developers.