Kiểm tra lỗi (lint) tệp DNS zone hoặc tệp CSV xuất bản ghi từ nhà cung cấp trước khi nó làm sập website hoặc hệ thống email - phát hiện CNAME ở tên miền gốc (apex) hoặc nằm cạnh các bản ghi khác, MX/NS trỏ tới CNAME hoặc địa chỉ IP, thiếu dấu chấm ở cuối, thiếu hoặc trùng lặp bản ghi SPF, lỗi +all/?all và giới hạn 10 lần truy vấn (lookup), sự hiện diện của DMARC và DKIM, cú pháp và độ dài khóa, giá trị TTL quá cao hoặc quá thấp, địa chỉ IP nội bộ (private IP) và các lỗi cấu hình SOA. Trả về các bản ghi đã được sửa chuẩn xác. Bao gồm bộ kiểm tra lỗi bằng Python chạy ngoại tuyến dùng thư viện chuẩn (stdlib) kèm tùy chọn --self-test.
---
name: dns-zone-record-linter
description: Kiểm tra lỗi (lint) tệp DNS zone hoặc tệp CSV xuất bản ghi từ nhà cung cấp trước khi nó làm sập website hoặc hệ thống email - phát hiện CNAME ở tên miền gốc (apex) hoặc nằm cạnh các bản ghi khác, MX/NS trỏ tới CNAME hoặc địa chỉ IP, thiếu dấu chấm ở cuối, thiếu hoặc trùng lặp bản ghi SPF, lỗi +all/?all và giới hạn 10 lần truy vấn (lookup), sự hiện diện của DMARC và DKIM, cú pháp và độ dài khóa, giá trị TTL quá cao hoặc quá thấp, địa chỉ IP nội bộ (private IP) và các lỗi cấu hình SOA. Trả về các bản ghi đã được sửa chuẩn xác. Bao gồm bộ kiểm tra lỗi bằng Python chạy ngoại tuyến dùng thư viện chuẩn (stdlib) kèm tùy chọn --self-test.
---
# Công Cụ Kiểm Tra Bản Ghi DNS Zone (Linter)
Bạn rà soát các bản ghi DNS giống như cách một quản trị viên DNS cẩn trọng thực hiện trước khi chuyển đổi hệ thống hoặc đưa cấu hình email vào vận hành thực tế. Bạn tìm ra những sai sót âm thầm làm hỏng dịch vụ (bản ghi CNAME đặt ở tên miền gốc apex, bản ghi MX trỏ tới một CNAME, `mail.example.com` viết thiếu dấu chấm ở cuối, hai bản ghi SPF cùng tồn tại), giải thích hậu quả bằng ngôn từ dễ hiểu, và trả lại các bản ghi đã được chỉnh sửa chuẩn xác. Bạn làm việc ngoại tuyến dựa trên tệp đầu vào; tuyệt đối không bao giờ tuyên bố đã thực hiện truy vấn trực tiếp (live lookup) khi bạn không hề làm điều đó.
## Các tệp trong kỹ năng này
- `scripts/dns_lint.py` - phân tích cú pháp tệp BIND zone ($ORIGIN, $TTL, @, tên tương đối, SOA nhiều dòng, chuỗi TXT trong dấu ngoặc kép) hoặc tệp xuất CSV (name,type,value,ttl,priority) và báo cáo các lỗi phát hiện được kèm số dòng, sau đó liệt kê các đích trỏ nằm ngoài zone cần kiểm tra trực tiếp trên mạng (chỉ dùng thư viện chuẩn của Python 3.8+)
- `references/record-rules.md` - bộ quy tắc đằng sau mỗi mục kiểm tra kèm tham chiếu RFC: tính độc quyền của CNAME, đích trỏ của MX/NS, dấu chấm cuối, SPF, DMARC, DKIM, các giá trị TTL và SOA
- `references/finding-codes.md` - toàn bộ mã lỗi kèm mức độ nghiêm trọng, trường hợp nào có thể chấp nhận được, và cách khắc phục thông thường
- `templates/dns-review.md` - mẫu báo cáo đánh giá để gửi lại cho người dùng (tóm tắt, bản ghi đã sửa, các mục cần kiểm tra trực tiếp, lưu ý khi triển khai)
- `examples/example.com.zone` - một tệp zone được xuất ra trước khi chuyển nhà cung cấp DNS, cố tình cài sẵn các lỗi phổ biến
- `examples/example.com.fixed.zone` - tệp zone đã được sửa hoàn chỉnh (chạy kiểm tra sạch lỗi hoàn toàn)
- `examples/lint-report.md` - kết quả đầu ra thực tế của công cụ kiểm tra đối với tệp ví dụ bị lỗi
## Quy trình thực hiện
### 1. Thu thập các bản ghi và ngữ cảnh sử dụng
Yêu cầu người dùng cung cấp tệp zone (định dạng BIND, kết quả lệnh `dig AXFR` hoặc tệp xuất từ nhà cung cấp) hoặc tệp CSV (`name,type,value,ttl,priority`), tên zone, và mục đích sử dụng của tên miền đó: chỉ chạy website, có gửi email (dùng dịch vụ nào: Google Workspace, Microsoft 365, công cụ gửi bản tin newsletter, hệ thống hỗ trợ khách hàng helpdesk), hay hoàn toàn không dùng email. Nếu người dùng dán các bản ghi từ giao diện web của nhà cung cấp, hãy lưu ý rằng tên máy chủ (host name) ở đó thường không có dấu chấm ở cuối và các bản ghi NS/SOA thường do nhà cung cấp tự quản lý.
### 2. Chạy công cụ kiểm tra (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
```
Mã thoát 0 = không có phát hiện mức HIGH, 1 = có phát hiện mức HIGH, 2 = dữ liệu đầu vào không hợp lệ (dấu ngoặc đơn hoặc dấu ngoặc kép chưa đóng, chưa rõ tên zone). Nếu không thể chạy mã, hãy áp dụng thủ công các quy tắc trong `references/record-rules.md` theo đúng trình tự đó.
### 3. Đọc kết quả phát hiện
- **HIGH** làm hỏng quá trình phân giải tên miền hoặc gửi/nhận email ngay lập tức (xung đột CNAME, MX trỏ tới CNAME, thiếu dấu chấm ở cuối, tồn tại hai bản ghi SPF, dùng `+all`, đặt DMARC sai tên bản ghi, cú pháp SPF không hợp lệ, khóa DKIM yếu).
- **MEDIUM** là cấu hình sai hoặc tiềm ẩn rủi ro nhưng có thể vẫn hoạt động tạm thời (thiếu DMARC/DKIM/SPF, dùng `?all`, địa chỉ IP nội bộ, giá trị TTL quá cực đoan, đích trỏ lơ lửng không tồn tại, chỉ có duy nhất một máy chủ phân giải tên miền NS).
- **LOW** là các vấn đề về chuẩn hóa và dọn dẹp (bản ghi trùng lặp, `p=none`, khóa DKIM 1024-bit, không có bản ghi CAA, dùng kiểu bản ghi SPF cũ đã lỗi thời).
- Công cụ chỉ đếm các lượt truy vấn SPF ở cấp cao nhất; các chỉ thị `include` lồng nhau bên trong sẽ làm tăng thêm số lượt truy vấn thực tế. Công cụ không thể nhìn thấy các bản ghi nằm ở phía nhà cung cấp dịch vụ ngoài (như các selector DKIM do dịch vụ email tạo ra) hoặc các đích trỏ nằm ngoài phạm vi zone. Hãy nói rõ giới hạn này, đồng thời liệt kê các lệnh kiểm tra trực tiếp từ bảng "Out-of-zone targets" (`dig +short CNAME shop.example.com`, `dig TXT _dmarc.example.com`).
### 4. Bàn giao kết quả
Điền vào `templates/dns-review.md`: phần tóm tắt ngắn gọn, các bản ghi đã sửa theo đúng định dạng của người dùng (dòng cấu hình tệp zone hoặc các trường nhập liệu trên giao diện nhà cung cấp), những nội dung cần xác minh trực tiếp trên mạng, và trình tự triển khai an toàn: hạ thấp TTL trước một ngày, thực hiện thay đổi, kiểm tra xác nhận, sau đó nâng TTL trở lại; đừng quên tăng số serial của bản ghi SOA. Đối với các thay đổi liên quan đến email, hãy khuyến nghị cấu hình DMARC ở mức `p=none` kèm địa chỉ `rua` trước tiên, sau đó nâng lên `quarantine`, rồi mới đến `reject` một khi các báo cáo trả về đã hoàn toàn sạch lỗi.
## Ví dụ kết quả
Trên tệp `examples/example.com.zone`, công cụ báo cáo 19 phát hiện (7 mức HIGH, 8 mức MEDIUM, 4 mức LOW): website được chuyển đi bằng bản ghi `@ CNAME bakery-site.example.net.` (đặt CNAME ở ngay tên miền gốc apex, nằm cạnh SOA, NS, MX và TXT), `www` vừa có CNAME vừa có bản ghi TXT xác minh tên miền, `MX 10 mail.example.com` thiếu dấu chấm ở cuối nên bị hiểu thành `mail.example.com.example.com.`, MX dự phòng lại trỏ tới một bản ghi CNAME, có tới hai bản ghi `v=spf1` (một bản ghi dùng `?all`), DMARC đặt ở tên `dmarc` thay vì `_dmarc`, một selector DKIM cũ đã bị thu hồi khóa, địa chỉ liên hệ trong SOA chứa ký tự `@`, và bản ghi `intranet` công khai địa chỉ IP nội bộ 10.0.4.12. Tệp `examples/example.com.fixed.zone` đã khắc phục toàn bộ các lỗi trên và vượt qua bài kiểm tra hoàn toàn sạch sẽ (mã thoát 0).
## Quy tắc
- Không bao giờ tự bịa ra giá trị bản ghi (địa chỉ IP, khóa công khai DKIM, tên máy chủ của nhà cung cấp); hãy dùng các ký hiệu giữ chỗ (placeholder) và hướng dẫn rõ người dùng cần sao chép thông tin gì từ nhà cung cấp dịch vụ.
- Chỉ giữ duy nhất một bản ghi SPF cho mỗi tên miền/tên miền phụ; hãy gộp chung các chỉ thị `include` vào cùng một bản ghi thay vì tạo thêm bản ghi SPF thứ hai.
- Không đề xuất xóa các selector DKIM hoặc bản ghi MX có khả năng vẫn đang được sử dụng mà chưa hỏi xem những dịch vụ nào đang gửi email.
- Giải thích tác hại của việc thiếu dấu chấm ở cuối bằng cách viết rõ tên miền sau khi bị nối đuôi tự động, để người dùng nhìn thấy tận mắt lỗi sai đó.
- Đánh dấu bất kỳ mục nào cần truy vấn mạng thực tế là "cần xác minh trực tiếp" (to verify); tuyệt đối không trình bày như thể bạn đã kiểm tra rồi.
- Mọi thay đổi đối với bản ghi NS, MX hoặc các bản ghi ở tên miền gốc (apex) phải luôn đi kèm hướng dẫn triển khai và phương án hoàn tác (ghi lại giá trị cũ, thông số TTL, thời điểm cần kiểm tra).
FILE:references/record-rules.md
# Các quy tắc bản ghi DNS mà công cụ áp dụng
## Tên bản ghi và dấu chấm ở cuối (trailing dots)
- Trong tệp zone, một tên không có dấu chấm ở cuối được hiểu là tên tương đối: cả `www` và `mail.example.com` đều sẽ bị nối thêm hậu tố tên miền gốc ($ORIGIN) vào đuôi. Khi đó, `mail.example.com` nằm trong zone `example.com.` sẽ biến thành `mail.example.com.example.com.` - đây là lỗi sao chép và dán (copy-paste) phổ biến nhất. Hãy viết đầy đủ là `mail.example.com.` hoặc chỉ viết ngắn gọn là `mail`.
- Ký hiệu `@` đại diện cho tên miền gốc (origin / apex). Một dòng bắt đầu bằng khoảng trắng sẽ kế thừa lại tên chủ sở hữu (owner name) của dòng ngay phía trên nó.
- Biểu mẫu web của các nhà cung cấp DNS thường tự động xử lý dấu chấm cuối cho bạn; vì vậy công cụ sẽ bỏ qua bước kiểm tra dấu chấm cuối đối với dữ liệu đầu vào dạng CSV.
## CNAME (RFC 1034 3.6.2, RFC 2181 10.1)
- Một tên đã gắn bản ghi CNAME thì không được phép chứa bất kỳ dữ liệu bản ghi nào khác cùng tên (ngoại trừ các bản ghi DNSSEC). Các máy chủ phân giải (resolver) khi gặp CNAME sẽ chỉ trả về CNAME đó và phớt lờ toàn bộ phần còn lại, do đó nếu đặt bản ghi TXT hoặc MX cùng tên bên cạnh CNAME thì chúng sẽ bị vô hiệu hóa hoặc gây lỗi.
- Tên miền gốc (apex) luôn bắt buộc phải có bản ghi SOA và NS, vì vậy đặt bản ghi CNAME ở tên miền gốc không bao giờ hợp lệ theo chuẩn DNS. Hãy sử dụng bản ghi A/AAAA, hoặc tính năng ALIAS / ANAME / CNAME flattening của nhà cung cấp DNS (các tính năng này thực chất sẽ trả kết quả về dưới dạng bản ghi A/AAAA).
- Mỗi tên chỉ được phép có duy nhất một bản ghi CNAME. Việc nối chuỗi (CNAME trỏ sang một CNAME khác) vẫn hoạt động nhưng làm tốn thêm lượt truy vấn phân giải; hãy giữ chuỗi trỏ ngắn nhất có thể.
- Một bản ghi CNAME trỏ tới dịch vụ bên ngoài (trang bán hàng, trang trạng thái hệ thống, CDN) mà sau đó tài khoản dịch vụ đã bị hủy có thể bị kẻ khác đăng ký chiếm quyền điều khiển (tấn công chiếm quyền tên miền phụ - subdomain takeover). Hãy liệt kê các bản ghi dạng này và gỡ bỏ ngay những bản ghi không còn sử dụng.
## Đích trỏ của MX, NS, SRV (RFC 2181 10.3, RFC 5321 5.1, RFC 7505)
- Bản ghi MX, NS và SRV bắt buộc phải trỏ tới một tên máy chủ (host) có bản ghi A/AAAA trực tiếp, không được trỏ tới một bản ghi CNAME và cũng không được điền trực tiếp địa chỉ IP.
- Giá trị độ ưu tiên (preference) của MX càng nhỏ thì độ ưu tiên càng cao. Các máy chủ MX dự phòng phải thực sự được cấu hình để nhận thư cho tên miền đó, nếu không chúng sẽ chỉ trở thành nơi thu hút thư rác (spam).
- Bản ghi Null MX `MX 0 .` tuyên bố rõ rằng tên miền này không nhận bất kỳ email nào. Nếu sử dụng, nó phải là bản ghi MX duy nhất của tên miền đó.
- Hãy sử dụng ít nhất hai máy chủ NS nằm trên các mạng độc lập nhau; danh sách máy chủ NS khai báo tại nhà đăng ký tên miền (registrar delegation) phải khớp với các bản ghi NS bên trong tệp zone.
## SPF (RFC 7208)
- Chỉ được phép có duy nhất một bản ghi TXT `v=spf1` cho mỗi tên. Có hai bản ghi SPF = lỗi `permerror` = kiểm tra SPF thất bại đối với toàn bộ email gửi đi.
- Các thành phần (terms): `ip4:`, `ip6:`, `a`, `mx`, `include:`, `exists:`, `ptr` (đã ngừng khuyến nghị sử dụng), `all`, cùng các biến điều chỉnh (modifiers) `redirect=` và `exp=`. Các tiền tố định tính (qualifiers): `+` cho phép / pass (mặc định), `-` từ chối cứng / fail, `~` từ chối mềm / softfail, `?` trung lập / neutral.
- Tối đa 10 thành phần làm phát sinh truy vấn DNS (`include`, `a`, `mx`, `ptr`, `exists`, `redirect`), được tính tổng cộng xuyên suốt cả các chỉ thị `include` lồng bên trong. Các thành phần `ip4`/`ip6` không tốn lượt truy vấn DNS nào.
- Hãy kết thúc bản ghi bằng `-all` (hoặc `~all` trong giai đoạn thử nghiệm). Dùng `+all` đồng nghĩa với việc cho phép bất kỳ ai trên internet mạo danh bạn gửi thư; `?all` không mang lại tín hiệu bảo vệ nào. Mọi thành phần viết phía sau `all` đều bị bỏ qua.
- Kiểu bản ghi DNS mang tên SPF (type 99) đã bị khai tử; chỉ xuất bản SPF dưới dạng bản ghi TXT.
- Một chuỗi nằm trong cặp dấu ngoặc kép chỉ chứa được tối đa 255 byte; các bản ghi dài phải được tách thành nhiều chuỗi `"phần một" "phần hai"` và sẽ được hệ thống tự động nối liền lại với nhau (không tự thêm dấu cách ở giữa).
## DMARC (RFC 7489)
- Bản ghi TXT chỉ đặt tại `_dmarc.<domain>`. Phải bắt đầu bằng `v=DMARC1;` và bao gồm `p=none|quarantine|reject`. Chỉ có một bản ghi duy nhất.
- Thêm `rua=mailto:` để nhận báo cáo tổng hợp. Triển khai theo lộ trình `p=none` -> `quarantine` -> `reject`; giá trị `pct` dưới 100 sẽ áp dụng chính sách cho một phần lượng thư.
- Từ năm 2024, Gmail và Yahoo yêu cầu SPF, DKIM và DMARC (ít nhất là `p=none`) đối với những bên gửi thư số lượng lớn.
## DKIM (RFC 6376, RFC 8301, RFC 8463)
- Khóa được đặt tại `<selector>._domainkey.<domain>` dưới dạng TXT (`v=DKIM1; k=rsa; p=<base64>`) hoặc dưới dạng CNAME trỏ tới nhà cung cấp (Microsoft 365, nhiều ESP).
- Không được sử dụng khóa RSA dưới 1024 bit; mức khuyến nghị là 2048 bit. Có thể thêm Ed25519 (`k=ed25519`) song song với RSA.
- Giá trị `p=` để trống nghĩa là khóa đã bị thu hồi. Các selector không thể tự dò tìm được: việc không xuất hiện trong một tệp có nghĩa là "không có ở đây", chứ không phải "không có ở bất kỳ đâu".
## TTL và SOA (RFC 1035, RFC 2181 8, RFC 2308)
- TTL nằm trong khoảng 0..2147483647 s. Các giá trị thường dùng: 300-3600 s cho các bản ghi hay thay đổi, 3600-86400 s cho các bản ghi ổn định. Dưới 60 s thì nhiều bộ phân giải (resolver) đằng nào cũng tự nâng lên; trên một tuần thì một sai sót sẽ tồn đọng suốt cả tuần. Hãy hạ thấp TTL trước một ngày khi có thay đổi theo kế hoạch.
- `$TTL` thiết lập giá trị mặc định. Trường minimum của SOA là TTL lưu đệm âm (negative-caching TTL - khoảng thời gian trạng thái "không tồn tại" được lưu vào bộ nhớ đệm): 300-3600 s.
- Địa chỉ liên hệ SOA (rname) là một email được viết bằng dấu chấm: `hostmaster.example.com.` thay cho hostmaster@example.com.
- Tăng số serial của SOA sau mỗi lần thay đổi (YYYYMMDDnn).
## Địa chỉ và CAA
- A chứa địa chỉ IPv4, AAAA chứa địa chỉ IPv6. Các địa chỉ nội bộ (private), loopback và link-local không thuộc về một vùng (zone) công khai (hãy dùng DNS phân tách tầm nhìn - split-horizon DNS). Các dải địa chỉ dành cho tài liệu (192.0.2.0/24, 2001:db8::/32) có thể dùng bình thường trong các ví dụ.
- CAA (RFC 8659) giới hạn những tổ chức cấp phát chứng chỉ (CA) nào được phép cấp chứng chỉ cho tên miền: `@ CAA 0 issue "letsencrypt.org"`.
FILE:references/finding-codes.md
# Mã lỗi phát hiện (Finding codes)
| Mã | Mức độ | Vấn đề | Chấp nhận được khi | Cách sửa thông thường |
|---|---|---|---|---|
| CNAME_AT_APEX | HIGH | CNAME ở đỉnh vùng (apex) xung đột với SOA/NS, làm hỏng luồng thư | không bao giờ | dùng A/AAAA hoặc ALIAS/flattening của nhà cung cấp |
| CNAME_WITH_OTHER_DATA | HIGH | các bản ghi khác nằm cạnh CNAME bị bỏ qua | chỉ các bản ghi DNSSEC | chuyển TXT/MX đi nơi khác hoặc dùng A/AAAA |
| MULTIPLE_CNAME | HIGH | hai bản ghi CNAME cho cùng một tên | không bao giờ | giữ lại một bản ghi |
| MX_TO_CNAME / NS_TO_CNAME | HIGH | đích của MX/NS/SRV là một bí danh (alias) | không bao giờ (một số máy chủ dung thứ, nhưng nhiều máy chủ thì không) | trỏ tới máy chủ A/AAAA cuối cùng |
| TARGET_IS_IP | HIGH | giá trị MX/NS/CNAME là một địa chỉ IP | không bao giờ | dùng tên máy chủ có bản ghi A |
| MISSING_TRAILING_DOT | HIGH | tên đầy đủ không có dấu chấm ở cuối sẽ bị nối thêm miền gốc (origin) | không bao giờ | thêm dấu chấm hoặc viết ở dạng tương đối |
| OWNER_DOUBLED_ORIGIN | HIGH | tên chủ sở hữu `www.example.com` không có dấu chấm | không bao giờ | `www` hoặc `www.example.com.` |
| SPF_MULTIPLE | HIGH | hai bản ghi v=spf1 -> lỗi permerror | không bao giờ | gộp thành một bản ghi |
| SPF_PASS_ALL | HIGH | `+all`/`all` cấp quyền cho tất cả mọi người | không bao giờ | `-all` / `~all` |
| SPF_SYNTAX | HIGH | thành phần không xác định hoặc ip4/ip6 sai định dạng -> permerror | không bao giờ | sửa lại thành phần đó |
| SPF_TOO_MANY_LOOKUPS | HIGH | nhiều hơn 10 thành phần truy vấn (lookup) | không bao giờ | làm phẳng (flatten), xóa các include không dùng |
| TXT_STRING_TOO_LONG | HIGH | chuỗi trong ngoặc kép dài quá 255 byte | giao diện nhà cung cấp tự tách giúp bạn (đầu vào CSV không được kiểm tra) | tách thành từng đoạn nhỏ |
| DMARC_WRONG_NAME | HIGH | v=DMARC1 không nằm ở _dmarc | không bao giờ | chuyển sang `_dmarc` |
| DMARC_MULTIPLE / DMARC_SYNTAX | HIGH | bên nhận bỏ qua DMARC | không bao giờ | giữ một bản ghi hợp lệ duy nhất |
| DKIM_WRONG_NAME / DKIM_BAD_KEY | HIGH | không thể tìm thấy hoặc phân tích cú pháp khóa | không bao giờ | sao chép lại từ nhà cung cấp |
| DKIM_WEAK_KEY | HIGH | khóa RSA dưới 1024 bit | không bao giờ | xoay vòng khóa lên 2048 bit |
| SOA_RNAME_AT | HIGH | có ký tự `@` trong liên hệ SOA | không bao giờ | `hostmaster.example.com.` |
| NULL_MX_WITH_OTHERS | HIGH | có cả `MX 0 .` lẫn MX thực | không bao giờ | chỉ chọn một trong hai |
| BAD_ADDRESS / WRONG_ADDRESS_FAMILY / TTL_INVALID | HIGH | không thể nạp giá trị | không bao giờ | sửa lại giá trị |
| SPF_MISSING | MEDIUM | có MX nhưng không có SPF | không bao giờ đối với tên miền gửi/nhận thư | thêm một bản ghi SPF |
| DMARC_MISSING | MEDIUM | tên miền thư không có DMARC | không bao giờ | `v=DMARC1; p=none; rua=...` sau đó siết chặt dần |
| DKIM_NOT_FOUND | MEDIUM | không có selector trong tệp này | selector được lưu trữ ở nơi khác (kiểm tra trực tiếp) | công bố selector của nhà cung cấp |
| DKIM_REVOKED | MEDIUM | `p=` để trống | selector được chủ đích ngừng sử dụng (sau đó hãy xóa đi) | xóa bản ghi hoặc công bố khóa mới |
| SPF_NEUTRAL_ALL / SPF_NO_ALL / SPF_TERMS_AFTER_ALL | MEDIUM | chính sách yếu hoặc bị bỏ qua | giai đoạn thử nghiệm ngắn | kết thúc bằng `-all` / `~all` |
| MAYBE_MISSING_TRAILING_DOT | MEDIUM | trông giống tên bên ngoài vùng nhưng thiếu dấu chấm | thực sự là một tên tương đối | thêm dấu chấm |
| DANGLING_TARGET | MEDIUM | đích trong vùng không có bản ghi nào | được định nghĩa ở tệp hoặc view khác | thêm bản ghi hoặc sửa lại |
| MX_TARGET_NO_ADDRESS | MEDIUM | máy chủ thư không có A/AAAA | địa chỉ đến từ một vùng khác | thêm A/AAAA |
| PRIVATE_ADDRESS | MEDIUM | địa chỉ RFC 1918/loopback/link-local trong DNS công khai | vùng được chủ đích dùng riêng trong nội bộ | dùng DNS phân tách tầm nhìn (split-horizon) |
| TTL_TOO_LOW / TTL_TOO_HIGH | MEDIUM | dưới 60 s / trên 7 ngày | TTL ngắn trong đợt chuyển đổi theo kế hoạch; `_acme-challenge` | 300 s - 1 ngày |
| NS_MISSING / NS_SINGLE / SOA_MISSING | MEDIUM | tệp vùng (zone file) chưa hoàn chỉnh | NS/SOA do nhà cung cấp quản lý (đầu vào CSV bỏ qua phần này) | bổ sung đầy đủ |
| UNKNOWN_TYPE | MEDIUM | loại bản ghi không được nhận diện | loại bản ghi mới mà kịch bản chưa biết | kiểm tra lỗi chính tả |
| SPF_PTR / SPF_TYPE99 / SPF_LOOKUPS_NEAR_LIMIT | LOW | đã lỗi thời hoặc gần chạm ngưỡng giới hạn | - | thay thế / đếm trực tiếp các include lồng nhau |
| DMARC_MONITOR_ONLY / DMARC_NO_RUA / DMARC_PARTIAL_PCT | LOW | DMARC chưa thực thi chặn hoặc chưa gửi báo cáo | trong quá trình triển khai ban đầu | siết chặt sau khi xem xét báo cáo |
| DKIM_1024_KEY | LOW | RSA 1024-bit | nhà cung cấp không hỗ trợ 2048 | xoay vòng khóa khi có thể |
| NO_MAIL_POLICY / NULL_MX_SPF_NOT_FAIL | LOW | tên miền không dùng thư có thể bị giả mạo | - | null MX + `v=spf1 -all` + DMARC reject |
| CNAME_CHAIN / DUPLICATE_RECORD / CAA_MISSING / NO_DEFAULT_TTL / SOA_NEGATIVE_TTL_HIGH / INCLUDE_NOT_FOLLOWED | LOW | vệ sinh cấu hình (hygiene) | - | xem cột cách sửa trong báo cáo |
Mã thoát (Exit codes): 0 = không có lỗi HIGH, 1 = có lỗi HIGH, 2 = đầu vào không hợp lệ. Trong CI, hãy chặn ở mức HIGH và lưu danh sách `--ignore` bất kỳ trong kho mã nguồn kèm theo lý do.
## Các kiểm tra trực tiếp mà bộ linter không thể thực hiện (ngoại tuyến)
- Các đích nằm ngoài vùng: `dig +short A <target>`; một bản ghi CNAME trỏ tới máy chủ SaaS đã hủy đăng ký sẽ dẫn đến nguy cơ bị chiếm quyền điều khiển (subdomain takeover).
- Các include SPF lồng nhau: đếm đệ quy bằng `dig TXT <include>`, hoặc dùng công cụ kiểm tra SPF dựa trên bộ phân giải.
- Các selector DKIM do nhà cung cấp dịch vụ thư lưu trữ: `dig TXT <selector>._domainkey.<domain>` (Google: `google`, Microsoft 365: các bản ghi CNAME `selector1`/`selector2`).
- Ủy quyền (Delegation): `dig NS <domain> @a.gtld-servers.net` (hoặc máy chủ của TLD tương ứng) phải khớp với tập hợp NS của vùng.
FILE:templates/dns-review.md
# Đánh giá DNS: <domain>
**Nguồn:** <zone file / provider export, date> **Mục đích:** <website + Google Workspace mail + newsletter tool>
**Kết quả:** <HIGH n, MEDIUM n, LOW n> - <nhận định trong một câu, ví dụ: "việc gửi nhận thư đang gặp rủi ro cho đến khi SPF được gộp lại và MX được sửa">
## Cần sửa ngay (HIGH)
| # | Bản ghi hiện tại | Vấn đề | Thay thế bằng |
|---|---|---|---|
| 1 | `<@ CNAME bakery-site.example.net.>` | <CNAME ở đỉnh vùng làm ẩn MX/TXT> | `<@ A 192.0.2.10>` (hoặc ALIAS tại nhà cung cấp) |
| 2 | `<@ MX 10 mail.example.com>` | <thiếu dấu chấm ở cuối -> mail.example.com.example.com.> | `<@ MX 10 mail.example.com.>` |
## Cần sửa sớm (MEDIUM)
- <thêm TXT cho `_dmarc` với giá trị `v=DMARC1; p=none; rua=mailto:dmarc-reports@<domain>`>
## Vệ sinh cấu hình (LOW)
- <xóa bản ghi A trùng lặp của blog; thêm CAA>
## Tóm tắt xác thực thư điện tử
| Hạng mục | Hiện tại | Sau khi sửa |
|---|---|---|
| SPF | <2 bản ghi, ?all> | <1 bản ghi, bao gồm A + B, -all> |
| DKIM | <selector google 1024-bit, s2019 đã thu hồi> | <khóa 2048-bit, đã xóa selector ngừng sử dụng> |
| DMARC | <sai tên bản ghi> | <_dmarc p=none -> chuyển sang quarantine vào <date>> |
## Xác minh trực tiếp (chưa kiểm tra ngoại tuyến)
- `<dig +short CNAME shop.<domain>>` - <đích trỏ tới có còn được sử dụng không?>
- `<dig TXT google._domainkey.<domain>>` - <có khớp với bảng điều khiển của nhà cung cấp không?>
## Lộ trình triển khai (Rollout)
1. <hạ TTL của các bản ghi sắp thay đổi xuống 300 s, chờ hết một chu kỳ TTL cũ>
2. <áp dụng thay đổi, tăng số serial của SOA lên YYYYMMDDnn>
3. <xác minh bằng lệnh dig với máy chủ có thẩm quyền (authoritative servers) và bộ phân giải công khai; gửi một thư thử nghiệm và đọc tiêu đề Authentication-Results>
4. <nâng TTL trở lại 3600 s; xem xét các báo cáo DMARC sau 1-2 tuần>
**Hoàn tác (Rollback):** <các giá trị cũ được liệt kê ở trên; khôi phục lại chúng và chờ một chu kỳ TTL>
**Câu hỏi bỏ ngỏ:** <những dịch vụ nào gửi thư dưới danh nghĩa tên miền này? backupmx có còn được sử dụng không?>
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
# Báo cáo kiểm tra (lint) bản ghi DNS: example.com.zone
Vùng: example.com. Định dạng: zone Số bản ghi: 19 Phát hiện: 19 (HIGH 7, MEDIUM 8, LOW 4)
| Mức độ | Mã | Dòng | Tên | Loại | Vấn đề | Cách sửa |
|---|---|---|---|---|---|---|
| HIGH | SOA_RNAME_AT | 4 | @ | SOA | thông tin liên hệ 'hostmaster@example.com.' chứa ký tự '@'; trong DNS ký tự này phải được viết bằng dấu chấm | viết thông tin liên hệ là hostmaster.example.com. (ký tự @ đầu tiên đổi thành dấu chấm) |
| LOW | SOA_NEGATIVE_TTL_HIGH | 4 | @ | SOA | giá trị minimum của SOA là 172800 s: một tên bị xóa rồi thêm lại sẽ bị lưu trong bộ nhớ đệm là 'không tồn tại' suốt khoảng thời gian đó | đặt giá trị minimum của SOA (TTL lưu đệm âm) thành 300-3600 s |
| HIGH | CNAME_AT_APEX | 14 | @ | CNAME | CNAME tại đỉnh vùng (zone apex) xung đột với các bản ghi SOA và NS tại đó; việc tra cứu thư và các truy vấn khác sẽ bị lỗi | sử dụng các bản ghi A/AAAA ở đỉnh vùng, hoặc bản ghi ALIAS/ANAME/CNAME-flattening của nhà cung cấp |
| HIGH | CNAME_WITH_OTHER_DATA | 15 | www | CNAME | tên này có cả CNAME lẫn TXT; các bộ phân giải sẽ chỉ trả về CNAME | một tên đã có CNAME thì không được phép có bản ghi nào khác: hãy chuyển các bản ghi khác đi nơi khác hoặc thay thế CNAME bằng A/AAAA |
| MEDIUM | TTL_TOO_LOW | 15 | www | CNAME | TTL 30 s: làm tăng tải truy vấn, tra cứu chậm hơn, và nhiều bộ phân giải đằng nào cũng tự nâng giá trị này lên | sử dụng 300 s trở lên cho các bản ghi thông thường (chỉ hạ thấp khi có thay đổi theo kế hoạch) |
| MEDIUM | MAYBE_MISSING_TRAILING_DOT | 17 | shop | CNAME | 'shops.myshopify.com' trông giống một tên máy chủ đầy đủ nhưng không có dấu chấm ở cuối, nên sẽ bị hiểu thành shops.myshopify.com.example.com. | nếu đây là một tên máy chủ bên ngoài, hãy thêm dấu chấm ở cuối |
| LOW | DUPLICATE_RECORD | 19 | blog | A | giống hệt dòng 18 | xóa bản ghi bị trùng lặp |
| MEDIUM | TTL_TOO_HIGH | 20 | legacy | A | TTL 1209600 s (14 ngày): một giá trị sai sẽ bị lưu giữ trong bộ nhớ đệm suốt khoảng thời gian đó | sử dụng từ 1 giờ đến 1 ngày; nếu trên một tuần, các sai sót sẽ mất cả tuần để khắc phục |
| MEDIUM | PRIVATE_ADDRESS | 21 | intranet | A | 10.0.4.12 là địa chỉ nội bộ (private)/loopback; người dùng bên ngoài không thể truy cập và nó làm lộ cấu trúc mạng nội bộ | không công bố địa chỉ nội bộ/loopback trong một vùng công khai (hãy sử dụng DNS phân tách tầm nhìn - split-horizon DNS nếu cần) |
| HIGH | MISSING_TRAILING_DOT | 24 | @ | MX | 'mail.example.com' không có dấu chấm ở cuối, nên sẽ bị hiểu thành mail.example.com.example.com. | kết thúc các tên miền đủ điều kiện (FQDN) bằng một dấu chấm (mail.example.com.) hoặc viết ở dạng tương đối (mail) |
| MEDIUM | DANGLING_TARGET | 24 | @ | MX | đích MX mail không có bản ghi nào trong vùng này | thêm bản ghi trong vùng còn thiếu hoặc sửa lại tên đích |
| HIGH | MX_TO_CNAME | 25 | @ | MX | đích MX backupmx là một CNAME (không được phép theo RFC 2181 10.3) | trỏ MX vào một tên có các bản ghi A/AAAA (đích cuối cùng của CNAME) |
| HIGH | SPF_MULTIPLE | 28 | @ | TXT | có 2 bản ghi v=spf1 tại tên này (dòng 27, 28) | gộp thành MỘT bản ghi v=spf1 duy nhất; việc có hai bản ghi sẽ làm mọi kiểm tra SPF bị lỗi (permerror) |
| MEDIUM | SPF_NEUTRAL_ALL | 28 | @ | TXT | '?all' mang tính trung lập: những người gửi trái phép không bị đánh dấu | sử dụng ~all hoặc -all; ?all không cung cấp tín hiệu xử lý nào cho bên nhận |
| HIGH | DMARC_WRONG_NAME | 29 | dmarc | TXT | bản ghi DMARC không nằm tại _dmarc.<domain>, nên bên nhận không bao giờ nhìn thấy nó | DMARC chỉ được đọc từ _dmarc.<domain>; hãy chuyển bản ghi về đó |
| LOW | DKIM_1024_KEY | 30 | google._domainkey | TXT | khóa RSA có độ dài 1024 bit; mức khuyến nghị hiện nay là 2048 | lên kế hoạch xoay vòng sang khóa 2048-bit |
| MEDIUM | DKIM_REVOKED | 31 | s2019._domainkey | TXT | p= để trống: selector này đã bị thu hồi và thư được ký bằng nó sẽ trượt kiểm tra DKIM | giá trị p= rỗng có tác dụng thu hồi khóa; hãy xóa bản ghi nếu selector đã ngừng sử dụng, hoặc công bố khóa mới |
| MEDIUM | DMARC_MISSING | - | _dmarc | TXT | không có bản ghi DMARC: bên nhận không thể phân biệt thư giả mạo với thư thật của bạn, đồng thời Gmail/Yahoo yêu cầu DMARC đối với người gửi số lượng lớn | thêm bản ghi TXT tại _dmarc: "v=DMARC1; p=none; rua=mailto:dmarc@<domain>" sau đó chuyển dần sang quarantine/reject |
| LOW | CAA_MISSING | - | @ | CAA | không có bản ghi CAA: bất kỳ CA công khai nào cũng có thể cấp chứng chỉ cho tên miền này | tăng cường bảo mật tùy chọn: CAA 0 issue "<CA của bạn>" giúp giới hạn tổ chức nào được phép cấp chứng chỉ |
## Các đích ngoài vùng cần kiểm tra trực tiếp (không thực hiện lệnh gọi mạng nào)
| Đích | Được sử dụng bởi |
|---|---|
| bakery-site.example.net. | @ CNAME (dòng 14); www CNAME (dòng 15) |
| mx2.example.net. | backupmx CNAME (dòng 26) |
| ns1.example.net. | @ NS (dòng 10) |
| ns2.example.net. | @ NS (dòng 11) |
| shops.myshopify.com. | shop CNAME (dòng 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@<domain>\" 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.<domain>; 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 (<selector>._domainkey)",
"DKIM_WRONG_NAME": "DKIM keys live at <selector>._domainkey.<domain>",
"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 \"<your CA>\" 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=DMARC1; p=reject; rua=mailto:d@example.com"\ns1._domainkey CNAME s1.dkim.example.net.\n@ CAA 0 issue "letsencrypt.org"\n'
k1024 = base64.b64encode(bytes.fromhex("30819f300d06092a864886f70d010101050003818d0030818902818100") + b"\xc1" + bytes(127) + bytes.fromhex("0203010001")).decode()
k512 = base64.b64encode(bytes.fromhex("305c300d06092a864886f70d0101010500034b003048024100") + b"\xc1" + bytes(63) + bytes.fromhex("0203010001")).decode()
def codes(text, **kw):
return [f["code"] for f in lint(text, **kw)["findings"]]
cases = [
("clean zone", Z + MAIL, lambda c: c == []),
("cname at apex", Z + MAIL + "@ CNAME shop.example.net.\n", lambda c: "CNAME_AT_APEX" in c),
("cname with other data", Z + MAIL + "www CNAME web.example.net.\nwww TXT \"x\"\n", lambda c: "CNAME_WITH_OTHER_DATA" in c),
("multiple cname", Z + MAIL + "www CNAME a.example.net.\nwww CNAME b.example.net.\n", lambda c: "MULTIPLE_CNAME" in c),
("mx to cname", Z + MAIL.replace("mail A 192.0.2.25", "mail CNAME mx.example.net."), lambda c: "MX_TO_CNAME" in c),
("ns to cname", Z.replace("ns1 A 192.0.2.53", "ns1 CNAME dns.example.net.") + MAIL, lambda c: "NS_TO_CNAME" in c),
("mx is ip", Z + MAIL.replace("MX 10 mail", "MX 10 192.0.2.25"), lambda c: "TARGET_IS_IP" in c),
("missing trailing dot", Z + MAIL + "www CNAME web.example.com\nweb A 192.0.2.80\n", lambda c: "MISSING_TRAILING_DOT" in c),
("maybe missing dot (outside name)", Z + MAIL + "shop CNAME shops.myshopify.com\n", lambda c: "MAYBE_MISSING_TRAILING_DOT" in c),
("owner doubled origin", Z + MAIL + "www.example.com A 192.0.2.80\n", lambda c: "OWNER_DOUBLED_ORIGIN" in c),
("relative target ok", Z + MAIL + "www CNAME web\nweb A 192.0.2.80\n", lambda c: c == []),
("dangling target", Z + MAIL + "www CNAME web\n", lambda c: "DANGLING_TARGET" in c),
("cname chain", Z + MAIL + "www CNAME web\nweb CNAME edge.example.net.\n", lambda c: "CNAME_CHAIN" in c),
("mx target no address", Z + MAIL.replace("mail A 192.0.2.25", "mail TXT \"x\""), lambda c: "MX_TARGET_NO_ADDRESS" in c),
("spf missing", Z + MAIL.replace('@ TXT "v=spf1 mx -all"\n', ""), lambda c: "SPF_MISSING" in c),
("spf multiple", Z + MAIL + '@ TXT "v=spf1 include:_spf.google.com ~all"\n', lambda c: "SPF_MULTIPLE" in c),
("spf +all", Z + MAIL.replace("mx -all", "mx +all"), lambda c: "SPF_PASS_ALL" in c),
("spf ?all", Z + MAIL.replace("mx -all", "mx ?all"), lambda c: "SPF_NEUTRAL_ALL" in c),
("spf no all", Z + MAIL.replace("mx -all", "mx"), lambda c: "SPF_NO_ALL" in c),
("spf terms after all", Z + MAIL.replace("mx -all", "-all mx"), lambda c: "SPF_TERMS_AFTER_ALL" in c),
("spf syntax typo", Z + MAIL.replace("mx -all", "ipv4:192.0.2.1 -all"), lambda c: "SPF_SYNTAX" in c),
("spf bad ip4", Z + MAIL.replace("mx -all", "ip4:300.1.1.1 -all"), lambda c: "SPF_SYNTAX" in c),
("spf too many lookups", Z + MAIL.replace("mx -all", " ".join(f"include:s{i}.example.net" for i in range(11)) + " -all"), lambda c: "SPF_TOO_MANY_LOOKUPS" in c),
("spf near limit", Z + MAIL.replace("mx -all", " ".join(f"include:s{i}.example.net" for i in range(8)) + " -all"), lambda c: "SPF_LOOKUPS_NEAR_LIMIT" in c and "SPF_TOO_MANY_LOOKUPS" not in c),
("spf ptr", Z + MAIL.replace("mx -all", "ptr -all"), lambda c: "SPF_PTR" in c),
("spf type99", Z + MAIL + '@ SPF "v=spf1 mx -all"\n', lambda c: "SPF_TYPE99" in c),
("split txt strings joined", Z + MAIL.replace('"v=spf1 mx -all"', '"v=spf1 mx " "-all"'), lambda c: c == []),
("txt string too long", Z + MAIL + '@ TXT "' + "a" * 300 + '"\n', lambda c: "TXT_STRING_TOO_LONG" in c),
("dmarc missing", Z + MAIL.replace('_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\n', ""), lambda c: "DMARC_MISSING" in c),
("dmarc wrong name", Z + MAIL.replace("_dmarc TXT", "dmarc TXT"), lambda c: "DMARC_WRONG_NAME" in c and "DMARC_MISSING" in c),
("dmarc multiple", Z + MAIL + '_dmarc TXT "v=DMARC1; p=none"\n', lambda c: "DMARC_MULTIPLE" in c),
("dmarc no p", Z + MAIL.replace("p=reject; ", ""), lambda c: "DMARC_SYNTAX" in c),
("dmarc none + no rua + pct", Z + MAIL.replace("p=reject; rua=mailto:d@example.com", "p=none; pct=50"), lambda c: {"DMARC_MONITOR_ONLY", "DMARC_NO_RUA", "DMARC_PARTIAL_PCT"} <= set(c)),
("dkim not found", Z + MAIL.replace("s1._domainkey CNAME s1.dkim.example.net.\n", ""), lambda c: "DKIM_NOT_FOUND" in c),
("dkim revoked", Z + MAIL + 's2._domainkey TXT "v=DKIM1; k=rsa; p="\n', lambda c: "DKIM_REVOKED" in c),
("dkim bad base64", Z + MAIL + 's2._domainkey TXT "v=DKIM1; p=MIGf!!"\n', lambda c: "DKIM_BAD_KEY" in c),
("dkim 512-bit key", Z + MAIL + f's2._domainkey TXT "v=DKIM1; k=rsa; p={k512}"\n', lambda c: "DKIM_WEAK_KEY" in c),
("dkim 1024-bit key", Z + MAIL + f's2._domainkey TXT "v=DKIM1; k=rsa; p={k1024}"\n', lambda c: "DKIM_1024_KEY" in c),
("dkim wrong name", Z + MAIL + f's2.domainkey TXT "v=DKIM1; p={k1024}"\n', lambda c: "DKIM_WRONG_NAME" in c),
("null mx + spf -all, no mail checks", Z + '@ MX 0 .\n@ TXT "v=spf1 -all"\n_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\n@ CAA 0 issue "letsencrypt.org"\n', lambda c: c == []),
("null mx with others", Z + MAIL + "@ MX 0 .\n", lambda c: "NULL_MX_WITH_OTHERS" in c),
("no mail policy", Z + '@ CAA 0 issue "letsencrypt.org"\n', lambda c: "NO_MAIL_POLICY" in c),
("ttl too low", Z + MAIL + "www 30 A 192.0.2.80\n", lambda c: "TTL_TOO_LOW" in c),
("acme ttl low ok", Z + MAIL + '_acme-challenge 30 TXT "token"\n', lambda c: c == []),
("ttl too high (units)", Z + MAIL + "www 2w A 192.0.2.80\n", lambda c: "TTL_TOO_HIGH" in c),
("no $TTL", Z.replace("$TTL 3600\n", "") + MAIL, lambda c: "NO_DEFAULT_TTL" in c),
("soa rname with @", Z.replace("hostmaster", "hostmaster@example.com.") + MAIL, lambda c: "SOA_RNAME_AT" in c),
("soa multi-line with comments", Z.replace("@ IN SOA ns1 hostmaster 1 7200 900 1209600 300", "@ IN SOA ns1 hostmaster (\n 2026103001 ; serial\n 7200 900 1209600\n 300 ) ; min") + MAIL, lambda c: c == []),
("single ns", Z.replace("@ NS ns2.example.net.\n", "") + MAIL, lambda c: "NS_SINGLE" in c),
("bad address", Z + MAIL + "www A 192.0.2.300\n", lambda c: "BAD_ADDRESS" in c),
("wrong family", Z + MAIL + "www AAAA 192.0.2.80\n", lambda c: "WRONG_ADDRESS_FAMILY" in c),
("private address", Z + MAIL + "intranet A 10.0.0.5\n", lambda c: "PRIVATE_ADDRESS" in c),
("duplicate record", Z + MAIL + "www A 192.0.2.80\nwww A 192.0.2.80\n", lambda c: "DUPLICATE_RECORD" in c),
("blank owner continues", Z + MAIL + "www A 192.0.2.80\n AAAA 2001:db8::80\n", lambda c: c == []),
("semicolon inside quotes kept", Z + MAIL.replace('"v=DMARC1; p=reject; rua=mailto:d@example.com"', '"v=DMARC1; p=reject; rua=mailto:d@example.com" ; comment'), lambda c: c == []),
("caa missing", Z + MAIL.replace('@ CAA 0 issue "letsencrypt.org"\n', ""), lambda c: c == ["CAA_MISSING"]),
("csv input", None, None),
("unterminated quote is input error", None, None),
]
passed = 0
for name, text, check in cases:
try:
if name == "csv input":
csvt = ("name,type,value,ttl,priority\n@,MX,mail.example.com,3600,10\nmail,A,192.0.2.25,3600,\n"
"@,TXT,v=spf1 include:_spf.google.com ~all,3600,\n@,TXT,v=spf1 mx -all,3600,\n"
"www.example.com,CNAME,example.com,300,\n@,CNAME,shop.example.net,300,\n")
res = lint(csvt, origin="example.com", fmt="csv")
c = [f["code"] for f in res["findings"]]
ok = {"SPF_MULTIPLE", "CNAME_AT_APEX", "DMARC_MISSING", "DKIM_NOT_FOUND"} <= set(c) and "MISSING_TRAILING_DOT" not in c and "MX_TO_CNAME" not in c
elif name == "unterminated quote is input error":
try:
lint(Z + '@ TXT "v=spf1 -all\n')
ok, c = False, "no error"
except InputError as e:
ok, c = True, str(e)
else:
c = codes(text)
ok = check(c)
except Exception as e: # noqa
ok, c = False, f"exception {e!r}"
passed += ok
print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" got {c}"))
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Lint a DNS zone file or record list (offline)")
ap.add_argument("file", nargs="?")
ap.add_argument("--origin", help="zone name, e.g. example.com (default: $ORIGIN or the file name)")
ap.add_argument("--format", choices=["zone", "csv"], help="input format (default: by extension/content)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--min-severity", default="LOW", choices=SEVERITIES)
ap.add_argument("--ignore", default="", help="comma-separated finding codes to skip")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("give a zone file or CSV, or --self-test")
try:
with open(a.file, encoding="utf-8") as fh:
text = fh.read()
res = lint(text, origin=a.origin, fmt=a.format, filename=a.file)
except (OSError, InputError, UnicodeDecodeError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
ignore = {c.strip().upper() for c in a.ignore.split(",") if c.strip()}
md, keep = render(res, os.path.basename(a.file), a.min_severity, ignore)
if a.json:
print(json.dumps({**res, "findings": keep}, indent=2))
else:
sys.stdout.write(md)
return 1 if any(f["severity"] == "HIGH" for f in keep) else 0
if __name__ == "__main__":
sys.exit(main())