Add in software files and templates

This commit is contained in:
2023-06-21 14:19:40 +01:00
parent f42fdb947c
commit 5228fc5e9f
143 changed files with 23175 additions and 2 deletions

View File

@@ -0,0 +1,165 @@
<?php
/**
* dmarc-srg - A php parser, viewer and summary report generator for incoming DMARC reports.
* Copyright (C) 2020 Aleksey Andreev (liuch)
*
* Available at:
* https://github.com/liuch/dmarc-srg
*
* 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 3 of the License.
*
* 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.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Liuch\DmarcSrg\Report;
use Liuch\DmarcSrg\Core;
use Liuch\DmarcSrg\Exception\SoftException;
use Liuch\DmarcSrg\Exception\DatabaseNotFoundException;
class Report
{
private $db = null;
private $data = null;
public function __construct($data, $db = null)
{
$this->data = $data;
$this->db = $db ?? Core::instance()->database();
}
public static function fromXmlFile($fd)
{
$data = ReportData::fromXmlFile($fd);
if (!self::checkData($data)) {
throw new SoftException('Incorrect or incomplete report data');
}
return new Report($data);
}
public function fetch()
{
$domain = $this->data['domain'];
$report_id = $this->data['report_id'];
if (empty($domain) || empty($report_id)) {
throw new SoftException('Not specified report\'s domain or id');
}
$this->data = [ 'domain' => $domain, 'report_id' => $report_id ];
try {
$this->db->getMapper('report')->fetch($this->data);
} catch (DatabaseNotFoundException $e) {
throw new SoftException('The report is not found');
}
}
public function save(string $real_fname)
{
$b_ts = $this->data['begin_time'];
$e_ts = $this->data['end_time'];
if (!$b_ts->getTimestamp() || !$e_ts->getTimestamp() || $b_ts > $e_ts) {
throw new SoftException('Failed to add an incoming report: wrong date range');
}
$this->db->getMapper('report')->save($this->data);
return [ 'message' => 'The report is loaded successfully' ];
}
public function get()
{
return $this->data;
}
public function set($name, $value)
{
if (empty($this->data['domain']) || empty($this->data['report_id'])) {
throw new SoftException('Not specified report\'s domain or id');
}
$this->db->getMapper('report')->setProperty($this->data, $name, $value);
return [ 'message' => 'Ok' ];
}
/**
* Checks report data for correctness and completeness
*
* @param array $data Report data
*
* @return bool
*/
private static function checkData(array $data): bool
{
static $fields = [
'domain' => [ 'required' => true, 'type' => 'string' ],
'begin_time' => [ 'required' => true, 'type' => 'object' ],
'end_time' => [ 'required' => true, 'type' => 'object' ],
'org' => [ 'required' => true, 'type' => 'string' ],
'external_id' => [ 'required' => true, 'type' => 'string' ],
'email' => [ 'required' => false, 'type' => 'string' ],
'extra_contact_info' => [ 'required' => false, 'type' => 'string' ],
'error_string' => [ 'required' => false, 'type' => 'array' ],
'policy_adkim' => [ 'required' => false, 'type' => 'string' ],
'policy_aspf' => [ 'required' => false, 'type' => 'string' ],
'policy_p' => [ 'required' => false, 'type' => 'string' ],
'policy_sp' => [ 'required' => false, 'type' => 'string' ],
'policy_np' => [ 'required' => false, 'type' => 'string' ],
'policy_pct' => [ 'required' => false, 'type' => 'string' ],
'policy_fo' => [ 'required' => false, 'type' => 'string' ],
'records' => [ 'required' => true, 'type' => 'array' ]
];
if (!self::checkRow($data, $fields) || count($data['records']) === 0) {
return false;
}
static $rfields = [
'ip' => [ 'required' => true, 'type' => 'string' ],
'rcount' => [ 'required' => true, 'type' => 'integer' ],
'disposition' => [ 'required' => true, 'type' => 'string' ],
'reason' => [ 'required' => false, 'type' => 'array' ],
'dkim_auth' => [ 'required' => false, 'type' => 'array' ],
'spf_auth' => [ 'required' => false, 'type' => 'array' ],
'dkim_align' => [ 'required' => true, 'type' => 'string' ],
'spf_align' => [ 'required' => true, 'type' => 'string' ],
'envelope_to' => [ 'required' => false, 'type' => 'string' ],
'envelope_from' => [ 'required' => false, 'type' => 'string' ],
'header_from' => [ 'required' => false, 'type' => 'string' ]
];
foreach ($data['records'] as &$rec) {
if (gettype($rec) !== 'array' || !self::checkRow($rec, $rfields)) {
return false;
}
}
return true;
}
/**
* Checks one row of report data
*
* @param array $row Data row
* @param array $def Row definition
*
* @return bool
*/
private static function checkRow(array &$row, array &$def): bool
{
foreach ($def as $key => &$dd) {
if (isset($row[$key])) {
if (gettype($row[$key]) !== $dd['type']) {
return false;
}
} elseif ($dd['required']) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,370 @@
<?php
/**
* dmarc-srg - A php parser, viewer and summary report generator for incoming DMARC reports.
* Copyright (C) 2020 Aleksey Andreev (liuch)
*
* Available at:
* https://github.com/liuch/dmarc-srg
*
* 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 3 of the License.
*
* 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.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Liuch\DmarcSrg\Report;
use Liuch\DmarcSrg\DateTime;
use Liuch\DmarcSrg\Exception\RuntimeException;
class ReportData
{
public static $rep_data = null;
public static $tag_id = null;
public static function fromXmlFile($fd)
{
self::$tag_id = '<root>';
self::$rep_data = [ 'records' => [] ];
$parser = xml_parser_create();
xml_set_element_handler(
$parser,
'Liuch\DmarcSrg\Report\ReportData::xmlStartTag',
'Liuch\DmarcSrg\Report\ReportData::xmlEndTag'
);
xml_set_character_data_handler($parser, 'Liuch\DmarcSrg\Report\ReportData::xmlTagData');
xml_set_external_entity_ref_handler($parser, function () {
throw new RuntimeException('The XML document has an external entity!');
});
try {
while ($file_data = fread($fd, 4096)) {
if (!xml_parse($parser, $file_data, feof($fd))) {
throw new RuntimeException('XML error!');
}
}
} finally {
xml_parser_free($parser);
unset($parser);
}
return self::$rep_data;
}
public static function xmlStartTag($parser, $name, $attrs)
{
self::xmlEnterTag($name);
switch (self::$tag_id) {
case 'rec':
self::$rep_data['records'][] = [];
break;
case 'error_string':
if (!isset(self::$rep_data['error_string'])) {
self::$rep_data['error_string'] = [];
}
break;
case 'reason':
$idx = count(self::$rep_data['records']) - 1;
if (!isset(self::$rep_data['records'][$idx]['reason'])) {
self::$rep_data['records'][$idx]['reason'] = [];
}
self::$report_tags['reason']['tmp_data'] = [];
break;
case 'dkim_auth':
$idx = count(self::$rep_data['records']) - 1;
if (!isset(self::$rep_data['records'][$idx]['dkim_auth'])) {
self::$rep_data['records'][$idx]['dkim_auth'] = [];
}
self::$report_tags['dkim_auth']['tmp_data'] = [];
break;
case 'spf_auth':
$idx = count(self::$rep_data['records']) - 1;
if (!isset(self::$rep_data['records'][$idx]['spf_auth'])) {
self::$rep_data['records'][$idx]['spf_auth'] = [];
}
self::$report_tags['spf_auth']['tmp_data'] = [];
break;
}
}
public static function xmlEndTag($parser, $name)
{
switch (self::$tag_id) {
case 'reason':
$idx = count(self::$rep_data['records']) - 1;
self::$rep_data['records'][$idx]['reason'][] = self::$report_tags['reason']['tmp_data'];
unset(self::$report_tags['reason']['tmp_data']);
break;
case 'dkim_auth':
$idx = count(self::$rep_data['records']) - 1;
self::$rep_data['records'][$idx]['dkim_auth'][] = self::$report_tags['dkim_auth']['tmp_data'];
unset(self::$report_tags['dkim_auth']['tmp_data']);
break;
case 'spf_auth':
$idx = count(self::$rep_data['records']) - 1;
self::$rep_data['records'][$idx]['spf_auth'][] = self::$report_tags['spf_auth']['tmp_data'];
unset(self::$report_tags['spf_auth']['tmp_data']);
break;
case 'feedback':
// Set the default value if it's necessary and there is no data
foreach (self::$report_tags as $tag_id => &$tag_data) {
if (array_key_exists('default', $tag_data)) { // not isset() because of null values
if (isset($tag_data['header']) && $tag_data['header']) {
if (!isset(self::$rep_data[$tag_id])) {
self::$rep_data[$tag_id] = $tag_data['default'];
}
} else {
foreach (self::$rep_data['records'] as $idx => &$rec_val) {
if (!isset($rec_val[$tag_id])) {
$rec_val[$tag_id] = $tag_data['default'];
}
}
unset($rec_val);
}
}
}
unset($tag_data);
$b_ts = intval(self::$rep_data['begin_time']);
$e_ts = intval(self::$rep_data['end_time']);
self::$rep_data['begin_time'] = new DateTime('@' . ($b_ts < 0 ? 0 : $b_ts));
self::$rep_data['end_time'] = new DateTime('@' . ($e_ts < 0 ? 0 : $e_ts));
foreach (self::$rep_data['records'] as &$rec_data) {
$rec_data['rcount'] = intval($rec_data['rcount']);
}
unset($rec_data);
break;
}
self::xmlLeaveTag();
}
public static function xmlTagData($parser, $data)
{
switch (self::$tag_id) {
case 'error_string':
if (self::$tag_id === 'error_string') {
self::$rep_data['error_string'][] = $data;
}
break;
case 'reason_type':
self::$report_tags['reason']['tmp_data']['type'] = $data;
break;
case 'reason_comment':
self::$report_tags['reason']['tmp_data']['comment'] = $data;
break;
case 'dkim_domain':
self::$report_tags['dkim_auth']['tmp_data']['domain'] = $data;
break;
case 'dkim_selector':
self::$report_tags['dkim_auth']['tmp_data']['selector'] = $data;
break;
case 'dkim_result':
self::$report_tags['dkim_auth']['tmp_data']['result'] = $data;
break;
case 'dkim_human_result':
self::$report_tags['dkim_auth']['tmp_data']['human_result'] = $data;
break;
case 'spf_domain':
self::$report_tags['spf_auth']['tmp_data']['domain'] = $data;
break;
case 'spf_scope':
self::$report_tags['spf_auth']['tmp_data']['scope'] = $data;
break;
case 'spf_result':
self::$report_tags['spf_auth']['tmp_data']['result'] = $data;
break;
default:
if (!isset(self::$report_tags[self::$tag_id]['children'])) {
if (isset(self::$report_tags[self::$tag_id]['header']) &&
self::$report_tags[self::$tag_id]['header']
) {
if (!isset(self::$rep_data[self::$tag_id])) {
self::$rep_data[self::$tag_id] = $data;
} else {
self::$rep_data[self::$tag_id] .= $data;
}
} else {
$last_idx = count(self::$rep_data['records']) - 1;
$last_rec =& self::$rep_data['records'][$last_idx];
if (!isset($last_rec[self::$tag_id])) {
$last_rec[self::$tag_id] = $data;
} else {
$last_rec[self::$tag_id] .= $data;
}
unset($last_rec);
}
}
}
}
public static function xmlEnterTag($name)
{
if (!isset(self::$report_tags[self::$tag_id]['children']) ||
!isset(self::$report_tags[self::$tag_id]['children'][$name])
) {
throw new RuntimeException("Unknown tag: {$name}");
}
self::$tag_id = self::$report_tags[self::$tag_id]['children'][$name];
}
public static function xmlLeaveTag()
{
self::$tag_id = self::$report_tags[self::$tag_id]['parent'];
}
public static $report_tags = [
'<root>' => [
'children' => [
'FEEDBACK' => 'feedback'
]
],
'feedback' => [
'parent' => '<root>',
'children' => [
'VERSION' => 'ver',
'REPORT_METADATA' => 'rmd',
'POLICY_PUBLISHED' => 'p_p',
'RECORD' => 'rec'
]
],
'ver' => [ 'parent' => 'feedback', 'header' => true, 'default' => null ],
'rmd' => [
'parent' => 'feedback',
'children' => [
'ORG_NAME' => 'org',
'EMAIL' => 'email',
'EXTRA_CONTACT_INFO' => 'extra_contact_info',
'REPORT_ID' => 'external_id',
'DATE_RANGE' => 'd_range',
'ERROR' => 'error_string'
]
],
'p_p' => [
'parent' => 'feedback',
'children' => [
'DOMAIN' => 'domain',
'ADKIM' => 'policy_adkim',
'ASPF' => 'policy_aspf',
'P' => 'policy_p',
'SP' => 'policy_sp',
'NP' => 'policy_np',
'PCT' => 'policy_pct',
'FO' => 'policy_fo'
]
],
'rec' => [
'parent' => 'feedback',
'children' => [
'ROW' => 'row',
'IDENTIFIERS' => 'ident',
'AUTH_RESULTS' => 'au_res'
]
],
'org' => [ 'parent' => 'rmd', 'header' => true ],
'email' => [ 'parent' => 'rmd', 'header' => true, 'default' => null ],
'extra_contact_info' => [ 'parent' => 'rmd', 'header' => true, 'default' => null ],
'external_id' => [ 'parent' => 'rmd', 'header' => true ],
'd_range' => [
'parent' => 'rmd',
'children' => [
'BEGIN' => 'begin_time',
'END' => 'end_time'
]
],
'error_string' => [ 'parent' => 'rmd', 'header' => true, 'default' => null ],
'begin_time' => [ 'parent' => 'd_range', 'header' => true ],
'end_time' => [ 'parent' => 'd_range', 'header' => true ],
'domain' => [ 'parent' => 'p_p', 'header' => true ],
'policy_adkim' => [ 'parent' => 'p_p', 'header' => true, 'default' => null ],
'policy_aspf' => [ 'parent' => 'p_p', 'header' => true, 'default' => null ],
'policy_p' => [ 'parent' => 'p_p', 'header' => true, 'default' => null ],
'policy_sp' => [ 'parent' => 'p_p', 'header' => true, 'default' => null ],
'policy_np' => [ 'parent' => 'p_p', 'header' => true, 'default' => null ],
'policy_pct' => [ 'parent' => 'p_p', 'header' => true, 'default' => null ],
'policy_fo' => [ 'parent' => 'p_p', 'header' => true, 'default' => null ],
'row' => [
'parent' => 'rec',
'children' => [
'SOURCE_IP' => 'ip',
'COUNT' => 'rcount',
'POLICY_EVALUATED' => 'p_e'
]
],
'ident' => [
'parent' => 'rec',
'children' => [
'ENVELOPE_TO' => 'envelope_to',
'ENVELOPE_FROM' => 'envelope_from',
'HEADER_FROM' => 'header_from'
]
],
'au_res' => [
'parent' => 'rec',
'children' => [
'DKIM' => 'dkim_auth',
'SPF' => 'spf_auth'
]
],
'ip' => [ 'parent' => 'row' ],
'rcount' => [ 'parent' => 'row' ],
'p_e' => [
'parent' => 'row',
'children' => [
'DISPOSITION' => 'disposition',
'DKIM' => 'dkim_align',
'SPF' => 'spf_align',
'REASON' => 'reason'
]
],
'disposition' => [ 'parent' => 'p_e' ],
'dkim_align' => [ 'parent' => 'p_e' ],
'spf_align' => [ 'parent' => 'p_e' ],
'reason' => [
'parent' => 'p_e',
'default' => null,
'children' => [
'TYPE' => 'reason_type',
'COMMENT' => 'reason_comment'
]
],
'envelope_to' => [ 'parent' => 'ident', 'default' => null ],
'envelope_from' => [ 'parent' => 'ident', 'default' => null ],
'header_from' => [ 'parent' => 'ident', 'default' => null ],
'dkim_auth' => [
'parent' => 'au_res',
'default' => null,
'children' => [
'DOMAIN' => 'dkim_domain',
'SELECTOR' => 'dkim_selector',
'RESULT' => 'dkim_result',
'HUMAN_RESULT' => 'dkim_human_result'
]
],
'spf_auth' => [
'parent' => 'au_res',
'default' => null,
'children' => [
'DOMAIN' => 'spf_domain',
'SCOPE' => 'spf_scope',
'RESULT' => 'spf_result'
]
],
'reason_type' => [ 'parent' => 'reason' ],
'reason_comment' => [ 'parent' => 'reason' ],
'dkim_domain' => [ 'parent' => 'dkim_auth' ],
'dkim_selector' => [ 'parent' => 'dkim_auth' ],
'dkim_result' => [ 'parent' => 'dkim_auth' ],
'dkim_human_result' => [ 'parent' => 'dkim_auth' ],
'spf_domain' => [ 'parent' => 'spf_auth' ],
'spf_scope' => [ 'parent' => 'spf_auth' ],
'spf_result' => [ 'parent' => 'spf_auth' ]
];
}

View File

@@ -0,0 +1,242 @@
<?php
/**
* dmarc-srg - A php parser, viewer and summary report generator for incoming DMARC reports.
* Copyright (C) 2020 Aleksey Andreev (liuch)
*
* Available at:
* https://github.com/liuch/dmarc-srg
*
* 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 3 of the License.
*
* 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.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*
* =========================
*
* This file contains the class ReportFetcher
*
* @category API
* @package DmarcSrg
* @author Aleksey Andreev (liuch)
* @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3
*/
namespace Liuch\DmarcSrg\Report;
use Liuch\DmarcSrg\Core;
use Liuch\DmarcSrg\ErrorHandler;
use Liuch\DmarcSrg\Report\Report;
use Liuch\DmarcSrg\Sources\Source;
use Liuch\DmarcSrg\ReportLog\ReportLogItem;
use Liuch\DmarcSrg\Exception\RuntimeException;
/**
* This class is designed to fetch report files from report sources and store them to the database.
*/
class ReportFetcher
{
private $source = null;
/**
* It's the constructor of the class.
*
* @param Source $sou Source for fetching report files.
*
* @return void
*/
public function __construct($sou)
{
$this->source = $sou;
}
/**
* Retrieves report files from the source and stores them in the database
* taking into account the limits from the configuration file.
*
* @return array Array of results.
*/
public function fetch(): array
{
try {
$this->source->rewind();
} catch (RuntimeException $e) {
return [[ 'source_error' => $e->getMessage() ]];
}
$core = Core::instance();
$limit = 0;
$stype = $this->source->type();
switch ($stype) {
case Source::SOURCE_MAILBOX:
$s_act = $core->config('fetcher/mailboxes/when_done', '');
$f_act = $core->config('fetcher/mailboxes/when_failed', '');
$limit = $core->config('fetcher/mailboxes/messages_maximum', 0);
break;
case Source::SOURCE_DIRECTORY:
$s_act = $core->config('fetcher/directories/when_done', '');
$f_act = $core->config('fetcher/directories/when_failed', '');
$limit = $core->config('fetcher/directories/files_maximum', 0);
break;
}
$limit = intval($limit);
if ($stype === Source::SOURCE_MAILBOX || $stype === Source::SOURCE_DIRECTORY) {
$this->source->setParams([
'when_done' => $s_act,
'when_failed' => $f_act
]);
}
$results = [];
while ($this->source->valid()) {
$result = null;
$fname = null;
$report = null;
$success = false;
$err_msg = null;
// Extracting and saving reports
try {
$rfile = $this->source->current();
$fname = $rfile->filename();
$report = Report::fromXmlFile($rfile->datastream());
$result = $report->save($fname);
$success = true;
} catch (RuntimeException $e) {
$err_msg = $e->getMessage();
$result = ErrorHandler::exceptionResult($e);
}
unset($rfile);
// Post processing
try {
if ($success) {
$this->source->accepted();
} else {
$this->source->rejected();
}
} catch (RuntimeException $e) {
$err_msg = $e->getMessage();
$result['post_processing_message'] = $err_msg;
}
// Adding a record to the log.
if (!$err_msg) {
$log = ReportLogItem::success($stype, $report, $fname, null)->save();
} else {
$log = ReportLogItem::failed($stype, $report, $fname, $err_msg)->save();
if ($this->source->type() === Source::SOURCE_MAILBOX) {
$msg = $this->source->mailMessage();
$ov = $msg->overview();
if ($ov) {
if (property_exists($ov, 'from')) {
$result['emailed_from'] = $ov->from;
}
if (property_exists($ov, 'date')) {
$result['emailed_date'] = $ov->date;
}
}
}
if ($report) {
$rd = $report->get();
if (isset($rd['external_id'])) {
$result['report_id'] = $rd['external_id'];
}
}
}
unset($report);
// Adding result to the results array.
$results[] = $result;
// Checking the fetcher limits
if ($limit > 0) {
if (--$limit === 0) {
break;
}
}
$this->source->next();
}
return $results;
}
/**
* Generates the final result based on the results of loading individual report files.
*
* @param array $results Array with results of loading report files.
*
* @return array Array of the final result to be sent to the client.
*/
public static function makeSummaryResult(array $results): array
{
$reps = [];
$others = [];
$r_count = 0;
$loaded = 0;
foreach ($results as &$r) {
if (isset($r['source_error'])) {
$others[] = $r['source_error'];
} else {
$reps[] = $r;
++$r_count;
if (!isset($r['error_code']) || $r['error_code'] === 0) {
++$loaded;
}
if (isset($r['post_processing_message'])) {
$others[] = $r['post_processing_message'];
}
}
}
unset($r);
$result = null;
$o_count = count($others);
if ($r_count + $o_count === 1) {
if ($r_count === 1) {
$result = $reps[0];
} else {
$result = [
'error_code' => -1,
'message' => $others[0]
];
}
} else {
$err_code = null;
$message = null;
if ($loaded === $r_count) {
$err_code = 0;
if ($r_count > 0) {
$message = strval($r_count) . ' report files have been loaded successfully';
} elseif ($o_count === 0) {
$message = 'There are no report files to load';
} else {
$err_code = -1;
}
} else {
$err_code = -1;
if ($loaded > 0) {
$message = "Only {$loaded} of the {$r_count} report files have been loaded";
} else {
$message = "None of the {$r_count} report files has been loaded";
}
}
$result['error_code'] = $err_code;
$result['message'] = $message;
if ($r_count > 0) {
$result['results'] = $reps;
}
if ($o_count > 0) {
$result['other_errors'] = $others;
}
}
return $result;
}
}

View File

@@ -0,0 +1,212 @@
<?php
/**
* dmarc-srg - A php parser, viewer and summary report generator for incoming DMARC reports.
* Copyright (C) 2020 Aleksey Andreev (liuch)
*
* Available at:
* https://github.com/liuch/dmarc-srg
*
* 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 3 of the License.
*
* 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.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*
* =========================
*
* This file contains ReportList class
*
* @category API
* @package DmarcSrg
* @author Aleksey Andreev (liuch)
* @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3
*/
namespace Liuch\DmarcSrg\Report;
use Liuch\DmarcSrg\Core;
/**
* It's the main class for working with the incoming reports, such as:
* - getting a list of reports
* - deleting several reports at once
* - getting the number of reports stored in the database
*/
class ReportList
{
public const ORDER_NONE = 0;
public const ORDER_BEGIN_TIME = 1;
public const ORDER_ASCENT = 2;
public const ORDER_DESCENT = 3;
private $db = null;
private $limit = 0;
private $filter = [];
private $order = null;
/**
* The constructor
*
* @param DatabaseController $db The database controller
*/
public function __construct($db = null)
{
$this->db = $db ?? Core::instance()->database();
}
/**
* Returns a list of reports with specified parameters from position $pos
*
* This method returns a list of reports that depends on the filter and order.
* The filter, order, and limit for the list can be set using the setFilter, setOrder and setMaxCount methods.
*
* @param int $pos The starting position from which the list will be returned
*
* @return array An array with keys `reports` and `more`.
* `reports` is an array of incoming reports which contains maximum 25 records by default.
* Another value of the number of records can be specified by calling
* the method setMaxCount.
* `more` is true if there are more records in the database, false otherwise.
*/
public function getList(int $pos): array
{
$order = [
'field' => 'begin_time',
'direction' => ($this->order ?? self::ORDER_DESCENT) === self::ORDER_ASCENT ? 'ascent' : 'descent'
];
$max_rec = $this->limit > 0 ? $this->limit : 25;
$limit = [
'offset' => $pos,
'count' => $max_rec + 1
];
$list = $this->db->getMapper('report')->list($this->filter, $order, $limit);
if (count($list) > $max_rec) {
$more = true;
unset($list[$max_rec]);
} else {
$more = false;
}
return [
'reports' => $list,
'more' => $more
];
}
/**
* Sets the sort order for the list and for deleting several reports at once
*
* @param int $field The field to sort by. Currently only ORDER_BEGIN_TIME is available.
* @param int $direction The sorting direction. ORDER_ASCENT or ORDER_DESCENT must be used here.
*
* @return void
*/
public function setOrder(int $field, int $direction): void
{
$this->order = null;
if ($field > self::ORDER_NONE && $field < self::ORDER_ASCENT) {
if ($direction !== self::ORDER_ASCENT) {
$direction = self::ORDER_DESCENT;
}
$this->order = [
'field' => $field,
'direction' => $direction
];
}
}
/**
* Sets maximum numbers of records in the list and for deleting reports
*
* @param int $num Maximum number of records in the list
*
* @return void
*/
public function setMaxCount(int $num): void
{
if ($num > 0) {
$this->limit = $num;
} else {
$this->limit = 0;
}
}
/**
* Sets filter values for the list and for deleting reports
*
* @param array $filter Key-value array:
* 'before_time' => DateTime, timestamp
* 'dkim' => string, 'fail' or 'pass'
* 'domain' => string or instance of Domain class
* 'month' => string, yyyy-mm format
* 'organization' => string
* 'spf' => string, 'fail' or 'pass'
* 'status' => string, 'read' or 'unread'
* Note! 'dkim' and 'spf' do not affect the delete and count methods
*
* @return void
*/
public function setFilter(array $filter): void
{
$this->filter = $filter;
}
/**
* Returns the number of reports in the database
*
* It returns the number of reports in the database.
* The limit and some filter items (`dkim`, `spf`) do not affect this.
*
* @return int The number of reports in the database
*/
public function count(): int
{
$limit = [ 'offset' => 0, 'count' => $this->limit ];
return $this->db->getMapper('report')->count($this->filter, $limit);
}
/**
* Deletes reports from the database
*
* It deletes repors form the database. The filter items `dkim` and `spf` do not affect this.
*
* @return void
*/
public function delete(): void
{
$order = [
'field' => 'begin_time',
'direction' => ($this->order ?? self::ORDER_DESCENT) === self::ORDER_ASCENT ? 'ascent' : 'descent'
];
$limit = [ 'offset' => 0, 'count' => $this->limit ];
$this->db->getMapper('report')->delete($this->filter, $order, $limit);
}
/**
* Returns a list of values for each filter item except for `before_time`
*
* @return array An key-value array, where the key is the filter item name
* and the value is an array of possible values for the item
*/
public function getFilterList(): array
{
$domainMapper = $this->db->getMapper('domain');
$reportMapper = $this->db->getMapper('report');
return [
'domain' => $domainMapper->names(),
'month' => $reportMapper->months(),
'organization' => $reportMapper->organizations(),
'dkim' => [ 'pass', 'fail' ],
'spf' => [ 'pass', 'fail' ],
'status' => [ 'read', 'unread' ]
];
}
}

View File

@@ -0,0 +1,400 @@
<?php
/**
* dmarc-srg - A php parser, viewer and summary report generator for incoming DMARC reports.
* Copyright (C) 2020 Aleksey Andreev (liuch)
*
* Available at:
* https://github.com/liuch/dmarc-srg
*
* 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 3 of the License.
*
* 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.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*
* =========================
*
* This file contains SummaryReport class
*
* @category API
* @package DmarcSrg
* @author Aleksey Andreev (liuch)
* @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3
*/
namespace Liuch\DmarcSrg\Report;
use Liuch\DmarcSrg\Statistics;
use Liuch\DmarcSrg\Exception\SoftException;
use Liuch\DmarcSrg\Exception\LogicException;
/**
* This class is for generating summary data for the specified period and domain
*/
class SummaryReport
{
private const LAST_WEEK = -1;
private const LAST_MONTH = -2;
private $period = 0;
private $domain = null;
private $stat = null;
private $subject = '';
/**
* Constructor
*
* @param string $period The period for which the report is created
* Must me one of the following values: `lastweek`, `lastmonth`, and `lastndays:N`
* where N is the number of days the report is created for
*/
public function __construct(string $period)
{
switch ($period) {
case 'lastweek':
$period = self::LAST_WEEK;
$subject = ' weekly';
break;
case 'lastmonth':
$period = self::LAST_MONTH;
$subject = ' monthly';
break;
default:
$ndays = 0;
$av = explode(':', $period);
if (count($av) === 2 && $av[0] === 'lastndays') {
$ndays = intval($av[1]);
if ($ndays <= 0) {
throw new SoftException('The parameter "days" has an incorrect value');
}
$subject = sprintf(' %d day%s', $ndays, ($ndays > 1 ? 's' : ''));
}
$period = $ndays;
break;
}
if (empty($subject)) {
throw new SoftException('The parameter "period" has an incorrect value');
}
$this->period = $period;
$this->subject = "DMARC{$subject} digest";
}
/**
* Binds a domain to the report
*
* @param Domain $domain The domain for which the report is created
*
* @return self
*/
public function setDomain($domain)
{
$this->domain = $domain;
$this->stat = null;
return $this;
}
/**
* Returns the report data as an array
*
* @return array
*/
public function toArray(): array
{
$this->ensureData();
$res = [];
$stat = $this->stat;
$range = $stat->range();
$res['date_range'] = [ 'begin' => $range[0], 'end' => $range[1] ];
$res['summary'] = $stat->summary();
$res['sources'] = $stat->ips();
$res['organizations'] = $stat->organizations();
return $res;
}
/**
* Returns the subject string. It is used in email messages.
*
* @return string
*/
public function subject(): string
{
return $this->subject;
}
/**
* Returns the report as an array of text strings
*
* @return array
*/
public function text(): array
{
$rdata = $this->reportData();
$res = [];
$res[] = '# Domain: ' . $this->domain->fqdn();
$res[] = ' Range: ' . $rdata['range'];
$res[] = '';
$res[] = '## Summary';
$total = $rdata['summary']['total'];
$res[] = sprintf(' Total: %d', $total);
$res[] = sprintf(' DKIM or SPF aligned: %s', self::num2percent($rdata['summary']['aligned'], $total));
$res[] = sprintf(' Not aligned: %s', self::num2percent($rdata['summary']['n_aligned'], $total));
$res[] = sprintf(' Organizations: %d', $rdata['summary']['organizations']);
$res[] = '';
if (count($rdata['sources']) > 0) {
$res[] = '## Sources';
$res[] = sprintf(
' %-25s %13s %13s %13s',
'',
'Total',
'SPF aligned',
'DKIM aligned'
);
foreach ($rdata['sources'] as &$it) {
$total = $it['emails'];
$spf_a = $it['spf_aligned'];
$dkim_a = $it['dkim_aligned'];
$spf_str = self::num2percent($spf_a, $total);
$dkim_str = self::num2percent($dkim_a, $total);
$res[] = sprintf(
' %-25s %13d %13s %13s',
$it['ip'],
$total,
$spf_str,
$dkim_str
);
}
unset($it);
$res[] = '';
}
if (count($rdata['organizations']) > 0) {
$res[] = '## Organizations';
$org_len = 15;
foreach ($rdata['organizations'] as &$org) {
$org_len = max($org_len, mb_strlen($org['name']));
}
unset($org);
$org_len = min($org_len, 55);
$res[] = sprintf(" %-{$org_len}s %8s %8s", '', 'emails', 'reports');
$frm_str = " %-{$org_len}s %8d %8d";
foreach ($rdata['organizations'] as &$org) {
$res[] = sprintf(
$frm_str,
trim($org['name']),
$org['emails'],
$org['reports']
);
}
unset($org);
$res[] = '';
}
return $res;
}
/**
* Returns the report as an array of html strings
*
* @return array
*/
public function html(): array
{
$h2a = 'style="margin:15px 0 5px;"';
$t2a = 'style="border-collapse:collapse;border-spacing:0;"';
$c1a = 'style="font-style:italic;"';
$d1s = 'padding-left:1em;';
$d2s = 'min-width:4em;';
$d3s = 'border:1px solid #888;';
$d4s = 'text-align:right;';
$d5s = 'padding:.3em;';
$add_red = function (int $num) {
return $num > 0 ? 'color:#f00;' : '';
};
$add_green = function (int $num) {
return $num > 0 ? 'color:#080;' : '';
};
$rdata = $this->reportData();
$res = [];
$res[] = "<h2 {$h2a}>Domain: " . htmlspecialchars($this->domain->fqdn()) . '</h2>';
$res[] = '<p style="margin:0;">Range: ' . htmlspecialchars($rdata['range']) . '</p>';
$res[] = "<h3 {$h2a}>Summary</h3>";
$res[] = '<table>';
$total = $rdata['summary']['total'];
$a_cnt = $rdata['summary']['aligned'];
$n_cnt = $rdata['summary']['n_aligned'];
$res[] = " <tr><td>Total: </td><td style=\"{$d1s}\">" . $total . '</td></tr>';
$color = $add_green($a_cnt);
$res[] = " <tr><td>DKIM or SPF aligned: </td><td style=\"{$d1s}{$color}\">{$a_cnt}</td></tr>";
$color = $add_red($n_cnt);
$res[] = " <tr><td>Not aligned: </td><td style=\"{$d1s}{$color}\">{$n_cnt}</td></tr>";
$res[] = " <tr><td>Organizations: </td><td style=\"{$d1s}\">" .
$rdata['summary']['organizations'] .
'</td></tr>';
$res[] = '</table>';
$rs2 = 'rowspan="2"';
$cs3 = 'colspan="3"';
$s_cnt = count($rdata['sources']);
if ($s_cnt > 0) {
$res[] = "<h3 {$h2a}>Sources</h3>";
$res[] = "<table {$t2a}>";
$res[] = " <caption {$c1a}>Total records: {$s_cnt}</caption>";
$res[] = ' <thead>';
$style = "style=\"{$d3s}{$d5s}\"";
$res[] = " <tr><th {$rs2} {$style}>IP address</th><th {$rs2} {$style}>Email volume</th>" .
"<th {$cs3} {$style}>SPF</th><th {$cs3} {$style}>DKIM</th></tr>";
$style = "style=\"{$d2s}{$d3s}{$d5s}\"";
$res[] = " <tr><th {$style}>pass</th><th {$style}>fail</th><th {$style}>rate</th>" .
"<th {$style}>pass</th><th {$style}>fail</th><th {$style}>rate</th></tr>";
$res[] = ' </thead>';
$res[] = ' <tbody>';
foreach ($rdata['sources'] as &$row) {
$ip = htmlspecialchars($row['ip']);
$total = $row['emails'];
$spf_a = $row['spf_aligned'];
$spf_n = $total - $spf_a;
$spf_p = sprintf('%.0f%%', $spf_a / $total * 100);
$dkim_a = $row['dkim_aligned'];
$dkim_n = $total - $dkim_a;
$dkim_p = sprintf('%.0f%%', $dkim_a / $total * 100);
$style = "style=\"{$d3s}{$d5s}";
$row_str = " <tr><td {$style}\">{$ip}</td><td {$style}{$d4s}\">{$total}</td>";
$row_str .= "<td {$style}{$d4s}{$add_green($spf_a)}\">{$spf_a}</td>";
$row_str .= "<td {$style}{$d4s}{$add_red($spf_n)}\">{$spf_n}</td>";
$row_str .= "<td {$style}{$d4s}\">{$spf_p}</td>";
$row_str .= "<td {$style}{$d4s}{$add_green($dkim_a)}\">{$dkim_a}</td>";
$row_str .= "<td {$style}{$d4s}{$add_red($dkim_n)}\">{$dkim_n}</td>";
$row_str .= "<td {$style}{$d4s}\">{$dkim_p}</td>";
$res[] = $row_str . '</tr>';
}
unset($row);
$res[] = ' </tbody>';
$res[] = '</table>';
}
$o_cnt = count($rdata['organizations']);
if ($o_cnt) {
$res[] = "<h3 {$h2a}>Organizations</h3>";
$res[] = "<table {$t2a}>";
$res[] = " <caption {$c1a}>Total records: {$o_cnt}</caption>";
$res[] = ' <thead>';
$style = "style=\"{$d3s}{$d5s}\"";
$res[] = " <tr><th {$style}>Name</th><th {$style}>Emails</th><th {$style}>Reports</th></tr>";
$res[] = ' </thead>';
$res[] = ' <tbody>';
foreach ($rdata['organizations'] as &$row) {
$name = htmlspecialchars($row['name']);
$style2 = "style=\"{$d3s}{$d4s}{$d5s}\"";
$res[] = " <tr><td {$style}>{$name}</td>" .
"<td {$style2}>{$row['emails']}</td>" .
"<td {$style2}>{$row['reports']}</td></tr>";
}
unset($row);
$res[] = ' </tbody>';
$res[] = '</table>';
}
return $res;
}
/**
* Returns the percentage with the original number. If $per is 0 then '0' is returned.
*
* @param int $per Value
* @param int $cent Divisor for percentage calculation
*
* @return string
*/
private static function num2percent(int $per, int $cent): string
{
if (!$per) {
return '0';
}
return sprintf('%.0f%%(%d)', $per / $cent * 100, $per);
}
/**
* Generates the report if it has not already been done
*
* @return void
*/
private function ensureData(): void
{
if (!$this->domain) {
throw new LogicException('No one domain was specified');
}
if (!$this->stat) {
switch ($this->period) {
case self::LAST_WEEK:
$this->stat = Statistics::lastWeek($this->domain);
break;
case self::LAST_MONTH:
$this->stat = Statistics::lastMonth($this->domain);
break;
default:
$this->stat = Statistics::lastNDays($this->domain, $this->period);
break;
}
}
}
/**
* Returns prepared data for the report
*
* @return array
*/
private function reportData(): array
{
$this->ensureData();
$stat = $this->stat;
$rdata = [];
$range = $stat->range();
$cyear = (new \Datetime())->format('Y');
$dform = ($range[0]->format('Y') !== $cyear || $range[1]->format('Y') !== $cyear) ? 'M d Y' : 'M d';
$rdata['range'] = $range[0]->format($dform) . ' - ' . $range[1]->format($dform);
$summ = $stat->summary();
$total = $summ['emails']['total'];
$aligned = $summ['emails']['dkim_spf_aligned'] +
$summ['emails']['dkim_aligned'] +
$summ['emails']['spf_aligned'];
$n_aligned = $total - $aligned;
$rdata['summary'] = [
'total' => $total,
'organizations' => $summ['organizations']
];
if ($total > 0) {
$rdata['summary']['aligned'] = $aligned;
$rdata['summary']['n_aligned'] = $n_aligned;
} else {
$rdata['summary']['aligned'] = $aligned;
$rdata['summary']['n_aligned'] = $aligned;
}
$rdata['sources'] = $stat->ips();
$rdata['organizations'] = $stat->organizations();
return $rdata;
}
}