%PDF- %PDF-
| Direktori : /www/varak.net/dmarc.varak.net/classes/Report/ |
| Current File : /www/varak.net/dmarc.varak.net/classes/Report/ReportData.php |
<?php
/**
* dmarc-srg - A php parser, viewer and summary report generator for incoming DMARC reports.
* Copyright (C) 2020-2025 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
{
private $rep_data = null;
private $tag_id = null;
private $skip_depth = 0;
private $strict_mode = false;
private function __construct(bool $strict = false)
{
$this->strict_mode = $strict;
}
public static function fromArray(array $data, bool $strict = false)
{
$new_data = [];
self::copyFields(self::$fields, $data, $new_data);
self::copySubdata(self::$dfields, $data, $new_data, 'date');
self::copySubdata(self::$pfields, $data, $new_data, 'policy');
if (isset($data['records']) && gettype($data['records']) === 'array') {
$new_data['records'] = [];
foreach ($data['records'] as &$rec) {
$new_rec = [];
self::copyFields(self::$rfields, $rec, $new_rec);
$new_data['records'][] = $new_rec;
}
unset($rec);
}
$rdata = new self($strict);
$rdata->rep_data = $new_data;
return $rdata;
}
public static function fromXmlFile($fd, $strict = false)
{
$rdata = new self($strict);
$rdata->tag_id = '<root>';
$rdata->rep_data = [ 'date' => [], 'policy' => [], 'records' => [] ];
$parser = xml_parser_create();
xml_set_element_handler($parser, [ $rdata, 'xmlStartTag' ], [ $rdata, 'xmlEndTag' ]);
xml_set_character_data_handler($parser, [ $rdata, '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))) {
$pc = xml_get_error_code($parser);
$error_str = 'XML error!' . PHP_EOL;
$error_str .= 'Parser code: ' . $pc . PHP_EOL;
$error_str .= 'Parser message: ' . xml_error_string($pc) . PHP_EOL;
$error_str .= 'Line: ' . xml_get_current_line_number($parser);
$error_str .= '; Column: ' . xml_get_current_column_number($parser) . PHP_EOL;
throw new RuntimeException('Incorrect XML report file', -1, new RuntimeException($error_str));
}
}
} finally {
xml_parser_free($parser);
unset($parser);
}
return $rdata;
}
public function toArray(): array
{
return $this->rep_data;
}
public function &__get(string $name)
{
if (!array_key_exists($name, self::$fields)) {
throw new RuntimeException('Getting an unknown report property: ' . $name);
}
if (!array_key_exists($name, $this->rep_data)) {
throw new RuntimeException('Getting an undefined report property: ' . $name);
}
return $this->rep_data[$name];
}
public function __set(string $name, $value): void
{
if (!array_key_exists($name, self::$fields)) {
throw new RuntimeException('Accessing an unknown report property: ' . $name);
}
$this->rep_data[$name] = $value;
}
public function __isset(string $name): bool
{
return isset($this->rep_data);
}
/**
* Checks report data for correctness and completeness
*
* @return bool
*/
public function isValid(): bool
{
if (!self::checkRow($this->rep_data, self::$fields) || count($this->rep_data['records']) === 0) {
return false;
}
if (!self::checkRow($this->rep_data['date'], self::$dfields)) {
return false;
}
if (!self::checkRow($this->rep_data['policy'], self::$pfields)) {
return false;
}
foreach ($this->rep_data['records'] as &$rec) {
if (gettype($rec) !== 'array' || !self::checkRow($rec, self::$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;
}
private function xmlStartTag($parser, string $name, array $attributes): void
{
if ($this->skip_depth || !$this->xmlEnterTag($name)) {
++$this->skip_depth;
return;
}
switch ($this->tag_id) {
case 'rec':
$this->rep_data['records'][] = [];
break;
case 'error_string':
if (!isset($this->rep_data['error_string'])) {
$this->rep_data['error_string'] = [];
}
break;
case 'reason':
case 'dkim_auth':
case 'spf_auth':
$idx = array_key_last($this->rep_data['records']);
if (!isset($this->rep_data['records'][$idx][$this->tag_id])) {
$this->rep_data['records'][$idx][$this->tag_id] = [];
}
$this->report_tags[$this->tag_id]['tmp_data'] = [];
break;
}
}
private function xmlEndTag($parser, string $name): void
{
if ($this->skip_depth) {
--$this->skip_depth;
return;
}
switch ($this->tag_id) {
case 'reason':
case 'dkim_auth':
case 'spf_auth':
$idx = array_key_last($this->rep_data['records']);
$this->rep_data['records'][$idx][$this->tag_id][] = $this->report_tags[$this->tag_id]['tmp_data'];
unset($this->report_tags[$this->tag_id]['tmp_data']);
break;
case 'feedback':
// Set the default value if it's necessary and there is no data
foreach ($this->report_tags as $tag_id => &$tag_data) {
if (array_key_exists('default', $tag_data)) { // not isset() because of null values
$def = $tag_data['default'];
$key = $tag_data['key'] ?? $tag_id;
$ptr = null;
switch ($tag_data['type'] ?? '') {
case 'M': // metadata
$ptr = &$this->rep_data;
break;
case 'D': // data range
$ptr = &$this->rep_data['date'];
break;
case 'P': // policy
$ptr = &$this->rep_data['policy'];
break;
default: // record
foreach ($this->rep_data['records'] as &$rec_val) {
if (!isset($rec_val[$key])) {
$rec_val[$key] = $def;
}
}
unset($rec_val);
}
if ($ptr) {
if (!isset($ptr[$key])) {
$ptr[$key] = $def;
}
unset($ptr);
}
}
}
unset($tag_data);
$b_ts = intval($this->rep_data['date']['begin'] ?? 0);
$e_ts = intval($this->rep_data['date']['end'] ?? 0);
$this->rep_data['date']['begin'] = new DateTime('@' . ($b_ts < 0 ? 0 : $b_ts));
$this->rep_data['date']['end'] = new DateTime('@' . ($e_ts < 0 ? 0 : $e_ts));
foreach ($this->rep_data['records'] as &$rec_data) {
$rec_data['count'] = intval($rec_data['rcount']);
}
unset($rec_data);
break;
}
$this->xmlLeaveTag();
}
private function xmlTagData($parser, $data): void
{
if ($this->skip_depth) {
return;
}
switch ($this->tag_id) {
case 'error_string':
if ($this->tag_id === 'error_string') {
$this->rep_data['error_string'][] = $data;
}
break;
case 'reason_type':
$this->report_tags['reason']['tmp_data']['type'] = $data;
break;
case 'reason_comment':
$this->report_tags['reason']['tmp_data']['comment'] = $data;
break;
case 'dkim_domain':
$this->report_tags['dkim_auth']['tmp_data']['domain'] = $data;
break;
case 'dkim_selector':
$this->report_tags['dkim_auth']['tmp_data']['selector'] = $data;
break;
case 'dkim_result':
$this->report_tags['dkim_auth']['tmp_data']['result'] = $data;
break;
case 'dkim_human_result':
$this->report_tags['dkim_auth']['tmp_data']['human_result'] = $data;
break;
case 'spf_domain':
$this->report_tags['spf_auth']['tmp_data']['domain'] = $data;
break;
case 'spf_scope':
$this->report_tags['spf_auth']['tmp_data']['scope'] = $data;
break;
case 'spf_result':
$this->report_tags['spf_auth']['tmp_data']['result'] = $data;
break;
default:
$t_id = $this->tag_id;
if (!isset($this->report_tags[$t_id]['children'])) {
$key = $this->report_tags[$t_id]['key'] ?? $t_id;
switch ($this->report_tags[$t_id]['type'] ?? '') {
case 'M': // metadata
$ptr = &$this->rep_data;
break;
case 'D': // date range
$ptr = &$this->rep_data['date'];
break;
case 'P': // policy
$ptr = &$this->rep_data['policy'];
break;
default: // record
$ptr = &$this->rep_data['records'][array_key_last($this->rep_data['records'])];
break;
}
if (isset($ptr[$key])) {
$ptr[$key] .= $data;
} else {
$ptr[$key] = $data;
}
unset($ptr);
}
}
}
private function xmlEnterTag(string $name): bool
{
if (!isset($this->report_tags[$this->tag_id]['children'][$name])) {
if ($this->strict_mode) {
throw new RuntimeException("Unknown tag: {$name}");
}
return false;
}
$this->tag_id = $this->report_tags[$this->tag_id]['children'][$name];
return true;
}
private function xmlLeaveTag(): void
{
$this->tag_id = $this->report_tags[$this->tag_id]['parent'];
}
private static function copyFields(array &$flist, array &$sou_data, array &$des_data): void
{
foreach ($flist as $fn => &$fp) {
$ft = $fp['type'];
if ($ft !== 'array' && array_key_exists($fn, $sou_data) && gettype($sou_data[$fn]) === $ft) {
$des_data[$fn] = $sou_data[$fn];
}
}
unset($fp);
}
private static function copySubdata(array &$flist, array &$sou_data, array &$des_data, string $key): void
{
if (isset($sou_data[$key]) && gettype($sou_data[$key]) === 'array') {
$des_data[$key] = [];
self::copyFields($flist, $sou_data[$key], $des_data[$key]);
}
}
private static $fields = [
'domain' => [ 'required' => true, 'type' => 'string' ],
'date' => [ 'required' => true, 'type' => 'array' ],
'org_name' => [ 'required' => true, 'type' => 'string' ],
'report_id' => [ 'required' => true, 'type' => 'string' ],
'email' => [ 'required' => false, 'type' => 'string' ],
'extra_contact_info' => [ 'required' => false, 'type' => 'string' ],
'error_string' => [ 'required' => false, 'type' => 'array' ],
'policy' => [ 'required' => true, 'type' => 'array' ],
'loaded_time' => [ 'required' => false, 'type' => 'object' ],
'records' => [ 'required' => true, 'type' => 'array' ]
];
private static $dfields = [
'begin' => [ 'required' => true, 'type' => 'object' ],
'end' => [ 'required' => true, 'type' => 'object' ],
];
private static $pfields = [
'adkim' => [ 'required' => false, 'type' => 'string' ],
'aspf' => [ 'required' => false, 'type' => 'string' ],
'p' => [ 'required' => false, 'type' => 'string' ],
'sp' => [ 'required' => false, 'type' => 'string' ],
'np' => [ 'required' => false, 'type' => 'string' ],
'pct' => [ 'required' => false, 'type' => 'string' ],
'fo' => [ 'required' => false, 'type' => 'string' ],
];
private static $rfields = [
'ip' => [ 'required' => true, 'type' => 'string' ],
'count' => [ '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' ]
];
private $report_tags = [
'<root>' => [
'children' => [
'FEEDBACK' => 'feedback'
]
],
'feedback' => [
'parent' => '<root>',
'children' => [
'VERSION' => 'ver',
'REPORT_METADATA' => 'rmd',
'POLICY_PUBLISHED' => 'p_p',
'RECORD' => 'rec'
]
],
'ver' => [ 'parent' => 'feedback', 'type' => 'M', 'default' => null ],
'rmd' => [
'parent' => 'feedback',
'children' => [
'ORG_NAME' => 'org_name',
'EMAIL' => 'email',
'EXTRA_CONTACT_INFO' => 'extra_contact_info',
'REPORT_ID' => 'report_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_name' => [ 'parent' => 'rmd', 'type' => 'M' ],
'email' => [ 'parent' => 'rmd', 'type' => 'M', 'default' => null ],
'extra_contact_info' => [ 'parent' => 'rmd', 'type' => 'M', 'default' => null ],
'report_id' => [ 'parent' => 'rmd', 'type' => 'M' ],
'd_range' => [
'parent' => 'rmd',
'children' => [
'BEGIN' => 'begin_time',
'END' => 'end_time'
]
],
'domain' => [ 'parent' => 'p_p', 'type' => 'M' ],
'error_string' => [ 'parent' => 'rmd', 'type' => 'M', 'default' => null ],
'begin_time' => [ 'parent' => 'd_range', 'type' => 'D', 'key' => 'begin' ],
'end_time' => [ 'parent' => 'd_range', 'type' => 'D', 'key' => 'end' ],
'policy_adkim' => [ 'parent' => 'p_p', 'type' => 'P', 'key' => 'adkim', 'default' => null ],
'policy_aspf' => [ 'parent' => 'p_p', 'type' => 'P', 'key' => 'aspf', 'default' => null ],
'policy_p' => [ 'parent' => 'p_p', 'type' => 'P', 'key' => 'p', 'default' => null ],
'policy_sp' => [ 'parent' => 'p_p', 'type' => 'P', 'key' => 'sp', 'default' => null ],
'policy_np' => [ 'parent' => 'p_p', 'type' => 'P', 'key' => 'np', 'default' => null ],
'policy_pct' => [ 'parent' => 'p_p', 'type' => 'P', 'key' => 'pct', 'default' => null ],
'policy_fo' => [ 'parent' => 'p_p', 'type' => 'P', 'key' => 'fo', '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' ]
];
}