diff --git a/.changeset/wide-moons-shave.md b/.changeset/wide-moons-shave.md new file mode 100644 index 0000000..45dbad8 --- /dev/null +++ b/.changeset/wide-moons-shave.md @@ -0,0 +1,57 @@ +--- +"mailgrade": major +--- + +Rework the public interface around the calls people actually make, put a +letter on the grade, and ship the grade as a CLI. + +**`npx mailgrade acme.com`.** The package now has a bin: the grade in a +terminal (letter, verdict, per-mechanism headlines, and the fixes) with +`--json` for scripts and `--strict` for CI, where a non-zero exit catches the +day someone "temporarily" weakens the DMARC record. Zero dependencies, like +everything else here. + +**A letter on the grade.** `DomainGrade` now carries `letter`, `"A+"` down to +`"F"`. The verdict sets the band (enforcing, partial, nothing) and the details +move within it; `+all` is an automatic `F`. The rubric lives in the spec +corpus (`spec/grade.json`), so ports share it. + +**One import.** `gradeDomain`, `verifyMessage` and `analyzeHeaders` are now on +`mailgrade` itself, so the common cases need no second import and no guessing +at a subpath. Every existing entry point still resolves; `mailgrade/doh` and +`mailgrade/verify` are now for narrowing a bundle, not for finding the API. + +**No setup.** `resolver` is optional everywhere it was required, defaulting to +DNS over HTTPS. `verifyMessage(raw, { ip, sender })` works on its own, the way +`checkDomain` already did. A Node service that wants the system resolver still +passes `nodeResolver()` from `mailgrade/node-dns`. + +**Headers you can use.** `verifyMessage` takes `mta` and returns `authResults` +and `headers`: a folded, CRLF-terminated `Received-SPF` and +`Authentication-Results` block, ready to prepend to the message. The values +embedded there arrive off the wire, so they are sanitised on the way in: a +CRLF in a hostile MAIL FROM becomes a space, never a forged header field. The +folder is quote-aware, so a legal `"a;b"@example.com` survives intact. + +`DmarcAnalysis` also gains `reporting`: whether the record carries a `rua=` +tag, read with the same tag parsing as everything else. + +**Names that match the package.** The headline call is `gradeDomain`, not +`checkDomain`: this library grades, and the README section has always been +called "Grade a domain". The pure function that used to hold that name is now +`gradeRecords`, which is what it always took; `gradeDomain(records)` named a +domain and accepted `DomainRecords`. The trio now reads in order: +`resolveDomain` -> `gradeRecords`, and `gradeDomain` for both at once. + +Breaking: the JSON conformance corpus is no longer published in the tarball, +and the `mailgrade/spec/*` subpath is gone with it. The corpus stays in the +repository, which is where a port reads it from. + +Breaking: the root barrel is now a curated list rather than `export *`, so +roughly 25 internals no longer appear on `mailgrade`. Each is still exported +from the entry point for its own area, so `blankCheck`, `editDistance`, +`parseHeaders` and `stripComments` move to `mailgrade/headers`, `txtChunks` +and `recordTags` to `mailgrade/dmarc`, and so on. `toAuthResults` keeps its +signature and now accepts any object with the five fields it reads. The +options type for `gradeDomain` and `resolveDomain` is now named +`GradeDomainOptions` (was `DohOptions`). diff --git a/CHANGELOG.md b/CHANGELOG.md index f78166a..0a0cbcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - [`e576838`](https://github.com/FallaxIO/mailgrade/commit/e576838dc1c305406f755bb4b8cb8aa3d58e2b02) Thanks [@IgnaceMaes](https://github.com/IgnaceMaes)! - First stable release. - No API changes from 0.2.0 — this marks the surface as settled and brings it + No API changes from 0.2.0; this marks the surface as settled and brings it under semver: the grading, verification, DMARC record and header-analysis entry points will not break without a major bump. @@ -21,17 +21,17 @@ let a caller supply the real Public Suffix List. The built-in suffix rules were a fixed list of 42 two-label pairs. A pair not - on it — `gov.uk`, `ac.jp`, `org.cn`, `com.ec` — collapsed every host under it + on it (`gov.uk`, `ac.jp`, `org.cn`, `com.ec`) collapsed every host under it to the pair itself, so `dept.gov.uk` and `attacker.gov.uk` shared an organizational domain and relaxed alignment reported them as aligned. In `mailgrade/verify` that is a DMARC pass for a signature the From domain's owner never made. - The suffix rules now treat the registry's own labels (`co`, `com`, `gov`, - `ac`, `org`, `ne`, `sch`, …) as part of the suffix under any country code, - rather than only for enumerated pairs. The remaining approximation errs - toward reading a host as more specific than it is, which under-reports - alignment instead of over-reporting it. + `ac`, `org`, `ne`, `sch`, and so on) as part of the suffix under any + country code, rather than only for enumerated pairs. The remaining + approximation errs toward reading a host as more specific than it is, + which under-reports alignment instead of over-reporting it. - `registrableDomain`, `domainLabel` and `aligns` take an optional `{ publicSuffixes }`, and `verifyDmarc` and `verifyMessage` accept and forward it. Supplying a list replaces the built-in rules entirely: the diff --git a/README.md b/README.md index f539cc6..9834e49 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

- mailgrade: grade a domain's SPF, DMARC and DKIM. Zero dependencies, no network in the core, runs anywhere. + mailgrade: grade a domain's SPF, DMARC and DKIM. Zero dependencies, one call, runs anywhere.

@@ -12,51 +12,59 @@

- Grade and verify SPF, DKIM and DMARC.
+ Grade and verify SPF, DKIM and DMARC. One call, no setup.
Zero dependencies. Runs wherever fetch and WebCrypto do: Node, Bun, Deno, Cloudflare Workers, browsers.

-

- Grade a domain  ·  - Verify a message  ·  - Triage headers  ·  - DMARC records  ·  - Compared -

- ```sh -npm i mailgrade +npm i mailgrade # the library +npx mailgrade acme.com # the grade, right now +``` + +## In the terminal + +``` +$ npx mailgrade acme.com + + acme.com + + D spoofable + mail claiming to be this domain can reach inboxes + + ✓ SPF SPF ends in -all + ✓ DKIM DKIM keys published (google) + ✗ DMARC DMARC is monitoring only (p=none) + · MX Google Workspace + + → Graduate DMARC from p=none. Monitoring mode was designed as a + transition, not a destination: once the reports show your real senders + passing, move to p=quarantine, then p=reject. ``` +`--json` prints the whole grade for scripts, and `--strict` exits non-zero +unless the verdict is `protected`, so one line of CI catches the day someone +"temporarily" weakens the DMARC record. + ## Grade a domain Is this domain spoofable, and what is the fix? ```ts -import { checkDomain } from "mailgrade/doh"; +import { gradeDomain } from "mailgrade"; -const grade = await checkDomain("acme.com"); +const grade = await gradeDomain("acme.com"); +grade.letter; // "D", from "A+" down to "F" grade.verdict; // "spoofable" | "partial" | "protected" grade.dmarc.headline; // "DMARC is monitoring only (p=none)" grade.recommendations[0]; // { id: "graduate-dmarc", text: "Graduate DMARC from p=none. ..." } ``` -DNS goes over DNS-over-HTTPS by default, against Cloudflare. Swap it: - -| To resolve with | Pass | -| --- | --- | -| Another DoH provider | `{ endpoint }` | -| The system resolver | `{ resolver: nodeResolver() }` from `mailgrade/node-dns` | -| Anything else | `{ resolver }`, any `(name, type) => Promise` | -| Nothing at all | `gradeDomain({ domain, txt, dmarc, ... })`, records you already hold | - -> [!NOTE] -> One `checkDomain` is about 21 lookups: three records, plus a probe per DKIM -> selector. Public DoH endpoints publish no rate limit and throttle bulk -> traffic from one IP at their discretion, so for volume trim `selectors`, -> lower `concurrency`, or resolve through `mailgrade/node-dns`. An HTTP 429 -> arrives as `DnsError`; backoff is yours to add. +The letter is the verdict as a report card. DMARC policy sets the band +(enforcing, partial, or nothing) and the details move within it, while an SPF +`+all` is an automatic `F`, because it authorises the spoof by name. +`gradeDomain` is `resolveDomain` and `gradeRecords` composed, and both are +exported: resolve once and grade later, or grade records you already hold. ## Verify a message @@ -64,47 +72,71 @@ For when you are the receiver: a Workers email handler, an inbound webhook, an SMTP server. ```ts -import { verifyMessage, toAuthResults } from "mailgrade/verify"; -import { dohResolver } from "mailgrade/doh"; +import { verifyMessage } from "mailgrade"; const result = await verifyMessage(rawMessage, { - resolver: dohResolver(), ip: "203.0.113.50", // who connected sender: "bounce@example.com", // MAIL FROM + helo: "mail.example.com", // HELO/EHLO + mta: "mx.acme.com", // you, the authserv-id in the headers below }); result.dmarc?.result; // "pass" | "fail" | "none" -result.dmarc?.disposition; // "none" | "quarantine" | "reject" +result.dmarc?.disposition; // what to do with it, once the policy is applied: + // "none" | "quarantine" | "reject" +``` + +`result.headers` is the block to prepend before handing the message on, +folded and CRLF-terminated: -toAuthResults(result, "mx.acme.com"); -// "mx.acme.com; spf=pass smtp.mailfrom=bounce@example.com; dkim=pass header.d=..." +``` +Received-SPF: pass (mx.acme.com: domain of bounce@example.com designates + 203.0.113.50 as permitted sender) client-ip=203.0.113.50; + envelope-from="bounce@example.com"; helo=mail.example.com +Authentication-Results: mx.acme.com; + spf=pass smtp.mailfrom=bounce@example.com; + dkim=pass header.d=example.com header.s=mail; + dmarc=pass header.from=example.com ``` SPF is evaluated against the connecting IP (RFC 7208, macros and lookup limits included), DKIM signatures are verified with WebCrypto (rsa-sha256 and ed25519-sha256; rsa-sha1 and sub-1024-bit keys refused per RFC 8301), and DMARC -alignment ties them to the From domain. `verifySpf`, `verifyDkim` and -`verifyDmarc` are also standalone calls. A DNS failure is always a `temperror`, -never a `fail`. In tests, `staticResolver({ "example.com": { TXT: [...] } })` -evaluates against a plain object. +alignment ties them to the From domain. A DNS failure is always a `temperror`, +never a `fail`. `verifySpf`, `verifyDkim` and `verifyDmarc` are also standalone +calls. > [!IMPORTANT] -> Where a verdict decides whether mail is delivered, pass `{ publicSuffixes }` -> too. The built-in list is an approximation, and it under-reports alignment -> rather than over-reporting it. [Why](#alignment-and-the-public-suffix-list). +> Alignment needs a public suffix list, and the built-in rules are an +> approximation that under-reports alignment rather than over-reporting it. +> Where a verdict decides whether mail is delivered, pass `{ publicSuffixes }`, +> the ICANN section of the real list, to `verifyMessage`, `verifyDmarc`, +> `registrableDomain` or `aligns`. -## Triage pasted headers +## Triage a header block -For "is this email real": no cryptography, no I/O. A pasted header block -carries private data and never leaves the process. +For "is this email real": the headers off a reported message, no cryptography, +no I/O, nothing leaving the process. ```ts -import { analyzeHeaders } from "mailgrade/headers"; - -const result = analyzeHeaders(paste); +import { analyzeHeaders } from "mailgrade"; + +const headers = `Received: from vps.hosting.example (185.22.11.9) by mx.acme.example; + Mon, 2 Jun 2025 03:41:02 +0000 +Authentication-Results: mx.acme.example; + spf=pass smtp.mailfrom=billing@acme-invoices.example; + dkim=none; + dmarc=fail header.from=acme.example +Return-Path: +From: Acme Finance +Reply-To: finance.acme@mail-secure.example +Subject: Overdue invoice +X-PHP-Originating-Script: 0:sendmail.php`; + +const result = analyzeHeaders(headers); result.verdict; // "suspicious" | "inconclusive" | "authentic" -result.spf.aligned; // false, the pass was for somebody else's domain +result.spf.aligned; // false, the SPF pass was for acme-invoices.example result.flags.map((f) => f.id); // ["dmarc-fail", "reply-to-mismatch", "return-path-mismatch", "php-origin"] ``` @@ -116,7 +148,7 @@ redirected replies. ## DMARC records ```ts -import { buildDmarcRecord, reviewDmarc, rolloutPlan } from "mailgrade/dmarc"; +import { buildDmarcRecord, reviewDmarc, rolloutPlan } from "mailgrade"; const options = { domain: "acme.com", policy: "reject", subdomainPolicy: "none", @@ -129,83 +161,44 @@ reviewDmarc(options)[0]; // { id: "sp-weaker", severity: "high", ... } rolloutPlan(options); // four staged records, monitoring to full reject ``` -## Compared - -| | `mailgrade` | [`mailauth`](https://github.com/postalsys/mailauth) | [`spf-check`](https://www.npmjs.com/package/spf-check) | [`dkim`](https://www.npmjs.com/package/dkim) | -| --- | :-: | :-: | :-: | :-: | -| Grade a domain, with fixes | ✓ | ✗ | ✗ | ✗ | -| SPF, DKIM and DMARC verification | ✓ | ✓ | SPF only | DKIM only | -| Analyze pasted headers | ✓ | ✗ | ✗ | ✗ | -| DMARC record tooling | ✓ | ✗ | ✗ | ✗ | -| ARC and BIMI | ✗ | ✓ | ✗ | ✗ | -| DNS transport | DoH or `node:dns` | `node:dns` | `node:dns` | `node:dns` | -| Workers / browsers / Deno | ✓ | ✗ | ✗ | ✗ | -| Runtime dependencies | **0** | 10 | 4 | 2 | -| Install size | **0.4 MB** | 15 MB | 7 MB | 1 MB | - -
-Full matrix, and when to use something else - -
- -| | `mailgrade` | `mailauth` | `spf-check` | `dkim` | -| --- | :-: | :-: | :-: | :-: | -| SPF evaluation (RFC 7208) | ✓ | ✓ | ✓ | ✗ | -| DKIM verification | ✓ | ✓ | ✗ | ✓ | -| DMARC discovery and alignment | ✓ | ✓ | ✗ | ✗ | -| Authentication-Results output | ✓ | ✓ | ✗ | ✗ | -| SPF lookup limits enforced | 10 + 2 void | 10 + 2 void | 10 | n/a | -| Bring-your-own resolver | ✓ | ✓ | ✗ | ✗ | -| Written in TypeScript | ✓ | ✗ | ✗ | ✗ | -| Types | generated | hand-written | ✗ | hand-written | -| Published format | ESM + CJS | CJS | CJS | CJS | -| Last published | current | current | 2019 | 2022 | - -Sizes are `npm install` into an empty project, measured 2026-08. - -**Use [`mailauth`](https://github.com/postalsys/mailauth)** for a high-volume -Node MTA, or when you need ARC or BIMI. No ARC has a consequence worth stating -plainly: mail that arrived through a forwarder or a mailing list has usually -had its SPF broken and its body rewritten, so DMARC fails here exactly as it -does at any receiver that ignores ARC. Honest for a grader, a real gap for an -inbox. - -**Use [`parsedmarc`](https://github.com/domainaware/parsedmarc)** to parse -DMARC aggregate reports. That is not this library's job. - -
+## Where DNS comes from -## Entry points +Every call that needs DNS takes an optional `resolver`, and defaults to +DNS-over-HTTPS against Cloudflare so that nothing needs configuring. Swap it: -| Import | What is in it | +| To resolve with | Pass | | --- | --- | -| `mailgrade` | everything below except `doh` and `node-dns` | -| `mailgrade/verify` | `verifyMessage`, `verifySpf`, `verifyDkim`, `verifyDmarc`, `toAuthResults`, `staticResolver` | -| `mailgrade/headers` | `analyzeHeaders` and its parsers | -| `mailgrade/dmarc` | build, parse, review and roll out a record | -| `mailgrade/spf` | `analyzeSpf`, `isSpfRecord` | -| `mailgrade/dkim` | `analyzeDkim`, `isDkimKey`, `dkimHost`, `DKIM_SELECTORS` | -| `mailgrade/domain` | `registrableDomain`, `aligns`, `coerceDomain` | -| `mailgrade/doh` | `checkDomain`, `resolveDomain`, `dohResolver`. The only code that opens a socket | -| `mailgrade/node-dns` | `nodeResolver`. The only code that imports a Node built-in | - -## Notes that matter +| The system resolver | `{ resolver: nodeResolver() }` from `mailgrade/node-dns` | +| Another DoH provider | `{ resolver: dohResolver({ endpoint }) }` | +| A plain object, in tests | `{ resolver: staticResolver({ "example.com": { TXT: [...] } }) }` | +| Anything else | `{ resolver }`, any `(name, type) => Promise` | +| Nothing at all | `gradeRecords({ domain, txt, dmarc, ... })`, records you already hold | + +A long-running Node service usually wants `nodeResolver()`: it keeps lookups +inside the network you already trust. + +## Entry points + +`mailgrade` carries the whole documented API, so a single import is the normal +way in. The subpaths (`/verify`, `/headers`, `/dmarc`, `/spf`, `/dkim`, +`/domain`, `/doh`) narrow what a bundle pulls in and reach the helpers each +area is built from. Only `mailgrade/node-dns` has to stay separate: it is the +one file that touches `node:dns`, so nothing else can drag a Node built-in +into a Worker or a browser bundle. + +## Notes - **Ids are the contract.** Every finding carries a stable `id` next to its English. Branch on ids; swap or translate the prose freely. - **The rules are portable.** They live as a language-neutral JSON corpus in - [`spec/`](./spec). A port passes the same files; the TypeScript adapter is - about 150 lines. - -

Alignment and the public suffix list

- -Alignment needs a public suffix list, and the built-in rules are an -approximation: known two-label suffixes, plus the registry's own labels (`co`, -`gov`, `ac`, `org`) under any country code. They err toward reading a host as -*more* specific than it is, so alignment is under-reported rather than -over-reported. Pass `{ publicSuffixes }`, the ICANN section of the real list, -to `verifyMessage`, `verifyDmarc`, `registrableDomain` or `aligns` wherever a -wrong answer costs something. + [`spec/`](https://github.com/FallaxIO/mailgrade/tree/main/spec) in the + repository. A port passes the same files; the TypeScript adapter is about + 150 lines. +- **No ARC.** Mail that arrived through a forwarder or a mailing list has + usually had its SPF broken and its body rewritten, so DMARC fails here + exactly as it does at any receiver that ignores ARC. For ARC or BIMI, or for + a high-volume Node MTA, use + [`mailauth`](https://github.com/postalsys/mailauth). ## About diff --git a/package.json b/package.json index 29545a6..d6223a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mailgrade", "version": "1.0.0", - "description": "Grade a domain's SPF, DMARC and DKIM. Zero dependencies, no network, runs anywhere.", + "description": "Grade and verify SPF, DMARC and DKIM. One call or `npx mailgrade `, no setup, zero dependencies, runs anywhere.", "keywords": [ "dmarc", "spf", @@ -12,11 +12,15 @@ "phishing", "dns", "deliverability", - "email-headers" + "email-headers", + "cli" ], "license": "MIT", - "author": "Fallax", - "homepage": "https://github.com/FallaxIO/mailgrade#readme", + "author": { + "name": "Fallax", + "url": "https://fallax.io" + }, + "homepage": "https://fallax.io/mailgrade", "repository": { "type": "git", "url": "git+https://github.com/FallaxIO/mailgrade.git" @@ -28,9 +32,11 @@ "node": ">=20" }, "files": [ - "dist", - "spec" + "dist" ], + "bin": { + "mailgrade": "./dist/cli.js" + }, "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -125,7 +131,6 @@ "default": "./dist/node-dns.cjs" } }, - "./spec/*": "./spec/*", "./package.json": "./package.json" }, "scripts": { diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index fb35f19..9f6ab0d 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -1,7 +1,7 @@ // Loads the built package the way a consumer would, on whichever runtime runs // this file. The zero-dependency claim is only worth something if the output // actually imports outside Node. -import { gradeDomain } from "../dist/index.js"; +import { gradeRecords } from "../dist/index.js"; import { analyzeHeaders } from "../dist/headers/index.js"; import { buildDmarcRecord, DEFAULT_OPTIONS } from "../dist/dmarc/index.js"; import { resolveDomain } from "../dist/doh.js"; @@ -13,8 +13,8 @@ function check(label, actual, expected) { } check( - "gradeDomain", - gradeDomain({ + "gradeRecords", + gradeRecords({ domain: "acme.com", txt: ["v=spf1 -all"], dmarc: ["v=DMARC1; p=reject; rua=mailto:d@acme.com"], diff --git a/spec/README.md b/spec/README.md index 420ee1e..a495514 100644 --- a/spec/README.md +++ b/spec/README.md @@ -53,7 +53,7 @@ finished when all of them pass. | `dkim.json` | DKIM key detection and grading | | `dmarc-lookup.json` | grading a published DMARC record | | `dmarc-record.json` | building, parsing, reviewing and rolling out a record | -| `grade.json` | the whole-domain verdict and its recommendations | +| `grade.json` | the whole-domain verdict, its letter grade and its recommendations | | `spf-eval.json` | RFC 7208 evaluation: mechanisms, macros, limits, exp= | | `headers.json` | header parsing, authentication results, impersonation | diff --git a/spec/grade.json b/spec/grade.json index a47d362..358e249 100644 --- a/spec/grade.json +++ b/spec/grade.json @@ -1,7 +1,7 @@ { "cases": [ { - "target": "gradeDomain", + "target": "gradeRecords", "name": "calls a fully locked-down domain protected", "input": { "domain": "acme.com", @@ -20,6 +20,7 @@ }, "expect": { "verdict": "protected", + "letter": "A+", "spfId": "spf-hardfail", "dmarcId": "dmarc-enforcing", "dkimId": "dkim-published", @@ -30,7 +31,7 @@ } }, { - "target": "gradeDomain", + "target": "gradeRecords", "name": "calls a domain with nothing published spoofable, and tells it to lock the parking", "input": { "domain": "parked.example", @@ -41,6 +42,7 @@ }, "expect": { "verdict": "spoofable", + "letter": "F", "spfId": "spf-missing", "dmarcId": "dmarc-missing", "dkimId": "dkim-absent", @@ -52,7 +54,7 @@ } }, { - "target": "gradeDomain", + "target": "gradeRecords", "name": "says a parked domain that is already locked has nothing left to do", "input": { "domain": "parked.example", @@ -67,13 +69,14 @@ }, "expect": { "verdict": "protected", + "letter": "A+", "recommendationIds": [ "nothing-to-add" ] } }, { - "target": "gradeDomain", + "target": "gradeRecords", "name": "lets +all defeat an enforcing DMARC policy", "input": { "domain": "acme.com", @@ -90,6 +93,7 @@ }, "expect": { "verdict": "spoofable", + "letter": "F", "spfId": "spf-allow-all", "dmarcId": "dmarc-enforcing", "provider": "Microsoft 365", @@ -100,7 +104,7 @@ } }, { - "target": "gradeDomain", + "target": "gradeRecords", "name": "calls monitoring-only spoofable however good the SPF is", "input": { "domain": "acme.com", @@ -119,6 +123,7 @@ }, "expect": { "verdict": "spoofable", + "letter": "D", "dmarcId": "dmarc-monitor", "recommendationIds": [ "graduate-dmarc" @@ -126,7 +131,7 @@ } }, { - "target": "gradeDomain", + "target": "gradeRecords", "name": "calls quarantine partial", "input": { "domain": "acme.com", @@ -145,6 +150,7 @@ }, "expect": { "verdict": "partial", + "letter": "C", "dmarcId": "dmarc-quarantine", "recommendationIds": [ "tighten-dmarc" @@ -152,7 +158,7 @@ } }, { - "target": "gradeDomain", + "target": "gradeRecords", "name": "tells a sending domain with no DMARC to publish one", "input": { "domain": "acme.com", @@ -167,6 +173,7 @@ }, "expect": { "verdict": "spoofable", + "letter": "D", "spfId": "spf-softfail", "recommendationIds": [ "enable-dkim", @@ -175,7 +182,7 @@ } }, { - "target": "gradeDomain", + "target": "gradeRecords", "name": "does not tell a domain with no record to fix its record", "input": { "domain": "acme.com", @@ -192,10 +199,86 @@ }, "expect": { "verdict": "protected", + "letter": "B", "recommendationIds": [ "publish-spf" ] } + }, + { + "target": "gradeRecords", + "name": "gives softfail SPF behind full enforcement an A, not an A+", + "input": { + "domain": "acme.com", + "txt": [ + "v=spf1 include:_spf.google.com ~all" + ], + "dmarc": [ + "v=DMARC1; p=reject; rua=mailto:dmarc@acme.com" + ], + "dkimSelectors": [ + "google" + ], + "mx": [ + "aspmx.l.google.com" + ] + }, + "expect": { + "verdict": "protected", + "letter": "A", + "spfId": "spf-softfail", + "dmarcId": "dmarc-enforcing" + } + }, + { + "target": "gradeRecords", + "name": "withholds the A+ when nobody is reading the reports", + "input": { + "domain": "acme.com", + "txt": [ + "v=spf1 include:_spf.google.com -all" + ], + "dmarc": [ + "v=DMARC1; p=reject" + ], + "dkimSelectors": [ + "google" + ], + "mx": [ + "aspmx.l.google.com" + ] + }, + "expect": { + "verdict": "protected", + "letter": "A", + "spfId": "spf-hardfail", + "dmarcId": "dmarc-enforcing" + } + }, + { + "target": "gradeRecords", + "name": "reads a null MX as a domain that takes no mail, and grades the lockdown", + "input": { + "domain": "parked.example", + "txt": [ + "v=spf1 -all" + ], + "dmarc": [ + "v=DMARC1; p=reject; rua=mailto:d@parked.example" + ], + "dkimSelectors": [], + "mx": [ + "." + ] + }, + "expect": { + "verdict": "protected", + "letter": "A+", + "provider": null, + "recommendationIds": [ + "nothing-to-add" + ] + } } ] } diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..c968f33 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/** + * The bin. All logic lives in `./cli/run.ts`; this file is the one place the + * CLI reads `process`, and it reads only globals, so the built output still + * runs on any runtime with a Node-compatible `process` (Node, Bun, Deno). + */ + +import { runCli } from "./cli/run.ts"; + +void runCli(process.argv.slice(2), { + write: (text) => void process.stdout.write(text), + writeError: (text) => void process.stderr.write(text), + columns: process.stdout.columns, + color: + process.env["NO_COLOR"] === undefined && + process.env["TERM"] !== "dumb" && + process.stdout.isTTY === true, +}).then((code) => { + if (code !== 0) process.exitCode = code; +}); diff --git a/src/cli/run.ts b/src/cli/run.ts new file mode 100644 index 0000000..26e9400 --- /dev/null +++ b/src/cli/run.ts @@ -0,0 +1,262 @@ +/** + * `npx mailgrade acme.com`: the grade, in a terminal. + * + * Everything here is pure rendering over `gradeDomain`; the bin wrapper in + * `../cli.ts` is the only place that touches `process`. Exit codes are part + * of the contract, because the second thing people do with this is put it in + * CI: 0 means graded (and `--strict` satisfied), 1 means graded but below + * `--strict`, 2 means the grade could not be produced at all. + */ + +import pkg from "../../package.json" with { type: "json" }; +import { coerceDomain } from "../domain.ts"; +import { gradeDomain, type GradeDomainOptions } from "../doh.ts"; +import type { DomainGrade, GradeLetter } from "../grade.ts"; +import type { FetchLike } from "../doh-resolver.ts"; +import type { Resolver } from "../verify/resolver.ts"; + +export type CliIo = { + readonly write: (text: string) => void; + readonly writeError: (text: string) => void; + /** Terminal width, for wrapping. 80 when unknown. */ + readonly columns?: number | undefined; + /** Whether to emit colour; `--no-color` wins over it. */ + readonly color?: boolean; + /** Injectable for tests, so no test ever leaves the process. */ + readonly fetch?: FetchLike; + readonly resolver?: Resolver; +}; + +const HELP = ` + Grade a domain's SPF, DKIM and DMARC, and say what to fix. + + Usage + $ mailgrade + + Options + --json print the full grade as JSON + --strict exit 1 unless the verdict is "protected" (for CI) + --selectors a,b DKIM selectors to probe; "none" skips the probe + --endpoint a DNS-over-HTTPS endpoint other than Cloudflare's + --no-color plain output + -v, --version print the version + -h, --help this text + + Exit codes + 0 graded + 1 graded, but --strict was set and the verdict is not "protected" + 2 no grade: bad arguments, or DNS could not be reached + + Examples + $ mailgrade acme.com + $ mailgrade acme.com --json | jq .letter + $ mailgrade acme.com --strict # fail the build when protection slips +`.replace(/^\n/, ""); + +type ParsedArgs = + | { readonly kind: "help" } + | { readonly kind: "version" } + | { readonly kind: "error"; readonly message: string } + | { + readonly kind: "grade"; + readonly domain: string; + readonly json: boolean; + readonly strict: boolean; + readonly noColor: boolean; + readonly selectors: readonly string[] | null; + readonly endpoint: string | null; + }; + +export function parseArgs(argv: readonly string[]): ParsedArgs { + let domain: string | null = null; + let json = false; + let strict = false; + let noColor = false; + let selectors: readonly string[] | null = null; + let endpoint: string | null = null; + + const take = (i: number): string | null => { + const arg = argv[i] as string; + const eq = arg.indexOf("="); + if (eq !== -1) return arg.slice(eq + 1); + const next = argv[i + 1]; + return next === undefined || next.startsWith("-") ? null : next; + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] as string; + const name = arg.startsWith("--") ? (arg.split("=")[0] as string) : arg; + + if (name === "-h" || name === "--help") return { kind: "help" }; + if (name === "-v" || name === "--version") return { kind: "version" }; + if (name === "--json") json = true; + else if (name === "--strict") strict = true; + else if (name === "--no-color") noColor = true; + else if (name === "--selectors" || name === "--endpoint") { + const value = take(i); + if (value === null) { + return { kind: "error", message: `${name} needs a value` }; + } + if (!arg.includes("=")) i++; + if (name === "--endpoint") endpoint = value; + else { + selectors = + value === "none" || value === "" + ? [] + : value.split(",").map((s) => s.trim()).filter((s) => s !== ""); + } + } else if (name.startsWith("-")) { + return { kind: "error", message: `unknown option ${name}` }; + } else if (domain === null) { + domain = arg; + } else { + return { kind: "error", message: "one domain at a time" }; + } + } + + if (domain === null) { + return { kind: "error", message: "which domain? try: mailgrade acme.com" }; + } + const coerced = coerceDomain(domain); + if (coerced === "" || !coerced.includes(".")) { + return { kind: "error", message: `"${domain}" does not look like a domain` }; + } + return { kind: "grade", domain: coerced, json, strict, noColor, selectors, endpoint }; +} + +/* ---------------------------------------------------------------- paint --- */ + +type Paint = (text: string, code: string) => string; + +const CODES: Record = { + bold: "1", dim: "2", red: "31", green: "32", yellow: "33", +}; + +function painter(color: boolean): Paint { + if (!color) return (text) => text; + return (text, code) => `\u001b[${CODES[code] ?? "0"}m${text}\u001b[0m`; +} + +const LETTER_CODE: Record = { + "A+": "green", A: "green", B: "yellow", C: "yellow", D: "red", F: "red", +}; + +const TAGLINE = { + spoofable: "mail claiming to be this domain can reach inboxes", + partial: "spoofed mail is degraded, not refused", + protected: "receivers are told to refuse mail that fails authentication", +} as const; + +function wrap(text: string, width: number, indent: string): string { + const words = text.split(/\s+/).filter((w) => w !== ""); + const lines: string[] = []; + let line = ""; + for (const word of words) { + if (line !== "" && line.length + 1 + word.length > width) { + lines.push(line); + line = word; + } else { + line = line === "" ? word : `${line} ${word}`; + } + } + if (line !== "") lines.push(line); + return lines.join(`\n${indent}`); +} + +export function renderGrade( + grade: DomainGrade, + options: { readonly columns?: number | undefined; readonly color?: boolean } = {}, +): string { + const paint = painter(options.color ?? false); + const width = Math.min(options.columns ?? 80, 96) - 2; + const out: string[] = [""]; + + const letter = paint(paint(` ${grade.letter} `, "bold"), LETTER_CODE[grade.letter]); + out.push(` ${paint(grade.domain, "bold")}`); + out.push(""); + out.push(` ${letter} ${paint(grade.verdict, "bold")}`); + out.push(` ${paint(wrap(TAGLINE[grade.verdict], width - 2, " "), "dim")}`); + out.push(""); + + const mark = (status: "pass" | "warn" | "fail") => + status === "pass" + ? paint("\u2713", "green") + : status === "warn" + ? paint("!", "yellow") + : paint("\u2717", "red"); + const row = (label: string, symbol: string, text: string) => + out.push( + ` ${symbol} ${paint(label.padEnd(7), "dim")}${wrap(text, width - 11, " ".repeat(11))}`, + ); + + row("SPF", mark(grade.spf.status), grade.spf.headline); + row("DKIM", mark(grade.dkim.status), grade.dkim.headline); + row("DMARC", mark(grade.dmarc.status), grade.dmarc.headline); + row( + "MX", + paint("\u00b7", "dim"), + grade.mx.provider ?? + (grade.mx.hosts.length > 0 + ? grade.mx.hosts.join(", ") + : "no MX records"), + ); + + for (const rec of grade.recommendations) { + out.push(""); + out.push(` ${paint("\u2192", "dim")} ${wrap(rec.text, width - 4, " ")}`); + } + + out.push(""); + return out.map((line) => `${line}\n`).join(""); +} + +/* ------------------------------------------------------------------ run --- */ + +export async function runCli( + argv: readonly string[], + io: CliIo, +): Promise { + const args = parseArgs(argv); + + if (args.kind === "help") { + io.write(HELP); + return 0; + } + if (args.kind === "version") { + io.write(`${pkg.version}\n`); + return 0; + } + if (args.kind === "error") { + io.writeError(`mailgrade: ${args.message}\n`); + return 2; + } + + const options: GradeDomainOptions = { + ...(args.selectors ? { selectors: args.selectors } : {}), + ...(args.endpoint ? { endpoint: args.endpoint } : {}), + ...(io.fetch ? { fetch: io.fetch } : {}), + ...(io.resolver ? { resolver: io.resolver } : {}), + }; + + let grade: DomainGrade; + try { + grade = await gradeDomain(args.domain, options); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + io.writeError(`mailgrade: ${message}\n`); + return 2; + } + + if (args.json) { + io.write(`${JSON.stringify(grade, null, 2)}\n`); + } else { + io.write( + renderGrade(grade, { + columns: io.columns, + color: args.noColor ? false : (io.color ?? false), + }), + ); + } + + return args.strict && grade.verdict !== "protected" ? 1 : 0; +} diff --git a/src/dmarc/analyze.ts b/src/dmarc/analyze.ts index 2b4c717..968e284 100644 --- a/src/dmarc/analyze.ts +++ b/src/dmarc/analyze.ts @@ -24,6 +24,8 @@ export type DmarcAnalysis = { readonly status: Status; readonly record: string | null; readonly policy: DmarcPolicy | null; + /** Whether the record requests aggregate reports (a rua= tag). */ + readonly reporting: boolean; /** Host the record was found at; the org domain when a subdomain inherited it. */ readonly source: string | null; readonly headline: string; @@ -69,6 +71,7 @@ export function analyzeDmarc( status: "fail", record: null, policy: null, + reporting: false, source: null, headline: "No DMARC record", detail: @@ -82,6 +85,7 @@ export function analyzeDmarc( status: "fail", record: records.join(" | "), policy: null, + reporting: false, source, headline: "Multiple DMARC records", detail: @@ -92,6 +96,7 @@ export function analyzeDmarc( const record = records[0] as string; const tags = dmarcTags(record); const policyTag = tags.get("p")?.toLowerCase(); + const reporting = tags.has("rua"); if ( policyTag !== "none" && @@ -100,6 +105,7 @@ export function analyzeDmarc( ) { return { id: "dmarc-no-policy", + reporting, status: "fail", record, policy: null, @@ -123,6 +129,7 @@ export function analyzeDmarc( if (policyTag === "none") { return { id: "dmarc-monitor", + reporting, status: "fail", record, policy: "none", @@ -137,6 +144,7 @@ export function analyzeDmarc( if (policyTag === "quarantine") { return { id: "dmarc-quarantine", + reporting, status: "warn", record, policy: "quarantine", @@ -151,6 +159,7 @@ export function analyzeDmarc( if (pct < 100) { return { id: "dmarc-sampled", + reporting, status: "warn", record, policy: "reject", @@ -165,6 +174,7 @@ export function analyzeDmarc( if (subPolicy === "none" || subPolicy === "quarantine") { return { id: "dmarc-weak-subdomain", + reporting, status: "warn", record, policy: "reject", @@ -178,6 +188,7 @@ export function analyzeDmarc( return { id: "dmarc-enforcing", + reporting, status: "pass", record, policy: "reject", diff --git a/src/doh-resolver.ts b/src/doh-resolver.ts new file mode 100644 index 0000000..1ca1614 --- /dev/null +++ b/src/doh-resolver.ts @@ -0,0 +1,132 @@ +/** + * A `Resolver` made of nothing but `fetch`. + * + * This is the library's default DNS, and it is deliberately the smallest + * module that can be one: it imports no grading code and no verification + * code, so `mailgrade/verify` can default its `resolver` without dragging the + * domain grader in behind it. + * + * DNS over HTTPS rather than a platform resolver, because `fetch` is the one + * lookup mechanism that exists in every runtime this targets. A Node service + * that would rather not leave the process for every lookup passes the + * `resolver` from `mailgrade/node-dns` instead. + */ + +import type { DnsRecordType, Resolver } from "./verify/resolver.ts"; + +export class DnsError extends Error { + readonly host: string; + readonly type: string; + + constructor(message: string, host: string, type: string) { + super(message); + this.name = "DnsError"; + this.host = host; + this.type = type; + } +} + +/** + * The slice of `fetch` this module uses, named structurally so the published + * types do not depend on which DOM or Node globals a consumer has loaded. + */ +export type FetchLike = ( + url: string, + init: { + headers: Record; + signal?: AbortSignal | undefined; + }, +) => Promise<{ ok: boolean; status: number; json(): Promise }>; + +export type DohResolverOptions = { + /** A DNS-over-HTTPS JSON endpoint. Defaults to Cloudflare's. */ + readonly endpoint?: string; + /** Injectable for tests, or for a runtime with a non-global fetch. */ + readonly fetch?: FetchLike; + readonly signal?: AbortSignal; +}; + +export const DEFAULT_ENDPOINT = "https://cloudflare-dns.com/dns-query"; + +const RECORD_TYPE = { A: 1, AAAA: 28, MX: 15, PTR: 12, TXT: 16 } as const; + +type DohAnswer = { name: string; type: number; data: string }; +type DohResponse = { Status: number; Answer?: DohAnswer[] }; + +/** + * A TXT value arrives quoted, and one longer than 255 bytes arrives as several + * quoted strings that the reader is expected to concatenate. + */ +function unquoteTxt(data: string): string { + const parts = data.match(/"(?:[^"\\]|\\.)*"/g); + if (!parts) return data.trim(); + return parts.map((p) => p.slice(1, -1).replace(/\\(.)/g, "$1")).join(""); +} + +function stripRootDot(host: string): string { + return host.replace(/\.$/, ""); +} + +async function query( + host: string, + type: DnsRecordType, + options: DohResolverOptions, +): Promise { + const doFetch: FetchLike = options.fetch ?? globalThis.fetch; + const url = `${options.endpoint ?? DEFAULT_ENDPOINT}?name=${encodeURIComponent(host)}&type=${type}`; + + const response = await doFetch(url, { + headers: { accept: "application/dns-json" }, + signal: options.signal, + }); + if (!response.ok) { + throw new DnsError( + `DNS query failed with HTTP ${response.status}`, + host, + type, + ); + } + + const body = (await response.json()) as DohResponse; + + // 0 is NOERROR and 3 is NXDOMAIN, which for a DMARC host or a DKIM selector + // is a legitimate "nothing published here". Anything else means the answer + // is unknown, and reporting unknown as absent would grade a protected + // domain as spoofable. + if (body.Status !== 0 && body.Status !== 3) { + throw new DnsError(`DNS query returned status ${body.Status}`, host, type); + } + + return (body.Answer ?? []) + .filter((a) => a.type === RECORD_TYPE[type]) + .map((a) => (type === "TXT" ? unquoteTxt(a.data) : a.data)); +} + +/** + * Honours the resolver contract: empty answers come back as `[]`, failures + * throw (and verification reports temperror rather than fail), MX values are + * bare host names. + */ +export function dohResolver(options: DohResolverOptions = {}): Resolver { + return async (name, type) => { + const records = await query(name, type, options); + if (type === "MX") { + return records.map((r) => stripRootDot(r.split(/\s+/).pop() ?? r)); + } + if (type === "PTR") return records.map(stripRootDot); + return records; + }; +} + +/** + * The resolver every call falls back to when the caller names none. + * + * Built once and reused, so that defaulting costs nothing per call, and built + * lazily so that merely importing the library never reads a global that a + * runtime may not have set up yet. + */ +let fallback: Resolver | undefined; + +export function defaultResolver(): Resolver { + return (fallback ??= dohResolver()); +} diff --git a/src/doh.ts b/src/doh.ts index 1d09f76..284d52a 100644 --- a/src/doh.ts +++ b/src/doh.ts @@ -1,51 +1,29 @@ /** - * The only part of this library that touches the network, kept behind its own - * entry point so that importing `mailgrade` never pulls it in. + * Resolving a whole domain, and grading what comes back. * - * DNS over HTTPS rather than a platform resolver, because `fetch` is the one - * lookup mechanism that exists in every runtime this targets. A Node service - * that would rather not leave the process for every lookup passes the - * `resolver` from `mailgrade/node-dns` instead. + * The DNS itself lives in `./doh-resolver.ts`; this module is the part that + * knows which names a grade needs and in what order to ask for them. */ import { DKIM_SELECTORS, dkimHost, isDkimKey } from "./dkim.ts"; import { dmarcHost } from "./dmarc/analyze.ts"; +import { + defaultResolver, + dohResolver, + type DohResolverOptions, +} from "./doh-resolver.ts"; import { registrableDomain } from "./domain.ts"; -import { gradeDomain, type DomainGrade, type DomainRecords } from "./grade.ts"; -import type { DnsRecordType, Resolver } from "./verify/resolver.ts"; +import { gradeRecords, type DomainGrade, type DomainRecords } from "./grade.ts"; +import type { Resolver } from "./verify/resolver.ts"; -export class DnsError extends Error { - readonly host: string; - readonly type: string; +export { DEFAULT_ENDPOINT, DnsError } from "./doh-resolver.ts"; +export { defaultResolver, dohResolver }; +export type { DohResolverOptions, FetchLike } from "./doh-resolver.ts"; - constructor(message: string, host: string, type: string) { - super(message); - this.name = "DnsError"; - this.host = host; - this.type = type; - } -} - -/** - * The slice of `fetch` this module uses, named structurally so the published - * types do not depend on which DOM or Node globals a consumer has loaded. - */ -export type FetchLike = ( - url: string, - init: { - headers: Record; - signal?: AbortSignal | undefined; - }, -) => Promise<{ ok: boolean; status: number; json(): Promise }>; - -export type DohOptions = { - /** A DNS-over-HTTPS JSON endpoint. Defaults to Cloudflare's. */ - readonly endpoint?: string; - /** Injectable for tests, or for a runtime with a non-global fetch. */ - readonly fetch?: FetchLike; +/** Options for `gradeDomain`, and for `resolveDomain`, its lookup half. */ +export type GradeDomainOptions = DohResolverOptions & { /** Selectors to probe for DKIM keys. Pass an empty array to skip DKIM. */ readonly selectors?: readonly string[]; - readonly signal?: AbortSignal; /** Lookups in flight at once. */ readonly concurrency?: number; /** @@ -55,64 +33,6 @@ export type DohOptions = { readonly resolver?: Resolver; }; -const DEFAULT_ENDPOINT = "https://cloudflare-dns.com/dns-query"; - -const RECORD_TYPE = { A: 1, AAAA: 28, MX: 15, PTR: 12, TXT: 16 } as const; - -type DohAnswer = { name: string; type: number; data: string }; -type DohResponse = { Status: number; Answer?: DohAnswer[] }; - -/** - * A TXT value arrives quoted, and one longer than 255 bytes arrives as several - * quoted strings that the reader is expected to concatenate. - */ -function unquoteTxt(data: string): string { - const parts = data.match(/"(?:[^"\\]|\\.)*"/g); - if (!parts) return data.trim(); - return parts - .map((p) => p.slice(1, -1).replace(/\\(.)/g, "$1")) - .join(""); -} - -async function query( - host: string, - type: DnsRecordType, - options: DohOptions, -): Promise { - const doFetch: FetchLike = options.fetch ?? globalThis.fetch; - const url = `${options.endpoint ?? DEFAULT_ENDPOINT}?name=${encodeURIComponent(host)}&type=${type}`; - - const response = await doFetch(url, { - headers: { accept: "application/dns-json" }, - signal: options.signal, - }); - if (!response.ok) { - throw new DnsError( - `DNS query failed with HTTP ${response.status}`, - host, - type, - ); - } - - const body = (await response.json()) as DohResponse; - - // 0 is NOERROR and 3 is NXDOMAIN, which for a DMARC host or a DKIM selector - // is a legitimate "nothing published here". Anything else means the answer - // is unknown, and reporting unknown as absent would grade a protected - // domain as spoofable. - if (body.Status !== 0 && body.Status !== 3) { - throw new DnsError( - `DNS query returned status ${body.Status}`, - host, - type, - ); - } - - return (body.Answer ?? []) - .filter((a) => a.type === RECORD_TYPE[type]) - .map((a) => (type === "TXT" ? unquoteTxt(a.data) : a.data)); -} - async function pool( items: readonly T[], limit: number, @@ -138,7 +58,7 @@ async function pool( } /** - * Every record `gradeDomain` needs, in one round of lookups. + * Every record `gradeRecords` needs, in one round of lookups. * * DMARC falls back to the organizational domain the way a receiver does, so a * subdomain is graded on the policy that actually applies to it, and `dmarcSource` @@ -146,12 +66,12 @@ async function pool( */ export async function resolveDomain( domain: string, - options: DohOptions = {}, + options: GradeDomainOptions = {}, ): Promise { const name = domain.trim().toLowerCase(); const selectors = options.selectors ?? DKIM_SELECTORS; const concurrency = options.concurrency ?? 10; - const resolve = options.resolver ?? dohResolver(options); + const resolve = resolverFor(options); const [txt, mx, dmarcAtDomain] = await Promise.all([ resolve(name, "TXT"), @@ -183,32 +103,31 @@ export async function resolveDomain( }; } +/** + * A caller's own resolver wins; otherwise DoH, configured if they tuned it and + * the shared default if they did not. + */ +function resolverFor(options: GradeDomainOptions): Resolver { + if (options.resolver) return options.resolver; + if (options.endpoint || options.fetch || options.signal) { + return dohResolver(options); + } + return defaultResolver(); +} + function stripRootDot(host: string): string { return host.replace(/\.$/, ""); } /** - * A `Resolver` for `mailgrade/verify`, made of nothing but `fetch`. + * The headline call: everything a domain publishes, in one verdict. * - * Honours the resolver contract: empty answers come back as `[]`, failures - * throw (and verification reports temperror rather than fail), MX values are - * bare host names. + * `resolveDomain` then `gradeRecords`, which are also exported separately for + * a caller who wants to cache the records or grade ones they already hold. */ -export function dohResolver(options: DohOptions = {}): Resolver { - return async (name, type) => { - const records = await query(name, type, options); - if (type === "MX") { - return records.map((r) => stripRootDot(r.split(/\s+/).pop() ?? r)); - } - if (type === "PTR") return records.map(stripRootDot); - return records; - }; -} - -/** Resolve a domain and grade it. */ -export async function checkDomain( +export async function gradeDomain( domain: string, - options: DohOptions = {}, + options: GradeDomainOptions = {}, ): Promise { - return gradeDomain(await resolveDomain(domain, options)); + return gradeRecords(await resolveDomain(domain, options)); } diff --git a/src/grade.ts b/src/grade.ts index 031910a..9ee1f77 100644 --- a/src/grade.ts +++ b/src/grade.ts @@ -1,5 +1,9 @@ /** - * The headline call: everything a domain publishes, in one verdict. + * Grading, given records somebody else fetched. + * + * `gradeDomain` in `mailgrade/doh` is this composed with the lookups; this is + * the half that is pure, so a caller who already holds the DNS answers (a + * cache, a bulk job, a fixture) never pays for a second round trip. * * The grade answers "how far does mail claiming to be this domain get", which * is why DMARC policy decides the verdict and SPF and DKIM decide whether that @@ -14,6 +18,8 @@ import type { Recommendation } from "./types.ts"; export type DomainVerdict = "spoofable" | "partial" | "protected"; +export type GradeLetter = "A+" | "A" | "B" | "C" | "D" | "F"; + /** Raw DNS answers, however the caller obtained them. */ export type DomainRecords = { readonly domain: string; @@ -33,6 +39,8 @@ export type DomainRecords = { export type DomainGrade = { readonly domain: string; readonly verdict: DomainVerdict; + /** The verdict as a report card, from A+ down to F. */ + readonly letter: GradeLetter; readonly spf: SpfAnalysis; readonly dmarc: DmarcAnalysis; readonly dkim: DkimAnalysis; @@ -55,42 +63,93 @@ export function domainVerdict( return "protected"; } -export function gradeDomain(records: DomainRecords): DomainGrade { +export function gradeRecords(records: DomainRecords): DomainGrade { const domain = records.domain.trim().toLowerCase(); - const mxHosts = records.mx ?? []; + // RFC 7505: a null MX ("MX 0 .") is a deliberate "this domain takes no + // mail", and arrives as "." or, root dot stripped, as "". Neither is a mail + // host, and treating one as a mail host would tell a no-mail domain to turn + // on DKIM. + const mxHosts = (records.mx ?? []).filter((h) => h !== "" && h !== "."); const spf = analyzeSpf(records.txt); const dmarc = analyzeDmarc(records.dmarc, records.dmarcSource ?? domain); const dkim = analyzeDkim( records.dkimSelectors ?? [], records.dkimProbed ?? DKIM_SELECTORS.length, ); + const verdict = domainVerdict(spf, dmarc); + const parked = looksParked(spf, mxHosts); return { domain, - verdict: domainVerdict(spf, dmarc), + verdict, + letter: letterFor(verdict, spf, dmarc, dkim, parked), spf, dmarc, dkim, mx: { hosts: mxHosts, provider: mailProvider(mxHosts) }, - recommendations: recommend(domain, spf, dmarc, dkim, mxHosts), + recommendations: recommend(domain, spf, dmarc, dkim, parked), }; } +/** + * No MX and either no SPF or a bare "v=spf1 -all" reads as a domain that + * does not do mail, so advice about signing and providers would be noise, + * and absent DKIM is not held against the grade. + */ +function looksParked(spf: SpfAnalysis, mxHosts: readonly string[]): boolean { + return ( + mxHosts.length === 0 && + (spf.record === null || /^v=spf1\s+-all$/i.test(spf.record.trim())) + ); +} + +/** + * The verdict as a report card. The verdict sets the band and the details + * move within it: + * + * - `F`: spoofable with nothing effective published, or an SPF `+all`, + * which actively authorises the spoof. + * - `D`: spoofable, but something is in place (a monitoring-only DMARC, or + * decent SPF with no DMARC behind it). + * - `C`: partial enforcement: quarantine, a sampled reject, or a weaker + * subdomain policy. + * - `B`: enforcing, but SPF is broken or missing, or a sending domain shows + * no DKIM keys, so enforcement rests on fewer legs than it should. + * - `A`: enforcing, with SPF and DKIM both standing behind it. + * - `A+`: as `A`, with SPF ending in `-all` and aggregate reports flowing + * somewhere (`rua=`), so drift will be noticed. + */ +function letterFor( + verdict: DomainVerdict, + spf: SpfAnalysis, + dmarc: DmarcAnalysis, + dkim: DkimAnalysis, + parked: boolean, +): GradeLetter { + if (verdict === "partial") return "C"; + + if (verdict === "spoofable") { + if (spf.id === "spf-allow-all") return "F"; + if (dmarc.record === null && spf.status === "fail") return "F"; + return "D"; + } + + const spfBroken = spf.status === "fail"; + const unsignedSender = dkim.selectorsFound.length === 0 && !parked; + if (spfBroken || unsignedSender) return "B"; + + return spf.id === "spf-hardfail" && dmarc.reporting ? "A+" : "A"; +} + function recommend( domain: string, spf: SpfAnalysis, dmarc: DmarcAnalysis, dkim: DkimAnalysis, - mxHosts: readonly string[], + sendsNothing: boolean, ): Recommendation[] { const recs: Recommendation[] = []; - // No MX and either no SPF or a bare "v=spf1 -all" reads as a domain that - // does not do mail, so advice about signing and providers would be noise. - const sendsNothing = - mxHosts.length === 0 && - (spf.record === null || /^v=spf1\s+-all$/i.test(spf.record.trim())); - if (sendsNothing && (spf.record === null || dmarc.status !== "pass")) { recs.push({ id: "lock-parked-domain", diff --git a/src/index.ts b/src/index.ts index 8bb29dd..4486928 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,106 @@ -export * from "./dkim.ts"; -export * from "./dmarc/index.ts"; -export * from "./domain.ts"; -export * from "./grade.ts"; -export * from "./headers/index.ts"; -export * from "./mx.ts"; -export * from "./spf.ts"; -export * from "./types.ts"; -export * from "./verify/index.ts"; +/** + * The public API, named one at a time. + * + * This barrel is curated rather than a set of `export *`: the point of the + * root import is that autocomplete on `mailgrade` shows the calls a reader + * would recognise from the README, not the sixty helpers those calls are + * built out of. Every internal remains reachable from the entry point for its + * own area (`mailgrade/headers`, `mailgrade/dmarc`, `mailgrade/spf`, ...), + * which is also where a rename is a smaller promise to keep. + */ + +/* -------------------------------------------------------------- domain --- */ + +export { gradeDomain, resolveDomain } from "./doh.ts"; +export type { GradeDomainOptions } from "./doh.ts"; +export { gradeRecords, domainVerdict } from "./grade.ts"; +export type { + DomainGrade, + DomainRecords, + DomainVerdict, + GradeLetter, +} from "./grade.ts"; + +/* ----------------------------------------------------------- messages --- */ + +export { verifyMessage, toAuthResults } from "./verify/index.ts"; +export type { + AuthResultsInput, + MessageVerification, + VerifyMessageOptions, +} from "./verify/index.ts"; +export { verifySpf, verifyDkim, verifyDmarc } from "./verify/index.ts"; +export type { + DkimResult, + DkimResultId, + DkimVerification, + DkimVerifyOptions, + DmarcParams, + DmarcVerification, + SpfParams, + SpfResult, + SpfVerification, +} from "./verify/index.ts"; + +/* ------------------------------------------------------------ headers --- */ + +export { analyzeHeaders } from "./headers/index.ts"; +export type { + AuthSource, + Flag, + HeaderAnalysis, + HeaderVerdict, + Identity, + MessageSummary, + Route, +} from "./headers/index.ts"; + +/* -------------------------------------------------------- dmarc records --- */ + +export { + buildDmarcRecord, + parseDmarcRecord, + reviewDmarc, + rolloutPlan, +} from "./dmarc/index.ts"; +export type { + Alignment, + DmarcError, + DmarcErrorId, + DmarcNote, + DmarcOptions, + DmarcParse, + DmarcPolicy, + RolloutStage, + SubdomainPolicy, +} from "./dmarc/index.ts"; + +/* ------------------------------------------------------------- records --- */ + +export { analyzeSpf, isSpfRecord } from "./spf.ts"; +export type { SpfAnalysis, SpfFinding, SpfQualifier } from "./spf.ts"; +export { analyzeDkim, isDkimKey, dkimHost, DKIM_SELECTORS } from "./dkim.ts"; +export type { DkimAnalysis, DkimFinding } from "./dkim.ts"; +export { analyzeDmarc, isDmarcRecord, dmarcHost } from "./dmarc/index.ts"; +export type { DmarcAnalysis, DmarcFinding } from "./dmarc/index.ts"; +export { mailProvider } from "./mx.ts"; + +/* ----------------------------------------------------------------- dns --- */ + +export { dohResolver, DnsError } from "./doh.ts"; +export { staticResolver } from "./verify/resolver.ts"; +export type { DnsRecordType, Resolver } from "./verify/resolver.ts"; +export type { DohResolverOptions, FetchLike } from "./doh-resolver.ts"; + +/* -------------------------------------------------------------- shared --- */ + +export { registrableDomain, aligns, domainOf, coerceDomain } from "./domain.ts"; +export type { SuffixOptions } from "./domain.ts"; +export type { + AuthStatus, + Identified, + Note, + Recommendation, + Severity, + Status, +} from "./types.ts"; diff --git a/src/verify/dkim.ts b/src/verify/dkim.ts index b8d4102..6aa6897 100644 --- a/src/verify/dkim.ts +++ b/src/verify/dkim.ts @@ -8,6 +8,7 @@ * never costs a lookup. */ +import { defaultResolver } from "../doh-resolver.ts"; import type { Resolver } from "./resolver.ts"; import { canonicalBodyRelaxed, @@ -56,7 +57,8 @@ export type DkimVerification = { }; export type DkimVerifyOptions = { - readonly resolver: Resolver; + /** Where DNS comes from. Defaults to DNS over HTTPS against Cloudflare. */ + readonly resolver?: Resolver; /** The clock to judge x= expiry against; defaults to now. */ readonly now?: Date; }; @@ -312,7 +314,10 @@ async function verifySignature( let keyRecords: readonly string[]; try { - keyRecords = await options.resolver(`${selector}._domainkey.${domain}`, "TXT"); + keyRecords = await (options.resolver ?? defaultResolver())( + `${selector}._domainkey.${domain}`, + "TXT", + ); } catch { return finish(verdict("dkim-dns-error")); } diff --git a/src/verify/dmarc.ts b/src/verify/dmarc.ts index 19da731..ff4591d 100644 --- a/src/verify/dmarc.ts +++ b/src/verify/dmarc.ts @@ -10,6 +10,7 @@ import { aligns, registrableDomain, type SuffixOptions } from "../domain.ts"; import { dmarcHost, isDmarcRecord } from "../dmarc/analyze.ts"; import { parseDmarcRecord, type Alignment, type DmarcPolicy } from "../dmarc/record.ts"; +import { defaultResolver } from "../doh-resolver.ts"; import type { Resolver } from "./resolver.ts"; import type { DkimVerification } from "./dkim.ts"; import type { SpfVerification } from "./spf.ts"; @@ -37,7 +38,8 @@ export type DmarcParams = { readonly spf?: Pick | null; /** One outcome per DKIM signature. */ readonly dkim?: readonly Pick[]; - readonly resolver: Resolver; + /** Where DNS comes from. Defaults to DNS over HTTPS against Cloudflare. */ + readonly resolver?: Resolver; } & SuffixOptions; function identityAligns( @@ -55,6 +57,7 @@ function identityAligns( export async function verifyDmarc(params: DmarcParams): Promise { const fromDomain = params.fromDomain.trim().toLowerCase(); const org = registrableDomain(fromDomain, params); + const resolve = params.resolver ?? defaultResolver(); const blank: DmarcVerification = { result: "none", @@ -73,7 +76,7 @@ export async function verifyDmarc(params: DmarcParams): Promise Promise` of your own. + */ + readonly resolver?: Resolver; /** The IP the message arrived from; without it SPF is skipped. */ readonly ip?: string; /** The MAIL FROM (envelope sender) address. */ readonly sender?: string; /** The HELO/EHLO host name, SPF's fallback identity for a null sender. */ readonly helo?: string; + /** + * The host doing the verifying, which becomes the authserv-id in the + * generated headers. Pass the name a downstream filter will trust. + */ + readonly mta?: string; readonly now?: Date; } & SuffixOptions; @@ -51,6 +67,14 @@ export type MessageVerification = { readonly dkim: readonly DkimVerification[]; /** Null when the message has no readable From domain. */ readonly dmarc: DmarcVerification | null; + /** The Authentication-Results field value, without the field name. */ + readonly authResults: string; + /** + * Complete header fields, folded and CRLF-terminated, ready to prepend to + * the message before handing it on: `Received-SPF` when SPF was evaluated, + * then `Authentication-Results`. + */ + readonly headers: string; }; /** @@ -60,8 +84,9 @@ export type MessageVerification = { */ export async function verifyMessage( message: string | Uint8Array, - options: VerifyMessageOptions, + options: VerifyMessageOptions = {}, ): Promise { + const resolver = options.resolver ?? defaultResolver(); const parsed = splitMessage(message); const from = parseAddress( parsed.headers.find((h) => h.lower === "from")?.value ?? null, @@ -75,10 +100,11 @@ export async function verifyMessage( ip: options.ip, sender: spfIdentity, helo: options.helo, - resolver: options.resolver, + receiver: options.mta, + resolver, }) : Promise.resolve(null), - verifyDkim(parsed, { resolver: options.resolver, now: options.now }), + verifyDkim(parsed, { resolver, now: options.now }), ]); const dmarc = fromDomain @@ -88,44 +114,238 @@ export async function verifyMessage( ? { result: spf.result, domain: domainOf(options.sender) ?? spf.domain } : null, dkim, - resolver: options.resolver, + resolver, publicSuffixes: options.publicSuffixes, }) : null; - return { fromDomain, sender: spf ? spfIdentity : null, spf, dkim, dmarc }; + const core = { + fromDomain, + sender: spf ? spfIdentity : null, + spf, + dkim, + dmarc, + }; + const authResults = toAuthResults(core, options.mta ?? ANONYMOUS_MTA); + + return { + ...core, + authResults, + headers: buildHeaders(core, authResults, options), + }; } /** * The verification as an RFC 8601 Authentication-Results header value, ready - * to stamp onto the message before handing it on: what `analyzeHeaders` and - * every downstream filter read. + * to stamp onto the message: what `analyzeHeaders` and every downstream + * filter read. `verifyMessage` already returns this as `authResults`; call it + * directly to restamp under a different authserv-id. */ export function toAuthResults( - verification: MessageVerification, - authservId = "mailgrade", + verification: AuthResultsInput, + authservId: string = ANONYMOUS_MTA, ): string { const parts: string[] = []; const { spf, dkim, dmarc, sender, fromDomain } = verification; if (spf) { const identity = sender?.includes("@") ? sender : (sender ?? spf.domain); - parts.push(`spf=${spf.result} smtp.mailfrom=${identity}`); + parts.push(`spf=${spf.result} smtp.mailfrom=${oneLine(identity)}`); } for (const signature of dkim) { parts.push( `dkim=${signature.result}` + - (signature.domain ? ` header.d=${signature.domain}` : "") + - (signature.selector ? ` header.s=${signature.selector}` : ""), + (signature.domain ? ` header.d=${oneLine(signature.domain)}` : "") + + (signature.selector ? ` header.s=${oneLine(signature.selector)}` : ""), ); } if (dmarc && dmarc.result !== "none") { - parts.push(`dmarc=${dmarc.result} header.from=${dmarc.fromDomain}`); + parts.push(`dmarc=${dmarc.result} header.from=${oneLine(dmarc.fromDomain)}`); } else if (fromDomain && (spf || dkim.length > 0)) { - parts.push(`dmarc=none header.from=${fromDomain}`); + parts.push(`dmarc=none header.from=${oneLine(fromDomain)}`); } return parts.length === 0 - ? `${authservId}; none` - : `${authservId}; ${parts.join("; ")}`; + ? `${oneLine(authservId)}; none` + : `${oneLine(authservId)}; ${parts.join("; ")}`; +} + +/** Everything `toAuthResults` reads, so a caller can hand it a partial. */ +export type AuthResultsInput = Pick< + MessageVerification, + "fromDomain" | "sender" | "spf" | "dkim" | "dmarc" +>; + +/* ------------------------------------------------------------ headers --- */ + +/** + * The values embedded in the generated headers come off the wire (MAIL FROM, + * HELO, a hostile message's own DKIM-Signature), so anything that could start + * a new header field is folded to a space before it is embedded. A CR or LF + * here is a header injection, not a value. + */ +function oneLine(value: string): string { + return value.replace(/[\r\n\0]/g, " ").replace(/[ \t]+/g, " ").trim(); +} + +/** As a quoted-string: RFC 7208's form for envelope-from, quotes escaped. */ +function quotedString(value: string): string { + return `"${oneLine(value).replace(/[\\"]/g, (c) => `\\${c}`)}"`; +} + +/** + * `Received-SPF` first, then `Authentication-Results`, each folded so no line + * runs past the 78 characters RFC 5322 asks for. A receiver prepends the + * block verbatim; the order is the order the next hop should read them in. + */ +function buildHeaders( + verification: AuthResultsInput, + authResults: string, + options: VerifyMessageOptions, +): string { + const fields: string[] = []; + const receivedSpf = buildReceivedSpf(verification, options); + if (receivedSpf) fields.push(receivedSpf); + fields.push(fold("Authentication-Results", authResults)); + return fields.map((field) => `${field}\r\n`).join(""); +} + +/** + * RFC 7208 section 9.1. The comment is generated rather than taken from the + * record's own `exp=`: an explanation the sender wrote is for a human bounce, + * not for a trace header the next hop parses. + */ +function buildReceivedSpf( + verification: AuthResultsInput, + options: VerifyMessageOptions, +): string | null { + const { spf, sender } = verification; + if (!spf || !options.ip) return null; + + const ip = oneLine(options.ip); + const identity = oneLine(sender ?? spf.domain); + const subject = identity.includes("@") + ? `domain of ${identity}` + : `${identity}`; + const comment = + spf.result === "pass" + ? `${subject} designates ${ip} as permitted sender` + : spf.result === "fail" + ? `${subject} does not designate ${ip} as permitted sender` + : `${spf.result} for ${subject}`; + + const keys = [`client-ip=${ip}`]; + if (options.sender) keys.push(`envelope-from=${quotedString(options.sender)}`); + if (options.helo) keys.push(`helo=${oneLine(options.helo)}`); + + const authority = oneLine(options.mta ?? ANONYMOUS_MTA); + return fold( + "Received-SPF", + `${spf.result} (${authority}: ${comment}) ${keys.join("; ")}`, + ); +} + +const LINE_LIMIT = 78; + +/** + * Fold to the 78 characters RFC 5322 asks for, preferring the semicolons that + * separate a header's parts and falling back to plain spaces when one part is + * longer than a line on its own, which the SPF comment usually is. + * + * Never mid-token and never inside quotes: a signature, an address or a + * quoted envelope-from broken across a fold is one the next parser gets + * wrong, and a header nobody downstream can read is worse than a long line. + */ +function fold(name: string, value: string): string { + const pieces = segments(value); + + let out = `${name}:`; + let line = out.length; + + for (const piece of pieces) { + // A part that will not fit starts its own line, unless that would leave + // the field name sitting alone on the first one. + if (line + 1 + piece.length > LINE_LIMIT && line > name.length + 1) { + out += "\r\n"; + line = 0; + } + for (const word of unquotedWords(piece)) { + if (line + 1 + word.length > LINE_LIMIT && line > 1) { + out += `\r\n ${word}`; + line = 1 + word.length; + } else { + out += ` ${word}`; + line += 1 + word.length; + } + } + } + + return out; +} + +/** + * Split after the semicolons that separate a header's parts, keeping each + * semicolon with its part and dropping the whitespace between parts. Only + * the semicolons outside quotes count: `envelope-from="\"a;b\"@x.com"` is + * one part, not two. + */ +function segments(value: string): string[] { + const out: string[] = []; + let current = ""; + let quoted = false; + let escaped = false; + + for (const char of value) { + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === "\\" && quoted) { + current += char; + escaped = true; + continue; + } + if (char === '"') quoted = !quoted; + if ((char === " " || char === "\t") && !quoted && current === "") continue; + current += char; + if (char === ";" && !quoted) { + out.push(current); + current = ""; + } + } + if (current !== "") out.push(current); + + return out; +} + +/** Split on the spaces that are foldable, which is the ones outside quotes. */ +function unquotedWords(value: string): string[] { + const out: string[] = []; + let current = ""; + let quoted = false; + let escaped = false; + + for (const char of value) { + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === "\\" && quoted) { + current += char; + escaped = true; + continue; + } + if (char === '"') quoted = !quoted; + if ((char === " " || char === "\t") && !quoted) { + if (current !== "") out.push(current); + current = ""; + } else { + current += char; + } + } + if (current !== "") out.push(current); + + return out; } diff --git a/src/verify/spf.ts b/src/verify/spf.ts index 4bde378..a46e2e6 100644 --- a/src/verify/spf.ts +++ b/src/verify/spf.ts @@ -9,6 +9,7 @@ */ import { macroIp, parseIp, readableIp, reverseName, inCidr, type ParsedIp } from "./ip.ts"; +import { defaultResolver } from "../doh-resolver.ts"; import type { Resolver } from "./resolver.ts"; export type SpfResult = @@ -42,7 +43,8 @@ export type SpfParams = { readonly helo?: string; /** Receiving host name, used by the %{r} macro. */ readonly receiver?: string; - readonly resolver: Resolver; + /** Where DNS comes from. Defaults to DNS over HTTPS against Cloudflare. */ + readonly resolver?: Resolver; }; const LOOKUP_LIMIT = 10; @@ -100,7 +102,7 @@ export async function verifySpf(params: SpfParams): Promise { if (!senderDomain || !senderDomain.includes(".")) return blank("none"); const ctx: Context = { - resolver: params.resolver, + resolver: params.resolver ?? defaultResolver(), ip, sender: `${local}@${senderDomain}`, local, diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..eeae024 --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,182 @@ +/** + * The CLI, run entirely in-process: `runCli` takes its I/O and its DNS as + * arguments, so these tests never spawn anything and never leave the process. + */ + +import { describe, expect, it } from "vitest"; + +import { parseArgs, renderGrade, runCli, type CliIo } from "../src/cli/run.ts"; +import { gradeRecords } from "../src/grade.ts"; +import { staticResolver } from "../src/verify/resolver.ts"; + +const LOCKED_ZONE = { + "acme.com": { + TXT: ["v=spf1 include:_spf.google.com -all"], + MX: ["aspmx.l.google.com"], + }, + "_dmarc.acme.com": { TXT: ["v=DMARC1; p=reject; rua=mailto:d@acme.com"] }, + "google._domainkey.acme.com": { TXT: ["v=DKIM1; k=rsa; p=MIIB"] }, +} as const; + +function io(overrides: Partial = {}) { + const out: string[] = []; + const err: string[] = []; + const handle: CliIo = { + write: (t) => void out.push(t), + writeError: (t) => void err.push(t), + resolver: staticResolver(LOCKED_ZONE), + ...overrides, + }; + return { handle, stdout: () => out.join(""), stderr: () => err.join("") }; +} + +describe("parseArgs", () => { + it("takes a domain and the flags in any order", () => { + expect(parseArgs(["--strict", "acme.com", "--json"])).toEqual({ + kind: "grade", + domain: "acme.com", + json: true, + strict: true, + noColor: false, + selectors: null, + endpoint: null, + }); + }); + + it("accepts a pasted URL or address and grades its domain", () => { + const parsed = parseArgs(["https://www.acme.com/careers"]); + expect(parsed).toMatchObject({ kind: "grade", domain: "acme.com" }); + expect(parseArgs(["ceo@acme.com"])).toMatchObject({ domain: "acme.com" }); + }); + + it("reads --selectors as a list, with none meaning skip the probe", () => { + expect(parseArgs(["acme.com", "--selectors", "google,pm"])).toMatchObject({ + selectors: ["google", "pm"], + }); + expect(parseArgs(["acme.com", "--selectors=none"])).toMatchObject({ + selectors: [], + }); + }); + + it("refuses what it cannot grade", () => { + expect(parseArgs([])).toMatchObject({ kind: "error" }); + expect(parseArgs(["not a domain"])).toMatchObject({ kind: "error" }); + expect(parseArgs(["acme.com", "--wat"])).toMatchObject({ kind: "error" }); + expect(parseArgs(["a.com", "b.com"])).toMatchObject({ kind: "error" }); + }); +}); + +describe("runCli", () => { + it("grades over the injected resolver and prints the report", async () => { + const { handle, stdout } = io(); + const code = await runCli(["acme.com", "--selectors", "google"], handle); + + expect(code).toBe(0); + expect(stdout()).toContain("A+"); + expect(stdout()).toContain("protected"); + expect(stdout()).toContain("SPF ends in -all"); + expect(stdout()).toContain("Google Workspace"); + }); + + it("prints the whole grade as JSON with --json", async () => { + const { handle, stdout } = io(); + const code = await runCli( + ["acme.com", "--json", "--selectors", "google"], + handle, + ); + + expect(code).toBe(0); + const grade = JSON.parse(stdout()); + expect(grade.letter).toBe("A+"); + expect(grade.verdict).toBe("protected"); + expect(grade.recommendations[0].id).toBe("keep-watching"); + }); + + it("exits 1 under --strict when the domain is not protected", async () => { + const { handle } = io({ + resolver: staticResolver({ + "acme.com": { TXT: ["v=spf1 -all"], MX: ["aspmx.l.google.com"] }, + "_dmarc.acme.com": { TXT: ["v=DMARC1; p=none"] }, + }), + }); + expect(await runCli(["acme.com", "--selectors=none"], handle)).toBe(0); + expect( + await runCli(["acme.com", "--selectors=none", "--strict"], handle), + ).toBe(1); + }); + + it("exits 2 when DNS cannot answer, and says why on stderr", async () => { + const { handle, stderr } = io({ + resolver: () => Promise.reject(new Error("socket closed")), + }); + expect(await runCli(["acme.com"], handle)).toBe(2); + expect(stderr()).toContain("socket closed"); + }); + + it("exits 2 on a usage error", async () => { + const { handle, stderr } = io(); + expect(await runCli([], handle)).toBe(2); + expect(stderr()).toContain("mailgrade:"); + }); + + it("keeps ANSI out of the output unless colour is on", async () => { + const plain = io(); + await runCli(["acme.com", "--selectors=none"], plain.handle); + expect(plain.stdout()).not.toContain("\u001b["); + + const colored = io({ color: true }); + await runCli(["acme.com", "--selectors=none"], colored.handle); + expect(colored.stdout()).toContain("\u001b["); + + const flagged = io({ color: true }); + await runCli(["acme.com", "--selectors=none", "--no-color"], flagged.handle); + expect(flagged.stdout()).not.toContain("\u001b["); + }); +}); + +describe("renderGrade", () => { + it("renders the README's terminal example, exactly as printed", () => { + const grade = gradeRecords({ + domain: "acme.com", + txt: ["v=spf1 include:_spf.google.com -all"], + dmarc: ["v=DMARC1; p=none; rua=mailto:dmarc@acme.com"], + dkimSelectors: ["google"], + mx: ["aspmx.l.google.com"], + }); + expect(renderGrade(grade, { columns: 78 })).toBe( + [ + "", + " acme.com", + "", + " D spoofable", + " mail claiming to be this domain can reach inboxes", + "", + " ✓ SPF SPF ends in -all", + " ✓ DKIM DKIM keys published (google)", + " ✗ DMARC DMARC is monitoring only (p=none)", + " · MX Google Workspace", + "", + " → Graduate DMARC from p=none. Monitoring mode was designed as a", + " transition, not a destination: once the reports show your real senders", + " passing, move to p=quarantine, then p=reject.", + "", + ] + .map((line) => `${line}\n`) + .join(""), + ); + }); + + it("wraps recommendation prose to the terminal width", () => { + const grade = gradeRecords({ + domain: "acme.com", + txt: ["v=spf1 include:_spf.google.com ~all"], + dmarc: ["v=DMARC1; p=none; rua=mailto:d@acme.com"], + mx: ["aspmx.l.google.com"], + }); + const narrow = renderGrade(grade, { columns: 50 }); + for (const line of narrow.split("\n")) { + expect(line.length).toBeLessThanOrEqual(50); + } + expect(narrow).toContain("Graduate DMARC"); + }); +}); diff --git a/test/doh.test.ts b/test/doh.test.ts index e1f8c64..fecda11 100644 --- a/test/doh.test.ts +++ b/test/doh.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; -import { checkDomain, DnsError, resolveDomain, type FetchLike } from "../src/doh.ts"; +import { gradeDomain, DnsError, resolveDomain, type FetchLike } from "../src/doh.ts"; import { staticResolver } from "../src/verify/resolver.ts"; type Zone = Record; @@ -136,7 +136,7 @@ describe("resolveDomain", () => { describe("bring your own DNS", () => { it("routes every lookup through an injected resolver instead of DoH", async () => { - const grade = await checkDomain("acme.com", { + const grade = await gradeDomain("acme.com", { selectors: ["google"], fetch: httpFailure, // proves fetch is never touched resolver: staticResolver({ @@ -154,9 +154,9 @@ describe("bring your own DNS", () => { }); }); -describe("checkDomain", () => { +describe("gradeDomain", () => { it("resolves and grades in one call", async () => { - const grade = await checkDomain("acme.com", { + const grade = await gradeDomain("acme.com", { selectors: ["google"], fetch: stubFetch({ "TXT acme.com": { answers: [[TXT, '"v=spf1 include:_spf.google.com -all"']] }, diff --git a/test/exports.test.ts b/test/exports.test.ts index 626f8a2..6b19955 100644 --- a/test/exports.test.ts +++ b/test/exports.test.ts @@ -15,66 +15,40 @@ import * as dkim from "../src/dkim.ts"; import * as domain from "../src/domain.ts"; import * as verify from "../src/verify/index.ts"; import * as nodeDns from "../src/node-dns.ts"; +import * as doh from "../src/doh.ts"; const names = (m: object) => Object.keys(m).toSorted(); describe("entry points", () => { - it("exposes the headline call and the pieces it is made of", () => { + it("exposes the documented API, and only that", () => { expect(names(root)).toEqual([ - "DEFAULT_OPTIONS", - "DEFAULT_RI", "DKIM_SELECTORS", - "DMARC_TAGS", - "IMPERSONATED_DOMAINS", - "INVISIBLE", + "DnsError", "aligns", "analyzeDkim", "analyzeDmarc", "analyzeHeaders", "analyzeSpf", - "blankCheck", "buildDmarcRecord", - "cleanAddresses", + "gradeDomain", "coerceDomain", - "decodeEncodedWords", - "detectImpersonation", - "dkimCheck", "dkimHost", - "dmarcCheck", - "dmarcGrade", "dmarcHost", - "domainLabel", + "dohResolver", "domainOf", "domainVerdict", - "editDistance", - "externalDestinations", - "gradeDomain", - "hasMixedScript", - "headerValue", - "headerValues", - "headerVerdict", - "identifierDomain", + "gradeRecords", "isDkimKey", "isDmarcRecord", - "isDomainName", - "isPrivateIp", "isSpfRecord", "mailProvider", - "parseAddress", - "parseAuthResults", "parseDmarcRecord", - "parseHeaders", - "parseReceived", "registrableDomain", - "reportDomain", - "recordTags", + "resolveDomain", "reviewDmarc", "rolloutPlan", - "spfCheck", "staticResolver", - "stripComments", "toAuthResults", - "txtChunks", "verifyDkim", "verifyDmarc", "verifyMessage", @@ -101,18 +75,56 @@ describe("entry points", () => { "verifyMessage", "verifySpf", ]); + expect(names(doh)).toEqual([ + "DEFAULT_ENDPOINT", + "DnsError", + "defaultResolver", + "dohResolver", + "gradeDomain", + "resolveDomain", + ]); }); it("exposes the Node resolver only from its own entry point", () => { expect(names(nodeDns)).toEqual(["nodeResolver"]); - for (const module of [root, spf, dkim, domain, dmarc, headers, verify]) { + for (const module of [root, spf, dkim, domain, dmarc, headers, verify, doh]) { expect(names(module)).not.toContain("nodeResolver"); } }); - it("keeps the network code out of every entry point but its own", () => { - for (const module of [root, spf, dkim, domain, dmarc, headers, verify]) { - expect(names(module)).not.toContain("checkDomain"); + // The headline calls belong on the headline import: a reader reaching for + // this library types `mailgrade`, not `mailgrade/doh`. What has to stay out + // of the root is `node:`, which is a bundler problem rather than a taste + // one, and that is pinned separately below. + it("puts the headline calls on the root import", () => { + expect(names(root)).toContain("gradeDomain"); + expect(names(root)).toContain("verifyMessage"); + expect(names(root)).toContain("analyzeHeaders"); + }); + + // The root is curated by hand, so this is the test that notices when + // somebody reaches for `export *` again and the surface doubles. + it("keeps the helpers off the root and on their own entry point", () => { + for (const name of [ + "blankCheck", + "editDistance", + "headerValue", + "parseHeaders", + "spfCheck", + "stripComments", + "txtChunks", + ]) { + expect(names(root)).not.toContain(name); + } + expect(names(headers)).toContain("blankCheck"); + expect(names(headers)).toContain("editDistance"); + expect(names(headers)).toContain("parseHeaders"); + expect(names(dmarc)).toContain("txtChunks"); + }); + + it("keeps the network code out of the entry points that promise none", () => { + for (const module of [spf, dkim, domain, dmarc, headers]) { + expect(names(module)).not.toContain("gradeDomain"); expect(names(module)).not.toContain("resolveDomain"); expect(names(module)).not.toContain("dohResolver"); } diff --git a/test/readme.test.ts b/test/readme.test.ts index 8ea84ac..adc1803 100644 --- a/test/readme.test.ts +++ b/test/readme.test.ts @@ -5,14 +5,23 @@ * cheapest way to find out. Change an example here and in the README together. */ +import { createHash, createSign, generateKeyPairSync } from "node:crypto"; import { expect, it } from "vitest"; -import { gradeDomain } from "../src/index.ts"; -import { buildDmarcRecord, reviewDmarc, rolloutPlan } from "../src/dmarc/index.ts"; -import { analyzeHeaders } from "../src/headers/index.ts"; -import { staticResolver, verifySpf } from "../src/verify/index.ts"; + +// Every import the README makes is from the root, so this is where a name +// that quietly left the public barrel shows up. +import { + analyzeHeaders, + buildDmarcRecord, + gradeRecords, + reviewDmarc, + rolloutPlan, + staticResolver, + verifyMessage, +} from "../src/index.ts"; it("grade example", () => { - const grade = gradeDomain({ + const grade = gradeRecords({ domain: "acme.com", txt: ["v=spf1 include:_spf.google.com ~all"], dmarc: ["v=DMARC1; p=none; rua=mailto:dmarc@acme.com"], @@ -20,6 +29,7 @@ it("grade example", () => { mx: ["aspmx.l.google.com"], }); expect(grade.verdict).toBe("spoofable"); + expect(grade.letter).toBe("D"); expect(grade.dmarc.id).toBe("dmarc-monitor"); expect(grade.dmarc.headline).toBe("DMARC is monitoring only (p=none)"); expect(grade.recommendations[0]!.id).toBe("graduate-dmarc"); @@ -37,9 +47,8 @@ it("dmarc example", () => { }); it("headers example", () => { - const raw = `Received: from vps.hosting.example (vps.hosting.example [185.22.11.9]) - by mx.acme.example with ESMTP id 4b2c1f; - Mon, 2 Jun 2025 03:41:02 +0000 + const headers = `Received: from vps.hosting.example (185.22.11.9) by mx.acme.example; + Mon, 2 Jun 2025 03:41:02 +0000 Authentication-Results: mx.acme.example; spf=pass smtp.mailfrom=billing@acme-invoices.example; dkim=none; @@ -47,13 +56,10 @@ Authentication-Results: mx.acme.example; Return-Path: From: Acme Finance Reply-To: finance.acme@mail-secure.example -To: bob@acme.example Subject: Overdue invoice -Date: Mon, 2 Jun 2025 03:41:00 +0000 -X-PHP-Originating-Script: 0:sendmail.php +X-PHP-Originating-Script: 0:sendmail.php`; -body`; - const result = analyzeHeaders(raw); + const result = analyzeHeaders(headers); expect(result.verdict).toBe("suspicious"); expect(result.spf.result).toBe("pass"); expect(result.spf.aligned).toBe(false); @@ -62,15 +68,61 @@ body`; ]); }); -it("verify example", async () => { - const spf = await verifySpf({ - ip: "192.0.2.10", - sender: "bob@example.com", +/** Relaxed header canonicalization, which is what the signature covers. */ +const relax = (header: string) => + header.replace(/^([^:]+):\s*/, (_, name: string) => `${name.toLowerCase()}:`); + +it("verify example: the header block, exactly as printed", async () => { + const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 }); + + const from = "From: Billing "; + const to = "To: customer@acme.com"; + const subject = "Subject: Invoice 42"; + const body = "Please find the invoice attached.\r\n"; + + const bh = createHash("sha256").update(Buffer.from(body, "latin1")).digest("base64"); + const value = + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=mail; ` + + `h=from:to:subject; bh=${bh}; b=`; + const signed = + [from, to, subject].map((h) => `${relax(h)}\r\n`).join("") + + relax(`DKIM-Signature: ${value}`); + const signer = createSign("RSA-SHA256"); + signer.update(Buffer.from(signed, "latin1")); + const b = signer.sign(rsa.privateKey).toString("base64"); + + const rawMessage = + `DKIM-Signature: ${value}${b}\r\n${from}\r\n${to}\r\n${subject}\r\n\r\n${body}`; + + const result = await verifyMessage(rawMessage, { + ip: "203.0.113.50", + sender: "bounce@example.com", + helo: "mail.example.com", + mta: "mx.acme.com", resolver: staticResolver({ - "example.com": { TXT: ["v=spf1 ip4:192.0.2.0/24 -all"] }, + "example.com": { TXT: ["v=spf1 ip4:203.0.113.0/24 -all"] }, + "_dmarc.example.com": { TXT: ["v=DMARC1; p=reject"] }, + "mail._domainkey.example.com": { + TXT: [ + `v=DKIM1; k=rsa; p=${rsa.publicKey + .export({ type: "spki", format: "der" }) + .toString("base64")}`, + ], + }, }), }); - expect(spf.result).toBe("pass"); - expect(spf.mechanism).toBe("+ip4:192.0.2.0/24"); - expect(spf.lookups).toBe(0); + + expect(result.dmarc?.result).toBe("pass"); + // What to do with this message, not the policy the domain publishes: it + // passed, so nothing. + expect(result.dmarc?.disposition).toBe("none"); + expect(result.headers).toBe( + "Received-SPF: pass (mx.acme.com: domain of bounce@example.com designates\r\n" + + " 203.0.113.50 as permitted sender) client-ip=203.0.113.50;\r\n" + + ' envelope-from="bounce@example.com"; helo=mail.example.com\r\n' + + "Authentication-Results: mx.acme.com;\r\n" + + " spf=pass smtp.mailfrom=bounce@example.com;\r\n" + + " dkim=pass header.d=example.com header.s=mail;\r\n" + + " dmarc=pass header.from=example.com\r\n", + ); }); diff --git a/test/spec.test.ts b/test/spec.test.ts index 472618b..7a904d3 100644 --- a/test/spec.test.ts +++ b/test/spec.test.ts @@ -24,7 +24,7 @@ import { } from "../src/dmarc/record.ts"; import { dmarcGrade, reviewDmarc, rolloutPlan } from "../src/dmarc/review.ts"; import { aligns, coerceDomain, registrableDomain } from "../src/domain.ts"; -import { gradeDomain, type DomainRecords } from "../src/grade.ts"; +import { gradeRecords, type DomainRecords } from "../src/grade.ts"; import { analyzeHeaders } from "../src/headers/analyze.ts"; import { detectImpersonation, editDistance } from "../src/headers/impersonation.ts"; import { @@ -59,7 +59,7 @@ const PROJECT: Record) => unknown> = { aligns: (i) => ({ aligns: aligns(i["a"], i["b"]) }), analyzeSpf: (i) => { - const r = gradeDomain({ domain: "x.example", txt: i["txt"], dmarc: [] }).spf; + const r = gradeRecords({ domain: "x.example", txt: i["txt"], dmarc: [] }).spf; return { id: r.id, status: r.status, @@ -118,10 +118,11 @@ const PROJECT: Record) => unknown> = { }; }, - gradeDomain: (i) => { - const g = gradeDomain(i as DomainRecords); + gradeRecords: (i) => { + const g = gradeRecords(i as DomainRecords); return { verdict: g.verdict, + letter: g.letter, spfId: g.spf.id, dmarcId: g.dmarc.id, dkimId: g.dkim.id, diff --git a/test/verify-headers.test.ts b/test/verify-headers.test.ts new file mode 100644 index 0000000..3549e17 --- /dev/null +++ b/test/verify-headers.test.ts @@ -0,0 +1,155 @@ +/** + * The headers `verifyMessage` hands back, which are the whole point of + * verifying on the way in: a receiver prepends them and every downstream + * filter reads them instead of redoing the work. + * + * The test that matters most here is the round trip. A header this library + * generates has to be one `analyzeHeaders` parses, folding included, or the + * two halves of the package disagree about the same message. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { analyzeHeaders } from "../src/headers/index.ts"; +import { staticResolver, verifyMessage } from "../src/verify/index.ts"; + +const from = "From: Billing "; +const message = `${from}\r\nTo: customer@example.net\r\nSubject: Invoice 42\r\n\r\nbody\r\n`; + +const resolver = staticResolver({ + "example.com": { TXT: ["v=spf1 ip4:192.0.2.0/24 -all"] }, + "_dmarc.example.com": { TXT: ["v=DMARC1; p=reject"] }, +}); + +const arrived = { + resolver, + ip: "192.0.2.7", + sender: "bounces@example.com", + helo: "mail.example.com", + mta: "mx.example.net", +} as const; + +/** What a receiving parser does before reading a field: undo the folding. */ +const unfold = (headers: string) => headers.replace(/\r\n[ \t]/g, " "); + +describe("generated headers", () => { + it("stamps Received-SPF and Authentication-Results, ready to prepend", async () => { + const r = await verifyMessage(message, arrived); + + expect(r.authResults).toBe( + "mx.example.net; spf=pass smtp.mailfrom=bounces@example.com; " + + "dmarc=pass header.from=example.com", + ); + expect(r.headers).toBe( + "Received-SPF: pass (mx.example.net: domain of bounces@example.com designates\r\n" + + " 192.0.2.7 as permitted sender) client-ip=192.0.2.7;\r\n" + + ' envelope-from="bounces@example.com"; helo=mail.example.com\r\n' + + "Authentication-Results: mx.example.net;\r\n" + + " spf=pass smtp.mailfrom=bounces@example.com;\r\n" + + " dmarc=pass header.from=example.com\r\n", + ); + }); + + it("prepends onto a message and reads back the same verdict", async () => { + const r = await verifyMessage(message, arrived); + const delivered = `${r.headers}${message}`; + + const read = analyzeHeaders(delivered); + expect(read.spf.result).toBe("pass"); + expect(read.spf.aligned).toBe(true); + expect(read.dmarc.result).toBe("pass"); + expect(read.verdict).toBe("authentic"); + }); + + it("folds every generated line inside the RFC 5322 limit", async () => { + const r = await verifyMessage(message, arrived); + for (const line of r.headers.split("\r\n")) { + expect(line.length).toBeLessThanOrEqual(78); + } + }); + + it("names the authserv-id mailgrade when no mta is given", async () => { + const r = await verifyMessage(message, { resolver }); + expect(r.authResults.startsWith("mailgrade; ")).toBe(true); + }); + + it("omits Received-SPF when there was no IP to evaluate", async () => { + const r = await verifyMessage(message, { resolver, mta: "mx.example.net" }); + expect(r.headers).not.toContain("Received-SPF"); + expect(r.headers.startsWith("Authentication-Results:")).toBe(true); + }); + + it("reports a failure honestly in the Received-SPF comment", async () => { + const r = await verifyMessage(message, { ...arrived, ip: "203.0.113.9" }); + expect(r.spf?.result).toBe("fail"); + expect(unfold(r.headers)).toContain( + "Received-SPF: fail (mx.example.net: domain of bounces@example.com " + + "does not designate 203.0.113.9 as permitted sender)", + ); + }); +}); + +describe("hostile values", () => { + // Every value in these headers arrived off the wire, and a receiver + // prepends the block verbatim: a CR or LF that survives is a forged header, + // not a value. + it("refuses to let a CRLF in the envelope become a new header field", async () => { + const r = await verifyMessage(message, { + ...arrived, + sender: "bounces@example.com\r\nX-Evil: injected", + helo: "mail.example.com\nX-Also-Evil: yes", + }); + + // The CRLFs are flattened to spaces, so "X-Evil" survives only inside a + // value. What must not happen is a line of the block starting anything + // but a field this library generates or a folded continuation of one. + for (const line of r.headers.split("\r\n").filter((l) => l !== "")) { + expect(line).toMatch(/^(?:Received-SPF:|Authentication-Results:| )/); + } + + // And a parser reading the prepended block sees exactly two fields. + const fieldNames = `${r.headers}\r\n`.match(/^[!-9;-~]+:/gm) ?? []; + expect(fieldNames).toEqual(["Received-SPF:", "Authentication-Results:"]); + }); + + it("keeps a quoted local part with a semicolon whole across the fold", async () => { + const r = await verifyMessage(message, { + ...arrived, + sender: '"a;b"@example.com', + }); + + // Escaped for the quoted-string, and unfolded back to one piece: the + // semicolon never became a segment boundary and no space crept inside. + expect(unfold(r.headers)).toContain('envelope-from="\\"a;b\\"@example.com"'); + for (const line of r.headers.split("\r\n")) { + expect(line.length).toBeLessThanOrEqual(78); + } + }); +}); + +describe("the default resolver", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // The whole ergonomic claim of 2.0 is that this call needs no setup. If the + // default ever stops being wired up, every example in the README breaks and + // nothing else in the suite would notice, because everything else injects. + it("resolves over DoH when the caller names none", async () => { + const fetched: string[] = []; + vi.stubGlobal("fetch", (url: string) => { + fetched.push(url); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ Status: 0, Answer: [] }), + }); + }); + + const r = await verifyMessage(message, { ip: "192.0.2.7", sender: "a@b.example" }); + + expect(fetched.some((url) => url.startsWith("https://cloudflare-dns.com/dns-query"))).toBe(true); + expect(fetched.some((url) => url.includes("_dmarc.example.com"))).toBe(true); + expect(r.dmarc?.result).toBe("none"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index cef0280..853db53 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "allowImportingTsExtensions": true, "moduleDetection": "force", "verbatimModuleSyntax": true, + "resolveJsonModule": true, "erasableSyntaxOnly": true, "isolatedDeclarations": true, "declaration": true, diff --git a/tsdown.config.ts b/tsdown.config.ts index 72e01f5..cf5ed7f 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -3,6 +3,7 @@ import { defineConfig, type UserConfig } from "tsdown"; const config: UserConfig = defineConfig({ entry: [ "src/index.ts", + "src/cli.ts", "src/spf.ts", "src/dkim.ts", "src/domain.ts",