add contents

This commit is contained in:
Trevor Batley
2025-10-09 15:04:29 +11:00
parent 170362eec1
commit bce7dd054a
2537 changed files with 301282 additions and 0 deletions

View 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;
}
}

View 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
);
}
}

View 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] : '';
}
}

View 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
];
}
}