diff options
author | Ismaël Bouya <ismael.bouya@normalesup.org> | 2020-04-26 03:04:56 +0200 |
---|---|---|
committer | Ismaël Bouya <ismael.bouya@normalesup.org> | 2020-04-26 03:04:56 +0200 |
commit | 7df5e532c1ce2ab9e8527615c08c1178990870e6 (patch) | |
tree | 3790f2afe0be38e37ba82305a1139db6c6b61c79 /modules/private/websites/tools/tools/dmarc_reports | |
parent | a8ef1adb4a90c2524ac09a85463598e5d41d2a4a (diff) | |
download | Nix-7df5e532c1ce2ab9e8527615c08c1178990870e6.tar.gz Nix-7df5e532c1ce2ab9e8527615c08c1178990870e6.tar.zst Nix-7df5e532c1ce2ab9e8527615c08c1178990870e6.zip |
Add dmarc reports
Diffstat (limited to 'modules/private/websites/tools/tools/dmarc_reports')
4 files changed, 417 insertions, 0 deletions
diff --git a/modules/private/websites/tools/tools/dmarc_reports/api.php b/modules/private/websites/tools/tools/dmarc_reports/api.php new file mode 100644 index 0000000..9b7f0c0 --- /dev/null +++ b/modules/private/websites/tools/tools/dmarc_reports/api.php | |||
@@ -0,0 +1,87 @@ | |||
1 | <?php | ||
2 | |||
3 | require(getenv("SECRETS_FILE")); | ||
4 | |||
5 | $response = array( | ||
6 | "status" => "ok", | ||
7 | ); | ||
8 | $mysqli = new mysqli($dbhost, $dbuser, $dbpass, $dbname, $dbport); | ||
9 | |||
10 | function error_die($text, $number) { | ||
11 | http_response_code("500"); | ||
12 | $message = array( | ||
13 | "status" => "error", | ||
14 | "message" => $text, | ||
15 | "code" => $number | ||
16 | ); | ||
17 | |||
18 | die(json_encode($message)); | ||
19 | } | ||
20 | |||
21 | if ($mysqli->connect_errno) { | ||
22 | error_die($mysqli->connect_error, $mysqli->connect_errno); | ||
23 | } | ||
24 | |||
25 | if (!isset($_GET['serial'])) { | ||
26 | $response["domains"] = array(); | ||
27 | $query = $mysqli->query("SELECT DISTINCT domain FROM `report` ORDER BY domain"); | ||
28 | if ($mysqli->error) { error_die($mysqli->error, $mysqli->errno); } | ||
29 | while($row = $query->fetch_assoc()) { | ||
30 | $response["domains"][] = $row['domain']; | ||
31 | } | ||
32 | |||
33 | $response["orgs"] = array(); | ||
34 | $query = $mysqli->query("SELECT DISTINCT org FROM `report` ORDER BY org"); | ||
35 | if ($mysqli->error) { error_die($mysqli->error, $mysqli->errno); } | ||
36 | while($row = $query->fetch_assoc()) { | ||
37 | $response["orgs"][] = $row['org']; | ||
38 | } | ||
39 | |||
40 | $response["dates"] = array(); | ||
41 | $query = $mysqli->query("SELECT DISTINCT DISTINCT year(mindate) as year, month(mindate) as month FROM `report` ORDER BY year DESC,month DESC"); | ||
42 | if ($mysqli->error) { error_die($mysqli->error, $mysqli->errno); } | ||
43 | while($row = $query->fetch_assoc()) { | ||
44 | $response["dates"][] = sprintf( "%'.04d-%'.02d", $row['year'], $row['month'] ); | ||
45 | } | ||
46 | |||
47 | $response["summaries"] = array(); | ||
48 | if (isset($_GET['errors_only'])) { | ||
49 | $where = " WHERE (spfresult != 'pass' or dkimresult != 'pass')"; | ||
50 | } else { | ||
51 | $where = ""; | ||
52 | } | ||
53 | |||
54 | $sql = "SELECT report.* , sum(rptrecord.rcount) AS rcount, MIN(rptrecord.dkimresult) AS dkimresult, MIN(rptrecord.spfresult) AS spfresult FROM report LEFT JOIN (SELECT rcount, COALESCE(dkimresult, 'neutral') AS dkimresult, COALESCE(spfresult, 'neutral') AS spfresult, serial FROM rptrecord) AS rptrecord ON report.serial = rptrecord.serial$where GROUP BY serial ORDER BY mindate ASC, maxdate ASC, org"; | ||
55 | $query = $mysqli->query($sql); | ||
56 | if ($mysqli->error) { error_die($mysqli->error, $mysqli->errno); } | ||
57 | while($row = $query->fetch_assoc()) { | ||
58 | unset($row["raw_xml"]); | ||
59 | $response["summaries"][] = $row; | ||
60 | } | ||
61 | } else { | ||
62 | $response["rptrecord"] = []; | ||
63 | $sql = $mysqli->prepare("SELECT * FROM rptrecord where serial = ?"); | ||
64 | $sql->bind_param("s", $_GET["serial"]); | ||
65 | $sql->execute(); | ||
66 | $query = $sql->get_result(); | ||
67 | if ($mysqli->error) { error_die($mysqli->error, $mysqli->errno); } | ||
68 | while($row = $query->fetch_assoc()) { | ||
69 | if ($row['ip']) { | ||
70 | $ip = long2ip($row['ip']); | ||
71 | $host = gethostbyaddr($ip); | ||
72 | } elseif ( $row['ip6'] ) { | ||
73 | $ip = inet_ntop($row['ip6']); | ||
74 | $host = gethostbyaddr($ip); | ||
75 | } else { | ||
76 | $ip = "-"; | ||
77 | $host = "-"; | ||
78 | } | ||
79 | $row['ip'] = $ip; | ||
80 | $row['host'] = $host; | ||
81 | unset($row['ip6']); | ||
82 | $response["rptrecord"][] = $row; | ||
83 | } | ||
84 | } | ||
85 | |||
86 | echo json_encode($response, JSON_PRETTY_PRINT); | ||
87 | ?> | ||
diff --git a/modules/private/websites/tools/tools/dmarc_reports/app.js b/modules/private/websites/tools/tools/dmarc_reports/app.js new file mode 100644 index 0000000..7fe67d0 --- /dev/null +++ b/modules/private/websites/tools/tools/dmarc_reports/app.js | |||
@@ -0,0 +1,86 @@ | |||
1 | const app = new Vue({ | ||
2 | el: '#app', | ||
3 | data: { | ||
4 | info: null, | ||
5 | summaries: [], | ||
6 | selectedSummary: null, | ||
7 | filterGreen: true, | ||
8 | filterDomain: null, | ||
9 | filterOrg: null, | ||
10 | //filterDate: (new Date()).toISOString().substring(0, 7), | ||
11 | filterDate: null, | ||
12 | reverse: true, | ||
13 | }, | ||
14 | created: async function () { | ||
15 | let that = this; | ||
16 | |||
17 | try { | ||
18 | this.info = await this.getInfo(); | ||
19 | this.summaries = this.info.summaries; | ||
20 | } catch (error) {} | ||
21 | }, | ||
22 | methods: { | ||
23 | filtered: function () { | ||
24 | let that = this; | ||
25 | let filtered = this.summaries.filter(function (summary) { | ||
26 | return (!that.filterGreen || that.getColor(summary) !== "lime") | ||
27 | && (!that.filterDomain || summary.domain === that.filterDomain) | ||
28 | && (!that.filterOrg || summary.org === that.filterOrg) | ||
29 | && (!that.filterDate || that.inDates(summary)); | ||
30 | }); | ||
31 | if (this.reverse) { | ||
32 | return filtered.reverse(); | ||
33 | } else { | ||
34 | return filtered; | ||
35 | } | ||
36 | }, | ||
37 | toggle: async function(summary) { | ||
38 | if (this.selectedSummary && this.selectedSummary.serial === summary.serial) { | ||
39 | this.selectedSummary = null; | ||
40 | } else { | ||
41 | if (!summary.details) { | ||
42 | summary.details = await this.getDetails(summary.serial); | ||
43 | } | ||
44 | this.selectedSummary = summary; | ||
45 | } | ||
46 | }, | ||
47 | inDates: function(summary) { | ||
48 | if (!this.filterDate) { return true; } | ||
49 | |||
50 | let mindate = (new Date(summary.mindate)).toISOString().substring(0, 7); | ||
51 | let maxdate = (new Date(summary.maxdate)).toISOString().substring(0, 7); | ||
52 | |||
53 | return mindate === this.filterDate || maxdate === this.filterDate; | ||
54 | }, | ||
55 | printDate: function (date) { | ||
56 | return (new Date(date)).toISOString(); | ||
57 | }, | ||
58 | getColor: function (element) { | ||
59 | if (element.dkimresult === "fail" && element.spfresult === "fail") { | ||
60 | return "red"; | ||
61 | } else if (element.dkimresult === "fail" || element.spfresult === "fail") { | ||
62 | return "orange"; | ||
63 | } else if (element.dkimresult === "pass" && element.spfresult === "pass") { | ||
64 | return "lime"; | ||
65 | } else { | ||
66 | return "yellow"; | ||
67 | } | ||
68 | }, | ||
69 | getInfo: function (event) { | ||
70 | return fetch('api.php').then(function (response) { | ||
71 | if (response.status != 200) { return; } | ||
72 | return response.text().then(function (body) { | ||
73 | return JSON.parse(body); | ||
74 | }); | ||
75 | }); | ||
76 | }, | ||
77 | getDetails: function (serial) { | ||
78 | return fetch(`api.php?serial=${serial}`).then(function (response) { | ||
79 | if (response.status != 200) { return; } | ||
80 | return response.text().then(function (body) { | ||
81 | return JSON.parse(body); | ||
82 | }); | ||
83 | }); | ||
84 | } | ||
85 | } | ||
86 | }); | ||
diff --git a/modules/private/websites/tools/tools/dmarc_reports/default.css b/modules/private/websites/tools/tools/dmarc_reports/default.css new file mode 100644 index 0000000..11608cc --- /dev/null +++ b/modules/private/websites/tools/tools/dmarc_reports/default.css | |||
@@ -0,0 +1,126 @@ | |||
1 | h1 { | ||
2 | text-align: center; | ||
3 | } | ||
4 | |||
5 | table.reportlist { | ||
6 | margin: 2em auto 2em auto; | ||
7 | border-collapse: collapse; | ||
8 | clear: both; | ||
9 | } | ||
10 | |||
11 | table.reportlist td, table.reportlist th { | ||
12 | padding:3px; | ||
13 | } | ||
14 | |||
15 | table.reportlist thead { | ||
16 | border-top: 1px solid grey; | ||
17 | border-bottom: 1px solid grey; | ||
18 | |||
19 | } | ||
20 | table.reportlist tbody tr:first-child td { | ||
21 | padding-top: 10px; | ||
22 | } | ||
23 | table.reportlist tr.sum { | ||
24 | border-top: 1px solid grey; | ||
25 | } | ||
26 | table.reportlist tr.selected { | ||
27 | background-color: lightgrey; | ||
28 | } | ||
29 | .reportdesc { | ||
30 | font-weight: bold; | ||
31 | width: 90%; | ||
32 | margin-left: auto; | ||
33 | margin-right: auto; | ||
34 | } | ||
35 | |||
36 | tr.summaryrow { | ||
37 | cursor: pointer; | ||
38 | } | ||
39 | |||
40 | tr.summaryrow:hover, tr.summaryrow.selected { | ||
41 | background-color: lightgray; | ||
42 | border-left: 1px solid lightgray; | ||
43 | } | ||
44 | |||
45 | td.reportcell { | ||
46 | border-bottom: 1px solid lightgray; | ||
47 | border-left: 1px solid lightgray; | ||
48 | border-right: 1px solid lightgray; | ||
49 | } | ||
50 | |||
51 | table.reportdata { | ||
52 | margin: 0px auto 0px auto; | ||
53 | border-collapse: separate; | ||
54 | border-spacing: 2px; | ||
55 | } | ||
56 | |||
57 | table.reportdata tr th, table.reportdata tr td { | ||
58 | text-align: center; | ||
59 | padding: 3px; | ||
60 | } | ||
61 | |||
62 | table.reportdata tr.red { | ||
63 | background-color: #FF0000; | ||
64 | } | ||
65 | |||
66 | table.reportdata tr.orange { | ||
67 | background-color: #FFA500; | ||
68 | } | ||
69 | |||
70 | table.reportdata tr.lime { | ||
71 | background-color: #00FF00; | ||
72 | } | ||
73 | |||
74 | table.reportdata tr.yellow { | ||
75 | background-color: #FFFF00; | ||
76 | } | ||
77 | |||
78 | .optionblock { | ||
79 | background: lightgrey; | ||
80 | padding: 0.4em; | ||
81 | float: right; | ||
82 | margin: auto 2em 1em auto; | ||
83 | white-space: nowrap; | ||
84 | } | ||
85 | |||
86 | .optionlabel { | ||
87 | font-weight: bold; | ||
88 | float: left; clear: left; | ||
89 | margin-right: 1em; | ||
90 | } | ||
91 | |||
92 | .options { | ||
93 | font-size: 70%; | ||
94 | text-align: right; | ||
95 | border: none; | ||
96 | width: 97%; | ||
97 | padding: 0.4em; | ||
98 | } | ||
99 | |||
100 | .center { | ||
101 | text-align:center; | ||
102 | } | ||
103 | |||
104 | .circle_lime:before { | ||
105 | content: ' \25CF'; | ||
106 | font-size: 25px; | ||
107 | color: #00FF00; | ||
108 | } | ||
109 | |||
110 | .circle_red:before { | ||
111 | content: ' \25CF'; | ||
112 | font-size: 25px; | ||
113 | color: #FF0000; | ||
114 | } | ||
115 | |||
116 | .circle_yellow:before { | ||
117 | content: ' \25CF'; | ||
118 | font-size: 25px; | ||
119 | color: #FFFF00; | ||
120 | } | ||
121 | |||
122 | .circle_orange:before { | ||
123 | content: ' \25CF'; | ||
124 | font-size: 25px; | ||
125 | color: #FFA500; | ||
126 | } | ||
diff --git a/modules/private/websites/tools/tools/dmarc_reports/index.html b/modules/private/websites/tools/tools/dmarc_reports/index.html new file mode 100644 index 0000000..ded05e1 --- /dev/null +++ b/modules/private/websites/tools/tools/dmarc_reports/index.html | |||
@@ -0,0 +1,118 @@ | |||
1 | <!DOCTYPE html> | ||
2 | <html> | ||
3 | |||
4 | <head> | ||
5 | <meta charset="utf-8"> | ||
6 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
7 | <meta name="robots" content="noindex"> | ||
8 | <title>Dmarc reports</title> | ||
9 | <link rel="stylesheet" href="default.css"> | ||
10 | </head> | ||
11 | |||
12 | <body> | ||
13 | <div id="app" style="width: 100%"> | ||
14 | <div class="optionblock" v-if="info"> | ||
15 | <div class='options'> | ||
16 | <span class='optionlabel'>Hide all-green lines:</span> | ||
17 | <label><input type="radio" :value="false" v-model="filterGreen"> no</label> | ||
18 | <label><input type="radio" :value="true" v-model="filterGreen"> yes</label> | ||
19 | </div> | ||
20 | <div class='options'> | ||
21 | <span class='optionlabel'>Sort order:</span> | ||
22 | <label><input type="radio" :value="false" v-model="reverse"> ascending</label> | ||
23 | <label><input type="radio" :value="true" v-model="reverse"> descending</label> | ||
24 | </div> | ||
25 | <div class='options'> | ||
26 | <span class='optionlabel'>Domain(s):</span> | ||
27 | <select v-model="filterDomain"> | ||
28 | <option selected="selected" :value="null">[all]</option> | ||
29 | <option v-for="domain in info.domains" :value="domain">{{ domain }}</option> | ||
30 | </select> | ||
31 | </div> | ||
32 | <div class='options'> | ||
33 | <span class='optionlabel'>Organisation(s):</span> | ||
34 | <select v-model="filterOrg"> | ||
35 | <option selected="selected" :value="null">[all]</option> | ||
36 | <option v-for="org in info.orgs" :value="org">{{ org }}</option> | ||
37 | </select> | ||
38 | </div> | ||
39 | <div class='options'> | ||
40 | <span class='optionlabel'>Time:</span> | ||
41 | <select v-model="filterDate"> | ||
42 | <option selected="selected" :value="null">[all]</option> | ||
43 | <option v-for="date in info.dates" :value="date">{{ date }}</option> | ||
44 | </select> | ||
45 | </div> | ||
46 | </div> | ||
47 | |||
48 | <h1 class='main'>DMARC Reports</h1> | ||
49 | <table class='reportlist' v-if="summaries"> | ||
50 | <thead> | ||
51 | <tr> | ||
52 | <th></th> | ||
53 | <th>Start Date</th> | ||
54 | <th>End Date</th> | ||
55 | <th>Domain</th> | ||
56 | <th>Reporting Organization</th> | ||
57 | <th>Report ID</th> | ||
58 | <th>Messages</th> | ||
59 | </tr> | ||
60 | </thead> | ||
61 | <tbody> | ||
62 | <template v-for="summary in filtered()"> | ||
63 | <tr v-on:click="toggle(summary)" class="summaryrow" | ||
64 | v-bind:class="[{ selected: selectedSummary && summary.serial === selectedSummary.serial }]"> | ||
65 | <td class='right'><span :class="'circle_' + getColor(summary)"></span></td> | ||
66 | <td class='right'>{{ printDate(summary.mindate) }}</td> | ||
67 | <td class='right'>{{ printDate(summary.maxdate) }}</td> | ||
68 | <td class='center'>{{ summary.domain }}</td> | ||
69 | <td class='center'>{{ summary.org }}</td> | ||
70 | <td class='center'>{{ summary.reportid }}</td> | ||
71 | <td class='center'>{{ summary.rcount }}</td> | ||
72 | </tr> | ||
73 | <tr v-if="selectedSummary && summary.serial === selectedSummary.serial"> | ||
74 | <td colspan="6" class="reportcell"> | ||
75 | <div class='center reportdesc'> | ||
76 | <p>Policies: adkim={{ summary.policy_adkim }}, aspf={{ summary.policy_aspf }}, p={{ summary.policy_none }}, sp={{ summary.policy_sp }}, pct={{ summary.policy_pct }}</p> | ||
77 | </div> | ||
78 | <table v-if="summary.details" class='reportdata'> | ||
79 | <thead> | ||
80 | <tr> | ||
81 | <th>IP Address</th> | ||
82 | <th>Host Name</th> | ||
83 | <th>Message Count</th> | ||
84 | <th>Disposition</th> | ||
85 | <th>Reason</th> | ||
86 | <th>DKIM Domain</th> | ||
87 | <th>Raw DKIM Result</th> | ||
88 | <th>SPF Domain</th> | ||
89 | <th>Raw SPF Result</th> | ||
90 | </tr> | ||
91 | </thead> | ||
92 | <tbody> | ||
93 | <tr v-for="record in summary.details.rptrecord" :class='getColor(record)'> | ||
94 | <td>{{ record.ip }}</td> | ||
95 | <td>{{ record.host }}</td> | ||
96 | <td>{{ record.rcount }}</td> | ||
97 | <td>{{ record.disposition }}</td> | ||
98 | <td>{{ record.reason }}</td> | ||
99 | <td>{{ record.dkimdomain }}</td> | ||
100 | <td>{{ record.dkimresult }}</td> | ||
101 | <td>{{ record.spfdomain }}</td> | ||
102 | <td>{{ record.spfresult }}</td> | ||
103 | </tr> | ||
104 | </tbody> | ||
105 | </table> | ||
106 | </td> | ||
107 | <td></td> | ||
108 | </tr> | ||
109 | </template> | ||
110 | </tbody> | ||
111 | </table> | ||
112 | </div> | ||
113 | |||
114 | <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11"></script> | ||
115 | <script src="app.js"></script> | ||
116 | </body> | ||
117 | |||
118 | </html> | ||