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
+
@@ -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