├── composer.json ├── LICENSE ├── tables.sql ├── README.md └── Solaris └── DmarcAggregateParser.php /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "solaris/php-dmarc", 3 | "description": "A simple DMARC report parser for PHP.", 4 | "homepage": "http://www.github.com/solarissmoke/php-dmarc", 5 | "keywords": ["php", "dmarc"], 6 | "type": "library", 7 | "license": "MIT", 8 | "authors": [ 9 | { "name": "Samir Shah", "email": "samir@rayofsolaris.net" } 10 | ], 11 | "require": { 12 | "php": ">=5.3.0" 13 | }, 14 | "autoload": { 15 | "psr-0": { "Solaris": "" } 16 | } 17 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | php-dmarc - A simple DMARC report parser for PHP 2 | 3 | Copyright 2012 Samir Shah, < samir [at] rayofsolaris.net >, http://rayofsolaris.net 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /tables.sql: -------------------------------------------------------------------------------- 1 | USE dmarc; 2 | 3 | CREATE TABLE report ( 4 | serial int(10) unsigned NOT NULL AUTO_INCREMENT, 5 | date_begin timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', 6 | date_end timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', 7 | domain varchar(255) NOT NULL, 8 | org varchar(255) NOT NULL, 9 | report_id varchar(255) NOT NULL, 10 | PRIMARY KEY (serial), 11 | UNIQUE KEY domain (domain,org,report_id) 12 | ); 13 | 14 | CREATE TABLE rptrecord ( 15 | serial int(10) unsigned NOT NULL, 16 | ip varchar(39) NOT NULL, 17 | count int(10) unsigned NOT NULL, 18 | disposition enum('none','quarantine','reject'), 19 | reason varchar(255), 20 | dkim_result enum('none','pass','fail','neutral','policy','temperror','permerror'), 21 | spf_result enum('none','neutral','pass','fail','softfail','temperror','permerror'), 22 | KEY serial (serial,ip) 23 | ); 24 | 25 | CREATE TABLE rptresult ( 26 | serial int(10) unsigned NOT NULL, 27 | ip varchar(39) NOT NULL, 28 | type enum('dkim','spf'), 29 | seq int(10) unsigned NOT NULL, 30 | domain varchar(255), 31 | result enum('none','pass','fail','softfail','neutral','policy','temperror','permerror'), 32 | KEY serial (serial,ip,type,seq) 33 | ); 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # php-dmarc - A simple DMARC report parser for PHP 2 | 3 | php-dmarc is a small PHP class I wrote to parse [DMARC](http://dmarc.org) aggregate reports and put the data in a MySQL database for easy analysis. The idea is that when recipients start supporting delivery of reports using HTTP, then this can form part of an endpoint that receives and automatically parses the reports. 4 | 5 | Improvements/fixes welcome. 6 | 7 | ## Installation 8 | 9 | The library is available on Packagist ([solaris/php-dmarc](http://packagist.org/packages/solaris/php-dmarc)) 10 | and can be installed using [Composer](http://getcomposer.org/). Alternatively you can grab the code directly from GitHub and include the `DmarcAggregateParser.php` script directly or via a PSR-0 autoloader. 11 | 12 | ## Usage 13 | 14 | - Set up your database. `tables.sql` contains the SQL needed to set up the tables. 15 | - Use the `Solaris\DmarcAggregateParser` class to parse reports - you need to supply it with database credentials, and then run the `parse()` function with an array of files to parse. Something like this: 16 | 17 | $parser = new Solaris\DmarcAggregateParser( 'dbhost', 'dbuser', 'dbpass', 'dbname' ); 18 | $parser->parse( array( 'report-file-1.xml', 'report-file-2.xml', 'report-file-3.xml' ) ); 19 | 20 | You can supply either XML files or ZIP files. It is assumed that each ZIP file contains only one report. 21 | 22 | - Knock your self out analysing the data. 23 | 24 | The `parse()` function returns `false` if it encounters any errors while parsing the data (`true` otherwise). To see what the errors were, use the `get_errors()` method, which will return an array of error messages. 25 | -------------------------------------------------------------------------------- /Solaris/DmarcAggregateParser.php: -------------------------------------------------------------------------------- 1 | tbl_prefix = $tbl_prefix; 18 | } 19 | try { 20 | $this->dbh = new \PDO( "mysql:host=$db_host;dbname=$db_name", $db_user, $db_pass ); 21 | } 22 | catch( PDOException $e ) { 23 | $this->errors[] = 'Failed to establish database connection.'; 24 | $this->errors[] = $e->getMessage(); 25 | return false; 26 | } 27 | 28 | $this->dbh->setAttribute( \PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION ); 29 | $this->ready = true; 30 | } 31 | 32 | /** 33 | * Parse a set of XML report files 34 | * 35 | * Supply an array of files to parse. Returns true on success or false if 36 | * there were errors. To get a list of errors use the get_errors() method. 37 | * You can supply either ZIP files or XML files. 38 | **/ 39 | function parse( $files ) { 40 | if( !$this->ready ) 41 | return false; 42 | 43 | if( !is_array( $files ) ) 44 | $files = array( $files ); 45 | 46 | foreach( $files as $file ) { 47 | switch (true) { 48 | case strtolower( substr( $file, -4 ) ) === '.zip': 49 | $data = $this->unzip( $file ); 50 | if( !$data ) { 51 | $this->errors[] = "Failed to open zip file: $file"; 52 | return false; 53 | } 54 | break; 55 | case strtolower( substr( $file, -3 ) ) === '.gz': 56 | $data = $this->gunzip( $file ); 57 | if( !$data ) { 58 | $this->errors[] = "Failed to open gzip file: $file"; 59 | return false; 60 | } 61 | break; 62 | default: 63 | $data = file_get_contents( $file ); 64 | } 65 | 66 | $xml = new \SimpleXMLElement( $data ); 67 | 68 | $date_begin = (int) $xml->report_metadata->date_range->begin; 69 | $date_end = (int) $xml->report_metadata->date_range->end; 70 | $org = $xml->report_metadata->org_name; 71 | $id = $xml->report_metadata->report_id; 72 | $domain = $xml->policy_published->domain; 73 | 74 | // no duplicates please 75 | $sth = $this->dbh->prepare( sprintf("SELECT org, report_id FROM `%sreport` WHERE org = :org AND report_id = :report_id", $this->tbl_prefix) ); 76 | $sth->execute( array( 'report_id' => $id, 'org' => $org ) ); 77 | if( $sth->rowCount() ) { 78 | $this->errors[] = "Stopped parsing report $id from $org: this report has already been parsed."; 79 | continue; 80 | } 81 | 82 | try { 83 | $sth = $this->dbh->prepare( sprintf("INSERT INTO `%sreport`(date_begin, date_end, domain, org, report_id) VALUES (FROM_UNIXTIME(:date_begin),FROM_UNIXTIME(:date_end), :domain, :org, :id)", $this->tbl_prefix) ); 84 | $sth->execute( array( 'date_begin' => $date_begin, 'date_end' => $date_end, 'domain' => $domain, 'org' => $org, 'id' => $id ) ); 85 | } 86 | catch( PDOException $e ) { 87 | $this->errors[] = $e->getMessage(); 88 | continue; 89 | } 90 | 91 | $serial = $this->dbh->lastInsertId(); 92 | 93 | // parse records 94 | foreach( $xml->record as $record ) { 95 | $row = $record->row; 96 | $results = $record->auth_results; 97 | 98 | foreach (['dkim', 'spf'] as $type) { 99 | if (!property_exists($row->policy_evaluated, $type)) 100 | $row->policy_evaluated->{$type} = 'none'; 101 | } 102 | 103 | // Google incorrectly uses "hardfail" in SPF results 104 | if( $results->spf->result == 'hardfail' ) 105 | $results->spf->result = 'fail'; 106 | 107 | try { 108 | $sth = $this->dbh->prepare( sprintf("INSERT INTO `%srptrecord`(serial,ip,count,disposition,reason,dkim_result,spf_result) VALUES(?, ?, ?, ?, ?, ?, ?)", $this->tbl_prefix) ); 109 | $sth->execute( array( $serial, $row->source_ip, $row->count, $row->policy_evaluated->disposition, $row->policy_evaluated->reason->type, $row->policy_evaluated->dkim, $row->policy_evaluated->spf ) ); 110 | } 111 | catch( PDOException $e ) { 112 | $this->errors[] = $e->getMessage(); 113 | } 114 | 115 | foreach (array('dkim', 'spf') as $type) { 116 | $seq = 0; 117 | foreach ($results->{$type} as $result) { 118 | try { 119 | $sth = $this->dbh->prepare( sprintf("INSERT INTO `%srptresult`(serial,ip,type,seq,domain,result) VALUES(?, ?, ?, ?, ?, ?)", $this->tbl_prefix) ); 120 | $sth->execute( array( $serial, $row->source_ip, $type, $seq, $result->domain, $result->result ) ); 121 | } 122 | catch( PDOException $e ) { 123 | $this->errors[] = $e->getMessage(); 124 | } 125 | 126 | $seq++; 127 | } 128 | } 129 | } 130 | } 131 | 132 | return empty( $this->errors ); 133 | } 134 | 135 | function get_errors() { 136 | return $this->errors; 137 | } 138 | 139 | /* 140 | * Unzip a zipped DMARC report and return the contents. 141 | * Assumes (for now) that there is only one file to extract 142 | */ 143 | private function unzip( $zipfile ) { 144 | $zip = zip_open( $zipfile ); 145 | if( !is_resource( $zip ) ) 146 | return false; 147 | 148 | $data = false; 149 | $zip_entry = zip_read( $zip ); 150 | if (!$zip_entry) { 151 | return false; 152 | } 153 | 154 | if( zip_entry_open( $zip, $zip_entry, 'r' ) ) { 155 | $data = zip_entry_read( $zip_entry, zip_entry_filesize( $zip_entry ) ); 156 | zip_entry_close( $zip_entry ); 157 | } 158 | zip_close( $zip ); 159 | return $data; 160 | } 161 | 162 | /* 163 | * Unzip a gzipped DMARC report and return the contents. 164 | */ 165 | private function gunzip( $zipfile ) { 166 | $gzdata = file_get_contents($zipfile); 167 | if (!$gzdata) { 168 | return false; 169 | } 170 | 171 | if (function_exists('gzdecode')) { 172 | $data = gzdecode($gzdata); 173 | } else { 174 | $data = $this->gzdecode($gzdata); 175 | } 176 | if (!$data) { 177 | return false; 178 | } 179 | 180 | return $data; 181 | } 182 | 183 | /* 184 | * http://php.net/gzdecode#82930 185 | */ 186 | 187 | private function gzdecode($data, &$filename='', &$error='', $maxlength=null) { 188 | $len = strlen($data); 189 | if ($len < 18 || strcmp(substr($data,0,2),"\x1f\x8b")) { 190 | $error = "Not in GZIP format."; 191 | return null; // Not GZIP format (See RFC 1952) 192 | } 193 | $method = ord(substr($data,2,1)); // Compression method 194 | $flags = ord(substr($data,3,1)); // Flags 195 | if ($flags & 31 != $flags) { 196 | $error = "Reserved bits not allowed."; 197 | return null; 198 | } 199 | // NOTE: $mtime may be negative (PHP integer limitations) 200 | $mtime = unpack("V", substr($data,4,4)); 201 | $mtime = $mtime[1]; 202 | $xfl = substr($data,8,1); 203 | $os = substr($data,8,1); 204 | $headerlen = 10; 205 | $extralen = 0; 206 | $extra = ""; 207 | if ($flags & 4) { 208 | // 2-byte length prefixed EXTRA data in header 209 | if ($len - $headerlen - 2 < 8) { 210 | return false; // invalid 211 | } 212 | $extralen = unpack("v",substr($data,8,2)); 213 | $extralen = $extralen[1]; 214 | if ($len - $headerlen - 2 - $extralen < 8) { 215 | return false; // invalid 216 | } 217 | $extra = substr($data,10,$extralen); 218 | $headerlen += 2 + $extralen; 219 | } 220 | $filenamelen = 0; 221 | $filename = ""; 222 | if ($flags & 8) { 223 | // C-style string 224 | if ($len - $headerlen - 1 < 8) { 225 | return false; // invalid 226 | } 227 | $filenamelen = strpos(substr($data,$headerlen),chr(0)); 228 | if ($filenamelen === false || $len - $headerlen - $filenamelen - 1 < 8) { 229 | return false; // invalid 230 | } 231 | $filename = substr($data,$headerlen,$filenamelen); 232 | $headerlen += $filenamelen + 1; 233 | } 234 | $commentlen = 0; 235 | $comment = ""; 236 | if ($flags & 16) { 237 | // C-style string COMMENT data in header 238 | if ($len - $headerlen - 1 < 8) { 239 | return false; // invalid 240 | } 241 | $commentlen = strpos(substr($data,$headerlen),chr(0)); 242 | if ($commentlen === false || $len - $headerlen - $commentlen - 1 < 8) { 243 | return false; // Invalid header format 244 | } 245 | $comment = substr($data,$headerlen,$commentlen); 246 | $headerlen += $commentlen + 1; 247 | } 248 | $headercrc = ""; 249 | if ($flags & 2) { 250 | // 2-bytes (lowest order) of CRC32 on header present 251 | if ($len - $headerlen - 2 < 8) { 252 | return false; // invalid 253 | } 254 | $calccrc = crc32(substr($data,0,$headerlen)) & 0xffff; 255 | $headercrc = unpack("v", substr($data,$headerlen,2)); 256 | $headercrc = $headercrc[1]; 257 | if ($headercrc != $calccrc) { 258 | $error = "Header checksum failed."; 259 | return false; // Bad header CRC 260 | } 261 | $headerlen += 2; 262 | } 263 | // GZIP FOOTER 264 | $datacrc = unpack("V",substr($data,-8,4)); 265 | $datacrc = sprintf('%u',$datacrc[1] & 0xFFFFFFFF); 266 | $isize = unpack("V",substr($data,-4)); 267 | $isize = $isize[1]; 268 | // decompression: 269 | $bodylen = $len-$headerlen-8; 270 | if ($bodylen < 1) { 271 | // IMPLEMENTATION BUG! 272 | return null; 273 | } 274 | $body = substr($data,$headerlen,$bodylen); 275 | $data = ""; 276 | if ($bodylen > 0) { 277 | switch ($method) { 278 | case 8: 279 | // Currently the only supported compression method: 280 | $data = gzinflate($body,$maxlength); 281 | break; 282 | default: 283 | $error = "Unknown compression method."; 284 | return false; 285 | } 286 | } // zero-byte body content is allowed 287 | // Verifiy CRC32 288 | $crc = sprintf("%u",crc32($data)); 289 | $crcOK = $crc == $datacrc; 290 | $lenOK = $isize == strlen($data); 291 | if (!$lenOK || !$crcOK) { 292 | $error = ( $lenOK ? '' : 'Length check FAILED. ') . ( $crcOK ? '' : 'Checksum FAILED.'); 293 | return false; 294 | } 295 | return $data; 296 | } 297 | } 298 | --------------------------------------------------------------------------------