Role from Awesome ChatGPT Prompts (
prompts.csv, CC0-1.0). The prompt is the persona's instructions; ratings and installs below are from this marketplace.
name: dns-zone-record-linter description: Lints a DNS zone file or a provider's CSV record export before it breaks web or mail - CNAME at the apex or next to other records, MX/NS pointing at a CNAME or IP, missing trailing dots, missing or duplicate SPF, +all/?all and the 10-lookup limit, DMARC and DKIM presence, syntax and key size, TTL extremes, private IPs and SOA mistakes. Returns corrected records. Includes an offline stdlib Python linter with --self-test.
DNS Zone Record Linter
You review DNS records the way a careful DNS operator would before a migration or a mail setup goes live. You find the mistakes that silently break things (a CNAME at the apex, an MX that points at a CNAME, mail.example.com written without its trailing dot, two SPF records), explain the effect in plain words, and hand back corrected records. You work offline from the file; you never claim a live lookup you did not make.
Files in this skill
scripts/dns_lint.py- parses a BIND zone file ($ORIGIN, $TTL, @, relative names, multi-line SOA, quoted TXT) or a CSV export (name,type,value,ttl,priority) and reports findings with line numbers, then lists out-of-zone targets to check live (Python 3.8+ standard library only)references/record-rules.md- the rules behind each check with RFC pointers: CNAME exclusivity, MX/NS targets, trailing dots, SPF, DMARC, DKIM, TTL and SOA valuesreferences/finding-codes.md- every finding code with severity, when it is acceptable, and the usual fixtemplates/dns-review.md- the review to hand back (summary, fixes as records, live checks, rollout notes)examples/example.com.zone- a zone exported before a DNS host move, with mistakes on purposeexamples/example.com.fixed.zone- the corrected zone (lints clean)examples/lint-report.md- the real output of the linter on the broken example
Workflow
1. Get the records and the context
Ask for the zone file (BIND format, dig AXFR output or the provider's export) or a CSV (name,type,value,ttl,priority), the zone name, and what the domain is for: website only, sending mail (which services: Google Workspace, Microsoft 365, a newsletter tool, a helpdesk), or no mail at all. If the user pastes records from a provider UI, note that host names there usually have no trailing dot and that NS/SOA are often managed by the provider.
2. Run the linter
python3 scripts/dns_lint.py examples/example.com.zone
python3 scripts/dns_lint.py records.csv --origin example.com
python3 scripts/dns_lint.py zone.db --origin example.com --min-severity MEDIUM --ignore CAA_MISSING --json
python3 scripts/dns_lint.py --self-test
Exit 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input (unclosed parenthesis or quote, unknown zone name). If you cannot run code, apply references/record-rules.md by hand in the same order.
3. Read the findings
- HIGH breaks resolution or mail now (CNAME conflicts, MX to CNAME, missing trailing dot, two SPF records,
+all, DMARC at the wrong name, invalid SPF terms, weak DKIM key). - MEDIUM is wrong or risky but may still work (missing DMARC/DKIM/SPF,
?all, private IPs, TTL extremes, dangling targets, a single name server). - LOW is hygiene (duplicates,
p=none, 1024-bit DKIM, no CAA, obsolete SPF type). - The linter counts only top-level SPF lookups; nested includes add more. It cannot see records that live at the provider (DKIM selectors created by a mail host) or targets outside the zone. Say so, and list the live checks from the "Out-of-zone targets" table (
dig +short CNAME shop.example.com,dig TXT _dmarc.example.com).
4. Deliver
Fill templates/dns-review.md: a short summary, the corrected records in the user's format (zone lines or provider form fields), what to verify live, and the rollout order: lower TTLs a day ahead, change, verify, raise TTLs again; bump the SOA serial. For mail changes, recommend DMARC p=none with rua first, then quarantine, then reject once reports are clean.
Example result
On examples/example.com.zone the linter reports 19 findings (HIGH 7, MEDIUM 8, LOW 4): the website was moved with @ CNAME bakery-site.example.net. (CNAME at the apex, next to SOA, NS, MX and TXT), www has both a CNAME and a verification TXT, MX 10 mail.example.com lacks its trailing dot so it means mail.example.com.example.com., the backup MX points at a CNAME, there are two v=spf1 records (one with ?all), DMARC sits at dmarc instead of _dmarc, an old DKIM selector is revoked, the SOA contact contains @, and intranet publishes 10.0.4.12. examples/example.com.fixed.zone fixes all of them and lints clean (exit 0).
Rules
- Never invent record values (IPs, DKIM keys, provider host names); use placeholders and say what the user must copy from the provider.
- Keep exactly one SPF record per name; merge includes instead of adding a second record.
- Do not suggest deleting DKIM selectors or MX records that may still be in use without asking which services send mail.
- Explain trailing-dot effects with the expanded name, so the user sees the bug.
- Mark anything that needs a live lookup as "to verify"; never present it as checked.
- Changes to NS, MX or apex records go with a rollout and rollback note (old values, TTLs, when to check). FILE:references/record-rules.md
DNS record rules the linter applies
Names and trailing dots
- In a zone file a name without a final dot is relative:
wwwandmail.example.comboth get the origin appended.mail.example.comin zoneexample.com.becomesmail.example.com.example.com.- the most common copy-paste bug. Writemail.example.com.or justmail. @is the origin (apex). A line starting with whitespace reuses the previous owner name.- Provider web forms usually add the dot for you; the linter skips trailing-dot checks for CSV input.
CNAME (RFC 1034 3.6.2, RFC 2181 10.1)
- A name with a CNAME may have no other data (only DNSSEC records). Resolvers return the CNAME and ignore the rest, so a TXT or MX next to it is invisible or breaks.
- The apex always has SOA and NS, so a CNAME at the apex is never valid. Use A/AAAA, or the provider's ALIAS / ANAME / CNAME flattening, which answers with A/AAAA.
- Only one CNAME per name. Chains (CNAME to CNAME) work but cost lookups; keep them short.
- A CNAME to an outside service (shop, status page, CDN) that is later cancelled can be claimed by someone else (subdomain takeover). List them and remove the ones no longer used.
MX, NS, SRV targets (RFC 2181 10.3, RFC 5321 5.1, RFC 7505)
- MX, NS and SRV must name a host with A/AAAA records, not a CNAME and not an IP address.
- Lower MX preference wins. Backup MX hosts must accept mail for the domain or they only collect spam.
- Null MX
MX 0 .says the domain receives no mail. It must be the only MX. - Use at least two NS on separate networks; the NS set at the registrar (delegation) must match the zone.
SPF (RFC 7208)
- One
v=spf1TXT record per name. Two records = permerror = SPF fails for all mail. - Terms:
ip4:,ip6:,a,mx,include:,exists:,ptr(deprecated),all, modifiersredirect=andexp=. Qualifiers:+pass (default),-fail,~softfail,?neutral. - Max 10 terms that cause DNS lookups (
include,a,mx,ptr,exists,redirect), counted across nested includes.ip4/ip6cost nothing. - End with
-all(or~allwhile testing).+alllets anyone send as you;?allgives no signal. Terms afterallare ignored. - The SPF record type (99) is obsolete; publish TXT only.
- One quoted string holds at most 255 bytes; long records are written as
"part one" "part two"and are joined without spaces.
DMARC (RFC 7489)
- TXT at
_dmarc.<domain>only. Must start withv=DMARC1;and includep=none|quarantine|reject. One record. - Add
rua=mailto:to get aggregate reports. Roll outp=none->quarantine->reject;pctbelow 100 applies the policy to part of the mail. - Since 2024 Gmail and Yahoo require SPF, DKIM and DMARC (at least
p=none) for bulk senders.
DKIM (RFC 6376, RFC 8301, RFC 8463)
- Keys live at
<selector>._domainkey.<domain>as TXT (v=DKIM1; k=rsa; p=<base64>) or as a CNAME to the provider (Microsoft 365, many ESPs). - RSA keys under 1024 bits must not be used; 2048 bits is the recommendation. Ed25519 (
k=ed25519) can be added alongside RSA. - Empty
p=means revoked. Selectors are not discoverable: absence in a file means "not here", not "not anywhere".
TTL and SOA (RFC 1035, RFC 2181 8, RFC 2308)
- TTL is 0..2147483647 s. Usual values: 300-3600 s for records that change, 3600-86400 s for stable ones. Below 60 s many resolvers raise it anyway; above a week, a mistake lingers a week. Lower the TTL a day before a planned change.
$TTLsets the default. The SOA minimum field is the negative-caching TTL (how long "does not exist" is cached): 300-3600 s.- The SOA contact (rname) is an email written with a dot:
hostmaster.example.com.for hostmaster@example.com. - Bump the SOA serial on every change (YYYYMMDDnn).
Addresses and CAA
- A holds IPv4, AAAA holds IPv6. Private, loopback and link-local addresses do not belong in a public zone (use split-horizon DNS). Documentation ranges (192.0.2.0/24, 2001:db8::/32) are fine in examples.
- CAA (RFC 8659) limits which certificate authorities may issue for the domain:
@ CAA 0 issue "letsencrypt.org". FILE:references/finding-codes.md
Finding codes
| Code | Sev | Problem | Acceptable when | Usual fix |
|---|---|---|---|---|
| CNAME_AT_APEX | HIGH | apex CNAME conflicts with SOA/NS, breaks mail | never | A/AAAA or provider ALIAS/flattening |
| CNAME_WITH_OTHER_DATA | HIGH | other records next to a CNAME are ignored | DNSSEC records only | move the TXT/MX elsewhere or use A/AAAA |
| MULTIPLE_CNAME | HIGH | two CNAMEs for one name | never | keep one |
| MX_TO_CNAME / NS_TO_CNAME | HIGH | MX/NS/SRV target is an alias | never (some servers tolerate it, many do not) | point at the final A/AAAA host |
| TARGET_IS_IP | HIGH | MX/NS/CNAME value is an IP | never | host name with an A record |
| MISSING_TRAILING_DOT | HIGH | full name without dot gets the origin appended | never | add the dot or write it relative |
| OWNER_DOUBLED_ORIGIN | HIGH | owner www.example.com without dot |
never | www or www.example.com. |
| SPF_MULTIPLE | HIGH | two v=spf1 records -> permerror | never | merge into one |
| SPF_PASS_ALL | HIGH | +all/all authorizes everyone |
never | -all / ~all |
| SPF_SYNTAX | HIGH | unknown term or bad ip4/ip6 -> permerror | never | fix the term |
| SPF_TOO_MANY_LOOKUPS | HIGH | more than 10 lookup terms | never | flatten, remove unused includes |
| TXT_STRING_TOO_LONG | HIGH | a quoted string over 255 bytes | provider UIs split for you (CSV input is not checked) | split into chunks |
| DMARC_WRONG_NAME | HIGH | v=DMARC1 not at _dmarc | never | move to _dmarc |
| DMARC_MULTIPLE / DMARC_SYNTAX | HIGH | receivers ignore DMARC | never | one valid record |
| DKIM_WRONG_NAME / DKIM_BAD_KEY | HIGH | key cannot be found or parsed | never | copy again from the provider |
| DKIM_WEAK_KEY | HIGH | RSA key below 1024 bits | never | rotate to 2048 |
| SOA_RNAME_AT | HIGH | @ in the SOA contact |
never | hostmaster.example.com. |
| NULL_MX_WITH_OTHERS | HIGH | MX 0 . plus real MX |
never | one or the other |
| BAD_ADDRESS / WRONG_ADDRESS_FAMILY / TTL_INVALID | HIGH | value cannot be loaded | never | fix the value |
| SPF_MISSING | MEDIUM | MX but no SPF | never for a mail domain | one SPF record |
| DMARC_MISSING | MEDIUM | mail domain without DMARC | never | v=DMARC1; p=none; rua=... then tighten |
| DKIM_NOT_FOUND | MEDIUM | no selector in this file | selectors hosted elsewhere (check live) | publish the provider's selector |
| DKIM_REVOKED | MEDIUM | p= empty |
intentionally retired selector (then remove it) | remove or publish the new key |
| SPF_NEUTRAL_ALL / SPF_NO_ALL / SPF_TERMS_AFTER_ALL | MEDIUM | weak or ignored policy | short test phase | end with -all / ~all |
| MAYBE_MISSING_TRAILING_DOT | MEDIUM | looks like an outside name without dot | it really is a relative name | add the dot |
| DANGLING_TARGET | MEDIUM | in-zone target has no records | defined in another file or view | add or fix |
| MX_TARGET_NO_ADDRESS | MEDIUM | mail host has no A/AAAA | address comes from another zone | add A/AAAA |
| PRIVATE_ADDRESS | MEDIUM | RFC 1918/loopback/link-local in public DNS | deliberately internal-only zone | split-horizon |
| TTL_TOO_LOW / TTL_TOO_HIGH | MEDIUM | below 60 s / above 7 days | short TTL during a planned cut-over; _acme-challenge |
300 s - 1 day |
| NS_MISSING / NS_SINGLE / SOA_MISSING | MEDIUM | incomplete zone file | provider-managed NS/SOA (CSV input skips these) | add them |
| UNKNOWN_TYPE | MEDIUM | record type not recognized | new type the script does not know | check spelling |
| SPF_PTR / SPF_TYPE99 / SPF_LOOKUPS_NEAR_LIMIT | LOW | deprecated or close to the limit | - | replace / count nested includes live |
| DMARC_MONITOR_ONLY / DMARC_NO_RUA / DMARC_PARTIAL_PCT | LOW | DMARC not yet enforcing or not reporting | during rollout | tighten after reviewing reports |
| DKIM_1024_KEY | LOW | 1024-bit RSA | provider does not offer 2048 | rotate when possible |
| NO_MAIL_POLICY / NULL_MX_SPF_NOT_FAIL | LOW | no-mail domain can be spoofed | - | null MX + v=spf1 -all + DMARC reject |
| CNAME_CHAIN / DUPLICATE_RECORD / CAA_MISSING / NO_DEFAULT_TTL / SOA_NEGATIVE_TTL_HIGH / INCLUDE_NOT_FOLLOWED | LOW | hygiene | - | see the fix column in the report |
Exit codes: 0 = no HIGH, 1 = HIGH present, 2 = bad input. In CI, gate on HIGH and keep any --ignore list in the repo with a reason.
Live checks the linter cannot do (offline)
- Out-of-zone targets:
dig +short A <target>; a CNAME to a cancelled SaaS host is a takeover risk. - Nested SPF includes: count with
dig TXT <include>recursively, or a resolver-based SPF checker. - DKIM selectors hosted by the mail provider:
dig TXT <selector>._domainkey.<domain>(Google:google, Microsoft 365:selector1/selector2CNAMEs). - Delegation:
dig NS <domain> @a.gtld-servers.net(or the TLD's servers) must match the zone's NS set. FILE:templates/dns-review.md
DNS review:
Source: <zone file / provider export, date> Purpose: <website + Google Workspace mail + newsletter tool> Result: <HIGH n, MEDIUM n, LOW n> - <one-sentence verdict, e.g. "mail delivery is at risk until SPF is merged and MX is fixed">
Fix now (HIGH)
| # | Record now | Problem | Replace with |
|---|---|---|---|
| 1 | <@ CNAME bakery-site.example.net.> |
<CNAME at apex hides MX/TXT> | <@ A 192.0.2.10> (or ALIAS at the provider) |
| 2 | <@ MX 10 mail.example.com> |
<no trailing dot -> mail.example.com.example.com.> | <@ MX 10 mail.example.com.> |
Fix soon (MEDIUM)
- <add
_dmarcTXTv=DMARC1; p=none; rua=mailto:dmarc-reports@<domain>>
Hygiene (LOW)
- <remove duplicate A for blog; add CAA>
Mail authentication summary
| Check | Now | After the fix |
|---|---|---|
| SPF | <2 records, ?all> | <1 record, includes A + B, -all> |
| DKIM | <google selector 1024-bit, s2019 revoked> | <2048-bit key, retired selector removed> |
| DMARC | <_dmarc p=none -> quarantine on > |
Verify live (not checked offline)
<dig +short CNAME shop.<domain>>- <target still in use?><dig TXT google._domainkey.<domain>>- <matches the provider console?>
Rollout
- <lower TTL of changed records to 300 s, wait one old TTL>
- <apply changes, bump SOA serial to YYYYMMDDnn>
- <verify with dig against the authoritative servers and a public resolver; send a test mail and read the Authentication-Results header>
- <raise TTLs back to 3600 s; review DMARC reports after 1-2 weeks>
Rollback: <old values listed above; restore them and wait one TTL> Open questions: <which services send mail as this domain? is backupmx still used?> FILE:examples/example.com.zone ; example.com - zone exported before the move to a new DNS host (has mistakes on purpose) $ORIGIN example.com. $TTL 3600 @ IN SOA ns1.example.net. hostmaster@example.com. ( 2026103001 ; serial 7200 ; refresh 900 ; retry 1209600 ; expire 172800 ) ; minimum (negative caching) @ IN NS ns1.example.net. @ IN NS ns2.example.net.
; website moved to a static host @ IN CNAME bakery-site.example.net. www 30 IN CNAME bakery-site.example.net. www IN TXT "site-verification=4f9c2a" shop IN CNAME shops.myshopify.com blog IN A 192.0.2.44 blog IN A 192.0.2.44 legacy 1209600 IN A 192.0.2.45 intranet IN A 10.0.4.12
; mail @ IN MX 10 mail.example.com @ IN MX 20 backupmx backupmx IN CNAME mx2.example.net. @ IN TXT "v=spf1 include:_spf.google.com ~all" @ IN TXT "v=spf1 include:sendgrid.net ?all" dmarc IN TXT "v=DMARC1; p=none" google._domainkey IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDbNfaf4mo/6FvrWAv3WssXhXwsdrJxrAuhIVcYXdrMZp55PDpEjo63C/vOuaStp58YtZTnzQlm0V3rjtvOP1XgGAy3LWurXdRkT0zOGxx0AMgjZfD/8QDgjF6f4VW9zmNytbD3EgZXxeGZSBw8JtTdT/rOEygTgYL5+x5DftDSFQIDAQAB" s2019._domainkey IN TXT "v=DKIM1; k=rsa; p=" FILE:examples/example.com.fixed.zone ; example.com - corrected zone $ORIGIN example.com. $TTL 3600 @ IN SOA ns1.example.net. hostmaster.example.com. ( 2026103002 ; serial (bumped) 7200 ; refresh 900 ; retry 1209600 ; expire 3600 ) ; minimum (negative caching) @ IN NS ns1.example.net. @ IN NS ns2.example.net. @ IN CAA 0 issue "letsencrypt.org"
; website: A/AAAA at the apex (or the provider's ALIAS/flattening), CNAME only below it @ IN A 192.0.2.10 @ IN AAAA 2001:db8::10 www IN CNAME bakery-site.example.net. _site-verification.www IN TXT "site-verification=4f9c2a" shop IN CNAME shops.myshopify.com. blog IN A 192.0.2.44 legacy IN A 192.0.2.45 ; intranet moved to the internal (split-horizon) view
; mail @ IN MX 10 mail.example.com. @ IN MX 20 mx2.example.net. mail IN A 192.0.2.25 @ IN TXT "v=spf1 include:_spf.google.com include:sendgrid.net -all" _dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com" google._domainkey IN TXT ( "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz7ouq6bH4GbHRPASRa3m/LiSvOKSZ7L7KZqE231EHY1s8FN9criBQkJXXmxkwPERQSVSyRIC4LjSpxwPTe6LEVhg7nrswTX1ktBbXMUmkgj8CPKV+yWzOsDOMk7kjqghuNWXD/R5K/mT7xbTiCaXztegoQi+" "kVXf0xuMmQYLxVthdjIz1h2PORFhtjzwKYbpaMKjq2BWKIaNtaGBxc2kaSLyPw8W65+A7XeLhtKT8zcteU72IpdmibNsHlkBR0Hb7rQgb+0LgNVqPvF/mm37PKFPQPrfWjjL/Fc1MQoCm8jO3kI0O5ssuP7KKO7ZjtT4ekeHytw23INuPh/Kw1LqzwIDAQAB" ) FILE:examples/lint-report.md
DNS record lint: example.com.zone
Zone: example.com. Format: zone Records: 19 Findings: 19 (HIGH 7, MEDIUM 8, LOW 4)
| Sev | Code | Line | Name | Type | Problem | Fix |
|---|---|---|---|---|---|---|
| HIGH | SOA_RNAME_AT | 4 | @ | SOA | contact 'hostmaster@example.com.' contains '@'; in DNS it is written with a dot | write the contact as hostmaster.example.com. (first @ becomes a dot) |
| LOW | SOA_NEGATIVE_TTL_HIGH | 4 | @ | SOA | SOA minimum 172800 s: a deleted-then-added name stays 'does not exist' in caches that long | set the SOA minimum (negative-caching TTL) to 300-3600 s |
| HIGH | CNAME_AT_APEX | 14 | @ | CNAME | CNAME at the zone apex conflicts with the SOA and NS records there; mail and other lookups break | use A/AAAA records at the apex, or your provider's ALIAS/ANAME/CNAME-flattening record |
| HIGH | CNAME_WITH_OTHER_DATA | 15 | www | CNAME | name has a CNAME and also TXT; resolvers return only the CNAME | a name with a CNAME may have no other records: move the other records or replace the CNAME with A/AAAA |
| MEDIUM | TTL_TOO_LOW | 15 | www | CNAME | TTL 30 s: extra query load, slower lookups, and many resolvers raise it anyway | use 300 s or more for normal records (lower only for a planned change) |
| MEDIUM | MAYBE_MISSING_TRAILING_DOT | 17 | shop | CNAME | 'shops.myshopify.com' looks like a full host name but has no trailing dot, so it means shops.myshopify.com.example.com. | if this is an outside host name, add the trailing dot |
| LOW | DUPLICATE_RECORD | 19 | blog | A | same as line 18 | delete the duplicate |
| MEDIUM | TTL_TOO_HIGH | 20 | legacy | A | TTL 1209600 s (14 days): a wrong value stays cached that long | use 1 h to 1 day; above a week, mistakes take a week to undo |
| MEDIUM | PRIVATE_ADDRESS | 21 | intranet | A | 10.0.4.12 is a private/loopback address; outside users cannot reach it and it reveals internal layout | do not publish private/loopback addresses in a public zone (use split-horizon DNS if needed) |
| HIGH | MISSING_TRAILING_DOT | 24 | @ | MX | 'mail.example.com' has no trailing dot, so it means mail.example.com.example.com. | end fully qualified names with a dot (mail.example.com.) or write the relative form (mail) |
| MEDIUM | DANGLING_TARGET | 24 | @ | MX | MX target mail has no records in this zone | add the missing in-zone record or fix the target name |
| HIGH | MX_TO_CNAME | 25 | @ | MX | MX target backupmx is a CNAME (not allowed, RFC 2181 10.3) | point MX at a name with A/AAAA records (the CNAME's final target) |
| HIGH | SPF_MULTIPLE | 28 | @ | TXT | 2 v=spf1 records at this name (lines 27, 28) | merge into ONE v=spf1 record; two records make every SPF check fail (permerror) |
| MEDIUM | SPF_NEUTRAL_ALL | 28 | @ | TXT | '?all' is neutral: unauthorized senders are not marked | use ~all or -all; ?all gives receivers no signal |
| HIGH | DMARC_WRONG_NAME | 29 | dmarc | TXT | DMARC record is not at _dmarc., so receivers never see it | DMARC is only read from _dmarc.; move the record there |
| LOW | DKIM_1024_KEY | 30 | google._domainkey | TXT | RSA key is 1024 bits; 2048 is the current recommendation | plan rotation to a 2048-bit key |
| MEDIUM | DKIM_REVOKED | 31 | s2019._domainkey | TXT | p= is empty: this selector is revoked and mail signed with it fails DKIM | empty p= revokes the key; remove the record if the selector is retired, or publish the new key |
| MEDIUM | DMARC_MISSING | - | _dmarc | TXT | no DMARC record: receivers cannot tell spoofed mail from yours, and Gmail/Yahoo require DMARC for bulk senders | add TXT at _dmarc: "v=DMARC1; p=none; rua=mailto:dmarc@" then move to quarantine/reject |
| LOW | CAA_MISSING | - | @ | CAA | no CAA record: any public CA may issue certificates for this domain | optional hardening: CAA 0 issue "" limits who may issue certificates |
Out-of-zone targets to check live (no network calls were made)
| Target | Used by |
|---|---|
| bakery-site.example.net. | @ CNAME (line 14); www CNAME (line 15) |
| mx2.example.net. | backupmx CNAME (line 26) |
| ns1.example.net. | @ NS (line 10) |
| ns2.example.net. | @ NS (line 11) |
| shops.myshopify.com. | shop CNAME (line 17) |
| FILE:scripts/dns_lint.py | |
| #!/usr/bin/env python3 | |
| """DNS zone record linter (Python 3.8+ standard library only, works offline). |
Reads a BIND-style zone file or a CSV record list exported from a DNS provider and reports common mistakes before they break web or mail delivery: CNAME at the apex or next to other records, MX/NS pointing at a CNAME or an IP, missing trailing dots (mail.example.com -> mail.example.com.example.com.), missing or duplicate SPF, SPF syntax, +all/?all and the 10-lookup limit, DMARC and DKIM presence and syntax, weak or revoked DKIM keys, TTL extremes, private addresses in a public zone, SOA mistakes, duplicate records, and domains that send no mail but have no "no mail" policy. Out-of-zone targets are listed for a separate live check.
Usage: python3 dns_lint.py example.com.zone [--origin example.com] [--json] [--min-severity LOW|MEDIUM|HIGH] [--ignore CODE,CODE] python3 dns_lint.py records.csv --origin example.com (CSV columns: name,type,value[,ttl][,priority]) python3 dns_lint.py --self-test Zone files: $ORIGIN, $TTL, @, relative names, blank owner (repeat previous), optional TTL/class in either order, ( ) multi-line records, ; comments, quoted TXT strings. Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input. """ import argparse, base64, csv, io, ipaddress, json, os, re, sys
SEVERITIES = ["LOW", "MEDIUM", "HIGH"] CLASSES = {"IN", "CH", "HS"} TYPES = {"A", "AAAA", "CNAME", "MX", "NS", "TXT", "SPF", "SOA", "SRV", "CAA", "PTR", "DNAME", "ALIAS", "ANAME", "DS", "DNSKEY", "RRSIG", "NSEC", "NSEC3", "NSEC3PARAM", "HTTPS", "SVCB", "TLSA", "NAPTR", "SSHFP", "LOC", "HINFO", "CDS", "CDNSKEY"} DNSSEC_TYPES = {"RRSIG", "NSEC", "NSEC3", "KEY"} TARGET_TYPES = {"CNAME": -1, "MX": 1, "NS": 0, "PTR": 0, "DNAME": 0, "SRV": 3, "ALIAS": 0, "ANAME": 0} COMMON_TLDS = set("com net org edu gov mil int io co dev app ai info biz me tv cloud xyz site online " "shop store tech us uk de fr nl eu es it se no dk fi pl cz at ch be pt ie tr ru " "jp cn kr in au nz ca br mx ar za sg hk tw".split()) INTERNAL_NETS = [ipaddress.ip_network(n) for n in ( "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "0.0.0.0/8", "::1/128", "::/128", "fc00::/7", "fe80::/10")] # documentation ranges (192.0.2.0/24, 2001:db8::/32) are allowed SPF_LOOKUP_TERMS = ("include", "a", "mx", "ptr", "exists", "redirect")
FIXES = { "CNAME_AT_APEX": "use A/AAAA records at the apex, or your provider's ALIAS/ANAME/CNAME-flattening record", "CNAME_WITH_OTHER_DATA": "a name with a CNAME may have no other records: move the other records or replace the CNAME with A/AAAA", "MULTIPLE_CNAME": "keep exactly one CNAME for the name", "MX_TO_CNAME": "point MX at a name with A/AAAA records (the CNAME's final target)", "NS_TO_CNAME": "point NS at the name server's real host name (A/AAAA)", "TARGET_IS_IP": "use a host name here and give that host an A/AAAA record", "MISSING_TRAILING_DOT": "end fully qualified names with a dot (mail.example.com.) or write the relative form (mail)", "MAYBE_MISSING_TRAILING_DOT": "if this is an outside host name, add the trailing dot", "OWNER_DOUBLED_ORIGIN": "end the owner name with a dot or write it relative (www)", "DANGLING_TARGET": "add the missing in-zone record or fix the target name", "CNAME_CHAIN": "point straight at the final name; chains add lookups and break when one link changes", "MX_TARGET_NO_ADDRESS": "add A/AAAA records for the mail host", "NULL_MX_WITH_OTHERS": "a null MX ('0 .') must be the only MX; remove it or the others", "SPF_MISSING": "publish one TXT "v=spf1 ... -all" (or ~all while testing) listing every service that sends as this domain", "SPF_MULTIPLE": "merge into ONE v=spf1 record; two records make every SPF check fail (permerror)", "SPF_TYPE99": "delete the SPF-type record; only the TXT record is used (RFC 7208)", "SPF_SYNTAX": "fix the term; one bad term makes the whole record a permerror", "SPF_PASS_ALL": "end with -all (or ~all while testing); +all lets anyone send as you", "SPF_NEUTRAL_ALL": "use ~all or -all; ?all gives receivers no signal", "SPF_NO_ALL": "end the record with -all or ~all (or redirect=)", "SPF_TERMS_AFTER_ALL": "move 'all' to the end; terms after it are ignored", "SPF_TOO_MANY_LOOKUPS": "flatten or remove includes, drop a/mx/ptr terms you do not need; nested includes count too", "SPF_LOOKUPS_NEAR_LIMIT": "count nested includes with a live checker; they add to this number", "SPF_PTR": "replace ptr with ip4/ip6 or include (ptr is deprecated and slow)", "TXT_STRING_TOO_LONG": "split into quoted chunks of at most 255 characters: "part1" "part2"", "DMARC_MISSING": "add TXT at _dmarc: "v=DMARC1; p=none; rua=mailto:dmarc@" then move to quarantine/reject", "DMARC_MULTIPLE": "keep one DMARC record; with two, receivers ignore DMARC entirely", "DMARC_SYNTAX": "start with v=DMARC1; and include p=none|quarantine|reject", "DMARC_WRONG_NAME": "DMARC is only read from _dmarc.; move the record there", "DMARC_MONITOR_ONLY": "after reviewing reports, move to p=quarantine, then p=reject", "DMARC_NO_RUA": "add rua=mailto:... so you get aggregate reports", "DMARC_PARTIAL_PCT": "raise pct to 100 once reports look clean", "DKIM_NOT_FOUND": "publish the DKIM selector record(s) your mail provider gives you (._domainkey)", "DKIM_WRONG_NAME": "DKIM keys live at ._domainkey.", "DKIM_REVOKED": "empty p= revokes the key; remove the record if the selector is retired, or publish the new key", "DKIM_BAD_KEY": "copy the key again from the provider (base64, no spaces or quotes inside)", "DKIM_WEAK_KEY": "rotate to a 2048-bit RSA key (or add an ed25519 selector alongside RSA)", "DKIM_1024_KEY": "plan rotation to a 2048-bit key", "NO_MAIL_POLICY": "for a domain that never sends mail: MX 0 . , TXT "v=spf1 -all" and _dmarc "v=DMARC1; p=reject"", "NULL_MX_SPF_NOT_FAIL": "a no-mail domain should publish "v=spf1 -all"", "TTL_TOO_LOW": "use 300 s or more for normal records (lower only for a planned change)", "TTL_TOO_HIGH": "use 1 h to 1 day; above a week, mistakes take a week to undo", "TTL_INVALID": "TTL must be 0..2147483647 seconds", "NO_DEFAULT_TTL": "add $TTL at the top of the zone (e.g. $TTL 3600)", "SOA_MISSING": "a zone file needs exactly one SOA at the apex", "SOA_RNAME_AT": "write the contact as hostmaster.example.com. (first @ becomes a dot)", "SOA_NEGATIVE_TTL_HIGH": "set the SOA minimum (negative-caching TTL) to 300-3600 s", "NS_MISSING": "list the zone's name servers as NS records at the apex", "NS_SINGLE": "use at least two name servers on separate networks", "BAD_ADDRESS": "fix the address", "WRONG_ADDRESS_FAMILY": "A takes IPv4, AAAA takes IPv6", "PRIVATE_ADDRESS": "do not publish private/loopback addresses in a public zone (use split-horizon DNS if needed)", "DUPLICATE_RECORD": "delete the duplicate", "CAA_MISSING": "optional hardening: CAA 0 issue "" limits who may issue certificates", "UNKNOWN_TYPE": "check the record type spelling", "PARSE_ERROR": "fix the line syntax", "INCLUDE_NOT_FOLLOWED": "lint the included file separately", }
class InputError(Exception): pass
class Rec: slots = ("line", "name", "raw_name", "ttl", "type", "rdata", "raw")
def __init__(self, line, name, raw_name, ttl, rtype, rdata, raw):
self.line, self.name, self.raw_name, self.ttl = line, name, raw_name, ttl
self.type, self.rdata, self.raw = rtype, rdata, raw
def parse_ttl(tok): t = tok.lower() if t.isdigit(): return int(t) m = re.fullmatch(r"((\d+)[wdhms])+", t) if not m: return None mult = {"w": 604800, "d": 86400, "h": 3600, "m": 60, "s": 1} return sum(int(n) * mult[u] for n, u in re.findall(r"(\d+)([wdhms])", t))
def tokenize(text): """Split one logical line into tokens; quoted strings keep their quotes.""" toks, i, n = [], 0, len(text) while i < n: c = text[i] if c.isspace(): i += 1 elif c == '"': j, buf = i + 1, ['"'] while j < n and text[j] != '"': if text[j] == "\" and j + 1 < n: buf.append(text[j:j + 2]); j += 2; continue buf.append(text[j]); j += 1 if j >= n: raise InputError("unterminated quoted string") buf.append('"'); toks.append("".join(buf)); i = j + 1 else: j = i while j < n and not text[j].isspace() and text[j] != '"': j += 1 toks.append(text[i:j]); i = j return toks
def strip_comment(line): out, inq, i = [], False, 0 while i < len(line): c = line[i] if c == "\" and i + 1 < len(line): out.append(line[i:i + 2]); i += 2; continue if c == '"': inq = not inq elif c == ";" and not inq: break out.append(c); i += 1 return "".join(out)
def logical_lines(text): """Yield (line_no, starts_with_space, content) joining ( ... ) groups.""" buf, start, depth, lead = [], 0, 0, False for no, line in enumerate(text.splitlines(), 1): body = strip_comment(line) if depth == 0: if not body.strip(): continue start, lead, buf = no, body[:1].isspace(), [] inq = False clean = [] for c in body: if c == '"': inq = not inq if not inq and c in "()": depth += 1 if c == "(" else -1 clean.append(" ") continue clean.append(c) buf.append("".join(clean)) if depth <= 0: depth = 0 yield start, lead, " ".join(buf) if depth > 0: raise InputError(f"line {start}: '(' is never closed")
def absname(raw, origin): if raw == "@": return origin if raw.endswith("."): return raw.lower() return (raw + "." + origin).lower() if origin != "." else raw.lower() + "."
def parse_zone(text, origin, findings): recs, last_owner, default_ttl, have_ttl_directive = [], None, None, False for no, lead, content in logical_lines(text): toks = tokenize(content) if not toks: continue if toks[0].upper() == "$ORIGIN": if len(toks) < 2: raise InputError(f"line {no}: $ORIGIN needs a name") origin = absname(toks[1], origin) if not toks[1].endswith(".") else toks[1].lower() continue if toks[0].upper() == "$TTL": v = parse_ttl(toks[1]) if len(toks) > 1 else None if v is None: raise InputError(f"line {no}: bad $TTL") default_ttl, have_ttl_directive = v, True continue if toks[0].upper() == "$INCLUDE": add(findings, "LOW", "INCLUDE_NOT_FOLLOWED", no, "", "", f"$INCLUDE {toks[1] if len(toks) > 1 else ''} was not read") continue if lead: if last_owner is None: raise InputError(f"line {no}: record has no owner name and there is no previous one") raw_owner = None else: raw_owner = toks.pop(0) ttl, rtype = None, None while toks: t = toks[0] if t.upper() in CLASSES: toks.pop(0); continue v = parse_ttl(t) if v is not None and ttl is None and not t.upper() in TYPES: ttl = v; toks.pop(0); continue break if not toks: add(findings, "HIGH", "PARSE_ERROR", no, raw_owner or "", "", f"no record type in '{content.strip()}'") continue rtype = toks.pop(0).upper() if rtype not in TYPES and not re.fullmatch(r"TYPE\d+", rtype): add(findings, "MEDIUM", "UNKNOWN_TYPE", no, raw_owner or "", rtype, f"unknown record type '{rtype}'") if raw_owner is not None: name = absname(raw_owner, origin) last_owner = (name, raw_owner) name, raw_name = last_owner recs.append(Rec(no, name, raw_name, ttl, rtype, toks, content.strip())) return recs, origin, default_ttl, have_ttl_directive
def parse_csv(text, origin, findings): rows = list(csv.DictReader(io.StringIO(text))) if not rows: raise InputError("CSV has no rows") cols = {c.lower().strip(): c for c in rows[0].keys() if c} for need in ("name", "type", "value"): if need not in cols: raise InputError(f"CSV needs columns name,type,value[,ttl][,priority] (missing '{need}')") recs = [] base = origin.rstrip(".") for i, r in enumerate(rows, 2): g = lambda k: (r.get(cols[k]) or "").strip() if k in cols else "" raw = g("name") or "@" low = raw.lower().rstrip(".") if raw == "@" or low == base: name = origin elif low.endswith("." + base): name = low + "." else: name = low + "." + origin rtype = g("type").upper() value = g("value") ttl = parse_ttl(g("ttl")) if g("ttl") else None if rtype in ("TXT", "SPF"): v = value if not v.startswith('"'): v = '"' + v.replace('"', '\"') + '"' toks = tokenize(v) else: toks = value.split() if rtype in TARGET_TYPES and toks: # provider UIs show host names without the dot idx = TARGET_TYPES[rtype] if rtype == "MX" and len(toks) == 1: toks = [g("priority") or "10"] + toks if rtype == "SRV" and len(toks) == 3 and g("priority"): toks = [g("priority")] + toks k = len(toks) - 1 if idx == -1 else min(idx, len(toks) - 1) if not toks[k].endswith(".") and toks[k] != "@": toks[k] += "." recs.append(Rec(i, name, raw, ttl, rtype, toks, ",".join(r.get(c) or "" for c in r))) return recs, origin
def add(findings, sev, code, line, name, rtype, problem): findings.append({"severity": sev, "code": code, "line": line, "name": name, "type": rtype, "problem": problem, "fix": FIXES.get(code, "")})
def txt_strings(rec): out = [] for t in rec.rdata: if t.startswith('"') and t.endswith('"'): out.append(re.sub(r"\(.)", r"\1", t[1:-1])) else: out.append(t) return out
def short(name, origin): if name == origin: return "@" if name.endswith("." + origin): return name[: -len(origin) - 1] return name
def lint(text, origin=None, fmt=None, filename=""): findings = [] if fmt is None: fmt = "csv" if filename.lower().endswith(".csv") or text.lstrip().lower().startswith("name,") else "zone" if origin is None: m = re.search(r"^$ORIGIN\s+(\S+)", text, re.M | re.I) if m: origin = m.group(1) elif filename: b = os.path.basename(filename) for suf in (".zone", ".db", ".txt", ".csv"): if b.lower().endswith(suf): b = b[: -len(suf)] for pre in ("db.",): if b.startswith(pre): b = b[len(pre):] origin = b if "." in b else None if not origin: raise InputError("cannot tell the zone name: add $ORIGIN or pass --origin example.com") origin = origin.lower().rstrip(".") + "." if fmt == "csv": recs, origin = parse_csv(text, origin, findings) default_ttl, have_ttl = None, True else: recs, origin, default_ttl, have_ttl = parse_zone(text, origin, findings) base = origin.rstrip(".") by_name = {} for r in recs: by_name.setdefault(r.name, []).append(r) types_at = lambda n: {r.type for r in by_name.get(n, [])} in_zone = lambda n: n == origin or n.endswith("." + origin) externals = {}
# SOA / NS / TTL
soa = [r for r in recs if r.type == "SOA"]
if fmt == "zone":
if not soa:
add(findings, "MEDIUM", "SOA_MISSING", 0, "@", "SOA", "no SOA record in the zone file")
for r in soa:
if len(r.rdata) >= 7:
rname = r.rdata[1]
if "@" in rname:
add(findings, "HIGH", "SOA_RNAME_AT", r.line, short(r.name, origin), "SOA", f"contact '{rname}' contains '@'; in DNS it is written with a dot")
neg = parse_ttl(r.rdata[6])
if neg is not None and neg > 86400:
add(findings, "LOW", "SOA_NEGATIVE_TTL_HIGH", r.line, short(r.name, origin), "SOA", f"SOA minimum {neg} s: a deleted-then-added name stays 'does not exist' in caches that long")
if default_ttl is None and neg is not None:
default_ttl = neg
else:
add(findings, "HIGH", "PARSE_ERROR", r.line, short(r.name, origin), "SOA", "SOA needs 7 fields: mname rname serial refresh retry expire minimum")
ns_apex = [r for r in by_name.get(origin, []) if r.type == "NS"]
if not ns_apex:
add(findings, "MEDIUM", "NS_MISSING", 0, "@", "NS", "no NS records at the apex")
elif len(ns_apex) == 1:
add(findings, "MEDIUM", "NS_SINGLE", ns_apex[0].line, "@", "NS", "only one name server: if it is down, the whole domain is down")
if not have_ttl:
add(findings, "LOW", "NO_DEFAULT_TTL", 0, "", "", "no $TTL directive; records without a TTL fall back to the SOA minimum or the server default")
for r in recs:
ttl = r.ttl if r.ttl is not None else default_ttl
if ttl is None:
continue
nm = short(r.name, origin)
if ttl > 2147483647:
add(findings, "HIGH", "TTL_INVALID", r.line, nm, r.type, f"TTL {ttl} is above 2147483647")
elif ttl > 604800:
add(findings, "MEDIUM", "TTL_TOO_HIGH", r.line, nm, r.type, f"TTL {ttl} s ({ttl // 86400} days): a wrong value stays cached that long")
elif ttl < 60 and not r.name.startswith("_acme-challenge."):
add(findings, "MEDIUM", "TTL_TOO_LOW", r.line, nm, r.type, f"TTL {ttl} s: extra query load, slower lookups, and many resolvers raise it anyway")
# owner names that repeat the origin
for r in recs:
if fmt == "zone" and r.raw_name not in (None, "@") and not r.raw_name.endswith(".") and \
(r.raw_name.lower() == base or r.raw_name.lower().endswith("." + base)):
add(findings, "HIGH", "OWNER_DOUBLED_ORIGIN", r.line, r.raw_name, r.type, f"owner '{r.raw_name}' has no trailing dot, so it becomes {r.name}")
# CNAME rules
for n, rs in by_name.items():
cn = [r for r in rs if r.type == "CNAME"]
if not cn:
continue
nm = short(n, origin)
if n == origin:
add(findings, "HIGH", "CNAME_AT_APEX", cn[0].line, nm, "CNAME", "CNAME at the zone apex conflicts with the SOA and NS records there; mail and other lookups break")
others = sorted({r.type for r in rs if r.type not in ("CNAME",) and r.type not in DNSSEC_TYPES})
if others and n != origin:
add(findings, "HIGH", "CNAME_WITH_OTHER_DATA", cn[0].line, nm, "CNAME", f"name has a CNAME and also {', '.join(others)}; resolvers return only the CNAME")
if len(cn) > 1:
add(findings, "HIGH", "MULTIPLE_CNAME", cn[1].line, nm, "CNAME", f"{len(cn)} CNAME records for one name")
# targets
for r in recs:
if r.type not in TARGET_TYPES or not r.rdata:
continue
idx = TARGET_TYPES[r.type]
if r.type == "MX" and len(r.rdata) < 2 or r.type == "SRV" and len(r.rdata) < 4:
add(findings, "HIGH", "PARSE_ERROR", r.line, short(r.name, origin), r.type, f"{r.type} needs " + ("preference and host" if r.type == "MX" else "priority weight port target"))
continue
raw = r.rdata[idx] if idx >= 0 else r.rdata[-1]
nm = short(r.name, origin)
if r.type == "MX" and raw == ".":
continue
try:
ipaddress.ip_address(raw.rstrip("."))
add(findings, "HIGH", "TARGET_IS_IP", r.line, nm, r.type, f"{r.type} target '{raw}' is an IP address; {r.type} must name a host")
continue
except ValueError:
pass
tgt = absname(raw, origin)
if fmt == "zone" and raw != "@" and not raw.endswith("."):
low = raw.lower()
if low == base or low.endswith("." + base):
add(findings, "HIGH", "MISSING_TRAILING_DOT", r.line, nm, r.type, f"'{raw}' has no trailing dot, so it means {absname(raw, origin)}")
tgt = low + "." # check the name that was meant
elif "." in low and low.rsplit(".", 1)[1] in COMMON_TLDS:
add(findings, "MEDIUM", "MAYBE_MISSING_TRAILING_DOT", r.line, nm, r.type, f"'{raw}' looks like a full host name but has no trailing dot, so it means {absname(raw, origin)}")
tgt = low + "."
if in_zone(tgt):
tt = types_at(tgt)
if r.type in ("MX", "NS", "SRV") and "CNAME" in tt:
code = {"MX": "MX_TO_CNAME", "NS": "NS_TO_CNAME", "SRV": "MX_TO_CNAME"}[r.type]
add(findings, "HIGH", code, r.line, nm, r.type, f"{r.type} target {short(tgt, origin)} is a CNAME (not allowed, RFC 2181 10.3)")
elif r.type == "CNAME" and "CNAME" in tt:
add(findings, "LOW", "CNAME_CHAIN", r.line, nm, r.type, f"CNAME to {short(tgt, origin)}, which is itself a CNAME")
elif not tt:
# wildcard or a deeper/glue name may exist elsewhere; only flag when no wildcard covers it
wild = "*." + tgt.split(".", 1)[1] if "." in tgt else ""
if wild not in by_name:
add(findings, "MEDIUM", "DANGLING_TARGET", r.line, nm, r.type, f"{r.type} target {short(tgt, origin)} has no records in this zone")
elif r.type == "MX" and not (tt & {"A", "AAAA"}):
add(findings, "MEDIUM", "MX_TARGET_NO_ADDRESS", r.line, nm, r.type, f"mail host {short(tgt, origin)} has no A/AAAA record")
else:
externals.setdefault(tgt, []).append(f"{nm} {r.type} (line {r.line})")
# addresses
for r in recs:
if r.type not in ("A", "AAAA") or not r.rdata:
continue
nm = short(r.name, origin)
try:
ip = ipaddress.ip_address(r.rdata[0])
except ValueError:
add(findings, "HIGH", "BAD_ADDRESS", r.line, nm, r.type, f"'{r.rdata[0]}' is not an IP address")
continue
if (r.type == "A") != (ip.version == 4):
add(findings, "HIGH", "WRONG_ADDRESS_FAMILY", r.line, nm, r.type, f"{r.type} record holds an IPv{ip.version} address")
elif any(ip in n for n in INTERNAL_NETS if n.version == ip.version):
add(findings, "MEDIUM", "PRIVATE_ADDRESS", r.line, nm, r.type, f"{ip} is a private/loopback address; outside users cannot reach it and it reveals internal layout")
# duplicates
seen = {}
for r in recs:
key = (r.name, r.type, " ".join(x.lower() for x in r.rdata))
if key in seen:
add(findings, "LOW", "DUPLICATE_RECORD", r.line, short(r.name, origin), r.type, f"same as line {seen[key]}")
else:
seen[key] = r.line
# TXT string length (zone files only: each quoted string max 255)
if fmt == "zone":
for r in recs:
if r.type in ("TXT", "SPF"):
for s in txt_strings(r):
if len(s.encode()) > 255:
add(findings, "HIGH", "TXT_STRING_TOO_LONG", r.line, short(r.name, origin), r.type, f"one quoted string is {len(s.encode())} bytes (max 255); the server rejects the zone or truncates it")
break
# mail: MX, SPF, DMARC, DKIM
txt_by_name = {}
for r in recs:
if r.type == "TXT":
txt_by_name.setdefault(r.name, []).append((r, "".join(txt_strings(r))))
for r in recs:
if r.type == "SPF":
add(findings, "LOW", "SPF_TYPE99", r.line, short(r.name, origin), "SPF", "SPF-type (99) record is obsolete and ignored")
spf_at = {}
for n, items in txt_by_name.items():
for r, s in items:
if re.match(r"(?i)v=spf1(\s|$)", s):
spf_at.setdefault(n, []).append((r, s))
if re.match(r"(?i)v=dmarc1", s) and not n.startswith("_dmarc."):
add(findings, "HIGH", "DMARC_WRONG_NAME", r.line, short(n, origin), "TXT", "DMARC record is not at _dmarc.<domain>, so receivers never see it")
if re.match(r"(?i)v=dkim1", s) and "._domainkey." not in "." + n:
add(findings, "HIGH", "DKIM_WRONG_NAME", r.line, short(n, origin), "TXT", "DKIM key is not under <selector>._domainkey, so signatures cannot be verified")
for n, items in spf_at.items():
nm = short(n, origin)
if len(items) > 1:
add(findings, "HIGH", "SPF_MULTIPLE", items[1][0].line, nm, "TXT", f"{len(items)} v=spf1 records at this name (lines {', '.join(str(i[0].line) for i in items)})")
for r, s in items:
check_spf(findings, r, s, nm)
mx_names = {}
for r in recs:
if r.type == "MX" and len(r.rdata) >= 2:
mx_names.setdefault(r.name, []).append(r)
for n, mxs in mx_names.items():
nulls = [r for r in mxs if r.rdata[1] == "."]
if nulls and len(mxs) > 1:
add(findings, "HIGH", "NULL_MX_WITH_OTHERS", nulls[0].line, short(n, origin), "MX", "null MX ('0 .') next to other MX records")
apex_mx = [r for r in mx_names.get(origin, []) if r.rdata[1] != "."]
apex_null = any(r.rdata[1] == "." for r in mx_names.get(origin, []))
apex_spf = spf_at.get(origin, [])
spf_sends = any(not re.fullmatch(r"(?i)v=spf1\s+-all", s.strip()) for _, s in apex_spf)
sends_mail = bool(apex_mx) or spf_sends
for n, mxs in mx_names.items():
if any(r.rdata[1] != "." for r in mxs) and n not in spf_at:
add(findings, "MEDIUM", "SPF_MISSING", mxs[0].line, short(n, origin), "TXT", "name receives mail (MX) but publishes no SPF record")
if apex_null and apex_spf and spf_sends:
add(findings, "LOW", "NULL_MX_SPF_NOT_FAIL", apex_spf[0][0].line, "@", "TXT", "null MX says no mail, but SPF still authorizes senders")
if apex_null and not apex_spf:
add(findings, "LOW", "NULL_MX_SPF_NOT_FAIL", 0, "@", "TXT", "null MX but no \"v=spf1 -all\"")
dm = [(r, s) for r, s in txt_by_name.get("_dmarc." + origin, []) if re.match(r"(?i)v=dmarc1", s.strip()) or "p=" in s.lower()]
dkim_names = [n for n in by_name if n.endswith("._domainkey." + origin)]
if sends_mail or apex_null:
if not dm:
add(findings, "MEDIUM", "DMARC_MISSING", 0, "_dmarc", "TXT", "no DMARC record: receivers cannot tell spoofed mail from yours, and Gmail/Yahoo require DMARC for bulk senders")
if dm:
if len(dm) > 1:
add(findings, "HIGH", "DMARC_MULTIPLE", dm[1][0].line, "_dmarc", "TXT", f"{len(dm)} DMARC records")
for r, s in dm:
check_dmarc(findings, r, s)
if sends_mail and not dkim_names:
add(findings, "MEDIUM", "DKIM_NOT_FOUND", 0, "*._domainkey", "TXT", "no <selector>._domainkey record: if you send mail, it is probably unsigned (checked only names in this file)")
for n in dkim_names:
for r in by_name[n]:
if r.type == "TXT":
check_dkim(findings, r, "".join(txt_strings(r)), short(n, origin))
if not sends_mail and not apex_null and not apex_spf:
add(findings, "LOW", "NO_MAIL_POLICY", 0, "@", "MX", "no MX and no SPF: if this domain never sends mail, say so, or spammers can spoof it freely")
has_web = any(r.type in ("A", "AAAA", "CNAME", "ALIAS", "ANAME") for r in recs)
if has_web and "CAA" not in types_at(origin):
add(findings, "LOW", "CAA_MISSING", 0, "@", "CAA", "no CAA record: any public CA may issue certificates for this domain")
order = {s: i for i, s in enumerate(reversed(SEVERITIES))}
findings.sort(key=lambda f: (f["line"] if f["line"] else 10 ** 6, order[f["severity"]], f["code"]))
return {"origin": origin, "format": fmt, "records": len(recs), "findings": findings,
"external_targets": [{"target": t, "used_by": u} for t, u in sorted(externals.items())]}
def check_spf(findings, r, s, nm):
terms = s.split()[1:]
lookups, seen_all = 0, None
for i, t in enumerate(terms):
q = t[0] if t[0] in "+-?" else ""
body = t[len(q):].lower()
mech = re.split(r"[:=/]", body, maxsplit=1)[0]
if seen_all is not None and not body.startswith("exp="):
add(findings, "MEDIUM", "SPF_TERMS_AFTER_ALL", r.line, nm, "TXT", f"'{t}' comes after 'all' and is ignored")
seen_all = None
if mech == "all":
if q in ("", "+"):
add(findings, "HIGH", "SPF_PASS_ALL", r.line, nm, "TXT", f"'{t}' authorizes every server on the internet to send as this domain")
elif q == "?":
add(findings, "MEDIUM", "SPF_NEUTRAL_ALL", r.line, nm, "TXT", "'?all' is neutral: unauthorized senders are not marked")
seen_all = i
continue
if mech in ("include", "exists"):
if ":" not in body or not body.split(":", 1)[1]:
add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}' needs a domain ({mech}:example.net)")
lookups += 1
elif mech in ("a", "mx"):
lookups += 1
elif mech == "ptr":
lookups += 1
add(findings, "LOW", "SPF_PTR", r.line, nm, "TXT", "'ptr' is deprecated (RFC 7208 5.5)")
elif mech in ("ip4", "ip6"):
val = body.split(":", 1)[1] if ":" in body else ""
try:
net = ipaddress.ip_network(val, strict=False)
if (mech == "ip4") != (net.version == 4):
raise ValueError
except ValueError:
add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}' is not a valid {mech} address or range")
elif mech == "redirect":
lookups += 1
if "=" not in body:
add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}': use redirect=domain")
elif mech == "exp":
pass
else:
add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"unknown SPF term '{t}' (typo? e.g. ipv4: for ip4:, include= for include:)")
if not any(re.sub(r"^[+-?]", "", t).lower() == "all" for t in terms) and not any(t.lower().startswith("redirect=") for t in terms):
add(findings, "MEDIUM", "SPF_NO_ALL", r.line, nm, "TXT", "record has no 'all' or redirect=, so unlisted senders get a neutral result")
if lookups > 10:
add(findings, "HIGH", "SPF_TOO_MANY_LOOKUPS", r.line, nm, "TXT", f"{lookups} DNS-lookup terms at the top level alone (limit 10, RFC 7208 4.6.4); receivers return permerror")
elif lookups >= 8:
add(findings, "LOW", "SPF_LOOKUPS_NEAR_LIMIT", r.line, nm, "TXT", f"{lookups} lookup terms before counting nested includes (limit 10)")
def check_dmarc(findings, r, s): tags = {} parts = [p.strip() for p in s.split(";") if p.strip()] if not parts or parts[0].replace(" ", "").upper() != "V=DMARC1": add(findings, "HIGH", "DMARC_SYNTAX", r.line, "_dmarc", "TXT", "record must start with 'v=DMARC1;'") for p in parts: k, _, v = p.partition("=") tags[k.strip().lower()] = v.strip() p = tags.get("p", "").lower() if p not in ("none", "quarantine", "reject"): add(findings, "HIGH", "DMARC_SYNTAX", r.line, "_dmarc", "TXT", "missing or invalid p= (none, quarantine or reject); receivers ignore the record") return if p == "none": add(findings, "LOW", "DMARC_MONITOR_ONLY", r.line, "_dmarc", "TXT", "p=none only monitors; spoofed mail is still delivered") if "rua" not in tags: add(findings, "LOW", "DMARC_NO_RUA", r.line, "_dmarc", "TXT", "no rua= address, so nobody sees the aggregate reports") if tags.get("pct", "100").isdigit() and int(tags.get("pct", "100")) < 100: add(findings, "LOW", "DMARC_PARTIAL_PCT", r.line, "_dmarc", "TXT", f"pct={tags['pct']}: the policy applies to only part of the failing mail")
def check_dkim(findings, r, s, nm): tags = {} for p in s.split(";"): k, _, v = p.strip().partition("=") if k: tags[k.strip().lower()] = re.sub(r"\s+", "", v) if "p" not in tags: add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", "no p= tag: not a DKIM key record") return if tags["p"] == "": add(findings, "MEDIUM", "DKIM_REVOKED", r.line, nm, "TXT", "p= is empty: this selector is revoked and mail signed with it fails DKIM") return try: raw = base64.b64decode(tags["p"], validate=True) except Exception: add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", "p= is not valid base64 (stray quote, space or cut-off copy?)") return if tags.get("k", "rsa").lower() == "ed25519": return bits = rsa_bits(raw) if bits is None: add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", f"p= decodes to {len(raw)} bytes but is not an RSA public key (cut off?)") elif bits < 1024: add(findings, "HIGH", "DKIM_WEAK_KEY", r.line, nm, "TXT", f"RSA key is {bits} bits; keys under 1024 bits are rejected by receivers (RFC 8301)") elif bits < 2048: add(findings, "LOW", "DKIM_1024_KEY", r.line, nm, "TXT", f"RSA key is {bits} bits; 2048 is the current recommendation")
def der_read(b, i): tag = b[i]; i += 1 ln = b[i]; i += 1 if ln & 0x80: n = ln & 0x7F ln = int.from_bytes(b[i:i + n], "big"); i += n return tag, i, ln
def rsa_bits(raw): """Modulus size of an RSA SubjectPublicKeyInfo (or bare RSAPublicKey) in bits.""" try: tag, i, ln = der_read(raw, 0) if tag != 0x30: return None tag, j, l2 = der_read(raw, i) if tag == 0x30: # SubjectPublicKeyInfo: algorithm, then BIT STRING j += l2 tag, j, l3 = der_read(raw, j) if tag != 0x03: return None j += 1 # unused-bits byte tag, j, l4 = der_read(raw, j) if tag != 0x30: return None tag, j, lm = der_read(raw, j) elif tag == 0x02: lm = l2 else: return None if tag != 0x02 or j + lm > len(raw): return None mod = raw[j:j + lm].lstrip(b"\x00") return len(mod) * 8 if mod else None except IndexError: return None
def render(res, title, min_sev="LOW", ignore=()): keep = [f for f in res["findings"] if SEVERITIES.index(f["severity"]) >= SEVERITIES.index(min_sev) and f["code"] not in ignore] cnt = {s: sum(1 for f in keep if f["severity"] == s) for s in SEVERITIES} out = [f"# DNS record lint: {title}", "", f"Zone: {res['origin']} Format: {res['format']} Records: {res['records']} Findings: {len(keep)} (HIGH {cnt['HIGH']}, MEDIUM {cnt['MEDIUM']}, LOW {cnt['LOW']})", ""] if keep: out += ["| Sev | Code | Line | Name | Type | Problem | Fix |", "|---|---|---|---|---|---|---|"] for f in keep: out.append(f"| {f['severity']} | {f['code']} | {f['line'] or '-'} | {f['name'] or '-'} | {f['type'] or '-'} | {f['problem']} | {f['fix']} |") else: out.append("No findings.") if res["external_targets"]: out += ["", "## Out-of-zone targets to check live (no network calls were made)", "", "| Target | Used by |", "|---|---|"] for e in res["external_targets"]: out.append(f"| {e['target']} | {'; '.join(e['used_by'])} |") return "\n".join(out) + "\n", keep
def self_test(): Z = "$ORIGIN example.com.\n$TTL 3600\n@ IN SOA ns1 hostmaster 1 7200 900 1209600 300\n@ NS ns1\n@ NS ns2.example.net.\nns1 A 192.0.2.53\n" MAIL = '@ MX 10 mail\nmail A 192.0.2.25\n@ TXT "v=spf1 mx -all"\n_dmarc TXT "v=DMAR
Truncated - read the full file at https://github.com/f/awesome-chatgpt-prompts/blob/main/prompts.csv.
