Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -3346,10 +3346,11 @@ function syslog_get_removal_rule_sql(&$remove, $table = 'syslog_incoming', $pref
* response cannot stream unbounded data.
*/
define('SYSLOG_RULE_PREVIEW_MAX_ROWS', 10);
define('SYSLOG_RULE_PREVIEW_HISTORY_ROWS', 10000);

/**
* syslog_rule_preview - Run a read-only preview of a rule against the
* incoming table.
* incoming table, with recent stored history as a structured-alert fallback.
*
* The rule is compiled through the same QueryBuilder/compiler paths the
* poller uses, so the preview reflects exactly what the rule would match
Expand Down Expand Up @@ -3504,6 +3505,27 @@ function syslog_rule_preview(array $rule, string $rule_type = 'alert', int $rows
];
}

$history = false;
if ((int) $count === 0 && $rule_type === 'alert' && $type === 'filter' && syslog_db_table_exists('syslog', false)) {
// Reuse normalized host/program matching; never reinterpret hand-written SQL.
$compiled = syslog_get_removal_rule_sql($rule, 'syslog', 's.', false);
if (!$compiled) {
return ['error' => __('The historical preview filter could not be compiled.', 'syslog'), 'count' => 0, 'rows' => []];
}
$limit = SYSLOG_RULE_PREVIEW_HISTORY_ROWS;
$sql = "SELECT s.*, h.host, p.program
FROM (SELECT * FROM `$syslogdb_default`.`syslog` ORDER BY seq DESC LIMIT $limit) AS s
LEFT JOIN `$syslogdb_default`.`syslog_hosts` AS h ON h.host_id = s.host_id
LEFT JOIN `$syslogdb_default`.`syslog_programs` AS p ON p.program_id = s.program_id
{$compiled['sql']}";
$params = $compiled['params'];
$count = syslog_db_fetch_cell_prepared("SELECT COUNT(*) FROM ($sql) AS preview", $params);
if (!is_numeric($count)) {
return ['error' => __('The historical preview query could not be evaluated.', 'syslog'), 'count' => 0, 'rows' => []];
}
$history = true;
}

$sample = syslog_db_fetch_assoc_prepared("$sql ORDER BY seq DESC LIMIT $rows", $params);

if (!is_array($sample)) {
Expand All @@ -3514,8 +3536,8 @@ function syslog_rule_preview(array $rule, string $rule_type = 'alert', int $rows
];
}

$time_field = isset($syslog_incoming_config['timeField']) ? $syslog_incoming_config['timeField'] : 'logtime';
$text_field = isset($syslog_incoming_config['textField']) ? $syslog_incoming_config['textField'] : 'message';
$time_field = !$history && isset($syslog_incoming_config['timeField']) ? $syslog_incoming_config['timeField'] : 'logtime';
$text_field = !$history && isset($syslog_incoming_config['textField']) ? $syslog_incoming_config['textField'] : 'message';

$out = [];

Expand All @@ -3529,7 +3551,11 @@ function syslog_rule_preview(array $rule, string $rule_type = 'alert', int $rows
];
}

return ['error' => '', 'count' => (int) $count, 'rows' => $out];
$result = ['error' => '', 'count' => (int) $count, 'rows' => $out];
if ($history) {
$result['history_limit'] = SYSLOG_RULE_PREVIEW_HISTORY_ROWS;
}
return $result;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions js/functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -1785,6 +1785,10 @@ function testSyslogRule(formSelector, dialogSelector, title) {
(result.count === 1 ? '1 matching message' : result.count + ' matching messages')
)));

if (result.history_limit) {
container.append($('<p/>').text('No matches in incoming data. Results are from the most recent ' + result.history_limit + ' stored messages. Testing does not send alarms.'));
}

var table = $('<table class="syslogRuleTestRows"><thead><tr>' +
'<th>Date</th><th>Host</th><th>Program</th><th>Message</th>' +
'</tr></thead><tbody></tbody></table>');
Expand Down
8 changes: 8 additions & 0 deletions locales/po/cacti.pot
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,14 @@ msgstr ""
msgid "The preview query could not be evaluated."
msgstr ""

#: includes/functions.php
msgid "The historical preview filter could not be compiled."
msgstr ""

#: includes/functions.php
msgid "The historical preview query could not be evaluated."
msgstr ""

#: includes/functions.php
msgid "The preview sample could not be read."
msgstr ""
Expand Down
166 changes: 166 additions & 0 deletions tests/Unit/RulePreviewHistoryFallbackTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDTool-based Graphing Solution |
+-------------------------------------------------------------------------+
| http://www.cacti.net/ |
+-------------------------------------------------------------------------+
*/

afterEach(function () {
syslog_test_reset_globals();
});

it('previews bounded stored history when an alert filter has no incoming matches', function () {
syslog_load_plugin_source('includes/functions.php');

$GLOBALS['syslogdb_default'] = 'syslog_preview_test';
$GLOBALS['syslog_incoming_config'] = [
'textField' => 'raw_message',
'timeField' => 'raw_time',
'hostField' => 'host',
'programField' => 'program',
'facilityField' => 'facility_id',
'priorityField' => 'priority_id'
];

$count_calls = 0;
$sample_sql = '';

test_override('syslog_db_table_exists', static fn($table) => $table === 'syslog');
test_override('syslog_db_fetch_cell_prepared', function () use (&$count_calls) {
return ++$count_calls === 1 ? 0 : '1';
});
test_override('syslog_db_fetch_assoc_prepared', function ($sql) use (&$sample_sql) {
$sample_sql = $sql;

return [[
'seq' => 12,
'logtime' => '2026-10-26 12:00:00',
'host' => 'router01',
'program' => 'sshd',
'message' => 'Disconnected from a test session'
]];
});

$rule = [
'type' => 'filter',
'message' => json_encode([
'version' => 1,
'conditions' => [[
'join' => 'AND',
'negative' => false,
'field' => 'message',
'operator' => 'begins',
'value' => 'Disconnect'
]]
], JSON_THROW_ON_ERROR)
];

$result = syslog_rule_preview($rule, 'alert');

expect($result['error'])->toBe('')
->and($result['count'])->toBe(1)
->and($result['history_limit'])->toBe(SYSLOG_RULE_PREVIEW_HISTORY_ROWS)
->and($result['rows'][0])->toBe([
'seq' => '12',
'logtime' => '2026-10-26 12:00:00',
'host' => 'router01',
'program' => 'sshd',
'message' => 'Disconnected from a test session'
])
->and($sample_sql)->toContain('ORDER BY seq DESC LIMIT 10000')
->and($count_calls)->toBe(2);
});

it('returns a compile error if host normalization exceeds the condition limit', function () {
syslog_load_plugin_source('includes/functions.php');

$GLOBALS['syslogdb_default'] = 'syslog_preview_test';
$GLOBALS['syslog_incoming_config'] = [
'textField' => 'message',
'hostField' => 'host',
'programField' => 'program',
'facilityField' => 'facility_id',
'priorityField' => 'priority_id'
];

test_override('syslog_db_table_exists', static fn($table) => $table === 'syslog');
test_override('syslog_db_fetch_cell_prepared', static fn() => 0);
test_override('syslog_db_fetch_assoc_prepared', static function () {
return array_map(static fn($id) => ['id' => $id], range(1, 129));
});

$rule = [
'type' => 'filter',
'message' => json_encode([
'version' => 1,
'conditions' => [[
'join' => 'AND',
'negative' => false,
'field' => 'host',
'operator' => 'contains',
'value' => 'router'
]]
], JSON_THROW_ON_ERROR)
];

$result = syslog_rule_preview($rule, 'alert');

expect($result['error'])->toBe('The historical preview filter could not be compiled.')
->and($result['count'])->toBe(0)
->and($result['rows'])->toBe([]);
});

it('reports failure when the historical preview count cannot be evaluated', function () {
syslog_load_plugin_source('includes/functions.php');

$GLOBALS['syslogdb_default'] = 'syslog_preview_test';
$GLOBALS['syslog_incoming_config'] = [
'textField' => 'message',
'hostField' => 'host',
'programField' => 'program',
'facilityField' => 'facility_id',
'priorityField' => 'priority_id'
];

$count_calls = 0;

test_override('syslog_db_table_exists', static fn($table) => $table === 'syslog');
test_override('syslog_db_fetch_cell_prepared', function () use (&$count_calls) {
return ++$count_calls === 1 ? 0 : false;
});

$rule = [
'type' => 'filter',
'message' => json_encode([
'version' => 1,
'conditions' => [[
'join' => 'AND',
'negative' => false,
'field' => 'message',
'operator' => 'begins',
'value' => 'Disconnect'
]]
], JSON_THROW_ON_ERROR)
];

$result = syslog_rule_preview($rule, 'alert');

expect($result['error'])->toBe('The historical preview query could not be evaluated.')
->and($result['count'])->toBe(0)
->and($result['rows'])->toBe([])
->and($count_calls)->toBe(2);
});
73 changes: 73 additions & 0 deletions tests/regression/rule_preview_history_database_test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php
// Uses only an explicitly supplied disposable MariaDB instance.
$socket = getenv('SYSLOG_TEST_MYSQL_SOCKET');
$dsn = $socket ? 'mysql:unix_socket=' . $socket : getenv('SYSLOG_TEST_MYSQL_DSN');
if (!$dsn) { fwrite(STDERR, "Set SYSLOG_TEST_MYSQL_SOCKET or SYSLOG_TEST_MYSQL_DSN.\n"); exit(2); }
$pdo = new PDO($dsn, 'root', getenv('SYSLOG_TEST_MYSQL_PASSWORD') ?: '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$syslogdb_default = 'syslog_preview_' . bin2hex(random_bytes(4));
$syslog_incoming_config = ['textField'=>'message','timeField'=>'logtime','hostField'=>'host','programField'=>'program','facilityField'=>'facility_id','priorityField'=>'priority_id'];
$calls = [];
$checks = 0;
function cacti_sizeof($value) { return count($value); }
function __($text, ...$args) { return $text; }
function syslog_db_fetch_assoc_prepared($sql, $params = [], ...$args) {
if (!preg_match('/^SELECT\s/i', $sql)) { throw new RuntimeException('Preview attempted a write'); }
$GLOBALS['calls'][] = $sql;
if (!empty($GLOBALS['fail_history']) && str_contains($sql, 'ORDER BY seq DESC LIMIT 10000')) { return false; }
$q = $GLOBALS['pdo']->prepare($sql); $q->execute($params); return $q->fetchAll();
}
function syslog_db_fetch_cell_prepared($sql, $params = [], ...$args) { $rows = syslog_db_fetch_assoc_prepared($sql, $params); return $rows === false ? false : array_values($rows[0] ?? [null])[0]; }
function syslog_db_table_exists($table, ...$args) { return true; }
require dirname(__DIR__, 2) . '/includes/functions.php';
function check_preview($condition, $message) { $GLOBALS['checks']++; if (!$condition) { throw new RuntimeException($message); } }
function preview_rule(array $conditions): array { return ['type'=>'filter','message'=>json_encode(['version'=>1,'conditions'=>$conditions], JSON_THROW_ON_ERROR)]; }
$prefix = ['join'=>'AND','negative'=>false,'field'=>'message','operator'=>'begins','value'=>'Disconnect'];
try {
$pdo->exec("CREATE DATABASE `$syslogdb_default`");
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
$pdo->exec("USE `$syslogdb_default`");
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
$pdo->exec('CREATE TABLE syslog_incoming (seq BIGINT PRIMARY KEY, logtime DATETIME, message TEXT, host VARCHAR(255), program VARCHAR(255), facility_id INT, priority_id INT, raw_message TEXT, raw_time DATETIME)');
$pdo->exec('CREATE TABLE syslog (seq BIGINT PRIMARY KEY, logtime DATETIME, message TEXT, host_id INT, program_id INT, facility_id INT, priority_id INT)');
$pdo->exec('CREATE TABLE syslog_hosts (host_id INT PRIMARY KEY, host VARCHAR(255))');
$pdo->exec('CREATE TABLE syslog_programs (program_id INT PRIMARY KEY, program VARCHAR(255))');
$pdo->exec("INSERT INTO syslog_hosts VALUES (1,'router01')");
$pdo->exec("INSERT INTO syslog_programs VALUES (1,'sshd')");
$values = [];
for ($i=1; $i<=10001; $i++) {
$message = $i===1 ? 'Disconnect outside preview range' : ($i===10001 ? 'Disconnected from user sean 192.168.2.100 port 47934' : 'Other message');
$values[] = "($i,'2026-10-26 12:53:48'," . $pdo->quote($message) . ',1,1,1,3)';
}
$pdo->exec('INSERT INTO syslog VALUES ' . implode(',', $values));
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
$rule = preview_rule([$prefix]);
$result = syslog_rule_preview($rule, 'alert', 100000);
check_preview($result['error']==='' && $result['count']===1, 'Empty incoming table falls back to stored prefix match');
check_preview($result['rows'][0]['message']==='Disconnected from user sean 192.168.2.100 port 47934', 'Original example matches prefix');
check_preview($result['history_limit']===10000 && $result['rows'][0]['seq']==='10001', 'History scan is bounded before filtering');
check_preview($result['rows'][0]['host']==='router01' && $result['rows'][0]['program']==='sshd', 'Normalized history names are restored');
$host = ['join'=>'AND','negative'=>false,'field'=>'host','operator'=>'begins','value'=>'router'];
$program = ['join'=>'AND','negative'=>false,'field'=>'program','operator'=>'=','value'=>'sshd'];
check_preview(syslog_rule_preview(preview_rule([$prefix,$host,$program]))['count']===1, 'Shared normalized compiler handles host and program conditions');
$syslog_incoming_config['textField']='raw_message'; $syslog_incoming_config['timeField']='raw_time';
check_preview(syslog_rule_preview($rule)['rows'][0]['message']===$result['rows'][0]['message'], 'History uses canonical fields with custom incoming mappings');
unset($syslog_incoming_config['textField'], $syslog_incoming_config['timeField']);
$pdo->exec("INSERT INTO syslog_incoming(seq,logtime,message,host,program) VALUES (1,'2026-10-26 13:00:00','Disconnected incoming','router01','sshd')");
$incoming = syslog_rule_preview($rule);
check_preview($incoming['count']===1 && !isset($incoming['history_limit']) && $incoming['rows'][0]['message']==='Disconnected incoming', 'Incoming matches keep existing priority and shape');
$pdo->exec('DELETE FROM syslog_incoming');
$calls=[];
check_preview(syslog_rule_preview($rule,'removal')['count']===0 && count($calls)===2, 'Removal previews retain incoming-only scope');
$calls=[];
check_preview(syslog_rule_preview(['type'=>'sql','message'=>"message LIKE 'Disconnect%'"])['count']===0 && count($calls)===2, 'SQL previews are not rewritten for historical data');
$invalid=preview_rule([array_replace($prefix,['field'=>'unknown'])]);
$calls=[];
check_preview(syslog_rule_preview($invalid)['error']!=='' && !$calls, 'Invalid filter fails before any query');
$fail_history=true;
check_preview(syslog_rule_preview($rule)['error']!=='', 'History read failures report an error instead of no matches');
$fail_history=false;
$pdo->exec("INSERT INTO syslog VALUES (10002,'2026-10-26 13:00:00','Disconnect%_ literal',1,1,1,3)");
check_preview(syslog_rule_preview(preview_rule([array_replace($prefix,['value'=>'Disconnect%_'])]))['count']===1, 'Historical LIKE wildcards remain literal');
check_preview((int)$pdo->query('SELECT COUNT(*) FROM syslog')->fetchColumn()===10002, 'Preview preserves history');
for ($i=10003; $i<=10017; $i++) { $pdo->exec("INSERT INTO syslog VALUES ($i,'2026-10-26 13:00:00','Disconnected sample',1,1,1,3)"); }
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
$bounded=syslog_rule_preview($rule,'alert',100000);
check_preview(count($bounded['rows'])===10 && $bounded['count']===17, 'Historical samples retain the ten-row response cap');
echo "Rule preview history MariaDB checks passed: $checks\n";
} finally { $pdo->exec("DROP DATABASE IF EXISTS `$syslogdb_default`"); }
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Loading