Skip to content
Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Repository files navigation

mailgrade-action

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.

marketplace license


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.

Watch it nightly

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.com

Every 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.

Review it in the pull request

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@v1

📮 mailgrade

Reviewing 2 DNS records in infra/dns/cloudflare.tf, graded as if this pull request had merged.

Domain Record Published now This PR Grade
acme.com SPF v=spf1 include:_spf.google.com -all v=spf1 include:_spf.google.com include:sendgrid.net ~all A+ → B
acme.com DMARC v=DMARC1; p=reject; rua=mailto:dmarc@acme.com v=DMARC1; p=none; rua=mailto:dmarc@acme.com

This 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.

fail-on: regression

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.

Committing the baseline

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.

What it checks

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.

The SPF lookup count

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.

Thresholds

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

Inputs

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.

Outputs

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.

Permissions

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.

Where the DNS config is found

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.

One gotcha worth knowing

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.

Notes

  • 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. getOctokit ships without them, so this adds @octokit/plugin-retry and @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 with src/.

About

Built and maintained by Fallax, phishing simulations and security awareness training on autopilot. MIT licensed.

About

Watch SPF, DKIM and DMARC for drift, and review DNS record changes before they merge.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages