diff --git a/public/index.html b/public/index.html index dd21f5b..e00351a 100644 --- a/public/index.html +++ b/public/index.html @@ -305,6 +305,23 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius +
+
False Positives
+
+

Lines matching these patterns will be ignored and not trigger any alert.

+
+ + + +
PatternDescription
+
+
+ + + +
+
+
How to get your user token
@@ -949,6 +966,10 @@ async function loadSettings() { document.getElementById('telegramBotToken').value = res.bot_token || ''; document.getElementById('telegramChatId').value = res.chat_id || ''; } catch (e) { console.error('load telegram error', e); } + + try { + await loadFalsePositives(); + } catch (e) { console.error('load false positives error', e); } } document.getElementById('saveTokensBtn').addEventListener('click', async () => { @@ -1010,6 +1031,54 @@ document.getElementById('testTelegramBtn').addEventListener('click', async () => btn.innerHTML = ' Test'; }); +// --- FALSE POSITIVES --- +let fpItems = []; + +async function loadFalsePositives() { + try { + fpItems = (await api('/false-positives')) || []; + renderFalsePositives(); + } catch (e) { console.error('fp load error', e); } +} + +function renderFalsePositives() { + const tbody = document.getElementById('falsePositivesBody'); + if (!fpItems.length) { + tbody.innerHTML = 'No false positives configured'; + } else { + tbody.innerHTML = fpItems.map(fp => ` + ${esc(fp.pattern)} + ${esc(fp.description || '')} + + `).join(''); + } +} + +async function deleteFp(id) { + if (!confirm('Delete this false positive pattern?')) return; + try { + await api('/false-positives/' + id, { method: 'DELETE' }); + toast('False positive deleted'); + loadFalsePositives(); + } catch (e) { toast('Delete failed', 'danger'); } +} + +document.getElementById('addFpBtn').addEventListener('click', async () => { + const pattern = document.getElementById('fpPattern').value.trim(); + const description = document.getElementById('fpDescription').value.trim(); + if (!pattern) { toast('Enter a pattern', 'danger'); return; } + try { + await api('/false-positives', { + method: 'POST', + body: JSON.stringify({ pattern, description }), + }); + document.getElementById('fpPattern').value = ''; + document.getElementById('fpDescription').value = ''; + toast('False positive added'); + loadFalsePositives(); + } catch (e) { toast('Failed to add', 'danger'); } +}); + // --- LOGS --- async function loadLogs(query) { if (!query) { diff --git a/src/Api/Router.php b/src/Api/Router.php index 256f167..a2ca876 100644 --- a/src/Api/Router.php +++ b/src/Api/Router.php @@ -94,6 +94,13 @@ class Router $path === '/config/telegram' && $method === 'PUT' => $this->updateTelegramConfig(), + $path === '/false-positives' && $method === 'GET' + => $this->repo->getFalsePositives(), + $path === '/false-positives' && $method === 'POST' + => $this->createFalsePositive(), + preg_match('#^/false-positives/(\d+)$#', $path, $m) && $method === 'DELETE' + => $this->deleteFalsePositive((int) $m[1]), + default => throw new \RuntimeException('Not found', 404), }; @@ -315,4 +322,24 @@ class Router $this->repo->setConfig('telegram_chat_id', $body['chat_id'] ?? ''); return $this->getTelegramConfig(); } + + private function createFalsePositive(): array + { + $body = json_decode(file_get_contents('php://input'), true); + $pattern = $body['pattern'] ?? ''; + $description = $body['description'] ?? ''; + + if (empty($pattern)) { + http_response_code(400); + return ['error' => 'Missing "pattern" field']; + } + + return $this->repo->createFalsePositive($pattern, $description); + } + + private function deleteFalsePositive(int $id): array + { + $this->repo->deleteFalsePositive($id); + return ['status' => 'deleted', 'id' => $id]; + } } \ No newline at end of file diff --git a/src/RuleEngine/Engine.php b/src/RuleEngine/Engine.php index b155a81..3b8c60c 100644 --- a/src/RuleEngine/Engine.php +++ b/src/RuleEngine/Engine.php @@ -16,6 +16,10 @@ class Engine public function evaluate(string $line, ?LogSource $source = null): ?Alert { + if ($this->repo->isFalsePositive($line)) { + return null; + } + $rules = $this->repo->getActiveRules(); foreach ($rules as $rule) { diff --git a/src/Storage/Database.php b/src/Storage/Database.php index c2b127f..96cda86 100644 --- a/src/Storage/Database.php +++ b/src/Storage/Database.php @@ -156,5 +156,14 @@ $this->pdo->exec(" INSERT OR IGNORE INTO log_entries_fts(rowid, line, source_name) SELECT id, line, source_name FROM log_entries "); + + $this->pdo->exec(" + CREATE TABLE IF NOT EXISTS false_positives ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pattern TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + ) + "); } } \ No newline at end of file diff --git a/src/Storage/Repository.php b/src/Storage/Repository.php index ea1420f..dfc16bf 100644 --- a/src/Storage/Repository.php +++ b/src/Storage/Repository.php @@ -290,4 +290,52 @@ class Repository return $row['count'] <= 1; } + + // --- False Positives --- + + public function getFalsePositives(): array + { + return $this->db->pdo()->query( + "SELECT id, pattern, description, created_at FROM false_positives ORDER BY id" + )->fetchAll(); + } + + public function createFalsePositive(string $pattern, string $description = ''): array + { + $stmt = $this->db->pdo()->prepare( + "INSERT INTO false_positives (pattern, description) VALUES (?, ?)" + ); + $stmt->execute([$pattern, $description]); + $id = (int) $this->db->pdo()->lastInsertId(); + return $this->getFalsePositive($id); + } + + public function getFalsePositive(int $id): ?array + { + $stmt = $this->db->pdo()->prepare( + "SELECT id, pattern, description, created_at FROM false_positives WHERE id = ?" + ); + $stmt->execute([$id]); + $row = $stmt->fetch(); + return $row ?: null; + } + + public function deleteFalsePositive(int $id): void + { + $this->db->pdo()->prepare("DELETE FROM false_positives WHERE id = ?")->execute([$id]); + } + + public function isFalsePositive(string $line): bool + { + $patterns = $this->db->pdo()->query( + "SELECT pattern FROM false_positives" + )->fetchAll(\PDO::FETCH_COLUMN); + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $line)) { + return true; + } + } + return false; + } } \ No newline at end of file