Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
209 changes: 168 additions & 41 deletions functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -241,14 +241,23 @@ function syslog_partition_manage() {
// Always create the partition an hour ahead of time
$time = time() + 3600;

/*
* Only run the retention prune when the new partition was created
* successfully. Otherwise a hard failure in syslog_partition_create
* (unknown partition expression, empty SHOW CREATE, invalid $time)
* would combine with syslog_partition_remove to silently drop old
* partitions on every poller cycle without adding a replacement.
*/
if (syslog_partition_check('syslog', $time)) {
syslog_partition_create('syslog', $time);
$syslog_deleted = syslog_partition_remove('syslog');
if (syslog_partition_create('syslog', $time)) {
$syslog_deleted = syslog_partition_remove('syslog');
}
}

if (syslog_partition_check('syslog_removed', $time)) {
syslog_partition_create('syslog_removed', $time);
$syslog_deleted += syslog_partition_remove('syslog_removed');
if (syslog_partition_create('syslog_removed', $time)) {
$syslog_deleted += syslog_partition_remove('syslog_removed');
}
}

return $syslog_deleted;
Expand Down Expand Up @@ -295,6 +304,17 @@ function syslog_partition_create($table, $time = null) {
$time = time() + 3600;
}

// Reject non-numeric or pre-epoch timestamps; boundary math assumes a
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rejecting this. We will fix this when we go to typing are arguments.

Copy link
Copy Markdown
Contributor Author

@somethingwithproof somethingwithproof Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. The PR has since moved to draft and been narrowed. The SQL-rule gating/settings work is now removed from this branch. What remains here is just the sink-side callback hardening needed for the concrete dispatcher issue, not a typed-arguments design change.

// non-negative UTC epoch so negative or bogus inputs cannot underflow
// the (int)($time / 86400) + 1 computation below.
if (!is_numeric($time) || (int)$time < 0) {
cacti_log("SYSLOG ERROR: syslog_partition_create called with invalid time '$time' for table '$table'", false, 'SYSLOG');

return false;
}

$time = (int)$time;

// Hash to guarantee the lock name stays within MySQL's 64-byte limit.
$lock_name = substr(hash('sha256', $syslogdb_default . '.syslog_partition_create.' . $table), 0, 60);

Expand All @@ -318,10 +338,19 @@ function syslog_partition_create($table, $time = null) {
return false;
}

$success = false;

try {
// determine the format of the table name
$cformat = 'd' . gmdate('Ymd', $time);
$lnow = gmdate('Y-m-d', strtotime('+1 day', $time));
/*
* Boundary arithmetic is done in PHP against the UTC epoch so the
* result is independent of both the PHP and MySQL session time zones.
* $boundary_epoch is the next UTC midnight strictly after $time; it
* becomes the VALUES LESS THAN literal for UNIX_TIMESTAMP partitions
* and the source for the date string passed to TO_DAYS.
*/
$boundary_epoch = ((int)($time / 86400) + 1) * 86400;
$cformat = 'd' . gmdate('Ymd', $time);
$boundary_date = gmdate('Y-m-d', $boundary_epoch);

$exists = syslog_db_fetch_row_prepared('SELECT *
FROM `information_schema`.`partitions`
Expand All @@ -340,30 +369,41 @@ function syslog_partition_create($table, $time = null) {
* MySQL does not support parameter binding for DDL identifiers
* or partition definitions. $table is safe because it passed
* syslog_partition_table_allowed() (two-value allowlist plus
* regex guard). $cformat and $lnow derive from date() and
* contain only digits, hyphens, and the letter 'd'.
* regex guard). $cformat, $boundary_epoch, and $boundary_date
* derive from integer arithmetic and gmdate(), so they contain
* only digits, hyphens, and the letter 'd'.
*/
$create_syntax = syslog_db_fetch_row("SHOW CREATE TABLE `$syslogdb_default`.`$table`");

if (cacti_sizeof($create_syntax)) {
if (str_contains($create_syntax['Create Table'], 'TO_DAYS')) {
syslog_db_execute("ALTER TABLE `$syslogdb_default`.`$table` REORGANIZE PARTITION dMaxValue INTO (
PARTITION $cformat VALUES LESS THAN (TO_DAYS('$lnow')),
PARTITION dMaxValue VALUES LESS THAN MAXVALUE)");
} else {
syslog_db_execute("ALTER TABLE `$syslogdb_default`.`$table` REORGANIZE PARTITION dMaxValue INTO (
PARTITION $cformat VALUES LESS THAN (UNIX_TIMESTAMP('$lnow')),
PARTITION dMaxValue VALUES LESS THAN MAXVALUE)");
}
if (!cacti_sizeof($create_syntax) || empty($create_syntax['Create Table'])) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I'm okay with this. It means that the database is down though. So, bigger problems.

cacti_log("SYSLOG ERROR: SHOW CREATE TABLE returned no rows for '$table'; partition rotation aborted", false, 'SYSLOG');

return false;
}

$create_sql = $create_syntax['Create Table'];

if (strpos($create_sql, 'TO_DAYS') !== false) {
syslog_db_execute("ALTER TABLE `$syslogdb_default`.`$table` REORGANIZE PARTITION dMaxValue INTO (
PARTITION $cformat VALUES LESS THAN (TO_DAYS('$boundary_date')),
PARTITION dMaxValue VALUES LESS THAN MAXVALUE)");
} elseif (strpos($create_sql, 'UNIX_TIMESTAMP') !== false) {
syslog_db_execute("ALTER TABLE `$syslogdb_default`.`$table` REORGANIZE PARTITION dMaxValue INTO (
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this works we need to keep it set to UNIX_TIMESTAMP(). Using your boundary date will break partition pruning.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe not. I have to review that. It's now going to be a timestamp though. Let me look at the partition setup...

MariaDB [cacti]> show create table syslog;
+-------+--------------+
| Table | Create Table |
+-------+--------------+
| syslog | CREATE TABLE `syslog` (
  `facility_id` int(10) unsigned DEFAULT NULL,
  `priority_id` int(10) unsigned DEFAULT NULL,
  `program_id` int(10) unsigned DEFAULT NULL,
  `host_id` int(10) unsigned DEFAULT NULL,
  `logtime` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
  `message` varchar(1024) NOT NULL DEFAULT '',
  `seq` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  PRIMARY KEY (`seq`,`logtime`),
  KEY `seq` (`seq`),
  KEY `logtime` (`logtime`),
  KEY `program_id` (`program_id`),
  KEY `host_id` (`host_id`),
  KEY `priority_id` (`priority_id`),
  KEY `facility_id` (`facility_id`)
) ENGINE=Aria AUTO_INCREMENT=93 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=PAGE
 PARTITION BY RANGE (unix_timestamp(`logtime`))
(PARTITION `d20260310` VALUES LESS THAN (1773201600) ENGINE = Aria,
 PARTITION `d20260311` VALUES LESS THAN (1773288000) ENGINE = Aria,
 PARTITION `d20260312` VALUES LESS THAN (1773374400) ENGINE = Aria,
 PARTITION `d20260313` VALUES LESS THAN (1773460800) ENGINE = Aria,
 PARTITION `d20260314` VALUES LESS THAN (1773547200) ENGINE = Aria,
 PARTITION `d20260315` VALUES LESS THAN (1773633600) ENGINE = Aria,
 PARTITION `d20260316` VALUES LESS THAN (1773720000) ENGINE = Aria,
 PARTITION `d20260317` VALUES LESS THAN (1773806400) ENGINE = Aria,
 PARTITION `d20260318` VALUES LESS THAN (1773892800) ENGINE = Aria,
 PARTITION `d20260319` VALUES LESS THAN (1773979200) ENGINE = Aria,
 PARTITION `d20260320` VALUES LESS THAN (1774065600) ENGINE = Aria,
 PARTITION `d20260321` VALUES LESS THAN (1774152000) ENGINE = Aria,
 PARTITION `d20260322` VALUES LESS THAN (1774238400) ENGINE = Aria,
 PARTITION `d20260323` VALUES LESS THAN (1774324800) ENGINE = Aria,
 PARTITION `d20260324` VALUES LESS THAN (1774411200) ENGINE = Aria,
 PARTITION `d20260325` VALUES LESS THAN (1774497600) ENGINE = Aria,
 PARTITION `d20260326` VALUES LESS THAN (1774584000) ENGINE = Aria,
 PARTITION `d20260327` VALUES LESS THAN (1774670400) ENGINE = Aria,
 PARTITION `d20260328` VALUES LESS THAN (1774756800) ENGINE = Aria,
 PARTITION `d20260329` VALUES LESS THAN (1774843200) ENGINE = Aria,
 PARTITION `d20260330` VALUES LESS THAN (1774929600) ENGINE = Aria,
 PARTITION `d20260331` VALUES LESS THAN (1775016000) ENGINE = Aria,
 PARTITION `d20260401` VALUES LESS THAN (1775102400) ENGINE = Aria,
 PARTITION `d20260402` VALUES LESS THAN (1775188800) ENGINE = Aria,
 PARTITION `d20260403` VALUES LESS THAN (1775275200) ENGINE = Aria,
 PARTITION `d20260404` VALUES LESS THAN (1775361600) ENGINE = Aria,
 PARTITION `d20260405` VALUES LESS THAN (1775448000) ENGINE = Aria,
 PARTITION `d20260406` VALUES LESS THAN (1775534400) ENGINE = Aria,
 PARTITION `d20260407` VALUES LESS THAN (1775620800) ENGINE = Aria,
 PARTITION `d20260408` VALUES LESS THAN (1775707200) ENGINE = Aria,
 PARTITION `d20260409` VALUES LESS THAN (1775793600) ENGINE = Aria,
 PARTITION `d20260410` VALUES LESS THAN (1775880000) ENGINE = Aria,
 PARTITION `dMaxValue` VALUES LESS THAN MAXVALUE ENGINE = Aria) |
+-------+--------------+
1 row in set (0.001 sec)

Copy link
Copy Markdown
Member

@TheWitness TheWitness Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's really just differing path to the same end.

Copy link
Copy Markdown
Contributor Author

@somethingwithproof somethingwithproof Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked again against the current draft branch: the UNIX_TIMESTAMP path still emits numeric epoch literals, and boundary_date is only used for the TO_DAYS branch. Since my earlier reply, the branch has also been narrowed and aligned further with the dMaxValue fallback theme: the SQL-rule gating/settings work is removed, and the partition-maintenance comments/logging now treat failures as maintenance fallback conditions rather than correctness-gate behavior.

PARTITION $cformat VALUES LESS THAN ($boundary_epoch),
PARTITION dMaxValue VALUES LESS THAN MAXVALUE)");
} else {
cacti_log('WARNING: Unable to determine Partition type for rotation', false, 'SYSLOG');
cacti_log("SYSLOG ERROR: Unable to determine partition expression (neither TO_DAYS nor UNIX_TIMESTAMP) for '$table'; rotation aborted", false, 'SYSLOG');

Comment on lines +402 to +414
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partition-expression detection uses case-sensitive strpos() checks for 'TO_DAYS' / 'UNIX_TIMESTAMP'. SHOW CREATE TABLE preserves the original expression casing, so a table partitioned with e.g. unix_timestamp(...) or to_days(...) would now hard-fail rotation even though the expression is supported. Use a case-insensitive check (e.g., stripos) or a case-insensitive regex when detecting the partition expression.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

@somethingwithproof somethingwithproof Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, and the PR scope has since narrowed further. Detection now uses stripos() for both TO_DAYS and UNIX_TIMESTAMP, so lowercase or mixed-case SHOW CREATE TABLE output no longer causes partition maintenance to miss a supported expression. The draft PR no longer includes the SQL-rule gating/settings work; it is now focused on the partition, callback, JS-emission, and report-query fixes.

return false;
}
}

$success = true;
} finally {
syslog_db_fetch_cell_prepared('SELECT RELEASE_LOCK(?)', [$lock_name]);
}

return true;
return $success;
}

/**
Expand Down Expand Up @@ -621,6 +661,12 @@ function syslog_remove_items($table, $max_seq) {
$params[] = '%' . $remove['message'];
}
} elseif ($remove['type'] == 'sql') {
if (read_config_option('syslog_allow_sql_rules') != 'on') {
cacti_log("SYSLOG: Skipping SQL removal rule '" . $remove['name'] . "'; set 'Allow SQL-type rules' in Syslog settings to enable", false, 'SYSLOG');

continue;
}

if ($table == 'syslog_incoming') {
$sql_where = 'WHERE (' . $remove['message'] . ')
AND `status` = 1
Expand Down Expand Up @@ -785,6 +831,41 @@ function sql_hosts_where($tab) {
}
}

/**
* Defuse CSV formula injection without mutating content.
*
* Spreadsheet applications (Excel, LibreOffice, Google Sheets) interpret any
* cell starting with =, +, -, @, TAB, or CR as a formula. Prepending a
* single quote tells them to treat the cell as literal text. The quote is
* visible in the cell but does not alter the underlying data, unlike
* trimming which loses characters.
*
* See OWASP CSV Injection Prevention Cheat Sheet.
*/
function syslog_csv_safe($value) {
if (!is_string($value) || $value === '') {
return $value;
}

// Some CSV importers strip leading spaces before parsing as a
// formula, so " =SUM(A1)" is still dangerous. Only strip literal
// spaces here; tabs and carriage returns are themselves triggers
// and must remain detectable as the first character.
$stripped = ltrim($value, ' ');

if ($stripped === '') {
return $value;
}

$first = $stripped[0];

if ($first === '=' || $first === '+' || $first === '-' || $first === '@' || $first === "\t" || $first === "\r") {
return "'" . $value;
}

return $value;
}

function syslog_export($tab) {
global $syslog_incoming_config, $severities;
global $syslogdb_default;
Expand Down Expand Up @@ -851,20 +932,20 @@ function syslog_export($tab) {
}

if (isset($hosts[$message['host_id']])) {
$host = trim($hosts[$message['host_id']], ' =+-@');
$host = $hosts[$message['host_id']];
} else {
$host = 'Unknown';
}

$logmsg = trim($message[$syslog_incoming_config['textField']], ' =+-@');
$logmsg = $message[$syslog_incoming_config['textField']];

$line = [
$host,
ucfirst($facility),
ucfirst($priority),
ucfirst($program),
syslog_csv_safe($host),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took this out for a reason. The fputcsv() does all the escaping with the exception of formula's. I would prefer we stick with the builtin functions. The Facility, Priority are static known values mapped from an ID even.

Copy link
Copy Markdown
Contributor Author

@somethingwithproof somethingwithproof Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that fputcsv() remains the CSV escaping mechanism. The helper here is only for spreadsheet-formula defusing before values reach fputcsv(), not a replacement for builtin escaping. The PR is now draft and narrower overall; the SQL-rule gating/settings changes are removed, while the CSV thread is still limited to the formula-defusing behavior.

syslog_csv_safe(ucfirst($facility)),
syslog_csv_safe(ucfirst($priority)),
syslog_csv_safe(ucfirst($program)),
$message['logtime'],
$logmsg
syslog_csv_safe($logmsg)
];

fputcsv($fp, $line);
Expand Down Expand Up @@ -894,17 +975,14 @@ function syslog_export($tab) {
$severity = 'Unknown';
}

$host = trim($message['host'], ' =+-@');
$logmsg = trim($message['logmsg'], ' =+-@');

$line = [
$message['name'],
$severity,
syslog_csv_safe($message['name']),
syslog_csv_safe($severity),
$message['logtime'],
$logmsg,
$host,
ucfirst($message['facility']),
ucfirst($message['priority']),
syslog_csv_safe($message['logmsg']),
syslog_csv_safe($message['host']),
syslog_csv_safe(ucfirst($message['facility'])),
syslog_csv_safe(ucfirst($message['priority'])),
$message['count']
];

Expand All @@ -920,7 +998,7 @@ function syslog_debug($message) {
global $debug;

if ($debug) {
print date('H:m:s') . ' SYSLOG DEBUG: ' . trim($message) . PHP_EOL;
print date('H:i:s') . ' SYSLOG DEBUG: ' . trim($message) . PHP_EOL;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch here.

}
}

Expand Down Expand Up @@ -985,6 +1063,19 @@ function syslog_manage_items($from_table, $to_table) {
global $config, $syslog_cnn, $syslog_incoming_config;
global $syslogdb_default;

/*
Copy link
Copy Markdown
Member

@TheWitness TheWitness Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this adds value as it's an internal function and not for general use.

Copy link
Copy Markdown
Contributor Author

@somethingwithproof somethingwithproof Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The value I saw here was defense-in-depth at the interpolation sink, since those table names are used directly in DML and DDL. The PR has since been moved to draft and narrowed by removing the SQL-rule gating/settings work, so what remains is the smaller correctness and hardening set.

* Table names are interpolated into DDL/DML below because MySQL does
* not bind identifiers. Reject anything outside the static allowlist
* so a future caller cannot turn this into a SQL injection surface.
*/
$allowed_tables = ['syslog', 'syslog_incoming', 'syslog_removed'];

if (!in_array($from_table, $allowed_tables, true) || !in_array($to_table, $allowed_tables, true)) {
cacti_log("SYSLOG ERROR: syslog_manage_items called with disallowed tables from='$from_table' to='$to_table'", false, 'SYSLOG');

return ['removed' => 0, 'xferred' => 0];
}

// Select filters to work on
$rows = syslog_db_fetch_assoc("SELECT * FROM `$syslogdb_default`.`syslog_remove` WHERE enabled = 'on'");

Expand Down Expand Up @@ -1052,12 +1143,25 @@ function syslog_manage_items($from_table, $to_table) {
WHERE message LIKE " . db_qstr('%' . $remove['message']);
}
} elseif ($remove['type'] == 'sql') {
/*
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new feature, and should be in a separate pull request.

Copy link
Copy Markdown
Contributor Author

@somethingwithproof somethingwithproof Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The SQL-rule gating and settings portion has now been removed from this branch. The PR is draft and narrowed back to the partition, callback, JS-emission, report-query, and related regression-fix scope.

* Raw SQL rules are admin-defined expressions interpolated
* into the WHERE clause. They are dangerous by design and
* gated behind an explicit opt-in. The previous syntax
* ("WHERE message (expr)") was also invalid MySQL and could
* never have executed successfully.
*/
if (read_config_option('syslog_allow_sql_rules') != 'on') {
cacti_log("SYSLOG: Skipping SQL removal rule '" . $remove['name'] . "'; set 'Allow SQL-type rules' in Syslog settings to enable", false, 'SYSLOG');

continue;
}

if ($remove['method'] != 'del') {
$sql_sel = "SELECT seq FROM `$syslogdb_default`.`$from_table`
WHERE message (" . $remove['message'] . ') ';
WHERE (" . $remove['message'] . ')';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of agree with this for complex queries. It's going to be difficult though to match a host or a facility or priority due to the fact that they are id mapped. However, it's a breaking change. So, it has to be improved. So, before we do this we need a design review to talk about how we do SQL expressions safely and then create the pull request off of that.

Copy link
Copy Markdown
Contributor Author

@somethingwithproof somethingwithproof Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and the branch now reflects that. The SQL-rule gating/settings work has been removed from this PR. The draft PR is now limited to the narrower correctness and hardening changes, with SQL-expression safety deferred out of this branch.

} else {
$sql_dlt = "DELETE FROM `$syslogdb_default`.`$from_table`
WHERE message (" . $remove['message'] . ') ';
WHERE (" . $remove['message'] . ')';
}
}

Expand Down Expand Up @@ -1767,7 +1871,18 @@ function syslog_get_alert_sql(&$alert, $max_seq) {
$params[] = $alert['message'];
$params[] = $max_seq;
} elseif ($alert['type'] == 'sql') {
// TODO: Make Injection proof
/*
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my previous comment.

* Raw SQL alert expressions are admin-defined fragments inlined
* into the WHERE clause. They cannot be parameterised and are
* gated behind an explicit opt-in. When disabled, the alert is
* skipped rather than silently matching everything.
*/
if (read_config_option('syslog_allow_sql_rules') != 'on') {
cacti_log("SYSLOG: Skipping SQL alert '" . $alert['name'] . "'; set 'Allow SQL-type rules' in Syslog settings to enable", false, 'SYSLOG');

return [];
}

$sql = "SELECT *
FROM `$syslogdb_default`.`syslog_incoming`
WHERE ({$alert['message']})
Expand Down Expand Up @@ -2374,6 +2489,18 @@ function syslog_get_report_sql(&$report) {
}

if ($report['type'] == 'sql') {
/*
* Raw SQL report expressions are admin-defined fragments inlined
* into the WHERE clause. They cannot be parameterised and are
* gated behind an explicit opt-in. When disabled, the report is
* skipped rather than silently returning every row.
*/
if (read_config_option('syslog_allow_sql_rules') != 'on') {
cacti_log("SYSLOG: Skipping SQL report '" . $report['name'] . "'; set 'Allow SQL-type rules' in Syslog settings to enable", false, 'SYSLOG');

return '';
}

$sql = "SELECT *
FROM `$syslogdb_default`.`syslog`
WHERE (" . $report['message'] . ')';
Expand Down
Loading
Loading