add contents
This commit is contained in:
190
lib/plugins/loglog/helper/alert.php
Normal file
190
lib/plugins/loglog/helper/alert.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Class helper_plugin_loglog_alert
|
||||
*/
|
||||
class helper_plugin_loglog_alert extends DokuWiki_Plugin
|
||||
{
|
||||
/**
|
||||
* @var \helper_plugin_loglog_main
|
||||
*/
|
||||
protected $mainHelper;
|
||||
|
||||
/**
|
||||
* @var \helper_plugin_loglog_logging
|
||||
*/
|
||||
protected $logHelper;
|
||||
|
||||
/** @var int */
|
||||
protected $interval;
|
||||
|
||||
/** @var int */
|
||||
protected $threshold;
|
||||
|
||||
/** @var int */
|
||||
protected $now;
|
||||
|
||||
/** @var int */
|
||||
protected $multiplier;
|
||||
|
||||
/** @var string */
|
||||
protected $statfile;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->mainHelper = $this->loadHelper('loglog_main');
|
||||
$this->logHelper = $this->loadHelper('loglog_logging');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any configured thresholds have been exceeded and trigger
|
||||
* alert notifications accordingly.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function checkAlertThresholds()
|
||||
{
|
||||
$this->handleThreshold(
|
||||
\helper_plugin_loglog_main::LOGTYPE_AUTH_FAIL,
|
||||
$this->getConf('login_failed_max'),
|
||||
$this->getConf('login_failed_interval'),
|
||||
$this->getConf('login_failed_email')
|
||||
);
|
||||
|
||||
$this->handleThreshold(
|
||||
\helper_plugin_loglog_main::LOGTYPE_AUTH_OK,
|
||||
$this->getConf('login_success_max'),
|
||||
$this->getConf('login_success_interval'),
|
||||
$this->getConf('login_success_email')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates threshold configuration for given type of logged event
|
||||
* and triggers email alerts.
|
||||
*
|
||||
* @param string $logType
|
||||
* @param int $threshold
|
||||
* @param int $minuteInterval
|
||||
* @param string $email
|
||||
*/
|
||||
protected function handleThreshold($logType, $threshold, $minuteInterval, $email)
|
||||
{
|
||||
// proceed only if we have sufficient configuration
|
||||
if (! $email || ! $threshold || ! $minuteInterval) {
|
||||
return;
|
||||
}
|
||||
$this->resetMultiplier();
|
||||
$this->threshold = $threshold;
|
||||
$this->interval = $minuteInterval * 60;
|
||||
$this->now = time();
|
||||
$max = $this->now;
|
||||
$min = $this->now - ($this->interval);
|
||||
|
||||
$msgNeedle = $this->mainHelper->getNotificationString($logType, 'msgNeedle');
|
||||
$lines = $this->logHelper->readLines($min, $max);
|
||||
$cnt = $this->logHelper->countMatchingLines($lines, $msgNeedle);
|
||||
if ($cnt < $threshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $conf;
|
||||
$this->statfile = $conf['cachedir'] . '/loglog.' . $logType . '.stat';
|
||||
|
||||
if ($this->actNow()) {
|
||||
io_saveFile($this->statfile, $this->multiplier);
|
||||
$this->sendAlert($logType, $email);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send alert email
|
||||
*
|
||||
* @param string $logType
|
||||
* @param string $email
|
||||
*/
|
||||
protected function sendAlert($logType, $email)
|
||||
{
|
||||
$template = $this->localFN($logType);
|
||||
$text = file_get_contents($template);
|
||||
$this->mainHelper->sendEmail(
|
||||
$email,
|
||||
$this->getLang($this->mainHelper->getNotificationString($logType, 'emailSubjectLang')),
|
||||
$text,
|
||||
[
|
||||
'threshold' => $this->threshold,
|
||||
'interval' => $this->interval / 60, // falling back to minutes for the view
|
||||
'now' => date('Y-m-d H:i', $this->now),
|
||||
'sequence' => $this->getSequencePhase(),
|
||||
'next_alert' => date('Y-m-d H:i', $this->getNextAlert()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if it is time to act or wait this interval out
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function actNow()
|
||||
{
|
||||
$act = true;
|
||||
|
||||
if (!is_file($this->statfile)) {
|
||||
return $act;
|
||||
}
|
||||
|
||||
$lastAlert = filemtime($this->statfile);
|
||||
$this->multiplier = (int)file_get_contents($this->statfile);
|
||||
|
||||
$intervalsAfterLastAlert = (int)floor(($this->now - $lastAlert) / $this->interval);
|
||||
|
||||
if ($intervalsAfterLastAlert === $this->multiplier) {
|
||||
$this->increaseMultiplier();
|
||||
} elseif ($intervalsAfterLastAlert < $this->multiplier) {
|
||||
$act = false;
|
||||
} elseif ($intervalsAfterLastAlert > $this->multiplier) {
|
||||
$this->resetMultiplier(); // no longer part of series, reset multiplier
|
||||
}
|
||||
|
||||
return $act;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate which phase of sequential events we are in (possible attacks),
|
||||
* based on the interval multiplier. 1 indicates the first incident,
|
||||
* otherwise evaluate the exponent (because we multiply the interval by 2 on each alert).
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
protected function getSequencePhase()
|
||||
{
|
||||
return $this->multiplier === 1 ? $this->multiplier : log($this->multiplier, 2) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate when the next alert is due based on the current multiplier
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
protected function getNextAlert()
|
||||
{
|
||||
return $this->now + $this->interval * $this->multiplier * 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset multiplier. Called when the triggering event is not part of a sequence.
|
||||
*/
|
||||
protected function resetMultiplier()
|
||||
{
|
||||
$this->multiplier = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increase multiplier. Called when the triggering event belongs to a sequence.
|
||||
*/
|
||||
protected function increaseMultiplier()
|
||||
{
|
||||
$this->multiplier *= 2;
|
||||
}
|
||||
}
|
166
lib/plugins/loglog/helper/logging.php
Normal file
166
lib/plugins/loglog/helper/logging.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Class helper_plugin_loglog_logging
|
||||
*/
|
||||
class helper_plugin_loglog_logging extends DokuWiki_Plugin
|
||||
{
|
||||
protected $file = '';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
global $conf;
|
||||
if(defined('DOKU_UNITTEST')) {
|
||||
$this->file = DOKU_PLUGIN . 'loglog/_test/loglog.log';
|
||||
} else {
|
||||
$this->file = $conf['cachedir'] . '/loglog.log';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a log entry from passed data and write a single line to log file
|
||||
*
|
||||
* @param string $msg
|
||||
* @param null $user
|
||||
* @param array $data
|
||||
*/
|
||||
public function writeLine($msg, $user = null, $data = [])
|
||||
{
|
||||
global $conf, $INPUT;
|
||||
|
||||
if (is_null($user)) $user = $INPUT->server->str('REMOTE_USER');
|
||||
if (!$user) $user = $_REQUEST['u'];
|
||||
if (!$user) return;
|
||||
|
||||
$t = time();
|
||||
$ip = clientIP(true);
|
||||
$data = !empty($data) ? json_encode($data) : '';
|
||||
|
||||
$line = join("\t", [$t, strftime($conf['dformat'], $t), $ip, $user, $msg, $data]);
|
||||
|
||||
io_saveFile($this->file, "$line\n", true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return logfile lines limited to specified $min - $max range
|
||||
*
|
||||
* @param int $min
|
||||
* @param int $max
|
||||
* @return array
|
||||
*/
|
||||
public function readLines($min, $max)
|
||||
{
|
||||
$lines = [];
|
||||
$candidateLines = $this->readChunks($min, $max);
|
||||
foreach ($candidateLines as $line) {
|
||||
if (empty($line)) continue; // Filter empty lines
|
||||
$parsedLine = $this->loglineToArray($line);
|
||||
if ($parsedLine['dt'] >= $min && $parsedLine['dt'] <= $max) {
|
||||
$lines[] = $parsedLine;
|
||||
}
|
||||
}
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read log lines backwards. Start and end timestamps are used to evaluate
|
||||
* only the chunks being read, NOT single lines. This method will return
|
||||
* too many lines, the dates have to be checked by the caller again.
|
||||
*
|
||||
* @param int $min start time (in seconds)
|
||||
* @param int $max end time (in seconds)
|
||||
* @return array
|
||||
*/
|
||||
protected function readChunks($min, $max)
|
||||
{
|
||||
$data = array();
|
||||
$lines = array();
|
||||
$chunk_size = 8192;
|
||||
|
||||
if (!@file_exists($this->file)) return $data;
|
||||
$fp = fopen($this->file, 'rb');
|
||||
if ($fp === false) return $data;
|
||||
|
||||
//seek to end
|
||||
fseek($fp, 0, SEEK_END);
|
||||
$pos = ftell($fp);
|
||||
$chunk = '';
|
||||
|
||||
while ($pos) {
|
||||
|
||||
// how much to read? Set pointer
|
||||
if ($pos > $chunk_size) {
|
||||
$pos -= $chunk_size;
|
||||
$read = $chunk_size;
|
||||
} else {
|
||||
$read = $pos;
|
||||
$pos = 0;
|
||||
}
|
||||
fseek($fp, $pos);
|
||||
|
||||
$tmp = fread($fp, $read);
|
||||
if ($tmp === false) break;
|
||||
$chunk = $tmp . $chunk;
|
||||
|
||||
// now split the chunk
|
||||
$cparts = explode("\n", $chunk);
|
||||
|
||||
// keep the first part in chunk (may be incomplete)
|
||||
if ($pos) $chunk = array_shift($cparts);
|
||||
|
||||
// no more parts available, read on
|
||||
if (!count($cparts)) continue;
|
||||
|
||||
// get date of first line:
|
||||
list($cdate) = explode("\t", $cparts[0]);
|
||||
|
||||
if ($cdate > $max) continue; // haven't reached wanted area, yet
|
||||
|
||||
// put all the lines from the chunk on the stack
|
||||
$lines = array_merge($cparts, $lines);
|
||||
|
||||
if ($cdate < $min) break; // we have enough
|
||||
}
|
||||
fclose($fp);
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert log line to array
|
||||
*
|
||||
* @param string $line
|
||||
* @return array
|
||||
*/
|
||||
protected function loglineToArray($line)
|
||||
{
|
||||
list($dt, $junk, $ip, $user, $msg, $data) = explode("\t", $line, 6);
|
||||
return [
|
||||
'dt' => $dt, // timestamp
|
||||
'ip' => $ip,
|
||||
'user' => $user,
|
||||
'msg' => $msg,
|
||||
'data' => $data, // JSON encoded additional data
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of lines where the given needle has been found in message
|
||||
*
|
||||
* @param array $lines
|
||||
* @param string $msgNeedle
|
||||
* @return mixed
|
||||
*/
|
||||
public function countMatchingLines(array $lines, string $msgNeedle)
|
||||
{
|
||||
return array_reduce(
|
||||
$lines,
|
||||
function ($carry, $line) use ($msgNeedle) {
|
||||
$carry = $carry + (int)(strpos($line['msg'], $msgNeedle) !== false);
|
||||
return $carry;
|
||||
},
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
}
|
97
lib/plugins/loglog/helper/main.php
Normal file
97
lib/plugins/loglog/helper/main.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Class helper_plugin_loglog_main
|
||||
*/
|
||||
class helper_plugin_loglog_main extends DokuWiki_Plugin
|
||||
{
|
||||
const LOGTYPE_AUTH_OK = 'auth_success';
|
||||
const LOGTYPE_AUTH_FAIL = 'auth_failed';
|
||||
|
||||
/**
|
||||
* @var helper_plugin_loglog_logging
|
||||
*/
|
||||
protected $logHelper;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->logHelper = $this->loadHelper('loglog_logging');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduce the type of logged event from message field. Those types are used in a dropdown filter
|
||||
* in admin listing of activities, as well as when generating reports to send per email.
|
||||
*
|
||||
* @param string $msg
|
||||
* @return string
|
||||
*/
|
||||
public function getLogTypeFromMsg($msg)
|
||||
{
|
||||
$filter = 'other';
|
||||
if (in_array(
|
||||
$msg,
|
||||
[
|
||||
'logged in temporarily',
|
||||
'logged in permanently',
|
||||
'logged off',
|
||||
'has been automatically logged off'
|
||||
]
|
||||
)) {
|
||||
$filter = 'auth_ok';
|
||||
} elseif (in_array(
|
||||
$msg,
|
||||
[
|
||||
'failed login attempt',
|
||||
]
|
||||
)) {
|
||||
$filter = 'auth_error';
|
||||
} elseif (strpos($msg, 'admin') === 0) {
|
||||
$filter = 'admin';
|
||||
}
|
||||
|
||||
return $filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends emails
|
||||
*
|
||||
* @param string $email
|
||||
* @param string $subject
|
||||
* @param string $text
|
||||
* @return bool
|
||||
*/
|
||||
public function sendEmail($email, $subject, $text, $textrep = [])
|
||||
{
|
||||
$html = p_render('xhtml', p_get_instructions($text), $info);
|
||||
|
||||
$mail = new Mailer();
|
||||
$mail->to($email);
|
||||
$mail->subject($subject);
|
||||
$mail->setBody($text, $textrep, null, $html);
|
||||
return $mail->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string corresponding to $key in a given $context,
|
||||
* empty string if nothing has been found in the string repository.
|
||||
*
|
||||
* @param string $context
|
||||
* @param string $key
|
||||
* @return string
|
||||
*/
|
||||
public function getNotificationString($context, $key)
|
||||
{
|
||||
$stringRepo = [
|
||||
self::LOGTYPE_AUTH_FAIL => [
|
||||
'msgNeedle' => 'failed login attempt',
|
||||
'emailSubjectLang' => 'email_max_failed_logins_subject'
|
||||
],
|
||||
self::LOGTYPE_AUTH_OK => [
|
||||
'msgNeedle' => 'logged in',
|
||||
'emailSubjectLang' => 'email_max_success_logins_subject'
|
||||
],
|
||||
];
|
||||
|
||||
return isset($stringRepo[$context][$key]) ? $stringRepo[$context][$key] : '';
|
||||
}
|
||||
}
|
129
lib/plugins/loglog/helper/report.php
Normal file
129
lib/plugins/loglog/helper/report.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Class helper_plugin_loglog_report
|
||||
*/
|
||||
class helper_plugin_loglog_report extends DokuWiki_Plugin
|
||||
{
|
||||
/**
|
||||
* @var \helper_plugin_loglog_main
|
||||
*/
|
||||
protected $mainHelper;
|
||||
|
||||
/**
|
||||
* @var \helper_plugin_loglog_logging
|
||||
*/
|
||||
protected $logHelper;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->mainHelper = $this->loadHelper('loglog_main');
|
||||
$this->logHelper = $this->loadHelper('loglog_logging');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the report has already been sent this month. If not, creates and
|
||||
* sends the report, and records this action in the log.
|
||||
*/
|
||||
public function handleReport()
|
||||
{
|
||||
$email = $this->getConf('report_email');
|
||||
if (!$email) return;
|
||||
|
||||
// calculate cutoff dates
|
||||
$lastMonthStart = mktime(0, 0, 0, date('n', strtotime('last month')), 1);
|
||||
$currentMonthStart = mktime(0, 0, 0, date('n'), 1);
|
||||
|
||||
// check if the report is due
|
||||
global $conf;
|
||||
$statfile = $conf['cachedir'] . '/loglog.stat';
|
||||
if (is_file($statfile) && filemtime($statfile) >= $currentMonthStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
// calculate stat
|
||||
$monthLines = $this->logHelper->readLines($lastMonthStart, $currentMonthStart);
|
||||
$stats = $this->getStats($monthLines);
|
||||
|
||||
// email the report
|
||||
$template = $this->localFN('report');
|
||||
$text = file_get_contents($template);
|
||||
// format access to admin pages
|
||||
$adminPages = implode(
|
||||
"\n",
|
||||
array_map(
|
||||
function ($page, $cnt) {
|
||||
return " - $page: $cnt";
|
||||
},
|
||||
array_keys($stats['admin']),
|
||||
$stats['admin']
|
||||
)
|
||||
);
|
||||
|
||||
$text = str_replace(
|
||||
['@@auth_ok@@', '@@auth_fail@@', '@@users@@', '@@admin_pages@@'],
|
||||
[$stats['auth_success'], $stats['auth_failed'], $stats['users'], $adminPages],
|
||||
$text
|
||||
);
|
||||
|
||||
if (
|
||||
$this->mainHelper->sendEmail(
|
||||
$email,
|
||||
$this->getLang('email_report_subject'),
|
||||
$text
|
||||
)
|
||||
) {
|
||||
// log itself
|
||||
$this->logHelper->writeLine('loglog - report', 'cron');
|
||||
// touch statfile
|
||||
touch($statfile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go through supplied log lines and aggregate basic activity statistics
|
||||
*
|
||||
* @param array $lines
|
||||
* @return array
|
||||
*/
|
||||
public function getStats(array $lines)
|
||||
{
|
||||
$authOk = 0;
|
||||
$authFail = 0;
|
||||
$users = [];
|
||||
$pages = ['start' => 0];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (
|
||||
strpos(
|
||||
$line['msg'],
|
||||
$this->mainHelper->getNotificationString(\helper_plugin_loglog_main::LOGTYPE_AUTH_OK, 'msgNeedle')
|
||||
) !== false
|
||||
) {
|
||||
$authOk++;
|
||||
if ($line['user']) $users[] = $line['user'];
|
||||
} elseif (
|
||||
strpos(
|
||||
$line['msg'],
|
||||
$this->mainHelper->getNotificationString(\helper_plugin_loglog_main::LOGTYPE_AUTH_FAIL, 'msgNeedle')
|
||||
) !== false
|
||||
) {
|
||||
$authFail++;
|
||||
} elseif (strpos($line['msg'], 'admin') !== false) {
|
||||
list($action, $page) = explode(' - ', $line['msg']);
|
||||
if ($page) {
|
||||
$pages[$page] = !isset($pages[$page]) ? 1 : $pages[$page] + 1;
|
||||
} else {
|
||||
$pages['start']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
\helper_plugin_loglog_main::LOGTYPE_AUTH_OK => $authOk,
|
||||
\helper_plugin_loglog_main::LOGTYPE_AUTH_FAIL => $authFail,
|
||||
'users' => count(array_unique($users)),
|
||||
'admin' => $pages
|
||||
];
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user