Dev.to Security 🔐 Cybersecurity 👁 0 📖 3 min read

Auditing SPF and DMARC for a whole list of domains: p=none, ~all and the other ways a "pass" misleads you

Checking one domain's email authentication takes a minute in a terminal. Checking 300 customer, supplier or portfolio domains is where people script it, and where most scripts quietly report the wrong thing. Below are th

Checking one domain's email authentication takes a minute in a terminal. Checking 300 customer, supplier or portfolio domains is where people script it, and where most scripts quietly report the wrong thing. Below are the checks that matter, with Node code using only the built-in dns module. Disclosure: SpellzCode, the brand behind this account, sells a tool that runs all of this in bulk (linked at the end), but the code below works on its own.

1. Read the records, not just "does a record exist"

SPF lives in a TXT record on the domain itself; DMARC lives in a TXT record on _dmarc.<domain>:

import { promises as dns } from 'node:dns';

async function txt(name) {
  try { return (await dns.resolveTxt(name)).map((parts) => parts.join('')); }
  catch (e) { if (['ENODATA', 'ENOTFOUND'].includes(e.code)) return []; throw e; }
}

async function emailAuth(domain) {
  const spf = (await txt(domain)).filter((r) => /^v=spf1\b/i.test(r));
  const dmarc = (await txt(`_dmarc.${domain}`)).filter((r) => /^v=DMARC1\b/i.test(r));
  return { spf, dmarc };
}

Note the join(''): long TXT records come back split into 255-character chunks, and a script that only reads the first chunk cuts SPF records in half.

2. Two SPF records is the same as none

If a domain publishes two v=spf1 records, receivers treat SPF as a permanent error. It happens a lot after a mail-provider switch. Flag spf.length > 1 as broken, not as "has SPF".

3. The policy is the last word, and it is the one that matters

function spfPolicy(record) {
  const all = record.match(/[-~?+]?all\b/i)?.[0] ?? null;
  return { '-all': 'fail', '~all': 'softfail', '?all': 'neutral', '+all': 'pass-everyone', all: 'pass-everyone' }[all?.toLowerCase()] ?? 'no-all';
}
  • -all (fail): mail from anywhere not listed should be rejected.
  • ~all (softfail): "probably not us", usually delivered to spam or accepted. Very common, and fine when DMARC enforces.
  • +all: anyone may send as this domain. Treat it as no SPF at all.

4. DMARC p=none means "watching", not "protected"

function dmarcPolicy(record) {
  const tags = Object.fromEntries(record.split(';').map((t) => t.trim().split('=')).filter((t) => t.length === 2).map(([k, v]) => [k.toLowerCase(), v.trim().toLowerCase()]));
  return { policy: tags.p ?? null, pct: Number(tags.pct ?? 100), reports: tags.rua ?? null };
}

p=none only asks for reports; spoofed mail still gets through. p=quarantine and p=reject are enforcement, but check pct too: p=reject; pct=10 enforces on one message in ten. In a list audit, sort domains into four buckets: enforced, monitoring only, SPF only, nothing.

5. A domain that sends no mail should say so

A parked domain with no SPF and no DMARC is easy to spoof. The fix is a record set that says "nothing sends from here": v=spf1 -all plus p=reject. Real example: example.com publishes exactly that, which is why it shows SPF "fail" and DMARC "reject" while having no mail servers at all. That is the correct, safe setup, not an error.

6. Who runs the mail is in the MX records

const mx = await dns.resolveMx(domain).catch(() => []);
const provider = mx.some((m) => /google\.com$|googlemail\.com$/i.test(m.exchange)) ? 'Google Workspace'
  : mx.some((m) => /outlook\.com$/i.test(m.exchange)) ? 'Microsoft 365' : mx[0]?.exchange ?? 'none';

Useful on its own for sales lists (who uses which provider) and for spotting a mismatch: Microsoft 365 MX records but an SPF record that only includes Google is a deliverability problem waiting to happen.

What a bulk run looks like

Three domains through the hosted version of this:

Domain Mail provider SPF DMARC Verdict
github.com Microsoft 365 / Outlook softfail quarantine spf+dmarc-enforced
stripe.com Google Workspace / Gmail softfail reject spf+dmarc-enforced
example.com none fail reject spf+dmarc-enforced (sends no mail)

It also returns registrar, domain age, expiry date and DNS host per domain, because the same list usually needs those too (newly registered lookalike domains are a classic fraud signal).

Running it without writing the script

WHOIS & DNS Lookup on Apify takes a list of domains and returns one row each with everything above. $2 per 1,000 domains; Apify's free monthly credit covers about 2,500.

// npm i apify-client
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('spellzcode/domain-whois-dns-lookup').call({ domains: ['github.com', 'stripe.com'] });
const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const d of items) console.log(d.domain, d.mailProvider, d.spfPolicy, d.dmarcPolicy, d.daysUntilExpiry);

Links

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.