bitslip6/bitfire

View on GitHub
firewall/src/server.php

Summary

Maintainability
A
30 mins
Test Coverage
<?php
/**
 * BitFire PHP based Firewall.
 * Author: BitFire (BitSlip6 company)
 * Distributed under the AGPL license: https://www.gnu.org/licenses/agpl-3.0.en.html
 * Please report issues to: https://github.com/bitslip6/bitfire/issues
 * 
 * all functions are called via api_call() from bitfire.php and all authentication 
 * is done there before calling any of these methods.
 */

namespace BitFireSvr;

use BitFire\Config;
use BitFire\Config as CFG;
use BitFire\ScanConfig;
use Exception;
use SodiumException;
use ThreadFin\Effect as EF;
use ThreadFin\CacheItem;
use ThreadFin\CacheStorage;
use ThreadFin\FileData;
use ThreadFin\FileMod;
use ThreadFin\Effect;
use ThreadFin\Maybe;
use ThreadFin\MaybeStr;

use const BitFire\APP;
use const BitFire\BITFIRE_SYM_VER;
use const BitFire\FILE_RW;
use const BitFire\FILE_W;
use const BitFire\STATUS_EACCES;
use const BitFire\STATUS_EEXIST;
use const BitFire\STATUS_ENOENT;
use const BitFire\STATUS_OK;
use const BitFire\STATUS_FAIL;
use const BitFire\WAF_INI;
use const BitFire\WAF_ROOT;
use const ThreadFin\DAY;
use const ThreadFin\DS;

use function BitFirePlugin\find_cms_root;
use function ThreadFin\contains;
use function ThreadFin\do_for_each;
use function ThreadFin\file_recurse;
use function ThreadFin\file_replace;
use function ThreadFin\HTTP\http2;
use function ThreadFin\partial as BINDL;
use function ThreadFin\random_str;
use function ThreadFin\debug;
use function ThreadFin\en_json;
use function ThreadFin\get_hidden_file;
use function ThreadFin\make_config_loader;
use function ThreadFin\recursive_copy;
use function ThreadFin\trace;
use function ThreadFin\take_nth;
use function ThreadFin\utc_date;
use function ThreadFin\utc_time;

const ACCESS_URL = 5;
const ACCESS_CODE = 6;
const ACCESS_ADDR = 0;
const ACCESS_REFERER = 8;
const ACCESS_AGENT = 9;
const ACCESS_URL_PROTO = 2;
const ACCESS_QUERY = 10;
const ACCESS_HOST = 11;
const ACCESS_URL_METHOD = 12;
const ACCESS_URL_URI = 13;

const CONFIG_KEY_NAMES = [ "bitfire_enabled","allow_ip_block","security_headers_enabled","enforce_ssl_1year","csp_policy_enabled","csp_default","csp_policy","csp_uri","pro_key","rasp_filesystem","max_cache_age","web_filter_enabled","spam_filter_enabled","xss_block","sql_block","file_block","block_profanity","filtered_logging","allowed_methods","whitelist_enable","blacklist_enable","require_full_browser","honeypot_url","check_domain","valid_domains","valid_domains[]","ignore_bot_urls","rate_limit","rr_5m","cache_type","cookies_enabled","wordfence_emulation","report_file","block_file","debug_file","debug_header","send_errors","dashboard_usage","browser_cookie","dashboard_path","encryption_key","secret","password","cms_root","cms_content_url","cms_content_dir","debug","skip_local_bots","response_code","ip_header","dns_service","short_block_time","medium_block_time","long_block_time","cache_ini_files","root_restrict","configured" ];


// helpers
// trim off everything after $trim_char
function trim_off(string $input, string $trim_char) : string { $idx = strpos($input, $trim_char); $x = substr($input, 0, ($idx) ? $idx : strlen($input)); return $x; }

class FileHash {
    public $file_path;
    public $rel_path;
    public $size;
    public $crc_path;
    public $crc_trim;
    public $unique;
    public $crc_expected;
    public $type;
    public $name;
    public $version;
    public $ctime;
    public $ver;
    public $skip;
}

/**
 * special handling of WordPress DOCUMENT_ROOT - requested by WP team
 */
function doc_root() : string {
    static $root = "/";
    if ($root === "/") { 
        $root = $_SERVER['DOCUMENT_ROOT'];
    }
    return $root;
}

/**
 * find the cms root path.  abstracted to cms plugin helper, config file
 * fallback to doc_root()
 * @return string 
 */
function cms_root() : string {
    trace("R1");
    $root = doc_root();
    if (function_exists("\BitFirePlugin\\find_cms_root")) {
        $root = \BitFirePlugin\find_cms_root();
    }
    else if (CFG::enabled("cms_root")) {
        $root = CFG::str("cms_root");
    }
    else if (CFG::enabled("cms_root")) { // backward compatibility
        $root = CFG::str("cms_root");
    }
    if (strlen($root) < strlen(doc_root())) { 
        debug("error finding doc_root [%s]", $root);
        $root = doc_root();
    }

    return realpath($root);
}


// helper function.  determines if ini value should be quoted (return false for boolean and numbers)
function need_quote(string $data) : bool {
    return ($data === "true" || $data === "false" || ctype_digit($data)) ? false : true;
}


/** 
 * take an array of strings and convert to ini array format
 */
function array_to_ini(string $value_name, array $data) : string {
    $result = "\n";
    foreach ($data as $item) {
        if (is_numeric($item)) {
            $result .= "{$value_name}[] = $item\n";
        } else if (is_bool($item)) {
            $result .= "{$value_name}[] = " . ($item ? "true" : "false") . "\n";
        } else {
            $result .= "{$value_name}[] = '$item'\n";
        }
    }
    return "$result\n";
}

/**
 * map $filename with $fn, return effect to write updated $filename   
 * @param callable $fn 
 * @param string $filename 
 */
function update_ini_fn(callable $fn, string $filename = "", bool $append = false) : EF {
    if (empty($filename)) {
        if (defined("\BitFire\WAF_INI")) {
            $filename = \BitFire\WAF_INI;
        } else {
            $filename = make_config_loader()->run()->read_out();
        }
    }
    assert(file_exists($filename), "[$filename] does not exist.  please create it.");

    $effect = EF::new();

    // UPDATE THE FILE
    $file = FileData::new($filename)->read(false);
    $x1 = count($file->lines);
    if ($append) {
        $file->append($fn());
    } else {
        $file->map($fn);
    }

    $x2 = count($file->lines);
    $raw = join("\n", $file->lines);
    $new_config = parse_ini_string($raw, false, INI_SCANNER_TYPED);


    $is = is_array($new_config);
    if ($new_config != false && $is) {
        // set status to success if the file has a reasonable size still...
        if ($new_config != false && $x1 > 10 && $x2 >= $x1) {
            // update the file abstraction with the edit, this will allow us 
            // to update the file multiple times, and not read from the FS multiple times
            FileData::mask_file($filename, $raw);

            $ini_code = "{$filename}.php";
            $effect->status(STATUS_OK)
            // write the raw ini content
            ->file(new FileMod($filename, $raw, FILE_W))
            // write the parsed config php file
            ->file(new FileMod($ini_code, '<?'.'php $config = ' . var_export($new_config, true) . ";\n", FILE_RW, time() + 5))
            // clear the config cache entry
            ->update(new CacheItem("parse_ini", "\ThreadFin\\nop", "\ThreadFin\\nop", -DAY));
        }
    }
    if (!$is || $new_config == false) {
        $effect->exit(false, STATUS_FAIL, "an error occurred updating $filename, [$x1/$x2] (is: $is) please repair with original file");
    }

    return $effect;
}



/**
 * if $value === "!" then config line is removed
 * @param string $param ini parameter name to change
 * @param string $value the value to set the parameter to
 */
function update_ini_value(string $param, string $value, ?string $default = NULL) : Effect {
    $param = htmlspecialchars(strtolower($param));
    $value = htmlspecialchars($value);
    // normalize values
    switch($value) {
        case "off":
            $value = "false";
        case "alert":
            $value = "report";
        case "block":
        case "on":
            $value = "true";
        default:
    }

    $quote_value = (need_quote($value) && !contains($value, '"')) ? "\"$value\"" : "$value";
    $param_esc = str_replace(["[", "]"], ["\[", "\]"], $param);
    $search = (!empty($default)) ? "/\s*[\#\;]*\s*{$param_esc}\s*\=.*[\"']?{$default}[\"']?/" : "/\s*[\#\;]*\s*{$param_esc}\s*\=.*/";
    $replace = "$param = $quote_value";

    debug("update ini value [%s] [%s]", $search, $replace);

    if ($value === "!") { $replace = ""; }
    $fn = (BINDL("preg_replace", $search, $replace));

    $effect = update_ini_fn($fn);
    if ($effect->read_status() == STATUS_OK) {
        debug("updated %s -> %s", $param, $value);
    } else {
        debug("config failed to update %s -> %s", $param, $value);
    }
    return $effect;
}

/**
 * if $value === "!" then config line is removed
 * @param string $param ini parameter name to change
 * @param string $value the value to set the parameter to
 */
function add_ini_value(string $param, string $value, ?string $default = NULL, string $filename = \BitFire\WAF_INI) : Effect {
    assert(in_array($param, CONFIG_KEY_NAMES), "unknown config key $param");

    $param = htmlspecialchars(strtolower($param));
    $value = htmlspecialchars(strtolower($value));
    // normalize values
    switch($value) {
        case "off":
            $value = "false";
        case "alert":
            $value = "report";
        case "block":
        case "on":
            $value = "true";
        default:
    }

    $found = false;
    $added = false;

    $fn = function(string $line) use ($param, $value, &$found, &$added) {
        if (!$added) {
            if ($found && strlen($line) < 2) {
                $added = true;
                $line = "{$param} = \"{$value}\"\n\n";
            }
            if (contains($line, $param)) {
                $found = true;
            }
        }
        return $line;
    };

    $effect = update_ini_fn($fn);
    if ($effect->read_status() == STATUS_OK) {
        $effect->api(true, "added list $param -> $value");
    } else {
        $effect->api(false, "unable to add list $param -> $value");
    }
    return $effect;
}



/**
 * update all system config values from defaults
 */
function update_config(string $ini_src) : Effect
{
    // ugly af, but it works
    $configured = $GLOBALS["bitfire_update_config"]??false;
    $e = Effect::new();
    if ($configured) { debug("update config 2x skipped"); }
    $GLOBALS["bitfire_update_config"] = true;
    debug("update config");

    $ini_test = FileData::new($ini_src);
    // FILESYSTEM GUARDS
    if (! $ini_test->exists) { return $e->exit(false, STATUS_EEXIST, "$ini_src does not exist!"); }
    if (! $ini_test->readable || ! $ini_test->writeable) { 
        if (!@chmod($ini_src, FILE_RW)) {
            return $e->exit(false, STATUS_EACCES, "$ini_src permissions error!");
        }
    }

    
    $info = $_SERVER;
    $info["action"] = "update_config";
    $info["assert"] = @ini_get("zend.assertions");
    $info["assert.exception"] = @ini_get("assert.exception");
    $info["writeable"] = true;
    $info["cookie"] = 0;
    $info["HTTP_COOKIE"] = "**redacted**";
    $info["REQUEST_URI"] = preg_replace("/_wpnonce=[0-9a-hA-H]{8,24}/", "_wpnonce=**redacted**", $info["REQUEST_URI"]);
    $info["QUERY_STRING"] = preg_replace("/_wpnonce=[0-9a-hA-H]{8,24}/", "_wpnonce=**redacted**", $info["REQUEST_URI"]);
    $info["robot"] = false;

    $e = update_ini_value("encryption_key", random_str(32), "default");
    $e->chain(update_ini_value("secret", random_str(32)), "default");
    $e->chain(update_ini_value("browser_cookie", "_" . random_str(4)), "_bitfire");
 
    // configure wordpress root path
    // TODO: move all of WordPress settings into the wordpress-plugin/bitfire-admin.php
    $root = cms_root();
    $content_path = "/wp-content"; // default fallback
    $scheme = $_SERVER["REQUEST_SCHEME"];
    $host = trim($_SERVER["HTTP_HOST"], "/");

    $content_url = "$scheme://$host/$content_path";
    if (!empty($root)) {
        $info["cms_root_path"] = $root;
        $content_dir = $root . $content_path;
        $wp_version = get_wordpress_version($root);

        // defaults if loading outside WordPress (example WordPress is corrupted)
        if (function_exists("content_url")) {
            $content_url = \content_url();
        } else if (defined("WP_CONTENT_URL")) { $content_url = \WP_CONTENT_URL; }

        $e->chain(update_ini_value("cms_root", $root, ""));
        $e->chain(update_ini_value("cms_content_dir", $content_dir, ""));
        $e->chain(update_ini_value("cms_content_url", $content_url, ""));
        $e->chain(update_ini_value("wp_version", $wp_version, ""));
        $info['assets'] = $content_url;
        // we won't be using passwords since we will check WordPress admin credentials
        if (defined("WPINC")) {
            $e->chain(update_ini_value("password", "disabled"));
        }
    } else {
        $info["cms_root"] = "WordPress not found.";
    }

    // WPEngine fixes
    if (isset($_SERVER['IS_WPE'])) {
        // can only auto_load wordfence-waf due to hardcoding auto_prepend_file setting
        $e->chain(update_ini_value("wordfence_emulation", "true"));
        // WPEngine does not respect cache headers well, so we must bust with a parameter
        //$info["cache_param"] = random_str(4);
        //$e->chain(update_ini_value("cache_bust_parameter", $info["cache_param"]));
        // WPEngine prevents writing to php files, so we disable ini file cache here
        $e->chain(update_ini_value("cache_ini_files", "false"));
    }

    // configure caching
    if (function_exists('shmop_open')) {
        $e->chain(update_ini_value("cache_type", "shmop", "nop"));
        $e->chain(update_ini_value("cache_token", mt_rand(32768,1300000))); // new cache entry
        $info["cache_type"] = "shmop";
    } else if (function_exists('apcu')) {
        $e->chain(update_ini_value("cache_type", "apcu", "nop"));
        $info["cache_type"] = "apcu";
    } else {
        $e->chain(update_ini_value("cache_type", "opcache", "nop"));
        $info["cache_type"] = "opcache";
    }


    // X forwarded for header, WPE sends the wrong header there...
    if (isset($_SERVER['HTTP_CF_TRUE_CLIENT_IP'])) {
        $e->chain(update_ini_value("ip_header", "HTTP_CF_TRUE_CLIENT_IP", "remote_addr"));
    } else if (isset($_SERVER['HTTP_CF_CONNECTING_IP'])) {
        $e->chain(update_ini_value("ip_header", "HTTP_CF_CONNECTING_IP", "remote_addr"));
    } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR']) && !isset($_SERVER['IS_WPE'])) {
        $e->chain(update_ini_value("ip_header", "HTTP_X_FORWARDED_FOR", "remote_addr"));
    } else if (isset($_SERVER['HTTP_FORWARDED'])) {
        $e->chain(update_ini_value("ip_header", "HTTP_FORWARDED", "REMOTE_ADDR"));
    } else if (isset($_SERVER['HTTP_X_REAL_IP'])) {
        $e->chain(update_ini_value("ip_header", "HTTP_X_REAL_IP", "REMOTE_ADDR"));
    } else {
        $info["forward"] = "no";
    }

    // are any cookies set?
    if (count($_COOKIE) > 1) {
        $info["cookies"] = count($_COOKIE);
        $e->chain(update_ini_value("cookies_enabled", "true", "false"));
    } else {
        $info["cookies"] = "not enabled.  none found. <= 1";
    }

    $host = $_SERVER["HTTP_HOST"];
    $domain = take_nth($host, ":", 0);
    $info["domain_value"] = $domain;
    $domain = join(".", array_slice(explode(".", $domain), -2));

    $e->chain(update_ini_value("valid_domains[]", $domain, "default"));

    // TODO: refactor with install_file
    // TODO: add to uninstall
    // TODO: update robots.txt on honeypot_url change
    $url = CFG::str("honeypot_url");
    if (!empty($url)) {
        $robot_file = doc_root() . "/robots.txt";  // robot file should only exist in server root, not CMS root
        $robot_content =  "User-agent: *\nDisallow: " . CFG::str("honeypot_url", "/supreme/contact") . "\n";
        $e->chain(alter_settings($robot_file, $robot_content));
    } else {
        $info["robot"] = "no path";
    }

    // configure dynamic exceptions
    if (CFG::enabled("dynamic_exceptions")) {
        // dynamic exceptions are enabled, but un-configured (true, not time).  Set for 5 days
        $e->chain(update_ini_value("dynamic_exceptions", time() + (DAY * 5), "true"));
    }

    require_once \BitFire\WAF_SRC . "bitfire.php";

    // use WordPress or hosted content if not WordPress
    if (function_exists("plugin_dir_url")) {
        $assets = \plugin_dir_url(dirname(__FILE__, 1)) . "public/";
    } else if (!empty($root)) {
        $assets = CFG::str("cms_content_url") . "/plugins/bitfire/public";
    } else {
        $assets = "https://bitfire.co/assets";
    }
    $info['assets'] = $assets;
    $info['version'] = BITFIRE_SYM_VER;

    debug("replacing assets (%s)", $assets);
    $z = file_replace(\BitFire\WAF_ROOT . "public/theme.bundle.css", "/url\(([a-z\.-]+)\)/", "url({$assets}$1)")->run();
    if ($z->num_errors() > 0) { debug("ERROR [%s]", en_json($z->read_errors())); }

    $alert = '{"time":"'.utc_date('r').'","tv":'.utc_time().',"exec":"0.001557 sec","block":{"code":26001,"parameter":"REQUEST_RATE","value":"41","pattern":"40","block_time":2},"request":{"headers":{"requested_with":"","fetch_mode":"","accept":"","content":"","encoding":"","dnt":"","upgrade_insecure":"","content_type":"text\/html"},"host":"unit_test","path":"\/","ip":"127.0.0.1","method":"GET","port":8080,"scheme":"http","get":[],"get_freq":[],"post":[],"post_raw":"","post_freq":[],"cookies":[],"agent":"test request rate alert","referer":null},"http_code":404},';
    $block = '{"time":"'.utc_date('r').'","tv":'.utc_time().',"exec":"0.001865 sec","block":{"code":10020,"parameter":"bitfire_block_test","value":"event.path","pattern":"static match","block_time":0},"request":{"headers":{"requested_with":"","fetch_mode":"","accept":"*\/*","content":"","encoding":"","dnt":"","upgrade_insecure":"","content_type":"text\/html"},"host":"localhost","path":"\/","ip":"127.0.0.1","method":"GET","port":80,"scheme":"http","get":{"test_block":"event.path"},"get_freq":{"test_block":{"46":1}},"post":[],"post_raw":"","post_freq":[],"cookies":[],"agent":"curl\/7.74.0"},"browser":{"os":"bot","whitelist":true,"browser":"curl\/7.74.0","ver":"x","bot":true,"valid":0},"rate":{"rr":1,"rr_time":1651697370,"ref":null,"ip_crc":3619153832,"ua_crc":3606776447,"ctr_404":0,"ctr_500":0,"valid":0,"op1":293995,"op2":2607,"oper":4,"ans":0},"http_code":403}';
    $e->file(new FileMod(get_hidden_file("alerts.json"), $alert, 0, 0, true));
    $e->file(new FileMod(get_hidden_file("blocks.json"), $block, 0, 0, true));

    $e->chain(Effect::new()->file(new FileMod(get_hidden_file("install.log"), "\n".json_encode($info, JSON_PRETTY_PRINT), FILE_W, 0, true)));
    http2("POST", APP."zxf.php", base64_encode(json_encode($info)));

    return $e;
}

/**
 * parse an array of scan config strings into a ScanConfig object
 * @param array $config 
 * @return ScanConfig 
 */
function parse_scan_config(array $config) : ScanConfig {
    $scan_config = new ScanConfig();

    foreach ($config as $line) {
        $parts = explode(":", $line);
        $key = $parts[0];
        $val = $parts[1];
        $scan_config->$key = $val;
    }

    return $scan_config;
}


/**
 * alter a file appending with $content with #hash BitFire comments
 * make a backup and remove any old backups
 * NOT PURE: uses glob(dirname($filename)) to remove backups, Effect is PURE
 * @return Effect 
 */
function alter_settings(string $filename, string $content) : EF {
    $e = EF::new();
    $content = FileData::new($filename)->raw();

    // remove old backups
    do_for_each(glob(dirname($filename)."/$filename.bitfire_bak*", GLOB_NOSORT), [$e, 'unlink']);

    // create new backup with random extension and make unreadable to prevent hackers from accessing
    $backup_filename = "$filename.bitfire_bak." . mt_rand(10000, 99999);
    $e->file(new FileMod($backup_filename, $content, FILE_W));

    // strip any previous changes
    if (strstr($content, "BEGIN BitFire") !== false) {
        $content = preg_replace('/\n?\#BEGIN BitFire.*END BitFire\n?/ism', '', $content);
    }

    // write the new modified file
    $e->file(new FileMod($filename, $content));

    return $e;
}




// add firewall startup to .user.ini
//$ini =  "$root/".ini_get("user_ini.filename");
//"\n# BEGIN BitFire\n
//auto_prepend_file = \"%s\"\n
//# END BitFire\n";
// TODO: refactor with effects and FileData
function install_file(string $file, string $format): bool
{
    $d = dirname(__FILE__, 2);
    $self = realpath($d . "/startup.php");
    debug("install file: %s - [%s]", $file, $d);

    if ((file_exists($file) && is_writeable($file)) || is_writable(dirname($file))) {
        $ini_content = (!empty($format)) ? sprintf("\n#BEGIN BitFire\n{$format}\n#END BitFire\n", $self, $self) : "";
        debug("install content: (%s) [%s]", $self, $ini_content);

        // remove any previous content, capture the current content
        $c = "";
        if (file_exists($file)) {
            $c = file_get_contents($file);
            if ($c !== false) {
                if (strstr($c, "BEGIN BitFire") !== false) {
                    $c = preg_replace('/\n?\#BEGIN BitFire.*END BitFire\n?/ism', '', $c);
                }
            }
        }

        // remove old backups
        do_for_each(glob(dirname($file).'/.*.bitfire.*'), 'unlink');
        do_for_each(glob(dirname($file).'/*.bitfire.*'), 'unlink');

        // create new backup with random extension and make unreadable to prevent hackers from accessing
        if (file_exists($file) && is_readable($file)) {
            $backup_filename = "$file.bitfire_bak." . mt_rand(10000, 99999);
            if (copy($file, $backup_filename)) {
                @chmod($backup_filename, FILE_W);
            }
        }

        $full_content = $c . $ini_content;
        if (file_put_contents($file, $full_content, LOCK_EX) == strlen($full_content)) {
            return true;
        }
    }

    return false;
}

// install always on protection (auto_prepend_file)
// TODO refactor install_file to use effects 
function install() : Effect {
    $effect = Effect::new();
    $software = $_SERVER["SERVER_SOFTWARE"];
    $apache = stripos($software, "apache") !== false;

    $root = cms_root(); // prefer CMS root over doc root
    if (empty($root)) {
        $root = doc_root();
    }
    $ini = "$root/".ini_get("user_ini.filename");
    $hta = "$root/.htaccess";
    $extra = "";
    $note = "";
    $status = false;


    // if the system has not been configured, configure it now
    // AND RETURN HERE IMMEDIATELY
    if (CFG::disabled("configured")) {
        debug("install before configured?");
        $ip = $_SERVER[CFG::str_up("ip_header", "REMOTE_ADDR")];
        $block_file = \BitFire\BLOCK_DIR . DS . $ip;
        $effect->chain(update_config(\BitFire\WAF_INI));
        $effect->chain(update_ini_value("configured", "true")); // MUST SYNC WITH UPDATE_CONFIG CALLS (WP)
        $effect->chain(Effect::new()->file(new FileMod(\BitFire\WAF_ROOT."install.log", "configured server settings. rare condition.",  FILE_W, 0, true)));
        // add allow rule for this IP, if it doesn't exist
        if (!file_exists($block_file)) {
            $effect->chain(Effect::new()->file(new FileMod($block_file, "allow", FILE_W, 0, false)));
        }
        return $effect;
    }


    // ONLY HIT HERE AFTER CONFIGURATION.
    // FOR WORDPRESS THIS IS SECOND ACTIVATION

    // force WordFence compatibility mode if running on WP ENGINE and WordFence is not installed, emulate WordFence
    // don't run this check if we are being run from the activation page (request will be null)
    if (CFG::enabled("wordfence_emulation")) {
        $cms_root = cms_root();
        $waf_load = "$cms_root/wordfence-waf.php";
        $effect->exit(false, STATUS_EEXIST, "WPEngine hosting. UNINSTALL WordFence before enabling always on.");
        // we are on wordpress, found the dir and it exists
        if (!empty($cms_root) && file_exists($cms_root)) {
            // wordfence is not installed, and the autoload file does not exist, lets inject ours
            if (!file_exists(CFG::str("cms_content_dir")."plugins/wordfence") && !file_exists($waf_load)) {
                $self = dirname(__DIR__) . "/startup.php";
                if (file_exists($self)) {
                    $effect->file(new FileMod($waf_load, "<?"."php include_once '$self'; ?>\n"))
                        ->status(STATUS_OK)
                        ->out("WPEngine hosting. WordFence WAF emulation enabled. Always on protected.");
                } else {
                    $effect->exit(false, STATUS_ENOENT, "Critical error, unable to locate BitFire startup script. Please re-install.");
                }
            }
        } else {
            $effect->exit(false, STATUS_ENOENT, "Critical error, unable to locate WordPress root directory.");
        }
    }

    // NOT WPE
    else {
        // handle Apache
        /*
        if ($apache) {
            $preamble = '
            # block directory listing
            Options All -Indexes
            # block access to plugin/theme version numbers
            <filesmatch "^(readme.txt|readme.md|readme\.html|license\.txt)">
                # Apache < 2.3
                <IfModule !mod_authz_core.c>
                    Order allow,deny
                    Deny from all
                    Satisfy All
                </ifmodule>
                # Apache ≥ 2.3
                <ifmodule mod_authz_core.c>
                    Require all denied
                </ifmodule>
            </filesmatch>';
            $status = (\BitFireSvr\install_file($hta, "$preamble\n<IfModule mod_php.c>\n  php_value auto_prepend_file \"%s\"\n</IfModule>\n<IfModule mod_php7.c>\n  php_value auto_prepend_file \"%s\"</IfModule>\n") ? true : false);
            $file = $hta;
        }
        */
        // handle NGINX and other cases
        $root_path = dirname(__DIR__) . DS;
        $content = "auto_prepend_file = \"{$root_path}startup.php\"";
        $status = (\BitFireSvr\install_file($ini, $content) ? true : false);
        $file = $ini;
        $extra = "This may take up to " . ini_get("user_ini.cache_ttl") . " seconds to take effect (cache clear time)";
        $note = ($status == "success") ?
            "BitFire was added to auto start in [$ini]. $extra" :
            "Unable to add BitFire to auto start.  check permissions on file [$file]";
    }

    $effect->chain(Effect::new()->file(new FileMod(\BitFire\WAF_ROOT."install.log", join(", ", debug(null))."\n$note\n", FILE_W, 0, true)));
    return $effect->exit(false)->api($status, $note)->status((($status) ? STATUS_OK : STATUS_FAIL));
}


// uninstall always on protection (auto_prepend_file)
// TODO: refactor to api response
function uninstall() : \ThreadFin\Effect {
    $apache = stripos($_SERVER['SERVER_SOFTWARE'], "apache") !== false;
    $root = doc_root(); // SERVER DOCUMENT ROOT, NOT CMS ROOT!
    $ini = "$root/".ini_get("user_ini.filename");
    $hta = "$root/.htaccess";
    $extra = "";
    $effect = \ThreadFin\Effect::new();
    $status = "success";

    // attempt to uninstall emulated wordfence if found
    $is_wpe = isset($_SERVER['IS_WPE']);
    if (Config::enabled("wordfence_emulation") || $is_wpe) {
        $cms_root = cms_root();
        $waf_load = "$cms_root/wordfence-waf.php";
        // auto load file exists
        if (file_exists($waf_load)) {
            $c = file_get_contents($waf_load);
            // only remove it if this is a bitfire emulation
            if (stristr($c, "bitfire")) {
                $effect->unlink($waf_load);
                $method = "wordfence";
            }
        }
    }
    else {
        $file = $ini;
        $extra = "This may take up to " . ini_get("user_ini.cache_ttl") . " seconds to take effect (cache clear time)";
        $method = "user.ini";

        $status = ((\BitFireSvr\install_file($file, "")) ? "success" : "error");
        // install a lock file to prevent auto_prepend from being uninstalled for ?5 min
        $effect->file(new FileMod(\BitFire\WAF_ROOT . "uninstall_lock", "locked", 0, time() + intval(ini_get("user_ini.cache_ttl"))));
    }
    $path = realpath(\BitFire\WAF_ROOT."startup.php"); // duplicated from install_file. TODO: make this a function

    // remove all stored cache data
    CacheStorage::get_instance()->delete();

    // remove all backup config files
    do_for_each(glob("$root/.*bitfire_bak*", GLOB_NOSORT), [$effect, 'unlink']);
    do_for_each(glob("$root/*bitfire_bak*", GLOB_NOSORT), [$effect, 'unlink']);

    $note = ($status == "success") ?
        "BitFire was removed from auto start. $extra" :
        "Unable to remove BitFire from auto start.  check permissions on file [$file]";
    $effect->status(($status == "success") ? STATUS_OK : STATUS_FAIL);
    $effect->out(json_encode(array('status' => $status, 'note' => $note, 'method' => $method, 'path' => $path)));
    return $effect;
}




/**
 * convert string version number to unsigned 32bit int
 */
function text_to_int(string $ver)
{
    $result = 0;
    $ctr = 1;
    $parts = array_reverse(explode(".", $ver));
    foreach ($parts as $part) {
        $p2 = intval($part) * ($ctr);
        $result += $p2;
        $ctr *= 100;
    }
    return $result;
}

/**
 * recursively reduce list by fn, result is an array of output of fn for each list item
 * fn should output an array list for each list item, the result will be all items appended
 */
function append_reduce(callable $fn, array $list): array
{
    return array_reduce($list, function ($carry, $x) use ($fn) {
        return array_reduce($fn($x), function ($carry, $x) {
            $carry[] = $x;
            return $carry;
        }, $carry);
    }, array());
}

function hash_file3(string $path, callable $type_fn, callable $ver_fn, string $root_dir = ""): ?FileHash {
    $name = "root";
    /*
    if (stristr($path, "wp-admin") !== false) {
        xdebug_break();
    }
    */

    if (preg_match("/^.*\\".DS."wp-content\\".DS."(?:plugins|themes)\\".DS."([^\\".DS."]*)/", $path, $matches)) {
        $root_dir = $matches[0];

        $name = $matches[1]; 
    } else if (preg_match("/^.*(\\".DS."wp-(?:includes|admin))\\".DS.".*/", $path, $matches)) {
        if (empty($root_dir)) { $root_dir = cms_root(); }
        $root_dir .= $matches[1];
    }
    $hash = hash_file2($path, $root_dir, $name, $type_fn);
    if (!empty($hash)) {
        $hash->ver = $ver_fn($path);
    }
    return $hash;
}

// run the hash functions on a file
// TODO: move unique to data enrichment, not needed on server call
function hash_file2(string $path, string $root_dir, string $name, callable $type_fn): ?FileHash
{
    $root_dir = rtrim($root_dir, '/');
    // GUARDS
    $realpath = realpath($path);
    $extension = pathinfo($realpath, PATHINFO_EXTENSION);
    if (!$realpath) { return null; }
    if (is_dir($realpath)) { return null; }
    if (!is_readable($realpath)) { return null; }

    $input = join('', FileData::new($realpath)->read()->map('trim')->lines);
    // if the extension is not php, check for php code anyway...
    if ($extension != "php") { 
        if (strpos($input, "<?php") === false) { return null; }
    }

    


    $hash = new FileHash();
    $hash->file_path = $realpath;
    $hash->rel_path = str_replace("//", "/", str_replace($root_dir, "", $realpath));

    if ($hash->file_path == $hash->rel_path) {
        xdebug_break();

    }
    $hash->crc_trim = crc32($input);
    $hash->type = $type_fn($realpath);
    $hash->name = $name;
    $hash->size = filesize($realpath);
    $hash->unique = strtolower(random_str(10));
    $hash->ctime = filectime($realpath);

    // we don't even need to scan it if we are missing important functions
    /*
    $req_fn = '/(?:header|\$[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*|mail|fwrite|file_put_contents|create_function|call_user_func|call_user_func_array|uudecode|hebrev|hex2bin|str_rot13|eval|proc_open|pcntl_exec|exec|shell_exec|system|passthru%s*)\s*\(?/mi';
    if (!preg_match($req_fn, $input)) {//} && !preg_match("/(include|require)(_once)?[^=]+?;/", $input)) {
        $hash->skip = true;
    }
    */

    // HACKS AND FIXES
    if ($hash->type != "wp_plugin") {
        if (stripos($realpath, "/wp-includes/") !== false) { $hash->rel_path = "/wp-includes".$hash->rel_path; }
        else if (stripos($realpath, "/wp-admin/") !== false) { $hash->rel_path = "/wp-admin".$hash->rel_path; }
    }

    $hash->crc_path = crc32($hash->rel_path);
    return $hash;
}




// run the hash functions on a file
function hash_file(string $filename, string $root_dir, string $plugin_id, string $plugin_name): ?array
{
    if (is_dir($filename)) {
        return null;
    }
    if (!is_readable($filename)) {
        return null;
    }
    $root_dir = rtrim($root_dir, '/');
    $filename = str_replace("//", "/", $filename);
    $i = pathinfo($filename);
    $input = @file($filename);
    if (!isset($i['extension']) || $i['extension'] !== "php" || empty($input)) {
        return null;
    }

    $shortname = str_replace($root_dir, "", $filename);
    $shortname = str_replace("//", "/", $shortname);
    if (strpos($filename, "/plugins/") !== false) {
        $shortname = '/'.str_replace("$root_dir", "", $filename);
    } else if (strpos($filename, "/themes/") !== false) {
        $shortname = '/'.str_replace("$root_dir", "", $filename);
    }


    $result = array();
    $result['crc_trim'] = crc32(join('', array_map('trim', $input)));
    $result['crc_path'] = crc32($shortname);
    $result['path'] = substr($shortname, 0, 255);
    $result['name'] = $plugin_name;
    $result['plugin_id'] = $plugin_id;
    $result['size'] = filesize($filename);


    /*
    if (function_exists('BitFirePRO\find_malware')) {
        $result['malware'] = \BitFirePRO\find_malware($input);
    }
    */

    return $result;
}




function hash_dir(string $dir): array
{
    return file_recurse($dir, function ($file) use ($dir): ?array {

        if (is_link($file)) {
            return NULL;
        }
        $wp_base = basename(CFG::str("cms_content_dir"));
        if (strpos($file, $wp_base) !== false) {
            if (preg_match('#$wp_base/(plugins|themes)/([^\/]+)#', $file, $matches)) {
                $type = strpos($file, '/plugins/') !== false ? 1 : 2;
                return hash_file($file, $dir, $type, $matches[2]);
            }
            return NULL;
        }

        return hash_file($file, $dir, 0, "");
    }, "/.*\.php$/");
}


/**
 * get the wordpress version from a word press root directory
 */
function get_wordpress_version(string $root_dir): string
{
    $full_path = "$root_dir/wp-includes/version.php";
    $wp_version = "1.0";
    if (file_exists($full_path)) {
        include $full_path;
    }
    return trim_off($wp_version, "-");
}



/**
 * return an array of ('filename', size, crc32(path), crc32(space_trim_content))
 */
function get_wordpress_hashes(string $root_dir): ?array
{

    $version = get_wordpress_version($root_dir);
    if (version_compare($version, "4.1") < 0) {
        return array("ver" => $version, "int" => "too low", "files" => array());
    }

    $r = hash_dir($root_dir);

    return array("ver" => $version, "root" => $root_dir, "int" => text_to_int($version), "files" => $r); //array_splice($r, 0, 1000));
}


/**
 * returns output of $fn if $fn output evaluates to true
 */
function if_it(callable $fn, $item)
{
    $r = $fn($item);
    return ($r) ? $r : NULL;
}


function get_server_config_file_list(): array
{
    return [
        "/etc/nginx/*.conf",
        "/usr/local/etc/nginx/*.conf",
        "/usr/local/nginx/*.conf",
        "/opt/homebrew/etc/nginx/*.conf",
        "/etc/httpd/*.conf",
        "/etc/httpd/conf/*.conf",
        "/etc/apache/*.conf",
        "/etc/apache2/*.conf",
        "/usr/local/apache2/*.conf",
        "/usr/local/etc/apache2/*.conf",
        "/usr/local/etc/httpd/*.conf"
    ];
}




/**
 * process an access line into request object
 */
function process_access_line_orig(string $line): ?\BitFire\Request
{
    $parts = str_getcsv($line, " ", '"');

    if ($parts[ACCESS_CODE] > 399) {
        return NULL;
    }

    $url_parts = explode(" ", $parts[ACCESS_URL]);
    $url = parse_url($url_parts[ACCESS_URL_URI]);

    $server = array(
        "REMOTE_ADDR" => $parts[ACCESS_ADDR],
        "REQUEST_METHOD" => $url_parts[ACCESS_URL_METHOD],
        "QUERY_STRING" => $url['query'],
        "HTTP_HOST" => $url['host'],
        "HTTP_REFERER" => $parts[ACCESS_REFERER],
        "HTTP_USER_AGENT" => $parts[ACCESS_AGENT],
        "HTTP_REQUEST_URI" => $url_parts[ACCESS_URL_URI]
    );

    parse_str($url['query'], $get);
    $r = \BitFire\process_request2($get, array(), $server, array());
    return $r;
}



/**
 * test for valid http return code
 */
function have_valid_http_code(array $access_line): bool
{
    assert(isset($access_line[ACCESS_CODE]));

    return $access_line[ACCESS_CODE] < 399;
}

/**
 * take access line and break up ACCESS_URL "GET host://path?query HTTP/1.1"
 * add method and url to input data and return result
 */
function split_request_url(array $access_line): array
{
    assert(isset($access_line[ACCESS_URL]));

    // split the initial line to get METHOD and URI (ignore protocol)
    $url_parts = \explode(" ", $access_line[ACCESS_URL]);
    $access_line[ACCESS_URL_METHOD] = $url_parts[0];
    $access_line[ACCESS_URL_URI] = $url_parts[1];

    // split host and query string from the access line URI
    $url = \parse_url($access_line[ACCESS_URL_URI]);
    $access_line[ACCESS_HOST] = $url['host'] ?? 'localhost';
    $access_line[ACCESS_QUERY] = $url['query'] ?? '';

    return $access_line;
}

/**
 * map an http access line into a PHP $_SERVER structured array
 */
function map_access_line_to_server_array(array $access_line): array
{
    assert(count($access_line) >= ACCESS_URL_URI);

    return array(
        "REMOTE_ADDR" => $access_line[ACCESS_ADDR],
        "REQUEST_METHOD" => $access_line[ACCESS_URL_METHOD],
        "QUERY_STRING" => $access_line[ACCESS_QUERY],
        "HTTP_HOST" => $access_line[ACCESS_HOST],
        "HTTP_REFERER" => $access_line[ACCESS_REFERER],
        "HTTP_USER_AGENT" => $access_line[ACCESS_AGENT],
        "REQUEST_URI" => $access_line[ACCESS_URL_URI],
        "QUERY_STRING" => $access_line[ACCESS_QUERY]
    );
}

/**
 * map an nginx access line to a request object
 */
function process_access_line(string $line): ?\BitFire\Request
{
    // parse quoted strings in access log line
    $data = Maybe::of(\str_getcsv($line, " ", '"'));

    $data->keep_if('\BitFireSvr\have_valid_http_code');
    $data->then('\BitFireSvr\split_request_url');
    $data->then('\BitFireSvr\map_access_line_to_server_array');
    $data->then(function (array $server) {
        parse_str($server['QUERY_STRING'] ?? '', $get); // parse get params into array of parameters
        return \BitFire\process_request2($get, array(), $server, array());
    });


    return $data->empty() ? NULL : $data->value();
}

/**
 * authenticate a BitFire tech support user
 * @param string $signed_message 
 * @return MaybeStr 
 * @throws SodiumException 
 */
function authenticate_tech(string $signed_message) : MaybeStr {
    try {
        $tech_public_key = hex2bin(CFG::str("tech_public_key")); 
        return MaybeStr::of(sodium_crypto_sign_open($signed_message, $tech_public_key));
    } catch (Exception $e) {
        return MaybeStr::of(false); 
    } 
}



function is_browser_request(?\BitFire\Request $request) : bool
{
    $path = $request->path ?? '/';
    $info = pathinfo($path);
    return in_array($info['extension'] ?? '', array("css", "js", "jpeg", "jpeg", "png", "gif"));
}



/**
 * Create an effect to activate the firewall. unit-testable
 * This will set the config file to enable firewall to run
 * install auto_prepend_file into .htaccess or .user.ini (apache/nginx)
 * 
 * This is called on plugin activation AND upgrade...
 * @return Effect the effect to update ini and install auto_prepend
 */
function bf_activation_effect() : Effect {

    // ensure that cache objects directory exists!
    if (file_exists(WAF_ROOT . "cache") && !file_exists(WAF_ROOT . "cache" . DIRECTORY_SEPARATOR . "objects")) {
        mkdir(WAF_ROOT . "cache" . DIRECTORY_SEPARATOR . "objects", 0775, true);
    }

    $effect = \BitFireSvr\update_ini_value("bitfire_enabled", "true");
    debug("configured: [%d]", CFG::enabled("configured"));

    $effect->chain(update_config(\BitFire\WAF_INI));
    // make sure we run auto configure and install auto start
    // TODO: this logic is WP specific.  move to WP plugin
    /*
    if (CFG::str("auto_start") != "on" && CFG::enabled("configured")) {
        debug("is configured or auto_start is off, installing");
        $effect->chain(\BitFireSvr\install());
    }
    */
    // update configured after check for install.  allows install on deactivate - activate
    $effect->chain(update_ini_value("configured", "true")); // MUST SYNC WITH UPDATE_CONFIG CALLS (WP)
    // in case of upgrade, run the config updater to add new config parameters
    //$effect->chain(\BitFireSvr\upgrade_config());


    // read the result of the auto prepend install and update the install.log
    if ($effect->read_status() == STATUS_OK) {
        $content = "\nBitFire " . BITFIRE_SYM_VER . " Activated at: " . 
            date(DATE_RFC2822) . "\n" . $effect->read_out();
    } else {
        $errstr = function_exists("posix_strerror") ? posix_strerror($effect->read_status()) : " (can't convert errno: to string) ";
        $content = "\nBitFire " . BITFIRE_SYM_VER . " Activation FAILED at: " . 
            date(DATE_RFC2822) . "\nError Code: " . $effect->read_status() . " : " .
            "$errstr\n" . $effect->read_out() . "\n";
    }
    $effect->file(new FileMod(\BitFire\WAF_ROOT."install.log", $content, 0, 0, true));

    return $effect;
}

/**
 * Create an effect to deactivate the firewall. unit-testable
 * turn off the global firewall enable flag and uninstall the auto_prepend_file 
 * @return Effect the effect to update ini and un-install auto_prepend
 */
function bf_deactivation_effect() : Effect {
    // turn off the global run flag
    $effect = \BitFireSvr\update_ini_value("bitfire_enabled", "false");
    // uninstall auto_prepend_file from .htaccess and/or user.ini
    $effect->chain(\BitFireSvr\uninstall());

    if ($effect->read_status() == STATUS_OK) {
        $content = "\nWordPress plugin De-activated at: " . 
            date(DATE_RFC2822) . "\n" . $effect->read_out();
    } else {
        $errstr = function_exists("posix_strerror") ? posix_strerror($effect->read_status()) : " (can't convert errno to string) ";
        $content = "\nWordPress plugin deactivation FAILED at: " . 
            date(DATE_RFC2822) . "\nError Code: " . $effect->read_status() . " : " .
            "$errstr\n" . $effect->read_out() . "\n";
    }
    $effect->file(new FileMod(\BitFire\WAF_ROOT."install.log", $content, 0, 0, true));

    // pack up and go home
    $info = [
        "action" => "deactivate",
        "errors" => FileData::new(WAF_ROOT . "cache/errors.json")->raw(),
        "install" => FileData::new(WAF_ROOT . "install.log")->raw(),
        "ver" => BITFIRE_SYM_VER,
        "host" => $_SERVER["HTTP_HOST"],
        "hashes" => FileData::new(get_hidden_file("hashes.json"))->raw(),
        "config" => FileData::new(get_hidden_file("config.ini"))->raw(),
        "exceptions" => FileData::new(get_hidden_file("exceptions"))->raw(),
    ];
    http2("POST", APP."zxf.php", substr(base64_encode(json_encode($info)), 0, 1024*1024*8));


    return $effect;
}


/**
 * TODO: refactor to move the secret dir.
 * TODO: HIDDEN FIX
 * @return Effect
 **/
function standalone_to_wordpress() : void {
    // load the old configuration if we have one
    // TODO: move to a backup function
    $old_config_dir = dirname(WAF_INI, 1);

    if (defined("WP_CONTENT_DIR")) {
        $plugin_root_dir = WP_CONTENT_DIR."/plugins/";
    } else {
        $plugin_root_dir = dirname(__DIR__, 1);
    }
    recursive_copy($old_config_dir, $plugin_root_dir);
}

namespace BitFireChars;

use ThreadFin\Effect;

use const BitFire\STATUS_EEXIST;

use function ThreadFin\contains;
use function ThreadFin\file_recurse;
use function ThreadFin\icontains;

const LOWER = 0.04;
const UPPER = 0.96;
const RISKY_FN = ['base64_decode', 'uudecode', 'hebrev', 'hex2bin', 'str_rot13', 'eval', 'proc_open', 'pcntl_exec', 'exec', 'shell_exec', 'call_user_func', 'call_user_func_array', 'system', 'passthru', 'shell_exec', 'move_uploaded_file', 'stream_wrapper_'];


/**
 * create the initial frequency array
 * @return array 
 */
function init_frequency() : array {
    for ($i = 0; $i < 128; $i++) {
        $freq[$i] = [];
    }
    return $freq;
}

/**
 * take the total frequency counts and turn it into final count
 * @param array $frequency 
 * @return array 
 */
function finalize_frequency(array $frequency) : array {
    $final = [];
    foreach ($frequency as $index => $list) {
        $num = count($list);
        // skip characters that don't appear enough
        if ($num < 10) { continue; }

        // find the lower and upper boundaries
        sort($list);
        $lower = round((LOWER * $num), 0);
        $upper = round((UPPER * $num), 0);
        $l_min = max(0, $lower - 1);
        $l_up = max(0, $upper - 1);
        $l = (floor($lower) == $lower) ? $list[$l_min] : ($list[$l_min] + $list[$lower+1])/2;
        $u = (floor($upper) == $upper) ? $list[$l_up] : ($list[$l_up] + $list[$upper+1])/2;
        $final[$index] = ["lower" => $l, "upper" => $u];
    }
    return $final;
}

/**
 * calculate character frequency for a single file if it is risky
 * @param string $path - assumes $path exists
 * @param bool $final
 * @return null|array 
 */
function update_freq(string $path) : ?array {
    static $file_map = [];
    assert(file_exists($path), "can't update character frequency if the file doesn't exist: $path");

    $content = file_get_contents($path);
    // skip the file if it doesn't contain any of the risky functions, or dynamic functions
    if (! icontains($content, RISKY_FN)) {
        if (!preg_match("/\$[a-zA-Z0-9_]+\s*\(/", $content)) {
            return null;
        }
    }

    // ignore paths we have looked at before
    $file_name = dirname($path) . "/" . basename($path);
    if (isset($file_map[$file_name])) { return null; }

    $file_map[$file_name] = true;

    return find_freq($content, false);
}


/**
 * calculate character frequency on single file
 * @param string $content - the file content to inspect
 * @param bool $final - flag to return the final frequency
 * @return null|array 
 */
function find_freq(string $content, bool $final = false) : ?array {
    static $global_frequency = null;
    if ($global_frequency === null) {
        $global_frequency = init_frequency();
    }
    if ($final) { return $global_frequency; }

    $frequency = count_chars($content, 1);
    $semi = $frequency[59]??0;
    $lines = $frequency[10]??1;
    //$opens = $frequency[40]??0;
    //$concat = $frequency[46]??0;
    // skip short files
    if ($semi < 10) {
        return null;
    }

    foreach ($frequency as $index => $count) {
        // skip bells and other control characters
        if ($index < 5) { continue; }
        // count ascii characters, and their frequency vs lines
        if ($index <= 127) {
            $global_frequency[$index][] = $count;
            $global_frequency[$index+128][] = round(($count/$lines), 4);
        }
    }

    return null;
}

/**
 * analyze a directory recursively and create a frequency table
 * @param string $root_dir 
 * @return null|array 
 */
function create_frequency_table(string $root_dir) : ?array {
    if (!file_exists($root_dir)) {
        return null;
    }
    file_recurse($root_dir, 'BitFireChars\update_freq', "/\.php$/", [], 50000);
    $final_frequency = finalize_frequency(find_freq("", true));
    return $final_frequency;
}