Created
December 16, 2025 17:32
-
-
Save silversword411/a9b83b53d445de04d2773edf79a24317 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Domain Analysis (RDAP + DoH) v1.0</title> | |
| <style> | |
| body{font-family:system-ui,Segoe UI,Roboto,Arial,sans-serif;margin:0;line-height:1.35;overflow-x:hidden} | |
| .container{max-width:100%;margin:0 auto;padding:20px 16px} | |
| input,button,textarea{font:inherit} | |
| .row{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-start} | |
| .row > *{margin:4px 0} | |
| input[type=text]{padding:10px;min-width:280px;border:1px solid #ddd;border-radius:8px;width:100%;box-sizing:border-box;display:block} | |
| textarea{padding:10px;width:100%;min-height:96px;border:1px solid #ddd;border-radius:8px;box-sizing:border-box;display:block} | |
| button{padding:10px 14px;cursor:pointer;border:1px solid #ddd;border-radius:8px;background:#fff} | |
| button:hover{background:#f7f7f7} | |
| .form-card{border:1px solid #ddd;border-radius:12px;padding:12px;background:#fff;margin-top:12px;box-sizing:border-box;width:100%} | |
| .form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px} | |
| .form-field{display:flex;flex-direction:column} | |
| .form-field label{font-weight:600;color:#333;margin-bottom:6px} | |
| .hint{color:#666;font-size:12px;margin-top:6px} | |
| .actions{display:flex;gap:10px;align-items:center;margin-top:8px;flex-wrap:wrap} | |
| @media (max-width: 900px){ | |
| .form-grid{grid-template-columns:1fr} | |
| .actions{flex-wrap:wrap} | |
| } | |
| .grid{display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px;width:100%;box-sizing:border-box} | |
| .card{border:1px solid #ddd;border-radius:10px;padding:12px;box-sizing:border-box;max-width:100%} | |
| .card h2{margin:0 0 8px 0;font-size:18px} | |
| pre{background:#f7f7f7;border:1px solid #eee;border-radius:8px;padding:10px;overflow:auto;white-space:pre-wrap;word-wrap:break-word;word-break:break-word;overflow-wrap:anywhere;max-width:100%;box-sizing:border-box} | |
| .muted{color:#666;font-size:13px;line-height:1.4} | |
| .ok{color:#0a7} | |
| .warn{color:#b80} | |
| .err{color:#c00} | |
| .pill{display:inline-block;border:1px solid #ddd;border-radius:999px;padding:2px 8px;margin-right:6px;font-size:12px;color:#444} | |
| .expires-soon{background:#fff3cd;color:#856404;font-weight:bold;padding:2px 4px;border-radius:3px} | |
| .expires-critical{background:#f8d7da;color:#721c24;font-weight:bold;padding:2px 4px;border-radius:3px} | |
| /* Responsive domain summary cards */ | |
| .domains-grid{display:block;width:100%;min-width:0} | |
| .domain-card{border:1px solid #e5e5e5;border-radius:10px;padding:12px;background:#fff;box-sizing:border-box;min-width:0;width:100%;max-width:100%;margin-bottom:12px;overflow:hidden} | |
| .domain-card + .domain-card{margin-top:8px} | |
| .domain-title{display:flex;align-items:center;justify-content:space-between;margin:0 0 8px 0} | |
| .domain-name{font-weight:600;color:#222} | |
| .badge{border-radius:999px;padding:2px 8px;font-size:12px;border:1px solid #eee} | |
| .badge-ok{background:#e9fbf5;color:#0a7;border-color:#c9f3e6} | |
| .badge-warn{background:#fff6e6;color:#b80;border-color:#ffe3b3} | |
| .badge-err{background:#fdeceb;color:#c00;border-color:#f6c8c5} | |
| .issues-section ul{margin:6px 0 0 0;padding-left:18px} | |
| .issues-section{word-wrap:break-word;word-break:break-word;overflow-wrap:anywhere} | |
| /* Summary: always one domain per line */ | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Domain Analysis v1.0</h1> | |
| <div class="muted"> | |
| Runs entirely in the browser using <span class="pill">RDAP</span> and <span class="pill">DNS-over-HTTPS</span>. | |
| Some TLDs don’t publish all dates/registrar details via RDAP. | |
| </div> | |
| <div class="form-card"> | |
| <div class="form-grid"> | |
| <div class="form-field"> | |
| <label for="domain">Domains</label> | |
| <textarea id="domain" placeholder="example.com\nexample.org"></textarea> | |
| <div class="hint">Paste list: comma, newline, space. Wildcards and URLs are normalized.</div> | |
| <div id="domainsPreview" class="muted"></div> | |
| </div> | |
| <div class="form-field"> | |
| <label for="selectors">DKIM selectors</label> | |
| <textarea id="selectors">selector1,selector2,default,google,s1,s2,k1,smtp,mail</textarea> | |
| <div class="hint">Accepts comma/newline/space; trimmed, blanks ignored, deduped.</div> | |
| <div id="selectorsPreview" class="muted"></div> | |
| </div> | |
| </div> | |
| <div class="actions"> | |
| <button id="run">Run</button> | |
| <button id="copy">Copy JSON</button> | |
| </div> | |
| </div> | |
| <div id="status" class="muted" style="margin-top:6px"></div> | |
| <div class="grid"> | |
| <div class="card"> | |
| <h2>Summary</h2> | |
| <div id="issues"></div> | |
| </div> | |
| <div class="card"> | |
| <h2>RDAP (Registrar / Dates)</h2> | |
| <pre id="rdap"></pre> | |
| </div> | |
| <div class="card"> | |
| <h2>DNS</h2> | |
| <pre id="dns"></pre> | |
| </div> | |
| <div class="card"> | |
| <h2>Raw JSON</h2> | |
| <pre id="raw"></pre> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // --- helpers --- | |
| const $ = (id) => document.getElementById(id); | |
| function normalizeDomain(d) { | |
| d = (d || "").trim().toLowerCase(); | |
| d = d.replace(/^\*\./, ""); | |
| d = d.replace(/^https?:\/\//, ""); | |
| d = d.split("/")[0]; | |
| d = d.replace(/\.$/, ""); | |
| return d; | |
| } | |
| function uniq(arr) { | |
| return [...new Set((arr || []).filter(x => x && String(x).trim()))]; | |
| } | |
| function parseList(text) { | |
| return uniq((text || "") | |
| .split(/[\s,;]+/) | |
| .map(s => s.trim()) | |
| .filter(Boolean)); | |
| } | |
| function parseSelectors(text) { | |
| // Supports comma, newline, semicolon, tab, and spaces | |
| return parseList(text); | |
| } | |
| function renderIssues(issues) { | |
| if (!issues.length) return `<div class="ok">No obvious issues found.</div>`; | |
| // Categorize issues | |
| const errors = []; | |
| const warnings = []; | |
| const info = []; | |
| issues.forEach(i => { | |
| let msg = i; | |
| if (i.startsWith("OK:")) { | |
| msg = i.substring(3); | |
| info.push(msg); | |
| } else if (i.startsWith("ERR:") || i.startsWith("❌")) { | |
| msg = i.startsWith("ERR:") ? i.substring(4) : i; | |
| errors.push(msg); | |
| } else if (i.startsWith("⚠️")) { | |
| warnings.push(i); | |
| } else if (i.startsWith("ℹ️")) { | |
| info.push(i); | |
| } else { | |
| warnings.push(i); | |
| } | |
| }); | |
| let html = ''; | |
| if (errors.length) { | |
| html += `<h3 style="color:#c00;margin:8px 0 4px 0">Errors</h3>`; | |
| html += `<ul>` + errors.map(msg => `<li class="err">${escapeHtml(msg)}</li>`).join("") + `</ul>`; | |
| } | |
| if (warnings.length) { | |
| html += `<h3 style="color:#b80;margin:8px 0 4px 0">Warnings</h3>`; | |
| html += `<ul>` + warnings.map(msg => `<li class="warn">${escapeHtml(msg)}</li>`).join("") + `</ul>`; | |
| } | |
| if (info.length) { | |
| html += `<h3 style="color:#0a7;margin:8px 0 4px 0">Info</h3>`; | |
| html += `<ul>` + info.map(msg => `<li class="ok">${escapeHtml(msg)}</li>`).join("") + `</ul>`; | |
| } | |
| return html; | |
| } | |
| function escapeHtml(s) { | |
| return String(s).replace(/[&<>"']/g, c => ({ | |
| "&":"&","<":"<",">":">",'"':""","'":"'" | |
| }[c])); | |
| } | |
| function checkExpirationWarning(expiresDate) { | |
| if (!expiresDate) return null; | |
| const now = new Date(); | |
| const expires = new Date(expiresDate); | |
| const diffMs = expires - now; | |
| const diffDays = diffMs / (1000 * 60 * 60 * 24); | |
| if (diffDays < 0) return 'expired'; | |
| if (diffDays <= 30) return 'critical'; // 1 month | |
| if (diffDays <= 90) return 'soon'; // 3 months | |
| return null; | |
| } | |
| function formatDateWithWarning(dateStr) { | |
| if (!dateStr) return 'null'; | |
| const warning = checkExpirationWarning(dateStr); | |
| const escaped = escapeHtml(dateStr); | |
| if (warning === 'critical') { | |
| return `<span class="expires-critical">${escaped}</span>`; | |
| } else if (warning === 'soon') { | |
| return `<span class="expires-soon">${escaped}</span>`; | |
| } else if (warning === 'expired') { | |
| return `<span class="err">${escaped} (EXPIRED)</span>`; | |
| } | |
| return escaped; | |
| } | |
| // --- DNS-over-HTTPS --- | |
| // Using Google's DoH JSON endpoint for simplicity: | |
| // https://dns.google/resolve?name=example.com&type=MX | |
| async function dohQuery(name, type) { | |
| const url = `https://dns.google/resolve?name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}`; | |
| const r = await fetch(url, { headers: { "accept": "application/dns-json" } }); | |
| if (!r.ok) throw new Error(`DoH query failed ${r.status} for ${name} ${type}`); | |
| return r.json(); | |
| } | |
| function extractDoHAnswers(resp) { | |
| // resp.Answer entries: { name, type, TTL, data } | |
| if (!resp || !resp.Answer) return []; | |
| return resp.Answer.map(a => a.data); | |
| } | |
| function findSpf(txts) { | |
| return (txts || []).filter(t => /^v=spf1\s/i.test(t.trim())); | |
| } | |
| function findDmarc(txts) { | |
| return (txts || []).filter(t => /^v=DMARC1\s*;/i.test(t.trim())); | |
| } | |
| function findDkim(txts) { | |
| return (txts || []).filter(t => /^v=DKIM1\s*;/i.test(t.trim())); | |
| } | |
| // --- SPF Recursive Lookup Counter --- | |
| async function countSpfLookups(spf, domain, visited = new Set(), depth = 0) { | |
| if (depth > 15 || visited.has(domain)) return 0; // Prevent infinite loops | |
| visited.add(domain); | |
| let count = 0; | |
| // Count direct lookup mechanisms | |
| const includes = spf.match(/include:([^\s]+)/gi) || []; | |
| const redirects = spf.match(/redirect=([^\s]+)/gi) || []; | |
| const aMatches = spf.match(/\ba\b|\ba:/gi) || []; | |
| const mxMatches = spf.match(/\bmx\b|\bmx:/gi) || []; | |
| const existsMatches = spf.match(/exists:([^\s]+)/gi) || []; | |
| count += aMatches.length; | |
| count += mxMatches.length; | |
| count += existsMatches.length; | |
| // Recursively fetch and count includes | |
| for (const inc of includes) { | |
| const incDomain = inc.replace(/include:/i, '').trim(); | |
| count++; // Count the include itself | |
| try { | |
| const txts = await dohQuery(incDomain, "TXT").then(r => extractDoHAnswers(r)); | |
| const incSpf = findSpf(txts)[0]; | |
| if (incSpf) { | |
| count += await countSpfLookups(incSpf, incDomain, visited, depth + 1); | |
| } | |
| } catch (e) { | |
| // If we can't fetch, still count the lookup attempt | |
| } | |
| } | |
| // Handle redirect (only if no 'all' mechanism) | |
| for (const redir of redirects) { | |
| const redirDomain = redir.replace(/redirect=/i, '').trim(); | |
| count++; // Count the redirect itself | |
| try { | |
| const txts = await dohQuery(redirDomain, "TXT").then(r => extractDoHAnswers(r)); | |
| const redirSpf = findSpf(txts)[0]; | |
| if (redirSpf) { | |
| count += await countSpfLookups(redirSpf, redirDomain, visited, depth + 1); | |
| } | |
| } catch (e) { | |
| // If we can't fetch, still count the lookup attempt | |
| } | |
| } | |
| return count; | |
| } | |
| // --- SPF Validation --- | |
| async function validateSpf(spfRecords, domain, issues) { | |
| if (!spfRecords || spfRecords.length === 0) { | |
| issues.push("❌ No SPF record found - emails may be rejected or marked as spam"); | |
| return; | |
| } | |
| if (spfRecords.length > 1) { | |
| issues.push(`❌ Multiple SPF records found (${spfRecords.length}) - only one SPF record is allowed per domain`); | |
| } | |
| const spf = spfRecords[0]; | |
| // Check for proper version tag | |
| if (!spf.match(/^v=spf1\s/i)) { | |
| issues.push("❌ SPF record must start with 'v=spf1 ' (version tag)"); | |
| } | |
| // Check for proper termination | |
| if (!spf.match(/[-~?]all$/i)) { | |
| issues.push("⚠️ SPF record doesn't end with a proper 'all' mechanism (-all, ~all, ?all)"); | |
| } | |
| // Check if it's too permissive | |
| if (spf.match(/\+all$/i)) { | |
| issues.push("❌ SPF record ends with '+all' - this allows ANY server to send email (severe security risk)"); | |
| } | |
| // Count DNS lookups recursively (max 10 allowed) | |
| const totalLookups = await countSpfLookups(spf, domain); | |
| if (totalLookups > 10) { | |
| issues.push(`❌ SPF has ${totalLookups} DNS lookups (max 10 allowed) - will cause PermError`); | |
| } else if (totalLookups > 8) { | |
| issues.push(`⚠️ SPF has ${totalLookups} DNS lookups (approaching limit of 10)`); | |
| } else if (totalLookups > 0) { | |
| issues.push(`OK:ℹ️ SPF has ${totalLookups} DNS lookups recursively (limit is 10)`); | |
| } | |
| // Check length (255 char limit for single string, 512 for combined) | |
| if (spf.length > 450) { | |
| issues.push(`⚠️ SPF record is ${spf.length} characters (approaching DNS TXT record limits)`); | |
| } | |
| // Check for common mistakes | |
| if (spf.includes('ip4:') && !spf.match(/ip4:\d{1,3}\.\d{1,3}\.\d{1,3}\./)) { | |
| issues.push("⚠️ SPF record has 'ip4:' but may be malformed"); | |
| } | |
| if (spf.includes('include:') && spf.match(/include:\s/)) { | |
| issues.push("⚠️ SPF 'include:' mechanism has a space after colon (should be no space)"); | |
| } | |
| // Warn about redirect without -all | |
| if (spf.includes('redirect=') && spf.match(/[-~?]all/)) { | |
| issues.push("⚠️ SPF has both 'redirect=' and 'all' mechanism - 'all' will be ignored"); | |
| } | |
| } | |
| // --- DKIM Validation --- | |
| function validateDkim(dkimRecords, domain, issues) { | |
| if (!dkimRecords || dkimRecords.length === 0) { | |
| issues.push("⚠️ No DKIM records found with common selectors - email authentication may fail"); | |
| return; | |
| } | |
| for (const dkimEntry of dkimRecords) { | |
| const rec = dkimEntry.record; | |
| const sel = dkimEntry.selector; | |
| // Check for proper version tag | |
| if (!rec.match(/v=DKIM1/i)) { | |
| issues.push(`❌ DKIM selector '${sel}' missing or invalid version tag (must have v=DKIM1)`); | |
| } | |
| // Check for public key | |
| if (!rec.includes('p=')) { | |
| issues.push(`❌ DKIM selector '${sel}' missing public key (p= tag)`); | |
| continue; | |
| } | |
| // Check if key is revoked (empty p= value) | |
| if (rec.match(/p=\s*;/) || rec.match(/p=\s*$/)) { | |
| issues.push(`❌ DKIM selector '${sel}' has revoked/empty public key`); | |
| continue; | |
| } | |
| // Extract and check key length | |
| const keyMatch = rec.match(/p=([A-Za-z0-9+/=]+)/); | |
| if (keyMatch && keyMatch[1]) { | |
| const keyData = keyMatch[1]; | |
| const keyLen = keyData.length; | |
| // RSA keys are typically 200+ chars for 1024-bit, 350+ for 2048-bit | |
| if (keyLen < 200) { | |
| issues.push(`⚠️ DKIM selector '${sel}' has a short public key (${keyLen} chars) - may be weak or test key`); | |
| } | |
| } | |
| // Check for key type | |
| if (rec.includes('k=')) { | |
| const keyType = rec.match(/k=([^;\s]+)/); | |
| if (keyType && keyType[1] !== 'rsa') { | |
| // Note: ed25519 is valid but less common | |
| issues.push(`ℹ️ DKIM selector '${sel}' uses key type: ${keyType[1]} (non-RSA)`); | |
| } | |
| } | |
| // Check testing flag | |
| if (rec.match(/t=.*y/)) { | |
| issues.push(`⚠️ DKIM selector '${sel}' is in testing mode (t=y) - verification failures will be ignored`); | |
| } | |
| // Check service type restrictions | |
| if (rec.includes('s=') && !rec.includes('s=email') && !rec.includes('s=*')) { | |
| const serviceMatch = rec.match(/s=([^;\s]+)/); | |
| if (serviceMatch) { | |
| issues.push(`ℹ️ DKIM selector '${sel}' has service type restriction: ${serviceMatch[1]}`); | |
| } | |
| } | |
| } | |
| } | |
| // --- DMARC Validation --- | |
| function validateDmarc(dmarcRecords, domain, issues) { | |
| if (!dmarcRecords || dmarcRecords.length === 0) { | |
| issues.push("⚠️ No DMARC record found - no policy for handling failed authentication"); | |
| return; | |
| } | |
| if (dmarcRecords.length > 1) { | |
| issues.push(`❌ Multiple DMARC records found (${dmarcRecords.length}) - only one is allowed`); | |
| } | |
| const dmarc = dmarcRecords[0]; | |
| // Check for proper version tag | |
| if (!dmarc.match(/^v=DMARC1\s*;/i)) { | |
| issues.push("❌ DMARC record must start with 'v=DMARC1;' (version tag)"); | |
| } | |
| // Check policy | |
| const policyMatch = dmarc.match(/p=([^;\s]+)/); | |
| if (!policyMatch) { | |
| issues.push("❌ DMARC record missing 'p=' (policy) tag"); | |
| return; | |
| } | |
| const policy = policyMatch[1]; | |
| if (policy === 'none') { | |
| issues.push("⚠️ DMARC policy is 'none' (monitoring only) - consider 'quarantine' or 'reject' for protection"); | |
| } else if (policy === 'quarantine') { | |
| issues.push("ℹ️ DMARC policy is 'quarantine' (good protection)"); | |
| } else if (policy === 'reject') { | |
| issues.push("ℹ️ DMARC policy is 'reject' (maximum protection)"); | |
| } | |
| // Check percentage | |
| const pctMatch = dmarc.match(/pct=(\d+)/); | |
| if (pctMatch && parseInt(pctMatch[1]) < 100) { | |
| issues.push(`ℹ️ DMARC policy applied to ${pctMatch[1]}% of messages (not 100%)`); | |
| } | |
| // Check for reporting | |
| if (!dmarc.includes('rua=')) { | |
| issues.push("ℹ️ DMARC record missing 'rua=' (aggregate reports) - no visibility into email authentication"); | |
| } | |
| // Check subdomain policy | |
| const spMatch = dmarc.match(/sp=([^;\s]+)/); | |
| if (!spMatch) { | |
| issues.push("ℹ️ DMARC record missing 'sp=' (subdomain policy) - subdomains inherit main policy"); | |
| } | |
| } | |
| // --- RDAP --- | |
| async function getRdap(domain) { | |
| // IANA bootstrap mapping TLD -> RDAP base URL | |
| const boot = await fetch("https://data.iana.org/rdap/dns.json", { headers: { "accept":"application/json" } }).then(r => r.json()); | |
| const tld = domain.split(".").pop(); | |
| let base = null; | |
| for (const svc of boot.services) { | |
| const tlds = svc[0], urls = svc[1]; | |
| if (tlds.includes(tld)) { base = urls[0]; break; } | |
| } | |
| if (!base) return null; | |
| base = base.replace(/\/$/, ""); | |
| const rdapUrl = `${base}/domain/${encodeURIComponent(domain)}`; | |
| try { | |
| const rdap = await fetch(rdapUrl, { headers: { "accept": "application/rdap+json, application/json" } }).then(r => r.ok ? r.json() : null); | |
| return { rdapUrl, rdap }; | |
| } catch { | |
| return { rdapUrl, rdap: null }; | |
| } | |
| } | |
| function rdapRegistrar(rdap) { | |
| if (!rdap || !rdap.entities) return null; | |
| for (const ent of rdap.entities) { | |
| if ((ent.roles || []).includes("registrar")) { | |
| let name = null; | |
| if (ent.vcardArray && ent.vcardArray.length >= 2) { | |
| for (const row of ent.vcardArray[1]) { | |
| if (row[0] === "fn") name = row[3]; | |
| } | |
| } | |
| return { name, handle: ent.handle || null }; | |
| } | |
| } | |
| return null; | |
| } | |
| function rdapEvent(rdap, actions) { | |
| if (!rdap || !rdap.events) return null; | |
| for (const e of rdap.events) { | |
| if (actions.includes(e.eventAction)) return e.eventDate || null; | |
| } | |
| return null; | |
| } | |
| async function run(domain, selectors) { | |
| const reportTime = new Date().toISOString(); | |
| const issues = []; | |
| // --- RDAP --- | |
| const { rdapUrl, rdap } = await getRdap(domain) || { rdapUrl:null, rdap:null }; | |
| const registrar = rdapRegistrar(rdap); | |
| const created = rdapEvent(rdap, ["registration","registered","created"]); | |
| const updated = rdapEvent(rdap, ["last changed","updated","modified"]); | |
| const expires = rdapEvent(rdap, ["expiration","expires","expiry"]); | |
| const renewal = rdapEvent(rdap, ["renewal","renewed"]); // often absent | |
| // --- DNS queries (DoH) --- | |
| const dns = {}; | |
| async function q(name, type) { | |
| try { | |
| const resp = await dohQuery(name, type); | |
| return uniq(extractDoHAnswers(resp)); | |
| } catch (e) { | |
| return []; | |
| } | |
| } | |
| dns.NS = await q(domain, "NS"); | |
| dns.SOA = await q(domain, "SOA"); | |
| dns.Apex = { | |
| A: await q(domain, "A"), | |
| AAAA: await q(domain, "AAAA"), | |
| CNAME: await q(domain, "CNAME") | |
| }; | |
| const www = `www.${domain}`; | |
| dns.WWW = { | |
| A: await q(www, "A"), | |
| AAAA: await q(www, "AAAA"), | |
| CNAME: await q(www, "CNAME") | |
| }; | |
| dns.MX = await q(domain, "MX"); | |
| dns.TXT = await q(domain, "TXT"); | |
| dns.SPF = findSpf(dns.TXT); | |
| const dmarcName = `_dmarc.${domain}`; | |
| dns.DMARC = findDmarc(await q(dmarcName, "TXT")); | |
| // DKIM by selector | |
| dns.DKIM = []; | |
| for (const sel of selectors) { | |
| const dkimName = `${sel}._domainkey.${domain}`; | |
| const txts = await q(dkimName, "TXT"); | |
| const rec = findDkim(txts)[0]; | |
| if (rec) dns.DKIM.push({ selector: sel, name: dkimName, record: rec }); | |
| } | |
| // Extras | |
| dns.CAA = await q(domain, "CAA"); | |
| dns.MTA_STS = await q(`_mta-sts.${domain}`, "TXT"); | |
| dns.TLS_RPT = await q(`_smtp._tls.${domain}`, "TXT"); | |
| dns.BIMI = await q(`default._bimi.${domain}`, "TXT"); | |
| // quick “issues” heuristics | |
| if (!dns.NS.length) issues.push("No NS records returned (DNS issue, DNSSEC failure, or blocked resolver)."); | |
| if (!dns.SOA.length) issues.push("No SOA record returned (DNS issue)."); | |
| // Validate SPF, DKIM, DMARC with detailed checks | |
| await validateSpf(dns.SPF, domain, issues); | |
| validateDkim(dns.DKIM, domain, issues); | |
| validateDmarc(dns.DMARC, domain, issues); | |
| // Check expiration | |
| const expWarning = checkExpirationWarning(expires); | |
| if (expWarning === 'expired') issues.push(`⚠️ Domain EXPIRED on ${expires}`); | |
| else if (expWarning === 'critical') issues.push(`⚠️ Domain expires VERY SOON (within 30 days): ${expires}`); | |
| else if (expWarning === 'soon') issues.push(`⚠️ Domain expires within 90 days: ${expires}`); | |
| const out = { | |
| domain, | |
| reportTime, | |
| rdap: { | |
| rdapUrl, | |
| registrar, | |
| created, | |
| updated, | |
| renewal, | |
| expires, | |
| note: "RDAP fields vary by registry/TLD; renewal date is often not published.", | |
| raw: rdap | |
| }, | |
| dns, | |
| issues | |
| }; | |
| return out; | |
| } | |
| function pretty(obj) { | |
| return JSON.stringify(obj, null, 2); | |
| } | |
| async function onRun() { | |
| const inputDomains = parseList($("domain").value).map(normalizeDomain).filter(d => d && d.indexOf('.') > 0); | |
| const selectors = parseSelectors($("selectors").value); | |
| if (!inputDomains.length) { | |
| $("status").innerHTML = `<span class="err">Enter at least one valid domain (e.g., example.com).</span>`; | |
| return; | |
| } | |
| $("status").textContent = "Running…"; | |
| $("issues").innerHTML = ""; | |
| $("rdap").innerHTML = ""; | |
| $("dns").textContent = ""; | |
| $("raw").textContent = ""; | |
| try { | |
| const results = []; | |
| for (const d of inputDomains) { | |
| const out = await run(d, selectors); | |
| results.push(out); | |
| } | |
| window.__LAST__ = results; | |
| // Render Issues per domain | |
| const issuesHtml = ` | |
| <div class="domains-grid"> | |
| ${results.map(r => { | |
| const hasErr = r.issues.some(i => i.startsWith('ERR:') || i.startsWith('❌')); | |
| const hasWarn = r.issues.some(i => i.startsWith('⚠️')); | |
| const badgeClass = hasErr ? 'badge-err' : (hasWarn ? 'badge-warn' : 'badge-ok'); | |
| const badgeText = hasErr ? 'Issues Found' : (hasWarn ? 'Warnings' : 'All Good'); | |
| return ` | |
| <div class="domain-card"> | |
| <div class="domain-title"> | |
| <div class="domain-name">${escapeHtml(r.domain)}</div> | |
| <span class="badge ${badgeClass}">${badgeText}</span> | |
| </div> | |
| <div class="issues-section">${renderIssues(r.issues)}</div> | |
| </div> | |
| `; | |
| }).join('')} | |
| </div>`; | |
| $("issues").innerHTML = issuesHtml; | |
| // Render RDAP per domain with expiration highlighting | |
| const rdapSections = results.map(r => { | |
| const rd = r.rdap; | |
| let rdapHtml = '{'; | |
| rdapHtml += `\n "domain": ${escapeHtml(JSON.stringify(r.domain))},`; | |
| rdapHtml += `\n "rdapUrl": ${escapeHtml(JSON.stringify(rd.rdapUrl))},`; | |
| rdapHtml += `\n "registrar": ${escapeHtml(JSON.stringify(rd.registrar))},`; | |
| rdapHtml += `\n "created": ${escapeHtml(JSON.stringify(rd.created))},`; | |
| rdapHtml += `\n "updated": ${escapeHtml(JSON.stringify(rd.updated))},`; | |
| rdapHtml += `\n "renewal": ${escapeHtml(JSON.stringify(rd.renewal))},`; | |
| rdapHtml += `\n "expires": ${formatDateWithWarning(rd.expires)},`; | |
| rdapHtml += `\n "note": ${escapeHtml(JSON.stringify(rd.note))}`; | |
| rdapHtml += '\n}'; | |
| return `<div style="margin:8px 0">${rdapHtml}</div>`; | |
| }).join('\n'); | |
| $("rdap").innerHTML = rdapSections; | |
| // Render DNS and Raw JSON (arrays of results) | |
| $("dns").textContent = pretty(results.map(r => ({ domain: r.domain, dns: r.dns }))); | |
| $("raw").textContent = pretty(results); | |
| $("status").innerHTML = `<span class="ok">Done.</span>`; | |
| } catch (e) { | |
| $("status").innerHTML = `<span class="err">Error: ${escapeHtml(e.message || String(e))}</span>`; | |
| } | |
| } | |
| async function onCopy() { | |
| const data = window.__LAST__ ? JSON.stringify(window.__LAST__, null, 2) : ""; | |
| if (!data) { alert("Run it first."); return; } | |
| await navigator.clipboard.writeText(data); | |
| $("status").innerHTML = `<span class="ok">Copied JSON to clipboard.</span>`; | |
| } | |
| $("run").addEventListener("click", onRun); | |
| $("copy").addEventListener("click", onCopy); | |
| // convenience: run when pressing Enter in domain box | |
| $("domain").addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) onRun(); | |
| }); | |
| // live preview of parsed domains | |
| function updateDomainsPreview() { | |
| const list = parseList($("domain").value).map(normalizeDomain).filter(Boolean); | |
| $("domainsPreview").innerHTML = `Parsed domains (${list.length}): ` + list.map(escapeHtml).join(", "); | |
| } | |
| $("domain").addEventListener("input", updateDomainsPreview); | |
| updateDomainsPreview(); | |
| // live preview of parsed selectors | |
| function updateSelectorsPreview() { | |
| const list = parseSelectors($("selectors").value); | |
| $("selectorsPreview").innerHTML = `Parsed selectors (${list.length}): ` + list.map(escapeHtml).join(", "); | |
| } | |
| $("selectors").addEventListener("input", updateSelectorsPreview); | |
| updateSelectorsPreview(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment