smeserver-dmarc-srg/root/opt/dmarc-srg/classes/Report/SummaryReport.php

401 lines
13 KiB
PHP

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