From e6ace6fcf408eb06919f2d7a01077342cc9b217e Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Tue, 17 Feb 2026 18:17:48 -0500 Subject: [PATCH] feat: add AUDIT logging level for tracking administrative changes Add a new AUDIT logging level (-5) between PANIC (-4) and NOLOG (shifted to -6) across C++, PHP, and Perl loggers. AUDIT entries use code 'AUD' and syslog priority LOG_NOTICE. They record who changed what, from where, for monitors, filters, users, config, roles, groups, zones, states, servers, storage, events, snapshots, control caps, and login/logout. AUDIT entries have their own retention period (ZM_LOG_AUDIT_DATABASE_LIMIT, default 1 year) separate from regular log pruning. The log pruning in zmstats.pl and zmaudit.pl now excludes AUDIT rows from regular pruning and prunes them independently. Critical safety: the C++ termination logic is changed from 'if (level <= FATAL)' to 'if (level == FATAL || level == PANIC)' to prevent AUDIT-level log calls from killing the process. Includes db migration zm_update-1.39.1.sql to shift any stored NOLOG config values from -5 to -6. Co-Authored-By: Claude Opus 4.6 --- db/zm_update-1.39.1.sql | 15 ++++++ .../lib/ZoneMinder/ConfigData.pm.in | 27 +++++++--- scripts/ZoneMinder/lib/ZoneMinder/Logger.pm | 11 +++- scripts/zmaudit.pl.in | 54 ++++++++++++++++--- scripts/zmstats.pl.in | 38 +++++++++++-- src/zm_logger.cpp | 8 +-- src/zm_logger.h | 6 ++- web/includes/actions/console.php | 5 +- web/includes/actions/controlcap.php | 1 + web/includes/actions/event.php | 3 ++ web/includes/actions/events.php | 4 +- web/includes/actions/filter.php | 2 + web/includes/actions/function.php | 1 + web/includes/actions/group.php | 1 + web/includes/actions/groups.php | 1 + web/includes/actions/logout.php | 1 + web/includes/actions/monitor.php | 2 + web/includes/actions/monitors.php | 2 + web/includes/actions/options.php | 17 ++++-- web/includes/actions/role.php | 1 + web/includes/actions/server.php | 1 + web/includes/actions/snapshot.php | 3 ++ web/includes/actions/state.php | 6 ++- web/includes/actions/storage.php | 1 + web/includes/actions/user.php | 2 + web/includes/actions/zone.php | 1 + web/includes/actions/zones.php | 1 + web/includes/auth.php | 1 + web/includes/logger.php | 20 ++++++- 29 files changed, 202 insertions(+), 34 deletions(-) create mode 100644 db/zm_update-1.39.1.sql diff --git a/db/zm_update-1.39.1.sql b/db/zm_update-1.39.1.sql new file mode 100644 index 000000000..3066ecf3e --- /dev/null +++ b/db/zm_update-1.39.1.sql @@ -0,0 +1,15 @@ +-- +-- Add AUDIT logging level between PANIC (-4) and NOLOG. +-- AUDIT is now -5; NOLOG shifts from -5 to -6. +-- Migrate any stored NOLOG config values from -5 to -6. +-- + +UPDATE Config SET Value = '-6' + WHERE Name IN ('ZM_LOG_LEVEL_SYSLOG','ZM_LOG_LEVEL_TERM', + 'ZM_LOG_LEVEL_FILE','ZM_LOG_LEVEL_WEBLOG','ZM_LOG_LEVEL_DATABASE') + AND Value = '-5'; + +UPDATE Config SET DefaultValue = '-6' + WHERE Name IN ('ZM_LOG_LEVEL_SYSLOG','ZM_LOG_LEVEL_TERM', + 'ZM_LOG_LEVEL_FILE','ZM_LOG_LEVEL_WEBLOG','ZM_LOG_LEVEL_DATABASE') + AND DefaultValue = '-5'; diff --git a/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in b/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in index 37e3a0ac8..2092a85dc 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in +++ b/scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in @@ -1179,7 +1179,7 @@ our @options = ( `, type => { db_type => 'integer', - hint => 'None=-5|Panic=-4|Fatal=-3|Error=-2|Warning=-1|Info=0|Debug=1', + hint => 'None=-6|Audit=-5|Panic=-4|Fatal=-3|Error=-2|Warning=-1|Info=0|Debug=1', pattern => qr|^(\d+)$|, format => q( $1 ) }, @@ -1205,7 +1205,7 @@ our @options = ( `, type => { db_type => 'integer', - hint => 'None=-5|Panic=-4|Fatal=-3|Error=-2|Warning=-1|Info=0|Debug=1', + hint => 'None=-6|Audit=-5|Panic=-4|Fatal=-3|Error=-2|Warning=-1|Info=0|Debug=1', pattern => qr|^(\d+)$|, format => q( $1 ) }, @@ -1235,7 +1235,7 @@ our @options = ( `, type => { db_type => 'integer', - hint => 'None=-5|Panic=-4|Fatal=-3|Error=-2|Warning=-1|Info=0|Debug=1', + hint => 'None=-6|Audit=-5|Panic=-4|Fatal=-3|Error=-2|Warning=-1|Info=0|Debug=1', pattern => qr|^(\d+)$|, format => q( $1 ) }, @@ -1243,7 +1243,7 @@ our @options = ( }, { name => 'ZM_LOG_LEVEL_WEBLOG', - default => '-5', + default => '-6', description => 'Save logging output to the weblog', help => q` ZoneMinder logging is now more integrated between @@ -1262,7 +1262,7 @@ our @options = ( `, type => { db_type => 'integer', - hint => 'None=-5|Panic=-4|Fatal=-3|Error=-2|Warning=-1|Info=0|Debug=1', + hint => 'None=-6|Audit=-5|Panic=-4|Fatal=-3|Error=-2|Warning=-1|Info=0|Debug=1', pattern => qr|^(\d+)$|, format => q( $1 ) }, @@ -1293,7 +1293,7 @@ our @options = ( `, type => { db_type => 'integer', - hint => 'None=-5|Panic=-4|Fatal=-3|Error=-2|Warning=-1|Info=0|Debug=1', + hint => 'None=-6|Audit=-5|Panic=-4|Fatal=-3|Error=-2|Warning=-1|Info=0|Debug=1', pattern => qr|^(\d+)$|, format => q( $1 ) }, @@ -1321,6 +1321,21 @@ our @options = ( type => $types{string}, category => 'logging', }, + { + name => 'ZM_LOG_AUDIT_DATABASE_LIMIT', + default => '1 year', + description => 'Maximum retention period for audit log entries', + help => q` + Audit log entries record administrative changes such as monitor + configuration, user management, filter changes, and system settings. + These entries are typically retained longer than regular log entries + for compliance and troubleshooting. Set to a row count (integer) or + time interval such as '1 year', '6 month', '90 day'. Set to empty + to retain audit logs indefinitely. + `, + type => $types{string}, + category => 'logging', + }, { name => 'ZM_LOG_FFMPEG', default => 'yes', diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Logger.pm b/scripts/ZoneMinder/lib/ZoneMinder/Logger.pm index 2c1c13fbb..73484df3d 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Logger.pm +++ b/scripts/ZoneMinder/lib/ZoneMinder/Logger.pm @@ -57,6 +57,7 @@ our %EXPORT_TAGS = ( ERROR FATAL PANIC + AUDIT NOLOG ) ], functions => [ qw( @@ -79,6 +80,7 @@ our %EXPORT_TAGS = ( Error Fatal Panic + Audit ) ] ); @@ -122,7 +124,8 @@ use constant { ERROR => -2, FATAL => -3, PANIC => -4, - NOLOG => -5 + AUDIT => -5, + NOLOG => -6 }; our %codes = ( @@ -141,6 +144,7 @@ our %codes = ( &ERROR => 'ERR', &FATAL => 'FAT', &PANIC => 'PNC', + &AUDIT => 'AUD', &NOLOG => 'OFF' ); @@ -159,7 +163,8 @@ our %priorities = ( &WARNING => 'warning', &ERROR => 'err', &FATAL => 'err', - &PANIC => 'err' + &PANIC => 'err', + &AUDIT => 'notice' ); our $logger; @@ -764,6 +769,8 @@ sub Panic { confess($_[0]); } +sub Audit { fetch()->logPrint(AUDIT, @_, caller); } + 1; __END__ diff --git a/scripts/zmaudit.pl.in b/scripts/zmaudit.pl.in index 2ad29a7a5..66d815886 100644 --- a/scripts/zmaudit.pl.in +++ b/scripts/zmaudit.pl.in @@ -844,22 +844,23 @@ FROM `Frames` WHERE `EventId`=?'; File::Find::find( { wanted=>\&deleteSwapImage, untaint=>1 }, $swap_image_root ); }; - # Prune the Logs table if required + # Prune the Logs table if required (excluding AUDIT entries) if ( $Config{ZM_LOG_DATABASE_LIMIT} ) { + my $audit_level = ZoneMinder::Logger::AUDIT; if ( $Config{ZM_LOG_DATABASE_LIMIT} =~ /^\d+$/ ) { # Number of rows - my $selectLogRowCountSql = 'SELECT count(*) AS `Rows` FROM `Logs`'; + my $selectLogRowCountSql = 'SELECT count(*) AS `Rows` FROM `Logs` WHERE `Level` != ?'; my $selectLogRowCountSth = $dbh->prepare_cached( $selectLogRowCountSql ) or Fatal("Can't prepare '$selectLogRowCountSql': ".$dbh->errstr()); - $res = $selectLogRowCountSth->execute() + $res = $selectLogRowCountSth->execute($audit_level) or Fatal("Can't execute: ".$selectLogRowCountSth->errstr()); my $row = $selectLogRowCountSth->fetchrow_hashref(); my $logRows = $row->{Rows}; if ( $logRows > $Config{ZM_LOG_DATABASE_LIMIT} ) { - my $deleteLogByRowsSql = 'DELETE low_priority FROM `Logs` ORDER BY `TimeKey` ASC LIMIT ?'; + my $deleteLogByRowsSql = 'DELETE low_priority FROM `Logs` WHERE `Level` != ? ORDER BY `TimeKey` ASC LIMIT ?'; my $deleteLogByRowsSth = $dbh->prepare_cached( $deleteLogByRowsSql ) or Fatal("Can't prepare '$deleteLogByRowsSql': ".$dbh->errstr()); - $res = $deleteLogByRowsSth->execute( $logRows - $Config{ZM_LOG_DATABASE_LIMIT} ) + $res = $deleteLogByRowsSth->execute( $audit_level, $logRows - $Config{ZM_LOG_DATABASE_LIMIT} ) or Fatal("Can't execute: ".$deleteLogByRowsSth->errstr()); if ( $deleteLogByRowsSth->rows() ) { aud_print('Deleted '.$deleteLogByRowsSth->rows().' log table entries by count'); @@ -867,7 +868,7 @@ FROM `Frames` WHERE `EventId`=?'; } } else { # Time of record - + # 7 days is invalid. We need to remove the s if ( $Config{ZM_LOG_DATABASE_LIMIT} =~ /^(.*)s$/ ) { $Config{ZM_LOG_DATABASE_LIMIT} = $1; @@ -876,16 +877,53 @@ FROM `Frames` WHERE `EventId`=?'; do { my $deleteLogByTimeSql = 'DELETE FROM `Logs` - WHERE `TimeKey` < unix_timestamp(now() - interval '.$Config{ZM_LOG_DATABASE_LIMIT}.') LIMIT 10'; + WHERE `Level` != ? AND `TimeKey` < unix_timestamp(now() - interval '.$Config{ZM_LOG_DATABASE_LIMIT}.') LIMIT 10'; my $deleteLogByTimeSth = $dbh->prepare_cached( $deleteLogByTimeSql ) or Fatal("Can't prepare '$deleteLogByTimeSql': ".$dbh->errstr()); - $res = $deleteLogByTimeSth->execute() + $res = $deleteLogByTimeSth->execute($audit_level) or Fatal("Can't execute: ".$deleteLogByTimeSth->errstr()); $deleted_rows = $deleteLogByTimeSth->rows(); aud_print("Deleted $deleted_rows log table entries by time"); } while ( $deleted_rows ); } } # end if ZM_LOG_DATABASE_LIMIT + + # Prune AUDIT log entries separately with their own retention period + if ( $Config{ZM_LOG_AUDIT_DATABASE_LIMIT} ) { + my $audit_level = ZoneMinder::Logger::AUDIT; + my $audit_limit = $Config{ZM_LOG_AUDIT_DATABASE_LIMIT}; + if ( $audit_limit =~ /^\d+$/ ) { + # Number of rows + my $sth = $dbh->prepare_cached('SELECT count(*) AS `Rows` FROM `Logs` WHERE `Level` = ?') + or Fatal("Can't prepare audit log count: ".$dbh->errstr()); + $res = $sth->execute($audit_level) + or Fatal("Can't execute audit log count: ".$sth->errstr()); + my $row = $sth->fetchrow_hashref(); + my $logRows = $row->{Rows}; + if ( $logRows > $audit_limit ) { + my $del_sth = $dbh->prepare_cached('DELETE low_priority FROM `Logs` WHERE `Level` = ? ORDER BY `TimeKey` ASC LIMIT ?') + or Fatal("Can't prepare audit log delete: ".$dbh->errstr()); + $res = $del_sth->execute($audit_level, $logRows - $audit_limit) + or Fatal("Can't execute audit log delete: ".$del_sth->errstr()); + if ( $del_sth->rows() ) { + aud_print('Deleted '.$del_sth->rows().' audit log entries by count'); + } + } + } else { + # Time of record + $audit_limit =~ s/s$//; + my $deleted_rows; + do { + my $del_sth = $dbh->prepare_cached( + 'DELETE FROM `Logs` WHERE `Level` = ? AND `TimeKey` < unix_timestamp(now() - interval '.$audit_limit.') LIMIT 10') + or Fatal("Can't prepare audit log time delete: ".$dbh->errstr()); + $res = $del_sth->execute($audit_level) + or Fatal("Can't execute audit log time delete: ".$del_sth->errstr()); + $deleted_rows = $del_sth->rows(); + aud_print("Deleted $deleted_rows audit log entries by time"); + } while ( $deleted_rows ); + } + } # end if ZM_LOG_AUDIT_DATABASE_LIMIT $loop = $continuous; my $eventcounts_sql = ' diff --git a/scripts/zmstats.pl.in b/scripts/zmstats.pl.in index 2127500de..2301e7f22 100644 --- a/scripts/zmstats.pl.in +++ b/scripts/zmstats.pl.in @@ -99,19 +99,20 @@ while (!$zm_terminate) { $event_ids = $dbh->selectcol_arrayref('SELECT EventId FROM Events_Month WHERE StartDateTime < DATE_SUB(NOW(), INTERVAL 1 month)'); zmDbDo('DELETE FROM Events_Month WHERE EventId IN ('.join(',', map { '?' } @$event_ids).')', @$event_ids) if $event_ids and @$event_ids; - # Prune the Logs table if required + # Prune the Logs table if required (excluding AUDIT entries) if ( $Config{ZM_LOG_DATABASE_LIMIT} ) { + my $audit_level = ZoneMinder::Logger::AUDIT; if ( $Config{ZM_LOG_DATABASE_LIMIT} =~ /^\d+$/ ) { # Number of rows - my $selectLogRowCountSql = 'SELECT count(*) AS `Rows` FROM `Logs`'; + my $selectLogRowCountSql = 'SELECT count(*) AS `Rows` FROM `Logs` WHERE `Level` != ?'; my $selectLogRowCountSth = $dbh->prepare_cached( $selectLogRowCountSql ) or Fatal("Can't prepare '$selectLogRowCountSql': ".$dbh->errstr()); - my $res = $selectLogRowCountSth->execute() + my $res = $selectLogRowCountSth->execute($audit_level) or Fatal("Can't execute: ".$selectLogRowCountSth->errstr()); my $row = $selectLogRowCountSth->fetchrow_hashref(); my $logRows = $row->{Rows}; if ( $logRows > $Config{ZM_LOG_DATABASE_LIMIT} ) { - my $rows = zmDbDo('DELETE low_priority FROM `Logs` ORDER BY `TimeKey` ASC LIMIT ?', $logRows - $Config{ZM_LOG_DATABASE_LIMIT}); + my $rows = zmDbDo('DELETE low_priority FROM `Logs` WHERE `Level` != ? ORDER BY `TimeKey` ASC LIMIT ?', $audit_level, $logRows - $Config{ZM_LOG_DATABASE_LIMIT}); Debug('Deleted '.$rows.' log table entries by count') if defined $rows; } } else { @@ -123,12 +124,39 @@ while (!$zm_terminate) { } my $rows; do { - $rows = zmDbDo('DELETE low_priority FROM `Logs` WHERE `TimeKey` < unix_timestamp(now() - interval '.$Config{ZM_LOG_DATABASE_LIMIT}.') LIMIT 100'); + $rows = zmDbDo('DELETE low_priority FROM `Logs` WHERE `Level` != ? AND `TimeKey` < unix_timestamp(now() - interval '.$Config{ZM_LOG_DATABASE_LIMIT}.') LIMIT 100', $audit_level); Debug("Deleted $rows log table entries by time") if $rows; } while ($rows and ($rows == 100) and !$zm_terminate); } } # end if ZM_LOG_DATABASE_LIMIT + # Prune AUDIT log entries separately with their own retention period + if ( $Config{ZM_LOG_AUDIT_DATABASE_LIMIT} ) { + my $audit_level = ZoneMinder::Logger::AUDIT; + my $audit_limit = $Config{ZM_LOG_AUDIT_DATABASE_LIMIT}; + if ( $audit_limit =~ /^\d+$/ ) { + # Number of rows + my $sth = $dbh->prepare_cached('SELECT count(*) AS `Rows` FROM `Logs` WHERE `Level` = ?') + or Fatal("Can't prepare audit log count: ".$dbh->errstr()); + my $res = $sth->execute($audit_level) + or Fatal("Can't execute audit log count: ".$sth->errstr()); + my $row = $sth->fetchrow_hashref(); + my $logRows = $row->{Rows}; + if ( $logRows > $audit_limit ) { + my $rows = zmDbDo('DELETE low_priority FROM `Logs` WHERE `Level` = ? ORDER BY `TimeKey` ASC LIMIT ?', $audit_level, $logRows - $audit_limit); + Debug('Deleted '.$rows.' audit log entries by count') if defined $rows; + } + } else { + # Time of record + $audit_limit =~ s/s$//; + my $rows; + do { + $rows = zmDbDo('DELETE low_priority FROM `Logs` WHERE `Level` = ? AND `TimeKey` < unix_timestamp(now() - interval '.$audit_limit.') LIMIT 100', $audit_level); + Debug("Deleted $rows audit log entries by time") if $rows; + } while ($rows and ($rows == 100) and !$zm_terminate); + } + } # end if ZM_LOG_AUDIT_DATABASE_LIMIT + { my $rows; do { diff --git a/src/zm_logger.cpp b/src/zm_logger.cpp index 51988f7ed..d9a5989e5 100644 --- a/src/zm_logger.cpp +++ b/src/zm_logger.cpp @@ -74,6 +74,7 @@ Logger::Logger() : smCodes[ERROR] = "ERR"; smCodes[FATAL] = "FAT"; smCodes[PANIC] = "PNC"; + smCodes[AUDIT] = "AUD"; smCodes[NOLOG] = "OFF"; smSyslogPriorities[INFO] = LOG_INFO; @@ -81,6 +82,7 @@ Logger::Logger() : smSyslogPriorities[ERROR] = LOG_ERR; smSyslogPriorities[FATAL] = LOG_ERR; smSyslogPriorities[PANIC] = LOG_ERR; + smSyslogPriorities[AUDIT] = LOG_NOTICE; char code[4] = ""; // Extra comparison against DEBUG1 to ensure GCC knows we are printing a single byte. @@ -420,7 +422,7 @@ void Logger::closeSyslog() { void Logger::logPrint(bool hex, const char *filepath, int line, int level, const char *fstring, ...) { if (level > mEffectiveLevel) return; - if (level < PANIC || level > DEBUG9) + if (level < AUDIT || level > DEBUG9) Panic("Invalid logger level %d", level); log_mutex.lock(); @@ -544,12 +546,12 @@ void Logger::logPrint(bool hex, const char *filepath, int line, int level, const } log_mutex.unlock(); - if (level <= FATAL) { + if (level == FATAL || level == PANIC) { zm_terminate = true; dbQueue.stop(); zmDbClose(); logTerm(); - if (level <= PANIC) abort(); + if (level == PANIC) abort(); exit(-1); } } // end logPrint diff --git a/src/zm_logger.h b/src/zm_logger.h index d7d8862d3..81d032060 100644 --- a/src/zm_logger.h +++ b/src/zm_logger.h @@ -34,8 +34,9 @@ class Logger { public: enum { - NOOPT = -6, - NOLOG, // -5 + NOOPT = -7, + NOLOG, // -6 + AUDIT, // -5 PANIC, // -4 FATAL, // -3 ERROR, // -2 @@ -229,6 +230,7 @@ inline Logger::Level logDebugging() { #define Error(params...) logPrintf(Logger::ERROR, ##params) #define Fatal(params...) logPrintf(Logger::FATAL, ##params) #define Panic(params...) logPrintf(Logger::PANIC, ##params) +#define Audit(params...) logPrintf(Logger::AUDIT, ##params) #define Mark() Info("Mark/%s/%d", __FILE__, __LINE__) #define Log() Info("Log") #ifdef __GNUC__ diff --git a/web/includes/actions/console.php b/web/includes/actions/console.php index f8e90d1fa..30993e004 100644 --- a/web/includes/actions/console.php +++ b/web/includes/actions/console.php @@ -31,7 +31,10 @@ if ($action == 'delete') { foreach ($_REQUEST['markMids'] as $markMid) { if (canEdit('Monitors', $markMid)) { $monitor = ZM\Monitor::find_one(['Id'=>$markMid]); - if ($monitor) $monitor->delete(); + if ($monitor) { + $monitor->delete(); + ZM\AuditAction('delete', 'monitor', $markMid, 'Name: '.$monitor->Name()); + } } else { $error_message .= 'You do not have permission to delete monitor '.$markMid.'
'; } // end if canedit this monitor diff --git a/web/includes/actions/controlcap.php b/web/includes/actions/controlcap.php index 13c30357c..aca8e8fae 100644 --- a/web/includes/actions/controlcap.php +++ b/web/includes/actions/controlcap.php @@ -94,6 +94,7 @@ if ( $action == 'Save' ) { global $error_message; $error_message .= "Error saving control: " . $Control->get_last_error().'
'; } else { + ZM\AuditAction((!empty($_REQUEST['cid']) ? 'update' : 'create'), 'control', $Control->Id(), 'Name: '.($Control->Name() ?? '')); $redirect = '?view=options&tab=control'; } } // end if action diff --git a/web/includes/actions/event.php b/web/includes/actions/event.php index a3c6d56c3..0e597246f 100644 --- a/web/includes/actions/event.php +++ b/web/includes/actions/event.php @@ -29,6 +29,7 @@ if ( canEdit('Events') ) { if ( ($action == 'rename') && isset($_REQUEST['eventName']) ) { dbQuery('UPDATE Events SET Name=? WHERE Id=?', array($_REQUEST['eventName'], $_REQUEST['eid'])); + ZM\AuditAction('rename', 'event', $_REQUEST['eid'], 'Name: '.$_REQUEST['eventName']); } else if ( $action == 'eventdetail' ) { dbQuery('UPDATE Events SET Cause=?, Notes=? WHERE Id=?', array( @@ -37,6 +38,7 @@ if ( canEdit('Events') ) { $_REQUEST['eid'] ) ); + ZM\AuditAction('update', 'event', $_REQUEST['eid'], 'Detail update'); $refreshParent = true; $closePopup = true; } else if ( $action == 'archive' ) { @@ -45,6 +47,7 @@ if ( canEdit('Events') ) { dbQuery('UPDATE Events SET Archived=? WHERE Id=?', array(0, $_REQUEST['eid'])); } else if ( $action == 'delete' ) { deleteEvent($_REQUEST['eid']); + ZM\AuditAction('delete', 'event', $_REQUEST['eid'], ''); $refreshParent = true; } } // end if canEdit(Events) diff --git a/web/includes/actions/events.php b/web/includes/actions/events.php index 37e747037..6192411e5 100644 --- a/web/includes/actions/events.php +++ b/web/includes/actions/events.php @@ -48,9 +48,11 @@ if ( $action == 'archive' ) { $dbConn->commit(); $refreshParent = true; } else if ( $action == 'delete' ) { - foreach ( getAffectedIds('eids') as $markEid ) { + $deletedEids = getAffectedIds('eids'); + foreach ( $deletedEids as $markEid ) { deleteEvent($markEid); } + ZM\AuditAction('delete', 'events', 0, 'Count: '.count($deletedEids)); $refreshParent = true; } else { ZM\Warning("Unsupported action $action in events"); diff --git a/web/includes/actions/filter.php b/web/includes/actions/filter.php index 15e047651..87277cb02 100644 --- a/web/includes/actions/filter.php +++ b/web/includes/actions/filter.php @@ -42,6 +42,7 @@ if (isset($_REQUEST['object']) and ($_REQUEST['object'] == 'filter')) { $filter->control('stop'); } $filter->delete(); + ZM\AuditAction('delete', 'filter', $_REQUEST['Id'], 'Name: '.$filter->Name()); } else { $error_message .= 'You do not have permission to delete the filter.
'; } @@ -89,6 +90,7 @@ if (isset($_REQUEST['object']) and ($_REQUEST['object'] == 'filter')) { $error_message = $filter->get_last_error(); return; } + ZM\AuditAction('save', 'filter', $filter->Id(), 'Name: '.$filter->Name().' Action: '.$action); if ($action == 'Save' or $action == 'SaveAs' ) { // We update the request id so that the newly saved filter is auto-selected $_REQUEST['Id'] = $filter->Id(); diff --git a/web/includes/actions/function.php b/web/includes/actions/function.php index c786abeea..9e7b8a3ca 100644 --- a/web/includes/actions/function.php +++ b/web/includes/actions/function.php @@ -46,6 +46,7 @@ if ($action == 'save') { $oldDecodingEnabled = $monitor->DecodingEnabled(); if ( $newFunction != $oldFunction || $newEnabled != $oldEnabled || $newDecodingEnabled != $oldDecodingEnabled ) { $monitor->save(array('Function'=>$newFunction, 'Enabled'=>$newEnabled, 'DecodingEnabled'=>$newDecodingEnabled)); + ZM\AuditAction('update', 'monitor', $mid, "Function: $oldFunction->$newFunction Enabled: $oldEnabled->$newEnabled"); if ( daemonCheck() && ($monitor->Type() != 'WebSite') ) { $monitor->zmcControl(($newFunction != 'None') ? 'restart' : 'stop'); diff --git a/web/includes/actions/group.php b/web/includes/actions/group.php index 37d109d6a..3258a46c8 100644 --- a/web/includes/actions/group.php +++ b/web/includes/actions/group.php @@ -44,6 +44,7 @@ if ( $action == 'save' ) { dbQuery('INSERT INTO `Groups_Monitors` (`GroupId`,`MonitorId`) VALUES (?,?)', array($group_id, $mid)); } } + ZM\AuditAction((!empty($_REQUEST['gid']) ? 'update' : 'create'), 'group', $group->Id(), 'Name: '.$_REQUEST['newGroup']['Name']); $redirect = '?view=groups'; } ?> diff --git a/web/includes/actions/groups.php b/web/includes/actions/groups.php index bc431998e..312261ccc 100644 --- a/web/includes/actions/groups.php +++ b/web/includes/actions/groups.php @@ -41,6 +41,7 @@ if ( $action == 'delete' ) { if ( !empty($_REQUEST['gid']) ) { foreach ( ZM\Group::find(array('Id'=>$_REQUEST['gid'])) as $Group ) { $Group->delete(); + ZM\AuditAction('delete', 'group', $Group->Id(), 'Name: '.$Group->Name()); } } $redirect = '?view=groups'; diff --git a/web/includes/actions/logout.php b/web/includes/actions/logout.php index 030ad142a..cc9674dbc 100644 --- a/web/includes/actions/logout.php +++ b/web/includes/actions/logout.php @@ -20,6 +20,7 @@ if ( $action == 'logout' ) { + ZM\Audit("user=".($user ? $user->Username() : 'unknown')." action=logout id=".($user ? $user->Id() : 0)." from=".($_SERVER['REMOTE_ADDR'] ?? 'local')); userLogout(); $view = 'login'; } elseif ( $action == 'config' ) { diff --git a/web/includes/actions/monitor.php b/web/includes/actions/monitor.php index 386531c5c..706637c82 100644 --- a/web/includes/actions/monitor.php +++ b/web/includes/actions/monitor.php @@ -226,6 +226,7 @@ if ($action == 'save') { } // end foreach zone } // end if rotation or just size change } // end if changes in width or height + ZM\AuditAction('update', 'monitor', $mid, 'Changed: '.implode(', ', array_keys($changes))); } else { $error_message .= $monitor->get_last_error(); } // end if successful save @@ -263,6 +264,7 @@ if ($action == 'save') { $error_message .= $zone->get_last_error(); ZM\Error('Error adding zone:' . $error_message); } + ZM\AuditAction('create', 'monitor', $mid, 'Name: '.($newMonitor['Name'] ?? '')); } else { ZM\Error('Error saving new Monitor.'); return; diff --git a/web/includes/actions/monitors.php b/web/includes/actions/monitors.php index 6ac6bd6b3..b059c404d 100644 --- a/web/includes/actions/monitors.php +++ b/web/includes/actions/monitors.php @@ -38,6 +38,8 @@ if ($action == 'save') { } if (!$Monitor->save($_REQUEST['newMonitor'])) { $error_message .= 'Error saving monitor: ' . $Monitor->get_last_error().'
'; + } else { + ZM\AuditAction('update', 'monitor', $mid, 'Bulk update: '.implode(', ', array_keys($_REQUEST['newMonitor']))); } if ($Monitor->Capturing() != 'None' && $Monitor->Type() != 'WebSite') { $Monitor->zmcControl('start'); diff --git a/web/includes/actions/options.php b/web/includes/actions/options.php index aa8f072ab..e90d3ed1c 100644 --- a/web/includes/actions/options.php +++ b/web/includes/actions/options.php @@ -30,27 +30,35 @@ if ( $action == 'delete' ) { if ( isset($_REQUEST['object']) ) { if ( $_REQUEST['object'] == 'server' ) { if ( !empty($_REQUEST['markIds']) ) { - foreach( $_REQUEST['markIds'] as $Id ) + foreach ( $_REQUEST['markIds'] as $Id ) { dbQuery('DELETE FROM Servers WHERE Id=?', array($Id)); + ZM\AuditAction('delete', 'server', $Id, ''); + } } $refreshParent = true; } else if ( $_REQUEST['object'] == 'storage' ) { if ( !empty($_REQUEST['markIds']) ) { - foreach( $_REQUEST['markIds'] as $Id ) + foreach ( $_REQUEST['markIds'] as $Id ) { dbQuery('DELETE FROM Storage WHERE Id=?', array($Id)); + ZM\AuditAction('delete', 'storage', $Id, ''); + } } $refreshParent = true; } else if ( $_REQUEST['object'] == 'role' ) { if ( !empty($_REQUEST['markRids']) ) { - foreach( $_REQUEST['markRids'] as $Id ) + foreach ( $_REQUEST['markRids'] as $Id ) { dbQuery('DELETE FROM User_Roles WHERE Id=?', array($Id)); + ZM\AuditAction('delete', 'role', $Id, ''); + } } $redirect = '?view=options&tab=roles'; } # end if isset($_REQUEST['object'] ) } else if ( isset($_REQUEST['markUids']) ) { // deletes users - foreach ($_REQUEST['markUids'] as $markUid) + foreach ($_REQUEST['markUids'] as $markUid) { dbQuery('DELETE FROM Users WHERE Id = ?', array($markUid)); + ZM\AuditAction('delete', 'user', $markUid, ''); + } if ($markUid == $user->Id()) { userLogout(); $redirect = '?view=login'; @@ -90,6 +98,7 @@ if ( $action == 'delete' ) { } # end if value changed } # end foreach config entry if ( $changed ) { + ZM\AuditAction('update', 'config', 0, 'Tab: '.$_REQUEST['tab']); switch ( $_REQUEST['tab'] ) { case 'system' : case 'config' : diff --git a/web/includes/actions/role.php b/web/includes/actions/role.php index da5d9c16d..84e476adf 100644 --- a/web/includes/actions/role.php +++ b/web/includes/actions/role.php @@ -58,6 +58,7 @@ if ($action == 'Save') { unset($_REQUEST['redirect']); return; } + ZM\AuditAction(($rid ? 'update' : 'create'), 'role', $dbRole->Id(), 'Name: '.$dbRole->Name()); } # Save group permissions diff --git a/web/includes/actions/server.php b/web/includes/actions/server.php index 137a95981..5e282ee9f 100644 --- a/web/includes/actions/server.php +++ b/web/includes/actions/server.php @@ -41,6 +41,7 @@ if ($action == 'save') { } else { dbQuery('INSERT INTO Servers SET '.implode(', ', $changes)); } + ZM\AuditAction((!empty($_REQUEST['id']) ? 'update' : 'create'), 'server', $_REQUEST['id'] ?? 0, 'Changed: '.implode(', ', array_keys($changes))); $refreshParent = true; } $redirect = '?view=options&tab=servers'; diff --git a/web/includes/actions/snapshot.php b/web/includes/actions/snapshot.php index 949ce4284..04f54a2a9 100644 --- a/web/includes/actions/snapshot.php +++ b/web/includes/actions/snapshot.php @@ -28,6 +28,7 @@ if ( $action == 'create' ) { } $snapshot = new ZM\Snapshot(); $snapshot->save(array('CreatedBy'=>$user->Id())); + ZM\AuditAction('create', 'snapshot', $snapshot->Id(), ''); foreach ( $_REQUEST['monitor_ids'] as $monitor_id ) { if (!validCardinal($monitor_id)) { @@ -73,12 +74,14 @@ if ( isset($_REQUEST['id']) ) { $changes = $snapshot->changes($_REQUEST['snapshot']); if ( count($changes) ) { $snapshot->save($changes); + ZM\AuditAction('update', 'snapshot', $snapshot->Id(), 'Changed: '.implode(', ', array_keys($changes))); } $redirect = '?view=snapshots'; } } else if ( $action == 'delete' ) { if ( canEdit('Events') ) { $snapshot->delete(); + ZM\AuditAction('delete', 'snapshot', $_REQUEST['id'], ''); $redirect = '?view=snapshots'; } } diff --git a/web/includes/actions/state.php b/web/includes/actions/state.php index d36a9bf85..9fb91c4fe 100644 --- a/web/includes/actions/state.php +++ b/web/includes/actions/state.php @@ -26,6 +26,7 @@ if (!canEdit('System')) { if ($action == 'state') { if (!empty($_REQUEST['runState'])) { packageControl($_REQUEST['runState']); + ZM\AuditAction('apply', 'state', 0, 'State: '.$_REQUEST['runState']); $refreshParent = true; } } else if ($action == 'save') { @@ -39,10 +40,13 @@ if ($action == 'state') { if ( $_REQUEST['newState'] ) $_REQUEST['runState'] = $_REQUEST['newState']; dbQuery('REPLACE INTO `States` SET `Name`=?, `Definition`=?', array($_REQUEST['runState'], $definition)); + ZM\AuditAction('save', 'state', 0, 'Name: '.$_REQUEST['runState']); } } else if ($action == 'delete') { - if (isset($_REQUEST['runState'])) + if (isset($_REQUEST['runState'])) { dbQuery('DELETE FROM `States` WHERE `Name`=?', array($_REQUEST['runState'])); + ZM\AuditAction('delete', 'state', 0, 'Name: '.$_REQUEST['runState']); + } } $redirect = '?view='.getHomeView(); ?> diff --git a/web/includes/actions/storage.php b/web/includes/actions/storage.php index 74845a61d..4d282cca9 100644 --- a/web/includes/actions/storage.php +++ b/web/includes/actions/storage.php @@ -33,6 +33,7 @@ if ($action == 'save') { if (count($changes)) { if ($storage->save($changes)) { + ZM\AuditAction(($_REQUEST['id'] ? 'update' : 'create'), 'storage', $storage->Id(), 'Changed: '.implode(', ', array_keys($changes))); } else { $error_message .= $storage->get_last_error(); } // end if successful save diff --git a/web/includes/actions/user.php b/web/includes/actions/user.php index 323817ac8..f2efce55a 100644 --- a/web/includes/actions/user.php +++ b/web/includes/actions/user.php @@ -60,6 +60,7 @@ if ($action == 'Save') { unset($_REQUEST['redirect']); return; } + ZM\AuditAction(($uid ? 'update' : 'create'), 'user', $dbUser->Id(), 'Username: '.$dbUser->Username().' Changed: '.implode(', ', array_keys($changes))); if ($uid) { if ($user and ($dbUser->Username() == $user->Username())) { @@ -120,6 +121,7 @@ if ($action == 'Save') { unset($_REQUEST['redirect']); return; } + ZM\AuditAction('self_update', 'user', $dbUser->Id(), 'Changed: '.implode(', ', array_keys($changes))); # We are the logged in user, need to update the $user object and generate a new auth_hash $user = ZM\User::find_one(['Enabled'=>1, 'Id'=>$uid]); diff --git a/web/includes/actions/zone.php b/web/includes/actions/zone.php index e48da16f1..07621aadf 100644 --- a/web/includes/actions/zone.php +++ b/web/includes/actions/zone.php @@ -60,6 +60,7 @@ if ( !empty($_REQUEST['mid']) && canEdit('Monitors', $_REQUEST['mid']) ) { } else { dbQuery('INSERT INTO Zones SET MonitorId=?, '.implode(', ', $changes), array($mid)); } + ZM\AuditAction(($zid > 0 ? 'update' : 'create'), 'zone', $zid, 'MonitorId: '.$mid); if ( daemonCheck() && ($monitor->Type() != 'WebSite') ) { $monitor->zmcControl('reload'); } diff --git a/web/includes/actions/zones.php b/web/includes/actions/zones.php index 70209fc02..6e02ef618 100644 --- a/web/includes/actions/zones.php +++ b/web/includes/actions/zones.php @@ -38,6 +38,7 @@ if ($action == 'delete') { # Could use true but store the object instead for easy access later $monitors_to_restart[] = $monitor; $error_message .= $zone->delete(); + ZM\AuditAction('delete', 'zone', $markZid, 'MonitorId: '.$monitor->Id()); } # end foreach Zone if (daemonCheck()) { diff --git a/web/includes/auth.php b/web/includes/auth.php index 7ddb3576f..7d7a4520d 100644 --- a/web/includes/auth.php +++ b/web/includes/auth.php @@ -604,6 +604,7 @@ if (ZM_OPT_USE_AUTH) { $password = $_REQUEST['password']; ZM\Info("Login successful for user \"$username\""); + ZM\Audit("user=$username action=login id=".$user->Id()." from=".($_SERVER['REMOTE_ADDR'] ?? 'local')); $password_type = password_type($user->Password()); if ( $password_type == 'mysql' or $password_type == 'mysql+bcrypt' ) { diff --git a/web/includes/logger.php b/web/includes/logger.php index c885819bc..f073bb86a 100644 --- a/web/includes/logger.php +++ b/web/includes/logger.php @@ -12,7 +12,8 @@ class Logger { const ERROR = -2; const FATAL = -3; const PANIC = -4; - const NOLOG = -5; // Special artificial level to prevent logging + const AUDIT = -5; + const NOLOG = -6; // Special artificial level to prevent logging private $initialised = false; @@ -46,6 +47,7 @@ class Logger { self::ERROR => 'ERR', self::FATAL => 'FAT', self::PANIC => 'PNC', + self::AUDIT => 'AUD', self::NOLOG => 'OFF', ); private static $syslogPriorities = array( @@ -55,6 +57,7 @@ class Logger { self::ERROR => LOG_ERR, self::FATAL => LOG_ERR, self::PANIC => LOG_ERR, + self::AUDIT => LOG_NOTICE, ); private static $phpErrorLevels = array( self::DEBUG => E_USER_NOTICE, @@ -63,6 +66,7 @@ class Logger { self::ERROR => E_USER_WARNING, self::FATAL => E_USER_ERROR, self::PANIC => E_USER_ERROR, + self::AUDIT => E_USER_NOTICE, ); private function __construct() { @@ -512,6 +516,20 @@ function Panic( $string ) { exit(1); } +function Audit($string) { + Logger::fetch()->logPrint(Logger::AUDIT, $string); +} + +function AuditAction($action, $target_type, $target_id, $details) { + global $user; + $username = $user ? $user->Username() : 'system'; + $ip = !empty($_SERVER['HTTP_X_FORWARDED_FOR']) + ? $_SERVER['HTTP_X_FORWARDED_FOR'] + : ($_SERVER['REMOTE_ADDR'] ?? 'local'); + Audit("user=$username action=$action target=$target_type" + ." id=$target_id details=\"$details\" from=$ip"); +} + function ErrorHandler( $error, $string, $file, $line ) { if ( ! (error_reporting() & $error) ) { // This error code is not included in error_reporting