|
| 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 — 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 — 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 — 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 — 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 — 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">⚠ 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 ▴</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 — 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 — see the <a href="/tenant.html">Tenant Map</a> for that signal.</p> |
| 70 | + </div> |
| 71 | + <div class="info-card"> |
| 72 | + <h3>Open source & 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…</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> · ' + 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;">… 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(', ')) : '—'; |
| 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> |
0 commit comments