feat: add WAF + Attack Intelligence system

- class-itk-waf.php: WordPress WAF scanning GET/POST/COOKIE/UA
- class-itk-attacks-api.php: queue/flush/history client for Attack API
- config/waf-rules.conf: 9 attack categories, 60+ WP-specific rules
- class-itk-database.php: itk_attack_log table, DB version 2
- class-itk-admin.php: WAF tab (toggles, response settings, API card),
  Attack Logs tab (filterable table), attacks dispatch in AJAX handlers
- informatiq-toolkit.php: wire WAF + Attacks API into plugin bootstrap
- .gitignore: exclude attack-api/ (separate repo)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 09:37:31 +02:00
parent a8d7972ad7
commit 742047915f
7 changed files with 1093 additions and 4 deletions

View File

@@ -135,6 +135,48 @@ class ITK_Admin {
], []);
$this->redirect(['tab' => 'honeypot', 'saved' => 1]);
break;
case 'save_settings_waf':
$this->save_settings_form('itk_waf', [
'action', 'response_code', 'redirect_url', 'custom_message',
], [
'enabled', 'log_attacks', 'scan_post', 'scan_cookies', 'scan_ua',
'block_sqli', 'block_xss', 'block_lfi', 'block_rfi', 'block_cmdi',
'block_xxe', 'block_php_inject', 'block_ssrf', 'block_wp_specific',
]);
(new ITK_WAF())->invalidate_cache();
$this->redirect(['tab' => 'waf', 'saved' => 1]);
break;
case 'clear_attack_log':
ITK_Database::clear_attack_log();
$this->redirect(['tab' => 'attack-logs', 'cleared' => 1]);
break;
case 'save_attacks_api':
$this->save_api_settings(ITK_Attacks_API::OPT_SETTINGS, 'itk_attacks_api_settings');
$this->redirect(['tab' => 'waf', 'saved' => 1]);
break;
case 'test_attacks_api':
$result = ITK_Attacks_API::test_connection();
$s = ITK_Attacks_API::settings();
$s['connection_ok'] = $result['ok']; $s['last_verified'] = time();
$s['last_error'] = $result['ok'] ? '' : $result['message'];
update_option(ITK_Attacks_API::OPT_SETTINGS, $s);
set_transient('itk_attacks_api_test_result', $result, 60);
$this->redirect(['tab' => 'waf', 'api_tested' => 1]);
break;
case 'flush_attacks_api':
ITK_Attacks_API::flush();
$this->redirect(['tab' => 'waf', 'api_flushed' => 1]);
break;
case 'send_attacks_history':
$result = ITK_Attacks_API::send_history_batch();
set_transient('itk_attacks_history_result', $result, 60);
$this->redirect(['tab' => 'waf', 'history_sent' => 1]);
break;
case 'reset_attacks_history':
delete_option('itk_attacks_history_last_id');
delete_option('itk_attacks_history_sent');
$this->redirect(['tab' => 'waf']);
break;
}
}
@@ -179,7 +221,11 @@ class ITK_Admin {
check_ajax_referer(self::NONCE_ACTION, 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error('unauthorized');
$which = sanitize_key($_POST['api'] ?? '');
$result = $which === 'bot' ? ITK_Bot_API::test_connection() : ITK_HP_API::test_connection();
$result = match ($which) {
'bot' => ITK_Bot_API::test_connection(),
'attacks' => ITK_Attacks_API::test_connection(),
default => ITK_HP_API::test_connection(),
};
wp_send_json($result);
}
@@ -189,7 +235,11 @@ class ITK_Admin {
check_ajax_referer(self::NONCE_ACTION, 'nonce');
if (!current_user_can('manage_options')) wp_send_json_error('unauthorized');
$which = sanitize_key($_POST['api'] ?? '');
if ($which === 'bot') ITK_Bot_API::flush(); else ITK_HP_API::flush();
match ($which) {
'bot' => ITK_Bot_API::flush(),
'attacks' => ITK_Attacks_API::flush(),
default => ITK_HP_API::flush(),
};
wp_send_json_success('Queue flushed.');
}
@@ -208,7 +258,7 @@ class ITK_Admin {
$setting = sanitize_key($_POST['setting'] ?? '');
$value = (int)($_POST['value'] ?? 0);
$allowed = ['itk_security','itk_optimization','itk_honeypot'];
$allowed = ['itk_security','itk_optimization','itk_honeypot','itk_waf'];
if (!in_array($option, $allowed, true) || empty($setting)) {
wp_send_json_error('invalid');
}
@@ -279,8 +329,10 @@ class ITK_Admin {
'protection' => 'Protection',
'optimization' => 'Optimization',
'honeypot' => 'Honeypot',
'waf' => 'WAF',
'bot-logs' => 'Bot Logs',
'honeypot-logs' => 'Honeypot Logs',
'attack-logs' => 'Attack Logs',
'config-files' => 'Config Files',
];
foreach ($tabs as $slug => $label):
@@ -299,8 +351,10 @@ class ITK_Admin {
'protection' => $this->tab_protection(),
'optimization' => $this->tab_optimization(),
'honeypot' => $this->tab_honeypot(),
'waf' => $this->tab_waf(),
'bot-logs' => $this->tab_bot_logs(),
'honeypot-logs' => $this->tab_honeypot_logs(),
'attack-logs' => $this->tab_attack_logs(),
'config-files' => $this->tab_config_files(),
default => $this->tab_dashboard(),
};
@@ -886,6 +940,276 @@ class ITK_Admin {
<?php
}
/* ══════════════════════════════════════════════════════════
* TAB: WAF
* ══════════════════════════════════════════════════════════ */
private function tab_waf(): void {
$opts = get_option('itk_waf', []);
?>
<div class="itk-settings-grid">
<!-- ── WAF Enable + Category Toggles ── -->
<section class="itk-card">
<h2>Web Application Firewall</h2>
<?php
$toggles = [
'enabled' => ['Enable WAF', 'Inspect incoming requests against all active rule categories'],
'block_sqli' => ['SQL Injection', 'Block SQLi patterns in query parameters, POST fields, and URIs'],
'block_xss' => ['Cross-Site Scripting', 'Block XSS payloads including script tags, event handlers, and JS URIs'],
'block_lfi' => ['Local File Inclusion', 'Block path traversal and LFI patterns (../../etc/passwd etc.)'],
'block_rfi' => ['Remote File Inclusion', 'Block attempts to include remote URLs as file paths'],
'block_cmdi' => ['Command Injection', 'Block OS command injection patterns (;, |, backticks, etc.)'],
'block_xxe' => ['XML External Entity', 'Block XXE payloads in request bodies and parameters'],
'block_php_inject' => ['PHP Code Injection', 'Block PHP code execution attempts (eval, base64_decode, etc.)'],
'block_ssrf' => ['SSRF', 'Block Server-Side Request Forgery patterns targeting internal hosts'],
'block_wp_specific' => ['WordPress-Specific','Block WP-targeted probes (xmlrpc abuse, admin enumeration, etc.)'],
];
foreach ($toggles as $key => [$label, $desc]):
$this->render_toggle('itk_waf', $key, $label, $desc, $opts);
endforeach;
?>
</section>
<!-- ── Scan Scope + Action Settings ── -->
<section class="itk-card">
<h2>Scan Scope &amp; Response</h2>
<?php
$scope_toggles = [
'scan_post' => ['Scan POST Data', 'Inspect POST body fields for attack patterns'],
'scan_cookies' => ['Scan Cookies', 'Inspect cookie values (adds overhead; off by default)'],
'scan_ua' => ['Scan User-Agent', 'Inspect the User-Agent header'],
'log_attacks' => ['Log Attacks', 'Record matched attacks to the local attack log table'],
];
foreach ($scope_toggles as $key => [$label, $desc]):
$this->render_toggle('itk_waf', $key, $label, $desc, $opts);
endforeach;
?>
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=waf" style="margin-top:20px">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="save_settings_waf">
<table class="form-table">
<tr>
<th>Action</th>
<td>
<select name="itk_waf[action]">
<option value="block" <?= selected($opts['action'] ?? 'block', 'block', false) ?>>Block request</option>
<option value="log_only" <?= selected($opts['action'] ?? 'block', 'log_only', false) ?>>Log only (no block)</option>
</select>
<p class="description">Choose whether to block the request or only log the attack.</p>
</td>
</tr>
<tr>
<th>Response Code</th>
<td>
<select name="itk_waf[response_code]">
<option value="403" <?= selected($opts['response_code'] ?? '403', '403', false) ?>>403 Forbidden</option>
<option value="400" <?= selected($opts['response_code'] ?? '403', '400', false) ?>>400 Bad Request</option>
<option value="301_custom" <?= selected($opts['response_code'] ?? '403', '301_custom', false) ?>>301 Redirect to URL</option>
</select>
</td>
</tr>
<tr>
<th>Redirect URL</th>
<td>
<input type="url" name="itk_waf[redirect_url]" value="<?= esc_attr($opts['redirect_url'] ?? '') ?>" class="regular-text" placeholder="https://example.com/blocked">
<p class="description">Only used when response code is 301 redirect.</p>
</td>
</tr>
<tr>
<th>Block Message</th>
<td>
<input type="text" name="itk_waf[custom_message]" value="<?= esc_attr($opts['custom_message'] ?? 'Access denied.') ?>" class="regular-text">
<p class="description">Message shown on 403/400 responses.</p>
</td>
</tr>
</table>
<?php submit_button('Save WAF Settings'); ?>
</form>
</section>
<!-- ── Central Attacks API card ── -->
<?php
$atk_api = ITK_Attacks_API::settings();
$atk_queue = count((array) get_option(ITK_Attacks_API::OPT_QUEUE, []));
$atk_total = ITK_Database::count_attack_rows();
$atk_sent = (int) get_option('itk_attacks_history_sent', 0);
$atk_rem = max(0, $atk_total - $atk_sent);
$atk_ok = $atk_api['connection_ok'];
$atk_cls = is_null($atk_ok) ? 'itk-api-unknown' : ($atk_ok ? 'itk-api-ok' : 'itk-api-err');
$atk_lbl = is_null($atk_ok) ? 'Not tested' : ($atk_ok ? 'Connected' : 'Connection failed');
$atk_test_r = get_transient('itk_attacks_api_test_result'); if ($atk_test_r) delete_transient('itk_attacks_api_test_result');
$atk_hist_r = get_transient('itk_attacks_history_result'); if ($atk_hist_r) delete_transient('itk_attacks_history_result');
?>
<section class="itk-card itk-api-card">
<h2>Central Attacks API</h2>
<p class="description itk-api-desc">Send WAF attack events to your self-hosted Attack Intelligence Docker stack (port 3083).</p>
<div class="itk-api-status-bar">
<span class="itk-api-badge <?= esc_attr($atk_cls) ?>"><?= esc_html($atk_lbl) ?></span>
<?php if ($atk_api['last_verified'] > 0): ?>
<span class="itk-api-time">Last tested <?= esc_html(human_time_diff((int)$atk_api['last_verified'])) ?> ago</span>
<?php endif; ?>
<?php if (!$atk_ok && !is_null($atk_ok) && !empty($atk_api['last_error'])): ?>
<span class="itk-api-err-msg"><?= esc_html($atk_api['last_error']) ?></span>
<?php endif; ?>
</div>
<?php if ($atk_test_r): ?>
<div class="itk-api-notice <?= $atk_test_r['ok'] ? 'itk-api-notice-ok' : 'itk-api-notice-err' ?>"><?= esc_html($atk_test_r['message']) ?></div>
<?php endif; ?>
<?php if ($atk_hist_r): ?>
<div class="itk-api-notice <?= $atk_hist_r['ok'] ? 'itk-api-notice-ok' : 'itk-api-notice-err' ?>"><?= esc_html($atk_hist_r['message']) ?></div>
<?php endif; ?>
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=waf">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="save_attacks_api">
<table class="form-table itk-api-table">
<tr>
<th>Enable</th>
<td><label><input type="checkbox" name="itk_attacks_api_settings[enabled]" value="1" <?= checked(!empty($atk_api['enabled'])) ?>> Send attacks to Central API</label></td>
</tr>
<tr>
<th>API URL</th>
<td>
<input type="url" name="itk_attacks_api_settings[api_url]" value="<?= esc_attr($atk_api['api_url'] ?? '') ?>" class="regular-text" placeholder="http://your-server:3083">
<p class="description">Base URL of your Attack API stack (e.g. <code>http://localhost:3083</code>)</p>
</td>
</tr>
<tr>
<th>API Token</th>
<td>
<input type="password" name="itk_attacks_api_settings[api_token]" value="" class="regular-text"
placeholder="<?= !empty($atk_api['api_token']) ? '●●●●●●●● (set — leave blank to keep)' : 'Enter bearer token' ?>"
autocomplete="new-password">
</td>
</tr>
</table>
<div class="itk-api-form-actions">
<?php submit_button('Save Attacks API Settings', 'primary', 'submit', false); ?>
<button type="button" class="button itk-btn-test-api" data-api="attacks" style="margin-left:8px">Test Connection</button>
<span class="itk-api-ajax-result" style="margin-left:10px;display:none"></span>
</div>
</form>
<div class="itk-api-footer">
<div class="itk-api-queue-row">
<strong><?= (int) $atk_queue ?></strong> event(s) pending in queue
<button type="button" class="button button-small itk-btn-flush-api" data-api="attacks" style="margin-left:8px">Flush Now</button>
<span class="itk-api-flush-result" style="margin-left:8px;display:none"></span>
</div>
<div class="itk-api-history-row">
<strong>Historical sync:</strong>
<?= number_format($atk_sent) ?> / <?= number_format($atk_total) ?> records sent
<?php if ($atk_rem > 0): ?><em class="itk-api-rem">(<?= number_format($atk_rem) ?> remaining)</em><?php endif; ?>
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=waf" style="display:inline;margin-left:10px">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="send_attacks_history">
<input type="submit" class="button button-small" value="Send Next 50">
</form>
<?php if ($atk_sent > 0): ?>
<form method="post" action="options-general.php?page=<?= self::MENU_SLUG ?>&tab=waf" style="display:inline;margin-left:6px" onsubmit="return confirm('Reset sync progress? No data is deleted.')">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="reset_attacks_history">
<input type="submit" class="button button-small" value="Reset Progress">
</form>
<?php endif; ?>
</div>
</div>
</section>
</div>
<?php
}
/* ══════════════════════════════════════════════════════════
* TAB: ATTACK LOGS
* ══════════════════════════════════════════════════════════ */
private function tab_attack_logs(): void {
$search = sanitize_text_field($_GET['atk_search'] ?? '');
$filter_ip = sanitize_text_field($_GET['atk_ip'] ?? '');
$filter_type = sanitize_key($_GET['atk_type'] ?? '');
$paged = max(1, (int)($_GET['paged'] ?? 1));
$offset = ($paged - 1) * self::PER_PAGE;
$args = ['per_page' => self::PER_PAGE, 'offset' => $offset];
if ($search) $args['search'] = $search;
if ($filter_ip) $args['ip'] = $filter_ip;
if ($filter_type) $args['attack_type'] = $filter_type;
$rows = ITK_Database::get_attack_rows($args);
$total = ITK_Database::count_attack_rows($args);
$attack_types = ITK_Database::get_attack_types();
$total_pages = max(1, (int)ceil($total / self::PER_PAGE));
$base_url = admin_url('options-general.php?page=' . self::MENU_SLUG . '&tab=attack-logs');
?>
<div class="itk-log-page">
<!-- Filters -->
<form method="get" class="itk-filters">
<input type="hidden" name="page" value="<?= self::MENU_SLUG ?>">
<input type="hidden" name="tab" value="attack-logs">
<input type="text" name="atk_search" placeholder="Search IP, param, payload…" value="<?= esc_attr($search) ?>">
<input type="text" name="atk_ip" placeholder="Filter by IP" value="<?= esc_attr($filter_ip) ?>">
<select name="atk_type">
<option value="">All attack types</option>
<?php foreach ($attack_types as $at): ?>
<option value="<?= esc_attr($at) ?>" <?= selected($filter_type, $at, false) ?>><?= esc_html($at) ?></option>
<?php endforeach; ?>
</select>
<input type="submit" class="button" value="Filter">
<a href="<?= esc_url($base_url) ?>" class="button">Reset</a>
</form>
<p class="itk-count">Showing <?= count($rows) ?> of <?= number_format($total) ?> result(s)</p>
<table class="itk-log-table widefat striped">
<thead>
<tr>
<th>Date / Time</th><th>IP</th><th>Type</th>
<th>Source</th><th>Param</th><th>Payload</th><th>URI</th><th>Method</th>
</tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr><td colspan="8" class="itk-no-results">No attacks logged yet.</td></tr>
<?php else: ?>
<?php foreach ($rows as $row): ?>
<tr>
<td class="itk-nowrap"><?= esc_html($row->logged_at) ?></td>
<td>
<?= esc_html($row->ip_address) ?>
<a href="<?= esc_url($base_url . '&atk_ip=' . urlencode($row->ip_address)) ?>" class="itk-filter-link">[filter]</a>
<a href="https://ipinfo.io/<?= urlencode($row->ip_address) ?>" target="_blank" class="itk-filter-link">[lookup]</a>
</td>
<td><span class="itk-badge itk-badge-block"><?= esc_html($row->attack_type) ?></span></td>
<td><?= esc_html($row->source) ?></td>
<td class="itk-uri"><?= esc_html(substr($row->param, 0, 60)) ?></td>
<td class="itk-uri" title="<?= esc_attr($row->payload) ?>"><?= esc_html(substr($row->payload, 0, 80)) ?></td>
<td class="itk-uri"><?= esc_html(substr($row->request_uri, 0, 80)) ?></td>
<td><?= esc_html($row->method) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<?php $this->render_pager($paged, $total_pages, $base_url); ?>
<!-- Clear logs -->
<form method="post" style="margin-top:16px" onsubmit="return confirm('Delete ALL attack log entries?')">
<?php wp_nonce_field(self::NONCE_ACTION); ?>
<input type="hidden" name="itk_action" value="clear_attack_log">
<input type="submit" class="button button-secondary itk-btn-danger" value="Clear All Attack Logs">
</form>
</div>
<?php
}
/* ══════════════════════════════════════════════════════════
* TAB: BOT LOGS
* ══════════════════════════════════════════════════════════ */