Watch SPF, DKIM and DMARC for drift, and review DNS record changes before they merge.
Powered by mailgrade. No service, no API key, nothing to sign up for.
Email authentication is production config that lives in DNS, drifts silently, and nothing watches it.
Somebody adds a SaaS tool to the SPF include list and crosses the ten-lookup
limit into permerror. Somebody flips DMARC to p=none "temporarily" during a
migration and never flips it back. A vendor rotates and a DKIM selector stops
answering. Every one of those is invisible until mail starts bouncing or
somebody spoofs you. Uptime monitors watch HTTP. Nothing watches your sending
identity.
This does, on the two occasions it can be caught: nightly, and in review.
name: Email auth
on:
schedule: [{ cron: "17 6 * * *" }]
workflow_dispatch:
permissions:
contents: read
security-events: write
jobs:
grade:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: FallaxIO/mailgrade-action@v1
with:
domains: acme.com, mail.acme.comEvery run writes a grade table to the job summary and, where the permission allows, sends its findings to the repository's Security tab. The build breaks on the day something gets worse than the committed baseline, and not before.
Nobody lints DNS records in code review. If your DNS lives in the repository, Terraform or octoDNS or DNSControl or Pulumi, this reads the records the diff proposes, grades them as if they had merged, and says what changes.
name: Email auth
on:
pull_request:
paths: ["infra/dns/**"]
permissions:
contents: read
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: FallaxIO/mailgrade-action@v1Reviewing 2 DNS records in
infra/dns/cloudflare.tf, graded as if this pull request had merged.
Domain Record Published now This PR Grade acme.comSPF v=spf1 include:_spf.google.com -allv=spf1 include:_spf.google.com include:sendgrid.net ~allA+ → B acme.comDMARC v=DMARC1; p=reject; rua=mailto:dmarc@acme.comv=DMARC1; p=none; rua=mailto:dmarc@acme.comThis weakens 1 domain.
acme.com: the grade dropped (A+ → D)
One comment per pull request, rewritten in place on every push. No domains
input needed: the zone comes out of the config.
This is the decision the whole action rests on, and the reason to read this section before any other.
A check that fails on every finding gives a new adopter a red build on the day they install it. A red build you were always going to get is a build people delete rather than fix. So the default compares against a reference and fails only on a downgrade:
fail-on |
Fails when |
|---|---|
never |
never; report only |
regression |
(default) something got worse than the reference |
error |
any error-severity finding |
any |
any finding at all, warnings included |
The reference is whichever one the trigger makes sense of:
- On a schedule, the committed baseline at
.mailgrade/baseline.json. No file yet means no regression is possible, so the first run passes however unflattering the grades are, and tells you to commit one. - On a pull request, live DNS. The question a diff raises is whether merging makes anything worse than it is right now.
Nobody sets out to weaken SPF. They add one include, or flip a policy for a migration. The regression is the diff, not the state, which is why this is what the action watches by default.
The baseline output is the current grades, ready to commit:
- uses: FallaxIO/mailgrade-action@v1
id: mailgrade
with:
domains: acme.com
update-baseline: true
- uses: peter-evans/create-pull-request@v7
if: always()
with:
add-paths: .mailgrade/baseline.json
commit-message: "chore: refresh the mailgrade baseline"
title: "Email auth baseline moved"A grade that improved does not fail anything; the summary just points out that the committed file is now behind.
Everything here is mailgrade's grading, which is the same engine behind fallax.io/tools/domain-spoofing-checker.
| SPF | present, exactly one record, ends in -all or ~all rather than +all or ?all, and the include tree costs fewer than 10 DNS lookups |
| DMARC | present, a policy at or above your threshold, rua= so reports have somewhere to land, pct=100, and sp= not quietly weaker than p= |
| DKIM | selectors answering with a usable key, across the names the major providers publish under |
| MX | consistent with the provider you say you use |
Every finding carries a stable id (spf-lookup-limit, dmarc-monitor,
mx-provider-mismatch), which is what the baseline diffs on and what the SARIF
rules are keyed to. Branch on ids; the English is free to change.
RFC 7208 caps SPF at ten DNS-costing terms, and a record over the cap is a
permerror for every message, including your own. The count is the whole
include tree, not the path one message happens to take, so the number reported
is the one receivers will refuse on. Nine or ten is a warning: the record still
works, with no room for the next tool anybody adds.
Every one of these is off unless you turn it on. mailgrade's own grade already
says a p=none domain is spoofable; these are for the team that has decided
p=quarantine is not good enough for them either.
with:
domains: acme.com
require-dmarc: reject # none | quarantine | reject | off
require-spf-all: "-all" # -all | ~all | off
expect-provider: Google Workspace| Input | Default | |
|---|---|---|
domains |
"" |
Comma or newline separated. Optional when the DNS config names its own zones. |
fail-on |
regression |
never, regression, error, any. |
baseline |
.mailgrade/baseline.json |
What a scheduled run compares against. |
update-baseline |
false |
Write the current grades to that path. Committing it is your workflow's job. |
require-dmarc |
off |
Weakest policy accepted. |
require-spf-all |
off |
Weakest all qualifier accepted. |
require-rua |
true |
Flag a DMARC record nobody reports to. |
expect-provider |
"" |
Provider the MX records should name. |
check-spf-lookups |
true |
Walk the include tree and count. |
dkim-selectors |
"" |
Comma separated. none skips the probe. |
review-dns-config |
auto |
On for pull_request events. |
dns-config-paths |
"" |
Globs. Defaults below. |
comment |
auto |
Comment on pull requests that touch DNS config. |
sarif-file |
mailgrade.sarif |
Written into the workspace. none disables. |
upload-sarif |
auto |
Send findings to code scanning. |
summary |
true |
Write the job summary. |
doh-endpoint |
"" |
A DNS-over-HTTPS JSON endpoint other than Cloudflare's. |
token |
${{ github.token }} |
For comments, the file list, and the SARIF upload. |
| Output | |
|---|---|
passed |
"true" when the run did not fail on its own threshold. |
letter |
The worst grade in the run, A+ down to F. |
verdict |
The worst verdict: protected, partial, spoofable. |
mode |
live for a monitor run, review when DNS config was graded. |
findings |
Every finding, as JSON. |
regressions |
What got worse, as JSON. |
baseline |
The current grades in baseline format, ready to commit. |
sarif-file |
Path to the report, empty when disabled. |
Nothing here is required. Each one buys one delivery channel, and a missing permission is a warning rather than a failed run: the grade and the exit code are the product, and they arrive either way. The same is true of a flaky API call, which is retried and then, if it still will not go through, warned about.
| Permission | Buys |
|---|---|
contents: read |
reading the DNS config out of the checkout |
pull-requests: write |
the review comment |
security-events: write |
findings in the Security tab |
Code scanning on a private repository also needs GitHub Advanced Security. On
upload-sarif: auto a refusal is a warning; set it to true if the Security
tab is the point and you want to hear about it loudly.
review-dns-config searches the checkout for records, and on a pull request
narrows that to the files the pull request touches. Default patterns:
**/*.tf **/*.tf.json **/*.yaml **/*.yml
**/dnsconfig.{js,ts,mjs,cjs,json}
A file only produces a finding if it actually contains a string beginning
v=spf1 or v=DMARC1, which nothing else does. Set dns-config-paths to
narrow it.
Attribution, which is to say working out which domain a record governs, is per format:
| Format | How the domain is worked out |
|---|---|
| Terraform | name and zone_name/zone/managed_zone on the enclosing *_record block. Cloudflare, Route 53, Google, Azure. |
| octoDNS | zone from the filename (zones/acme.com.yaml), name from the top-level key. |
| DNSControl | D("acme.com", …) for the zone, TXT("_dmarc", …) for the name. |
| Pulumi and the CDKs | the nearest name: field. |
A config that names its zone through a variable (name = var.mail_domain)
cannot be resolved without running Terraform. When that happens and you have
listed exactly one domain, that one is used; otherwise the record is skipped
with a warning on the line, because a wrong domain would grade the wrong zone.
GitHub disables scheduled workflows on public repositories after 60 days of
no repository activity. The nightly monitor stops, silently, on exactly the
quiet repositories that most need watching. Pair schedule with
workflow_dispatch so you can start it again by hand, and check in on it.
If you would rather have a monitor that does not depend on your repository being busy, that is a thing Fallax happens to sell.
- The grading is mailgrade. This action adds triggers, thresholds, a baseline and four output formats. It invents no rules of its own.
- DNS over HTTPS, not
node:dns. The same resolver runs here, in a Cloudflare Worker and in a browser, which is why the grade you get in CI is the grade the web tool gives you. node24, not Docker. Docker actions are Linux only and pull an image on every run. This is one bundled JavaScript file.- Transient failures are retried.
getOctokitships without them, so this adds@octokit/plugin-retryand@octokit/plugin-throttling: a 502 on the comment endpoint or a secondary rate limit costs a wait, not a review. dist/is committed, because an action runs from the checkout with no install step. CI checks it is in sync withsrc/.
Built and maintained by Fallax, phishing simulations and security awareness training on autopilot. MIT licensed.