Skip to content

Commit 6dd3a4a

Browse files
authored
Merge pull request #24 from davidhuser/lax/map
hyperscaler on dns map
2 parents 83ec5d3 + 90c297a commit 6dd3a4a

2 files changed

Lines changed: 341 additions & 0 deletions

File tree

dns.html

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6+
<link rel="preconnect" href="https://unpkg.com" crossorigin>
7+
<link rel="preconnect" href="https://a.basemaps.cartocdn.com" crossorigin>
8+
<link rel="preconnect" href="https://b.basemaps.cartocdn.com" crossorigin>
9+
<link rel="preconnect" href="https://c.basemaps.cartocdn.com" crossorigin>
10+
<link rel="preconnect" href="https://d.basemaps.cartocdn.com" crossorigin>
11+
<link rel="dns-prefetch" href="https://unpkg.com">
12+
<link rel="dns-prefetch" href="https://a.basemaps.cartocdn.com">
13+
<link rel="dns-prefetch" href="https://b.basemaps.cartocdn.com">
14+
<link rel="dns-prefetch" href="https://c.basemaps.cartocdn.com">
15+
<link rel="dns-prefetch" href="https://d.basemaps.cartocdn.com">
16+
<title>MXmap &mdash; Hyperscaler DNS Exposure of Swiss Municipalities</title>
17+
<meta name="description" content="Interactive map highlighting Swiss municipalities whose email-related DNS records (MX, SPF, DKIM, autodiscover, DMARC) reference a US hyperscaler &mdash; Microsoft, Google, or AWS.">
18+
<meta name="keywords" content="Switzerland, municipalities, DNS, SPF, MX, hyperscaler, Microsoft, Google, AWS, digital sovereignty, OSINT">
19+
<meta name="author" content="David Huser">
20+
<meta name="robots" content="index, follow">
21+
<link rel="canonical" href="https://mxmap.ch/dns.html">
22+
<meta property="og:type" content="website">
23+
<meta property="og:title" content="MXmap &mdash; Hyperscaler DNS Exposure of Swiss Municipalities">
24+
<meta property="og:description" content="Interactive map highlighting municipalities whose email DNS records reference Microsoft, Google, or AWS &mdash; even a single SPF include triggers exposure.">
25+
<meta property="og:url" content="https://mxmap.ch/dns.html">
26+
<meta property="og:locale" content="en_CH">
27+
<meta property="og:site_name" content="MXmap">
28+
<meta property="og:image" content="https://mxmap.ch/og-image.jpg">
29+
<meta name="twitter:card" content="summary_large_image">
30+
<meta name="twitter:title" content="MXmap &mdash; Hyperscaler DNS Exposure of Swiss Municipalities">
31+
<meta name="twitter:description" content="Interactive map highlighting municipalities with hyperscaler traces in their email DNS records.">
32+
<meta name="twitter:image" content="https://mxmap.ch/og-image.jpg">
33+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='2' y='7' width='28' height='18' rx='2' fill='%231a1a2e'/%3E%3Cpath d='M2 9l14 9 14-9' fill='none' stroke='%23d32f2f' stroke-width='2'/%3E%3C/svg%3E">
34+
<link rel="stylesheet" href="css/shared.css">
35+
<link rel="stylesheet" href="css/map.css">
36+
<link rel="preload" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
37+
<noscript><link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"></noscript>
38+
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
39+
<script defer src="https://unpkg.com/topojson-client@3/dist/topojson-client.min.js"></script>
40+
<script defer src="js/nav.js"></script>
41+
<script defer src="js/map-shared.js"></script>
42+
</head>
43+
<body>
44+
<header id="header">
45+
<div class="header-left">
46+
<h1 class="brand">MXmap</h1>
47+
<span class="beta-badge" title="Beta status, this website may contain errors">beta</span>
48+
<nav id="nav"></nav>
49+
</div>
50+
<span class="header-disclaimer">&#9888; This data may be out of date or incorrect. A research project is ongoing to further develop such maps.</span>
51+
<div class="header-right">
52+
<button class="toggle-info" id="toggle-info" aria-expanded="true" aria-controls="info-bar">About &#9652;</button>
53+
</div>
54+
</header>
55+
<section id="info-bar" aria-label="About this project">
56+
<script>if(window.innerWidth<=600)document.getElementById('info-bar').classList.add('collapsed');</script>
57+
<h2 class="info-title">Hyperscaler DNS Exposure of Swiss Municipalities</h2>
58+
<div class="info-grid">
59+
<div class="info-card">
60+
<h3>What is this?</h3>
61+
<p>A map of all ~2,100 Swiss municipalities colored <strong style="color:#d32f2f">red</strong> if <em>any</em> email-related DNS record references a US hyperscaler &mdash; Microsoft, Google, or AWS. Even a single <code>include:</code> in SPF is enough to trigger exposure.</p>
62+
</div>
63+
<div class="info-card">
64+
<h3>How is this different from the Email Map?</h3>
65+
<p>The <a href="/">Email Map</a> picks one winning provider per municipality. This map casts a wider net: a municipality whose email is hosted by a Swiss provider but whose SPF record still delegates to <code>spf.protection.outlook.com</code> shows green on the Email Map, but red here.</p>
66+
</div>
67+
<div class="info-card">
68+
<h3>Which records are checked?</h3>
69+
<p>MX, SPF, DKIM, autodiscover, DMARC, CNAME chain, TXT verification, and ASN of MX/SPF IPs. The M365 tenant check (<code>getuserrealm.srf</code>) is <strong>not</strong> counted here because it's not a DNS record &mdash; see the <a href="/tenant.html">Tenant Map</a> for that signal.</p>
70+
</div>
71+
<div class="info-card">
72+
<h3>Open source &amp; open data</h3>
73+
<p>The code and data are on <a href="https://github.com/davidhuser/mxmap" target="_blank">GitHub</a>.<br>
74+
If you have noticed an error, please <a href="https://github.com/davidhuser/mxmap/issues" target="_blank">submit an issue</a>.</p>
75+
<p id="generated"></p>
76+
</div>
77+
</div>
78+
</section>
79+
<main id="map" role="application" aria-label="Interactive map of Swiss municipality hyperscaler DNS exposure">
80+
<div id="map-loading" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:1000;background:rgba(255,255,255,0.85);font-size:15px;color:#555;">Loading map data&hellip;</div>
81+
</main>
82+
83+
<script>
84+
document.addEventListener('DOMContentLoaded', function () {
85+
86+
var DNS_COLORS = {
87+
hyperscaler: '#d32f2f',
88+
clean: '#4caf50',
89+
nodata: '#cfcfcf',
90+
lake: '#afd7f5',
91+
};
92+
93+
// Mirror of src/mail_sovereignty/signatures.py — keep in sync if Cloudflare
94+
// or another hyperscaler is added to SIGNATURES.
95+
var HYPERSCALER_MX = {
96+
microsoft: ['mail.protection.outlook.com', 'mx.microsoft'],
97+
google: ['aspmx.l.google.com', 'googlemail.com', 'smtp.google.com'],
98+
aws: ['amazonaws.com', 'awsapps.com'],
99+
};
100+
var HYPERSCALER_SPF = {
101+
microsoft: ['spf.protection.outlook.com'],
102+
google: ['_spf.google.com'],
103+
aws: ['amazonses.com'],
104+
};
105+
var HYPERSCALER_DETAIL_KEYS = {
106+
microsoft: 'ms365',
107+
google: 'google',
108+
aws: 'aws',
109+
};
110+
var DNS_SIGNAL_KINDS = {
111+
mx: 1, spf: 1, dkim: 1, autodiscover: 1, dmarc: 1,
112+
cname_chain: 1, txt_verification: 1, spf_ip: 1, asn: 1,
113+
};
114+
115+
var CANTON_CODES = {
116+
'Kanton Zürich': 'ZH', 'Kanton Bern': 'BE', 'Kanton Luzern': 'LU',
117+
'Kanton Uri': 'UR', 'Kanton Schwyz': 'SZ', 'Kanton Obwalden': 'OW',
118+
'Kanton Nidwalden': 'NW', 'Kanton Glarus': 'GL', 'Kanton Zug': 'ZG',
119+
'Kanton Freiburg': 'FR', 'Kanton Solothurn': 'SO', 'Kanton Basel-Stadt': 'BS',
120+
'Kanton Basel-Landschaft': 'BL', 'Kanton Schaffhausen': 'SH',
121+
'Kanton Appenzell Ausserrhoden': 'AR', 'Kanton Appenzell Innerrhoden': 'AI',
122+
'Kanton St. Gallen': 'SG', 'Kanton Graubünden': 'GR', 'Kanton Aargau': 'AG',
123+
'Kanton Thurgau': 'TG', 'Kanton Tessin': 'TI', 'Kanton Waadt': 'VD',
124+
'Kanton Wallis': 'VS', 'Kanton Neuenburg': 'NE', 'Kanton Genf': 'GE',
125+
'Kanton Jura': 'JU'
126+
};
127+
128+
var HYPERSCALER_LABELS = {
129+
microsoft: 'Microsoft',
130+
google: 'Google',
131+
aws: 'AWS',
132+
};
133+
134+
function detectHyperscalerInString(value, patternGroups) {
135+
if (!value) return null;
136+
var lower = value.toLowerCase();
137+
for (var h in patternGroups) {
138+
var pats = patternGroups[h];
139+
for (var i = 0; i < pats.length; i++) {
140+
if (lower.indexOf(pats[i]) !== -1) return h;
141+
}
142+
}
143+
return null;
144+
}
145+
146+
function getHyperscalerHits(m) {
147+
var hits = [];
148+
var seen = {};
149+
150+
function push(hyperscaler, kind, detail) {
151+
var key = hyperscaler + '|' + kind + '|' + detail;
152+
if (seen[key]) return;
153+
seen[key] = true;
154+
hits.push({ hyperscaler: hyperscaler, kind: kind, detail: detail });
155+
}
156+
157+
if (m && m.mx && m.mx.length) {
158+
for (var i = 0; i < m.mx.length; i++) {
159+
var host = m.mx[i];
160+
var h = detectHyperscalerInString(host, HYPERSCALER_MX);
161+
if (h) push(h, 'mx', host);
162+
}
163+
}
164+
165+
if (m && m.spf) {
166+
var spfLower = m.spf.toLowerCase();
167+
for (var sh in HYPERSCALER_SPF) {
168+
var sPats = HYPERSCALER_SPF[sh];
169+
for (var j = 0; j < sPats.length; j++) {
170+
if (spfLower.indexOf(sPats[j]) !== -1) {
171+
push(sh, 'spf', 'include:' + sPats[j]);
172+
}
173+
}
174+
}
175+
}
176+
177+
if (m && m.classification_signals) {
178+
for (var k = 0; k < m.classification_signals.length; k++) {
179+
var sig = m.classification_signals[k];
180+
if (!sig || !DNS_SIGNAL_KINDS[sig.kind]) continue;
181+
var dLower = (sig.detail || '').toLowerCase();
182+
for (var hs in HYPERSCALER_DETAIL_KEYS) {
183+
if (dLower.indexOf(HYPERSCALER_DETAIL_KEYS[hs]) !== -1) {
184+
push(hs, sig.kind, sig.detail);
185+
}
186+
}
187+
}
188+
}
189+
190+
return hits;
191+
}
192+
193+
function getDnsStatus(m) {
194+
if (!m) return 'nodata';
195+
var hasData = (m.mx && m.mx.length) || m.spf;
196+
if (!hasData) return 'nodata';
197+
return getHyperscalerHits(m).length > 0 ? 'hyperscaler' : 'clean';
198+
}
199+
200+
function getColor(m) {
201+
return DNS_COLORS[getDnsStatus(m)];
202+
}
203+
204+
function getStatusLabel(status) {
205+
if (status === 'hyperscaler') return 'Hyperscaler exposed';
206+
if (status === 'clean') return 'No hyperscaler';
207+
return 'No data';
208+
}
209+
210+
var map = initMap('map');
211+
setupInfoBar(map);
212+
213+
var muni = {};
214+
var muniLayer;
215+
var lakeLayer;
216+
217+
async function loadData() {
218+
var data = await fetchMapData();
219+
var topo = data.topo;
220+
var dnsData = data.dnsData;
221+
muni = dnsData.municipalities;
222+
showGenerated(dnsData);
223+
224+
var counts = { hyperscaler: 0, clean: 0, nodata: 0 };
225+
var byHyperscaler = { microsoft: 0, google: 0, aws: 0 };
226+
for (var bfs of Object.keys(muni)) {
227+
var status = getDnsStatus(muni[bfs]);
228+
counts[status]++;
229+
if (status === 'hyperscaler') {
230+
var hs = getHyperscalerHits(muni[bfs]);
231+
var seenH = {};
232+
for (var i = 0; i < hs.length; i++) {
233+
if (!seenH[hs[i].hyperscaler]) {
234+
seenH[hs[i].hyperscaler] = true;
235+
byHyperscaler[hs[i].hyperscaler]++;
236+
}
237+
}
238+
}
239+
}
240+
241+
var isMobile = window.innerWidth <= 600;
242+
var legend = L.control({ position: isMobile ? 'topright' : 'bottomright' });
243+
legend.onAdd = function () {
244+
var div = L.DomUtil.create('div', 'legend');
245+
if (isMobile) div.classList.add('legend-collapsed');
246+
div.innerHTML =
247+
'<button class="legend-toggle" aria-label="Toggle legend" aria-expanded="' + (!isMobile) + '">' + (isMobile ? 'Legend ▸' : '') + '</button>' +
248+
'<div class="legend-content">' +
249+
'<strong>Hyperscaler DNS Exposure</strong>' +
250+
'<div class="legend-group">' +
251+
'<i class="legend-swatch" style="background:' + DNS_COLORS.hyperscaler + '"></i>Hyperscaler exposed (' + counts.hyperscaler + ')<br>' +
252+
'<i class="legend-swatch" style="background:' + DNS_COLORS.clean + '"></i>No hyperscaler (' + counts.clean + ')<br>' +
253+
'<i class="legend-swatch" style="background:' + DNS_COLORS.nodata + '"></i>No data (' + counts.nodata + ')' +
254+
'</div>' +
255+
'<div class="legend-group" style="margin-top:6px;font-size:11px;color:#666;">' +
256+
'Microsoft: ' + byHyperscaler.microsoft + '<br>' +
257+
'Google: ' + byHyperscaler.google + '<br>' +
258+
'AWS: ' + byHyperscaler.aws +
259+
'</div>' +
260+
'</div>';
261+
L.DomEvent.disableClickPropagation(div);
262+
return div;
263+
};
264+
legend.addTo(map);
265+
document.querySelector('.legend-toggle').addEventListener('click', toggleLegend);
266+
267+
lakeLayer = addLakes(map, topo, DNS_COLORS.lake);
268+
269+
var geojson = topojson.feature(topo, topo.objects.municipalities);
270+
271+
muniLayer = L.geoJSON(geojson, {
272+
style: function (feature) {
273+
var bfs = String(feature.id);
274+
var m = muni[bfs];
275+
return {
276+
fillColor: getColor(m),
277+
weight: 0.6,
278+
color: '#afafaf',
279+
fillOpacity: 1
280+
};
281+
},
282+
onEachFeature: function (feature, layer) {
283+
var bfs = String(feature.id);
284+
var m = muni[bfs];
285+
if (!m) {
286+
layer.bindPopup('<div class="info-popup"><strong>BFS ' + bfs + '</strong><br>No data</div>');
287+
return;
288+
}
289+
var status = getDnsStatus(m);
290+
var statusLabel = getStatusLabel(status);
291+
var color = DNS_COLORS[status];
292+
var textColor = status === 'nodata' ? '#333' : '#fff';
293+
294+
var eName = escapeHtml(m.name);
295+
var cantonCode = CANTON_CODES[m.canton] || '';
296+
var nameDisplay = cantonCode ? eName + ' (' + cantonCode + ')' : eName;
297+
var eDomain = escapeHtml(m.domain || 'unknown');
298+
299+
var badge = '<span class="tenant-badge" style="background:' + color + ';color:' + textColor + '">' + statusLabel + '</span>';
300+
301+
var body =
302+
'<div class="info-popup">' +
303+
'<strong>' + nameDisplay + '</strong><br>' +
304+
eDomain + ' ' + badge;
305+
306+
if (status === 'hyperscaler') {
307+
var hits = getHyperscalerHits(m);
308+
var hyperscalersFound = {};
309+
for (var i = 0; i < hits.length; i++) hyperscalersFound[hits[i].hyperscaler] = true;
310+
var hsList = Object.keys(hyperscalersFound).map(function (h) { return HYPERSCALER_LABELS[h]; }).join(', ');
311+
body += '<div class="popup-section"><strong>Hyperscalers found:</strong> ' + escapeHtml(hsList) + '</div>';
312+
313+
var rows = hits.slice(0, 6).map(function (hit) {
314+
return '<div style="font-size:12px;margin-top:3px;"><span style="color:#888;text-transform:uppercase;font-size:10px;">' + escapeHtml(hit.kind) + '</span> &middot; ' + escapeHtml(hit.detail) + '</div>';
315+
}).join('');
316+
body += '<div class="popup-section">' + rows;
317+
if (hits.length > 6) body += '<div style="font-size:11px;color:#888;margin-top:3px;">&hellip; and ' + (hits.length - 6) + ' more</div>';
318+
body += '</div>';
319+
} else if (status === 'clean') {
320+
var mxLine = (m.mx && m.mx.length) ? escapeHtml(m.mx.join(', ')) : '&mdash;';
321+
body += '<div class="popup-section"><span style="color:#888;text-transform:uppercase;font-size:10px;">MX</span><br>' + mxLine + '</div>';
322+
}
323+
324+
body += '</div>';
325+
326+
layer.bindPopup(body, { maxWidth: 400 });
327+
layer.on('mouseover', function () { this.setStyle({ weight: 2, color: '#333' }); });
328+
layer.on('mouseout', function () { this.setStyle({ weight: 0.5, color: '#666' }); });
329+
}
330+
}).addTo(map);
331+
332+
removeLoading();
333+
map.invalidateSize({ animate: false });
334+
}
335+
336+
loadData().catch(handleLoadError);
337+
});
338+
</script>
339+
</body>
340+
</html>

js/nav.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
var primary = [
55
{ href: '/', label: 'Email Map', match: ['/', '/index.html'] },
66
{ href: '/tenant.html', label: 'Tenant Map' },
7+
{ href: '/dns.html', label: 'DNS Exposure' },
78
];
89
var secondary = [
910
{ href: '/impressum.html', label: 'Impressum' },

0 commit comments

Comments
 (0)