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:
239
includes/class-itk-attacks-api.php
Normal file
239
includes/class-itk-attacks-api.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
/**
|
||||
* ITK Attacks Central API Client
|
||||
*
|
||||
* Queues WAF attack events locally and batch-submits them to the
|
||||
* central Attack Intelligence API Docker stack (port 3092).
|
||||
*/
|
||||
class ITK_Attacks_API {
|
||||
|
||||
const OPT_SETTINGS = 'itk_attacks_api_settings';
|
||||
const OPT_QUEUE = 'itk_attacks_api_queue';
|
||||
const CRON_HOOK = 'itk_attacks_api_flush';
|
||||
const QUEUE_MAX = 500;
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
/* ── Bootstrap ────────────────────────────────────────────── */
|
||||
|
||||
public static function register_cron(): void {
|
||||
if (!wp_next_scheduled(self::CRON_HOOK)) {
|
||||
wp_schedule_event(time(), 'itk_5min', self::CRON_HOOK);
|
||||
}
|
||||
add_action(self::CRON_HOOK, [self::class, 'flush']);
|
||||
add_action('shutdown', [self::class, 'flush_shutdown']);
|
||||
}
|
||||
|
||||
public static function clear_cron(): void {
|
||||
wp_clear_scheduled_hook(self::CRON_HOOK);
|
||||
}
|
||||
|
||||
/* ── Settings ─────────────────────────────────────────────── */
|
||||
|
||||
public static function defaults(): array {
|
||||
return [
|
||||
'enabled' => false,
|
||||
'api_url' => '',
|
||||
'api_token' => '',
|
||||
'last_sync' => 0,
|
||||
'sent_total' => 0,
|
||||
'connection_ok' => null,
|
||||
'last_verified' => 0,
|
||||
'last_error' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public static function settings(): array {
|
||||
return array_merge(self::defaults(), (array)get_option(self::OPT_SETTINGS, []));
|
||||
}
|
||||
|
||||
private static function token(): string {
|
||||
if (defined('ITK_ATTACKS_API_TOKEN') && ITK_ATTACKS_API_TOKEN !== '') {
|
||||
return (string)ITK_ATTACKS_API_TOKEN;
|
||||
}
|
||||
return self::settings()['api_token'] ?? '';
|
||||
}
|
||||
|
||||
/* ── Queue ────────────────────────────────────────────────── */
|
||||
|
||||
public static function queue(array $data): void {
|
||||
$s = self::settings();
|
||||
if (empty($s['enabled']) || empty($s['api_url'])) return;
|
||||
|
||||
$queue = (array)get_option(self::OPT_QUEUE, []);
|
||||
if (count($queue) >= self::QUEUE_MAX) array_shift($queue);
|
||||
|
||||
$queue[] = [
|
||||
'ip' => sanitize_text_field($data['ip'] ?? ''),
|
||||
'attack_type' => sanitize_text_field($data['attack_type'] ?? ''),
|
||||
'rule_desc' => sanitize_text_field($data['rule_desc'] ?? ''),
|
||||
'source' => sanitize_text_field($data['source'] ?? ''),
|
||||
'param' => sanitize_text_field($data['param'] ?? ''),
|
||||
'payload' => sanitize_textarea_field(substr($data['payload'] ?? '', 0, 500)),
|
||||
'uri' => sanitize_text_field($data['uri'] ?? ''),
|
||||
'method' => sanitize_text_field($data['method'] ?? ''),
|
||||
'user_agent' => sanitize_textarea_field($data['ua'] ?? ''),
|
||||
'logged_at' => current_time('mysql'),
|
||||
];
|
||||
|
||||
update_option(self::OPT_QUEUE, $queue);
|
||||
|
||||
if (count($queue) >= self::BATCH_SIZE) {
|
||||
self::flush();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Flush ────────────────────────────────────────────────── */
|
||||
|
||||
public static function flush(): void {
|
||||
$s = self::settings();
|
||||
if (empty($s['enabled']) || empty($s['api_url'])) return;
|
||||
|
||||
$queue = (array)get_option(self::OPT_QUEUE, []);
|
||||
if (empty($queue)) return;
|
||||
|
||||
$batch = array_splice($queue, 0, self::BATCH_SIZE);
|
||||
update_option(self::OPT_QUEUE, $queue);
|
||||
|
||||
$headers = ['Content-Type' => 'application/json'];
|
||||
$token = self::token();
|
||||
if ($token !== '') $headers['Authorization'] = 'Bearer ' . $token;
|
||||
|
||||
$response = wp_remote_post(
|
||||
trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit',
|
||||
[
|
||||
'timeout' => 15,
|
||||
'headers' => $headers,
|
||||
'body' => wp_json_encode([
|
||||
'site_hash' => hash('sha256', home_url()),
|
||||
'attacks' => $batch,
|
||||
]),
|
||||
]
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
update_option(self::OPT_QUEUE, array_merge($batch, $queue));
|
||||
return;
|
||||
}
|
||||
|
||||
$s['last_sync'] = time();
|
||||
$s['sent_total'] = ($s['sent_total'] ?? 0) + count($batch);
|
||||
update_option(self::OPT_SETTINGS, $s);
|
||||
}
|
||||
|
||||
public static function flush_shutdown(): void {
|
||||
$queue = (array)get_option(self::OPT_QUEUE, []);
|
||||
if (count($queue) >= 5) self::flush();
|
||||
}
|
||||
|
||||
/* ── Test connection ──────────────────────────────────────── */
|
||||
|
||||
public static function test_connection(): array {
|
||||
$s = self::settings();
|
||||
if (empty($s['api_url'])) {
|
||||
return ['ok' => false, 'message' => 'No API URL configured.'];
|
||||
}
|
||||
|
||||
$base = trailingslashit(esc_url_raw($s['api_url']));
|
||||
$headers = ['Content-Type' => 'application/json'];
|
||||
$token = self::token();
|
||||
if ($token !== '') $headers['Authorization'] = 'Bearer ' . $token;
|
||||
|
||||
$health = wp_remote_get($base . 'api/v1/health', ['timeout' => 8]);
|
||||
if (is_wp_error($health)) {
|
||||
return ['ok' => false, 'message' => 'Cannot reach API: ' . $health->get_error_message()];
|
||||
}
|
||||
if (wp_remote_retrieve_response_code($health) !== 200) {
|
||||
return ['ok' => false, 'message' => 'API returned HTTP ' . wp_remote_retrieve_response_code($health)];
|
||||
}
|
||||
|
||||
$auth = wp_remote_post($base . 'api/v1/submit', [
|
||||
'timeout' => 8,
|
||||
'headers' => $headers,
|
||||
'body' => wp_json_encode(['site_hash' => 'connectivity_test', 'attacks' => []]),
|
||||
]);
|
||||
if (is_wp_error($auth)) {
|
||||
return ['ok' => false, 'message' => 'Token check failed: ' . $auth->get_error_message()];
|
||||
}
|
||||
if (wp_remote_retrieve_response_code($auth) === 403) {
|
||||
return ['ok' => false, 'message' => 'API reachable but token rejected (HTTP 403).'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'message' => 'Connection verified. API is reachable and token accepted.'];
|
||||
}
|
||||
|
||||
/* ── Send history batch ───────────────────────────────────── */
|
||||
|
||||
public static function send_history_batch(int $batch_size = 50): array {
|
||||
$s = self::settings();
|
||||
if (empty($s['api_url'])) {
|
||||
return ['ok' => false, 'message' => 'No API URL configured.'];
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = ITK_Database::attack_table();
|
||||
$last_id = (int)get_option('itk_attacks_history_last_id', 0);
|
||||
$total = ITK_Database::count_attack_rows();
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE id > %d ORDER BY id ASC LIMIT %d",
|
||||
$last_id, $batch_size
|
||||
), ARRAY_A);
|
||||
|
||||
if (empty($rows)) {
|
||||
return ['ok' => true, 'sent' => 0, 'remaining' => 0, 'has_more' => false,
|
||||
'message' => 'All records have already been sent.'];
|
||||
}
|
||||
|
||||
$attacks = array_map(fn($r) => [
|
||||
'ip' => $r['ip_address'],
|
||||
'attack_type' => $r['attack_type'],
|
||||
'rule_desc' => $r['rule_desc'],
|
||||
'source' => $r['source'],
|
||||
'param' => $r['param'],
|
||||
'payload' => $r['payload'],
|
||||
'uri' => $r['request_uri'],
|
||||
'method' => $r['method'],
|
||||
'user_agent' => $r['user_agent'],
|
||||
'logged_at' => $r['logged_at'],
|
||||
], $rows);
|
||||
|
||||
$headers = ['Content-Type' => 'application/json'];
|
||||
$token = self::token();
|
||||
if ($token !== '') $headers['Authorization'] = 'Bearer ' . $token;
|
||||
|
||||
$response = wp_remote_post(
|
||||
trailingslashit(esc_url_raw($s['api_url'])) . 'api/v1/submit',
|
||||
[
|
||||
'timeout' => 30,
|
||||
'headers' => $headers,
|
||||
'body' => wp_json_encode([
|
||||
'site_hash' => hash('sha256', home_url()),
|
||||
'attacks' => $attacks,
|
||||
]),
|
||||
]
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return ['ok' => false, 'message' => 'Request failed: ' . $response->get_error_message()];
|
||||
}
|
||||
if (wp_remote_retrieve_response_code($response) !== 200) {
|
||||
return ['ok' => false, 'message' => 'API returned HTTP ' . wp_remote_retrieve_response_code($response)];
|
||||
}
|
||||
|
||||
$new_last = (int)end($rows)['id'];
|
||||
$sent_total = (int)get_option('itk_attacks_history_sent', 0) + count($rows);
|
||||
update_option('itk_attacks_history_last_id', $new_last);
|
||||
update_option('itk_attacks_history_sent', $sent_total);
|
||||
$remaining = max(0, $total - $sent_total);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'sent' => count($rows),
|
||||
'remaining' => $remaining,
|
||||
'has_more' => $remaining > 0,
|
||||
'message' => sprintf('Sent %d records. %d remaining.', count($rows), $remaining),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user