From b58dc9b065465fc00bc6036feb6a96ddf94c1a03 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 29 Apr 2026 17:29:37 +0700 Subject: [PATCH 1/5] Remove raw JSON observation block from report --- checker/report.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/checker/report.go b/checker/report.go index d2c6289..c9767cc 100644 --- a/checker/report.go +++ b/checker/report.go @@ -71,7 +71,6 @@ type reportView struct { PerServer []serverView OtherFindings []otherFinding GlobalErrors []string - RawJSON string } type bannerView struct { @@ -157,12 +156,6 @@ func buildReportView(d *DNSSECData, states []sdk.CheckState) *reportView { } v.GlobalErrors = d.Errors - if raw, err := json.MarshalIndent(d, "", " "); err == nil { - v.RawJSON = string(raw) - } else { - v.GlobalErrors = append(v.GlobalErrors, fmt.Sprintf("render raw JSON: %v", err)) - } - v.Banner = buildBanner(d) v.Enumerability = buildEnum(d) v.Keys = buildKeys(d) @@ -746,10 +739,5 @@ const reportTemplate = ` {{end}} - {{if .RawJSON}} -

Raw observation

-
Show JSON
{{.RawJSON}}
- {{end}} - ` From 49d9f0556c58352d0fccaecf34f9835216d3120e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 30 Apr 2026 09:23:56 +0700 Subject: [PATCH 2/5] Update rules section --- README.md | 42 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index f35a386..9866664 100644 --- a/README.md +++ b/README.md @@ -96,28 +96,26 @@ make test # tests ## Rules -Each rule emits a finding code. Severity may be affected by the options above. - -| Code | Default severity | Condition | -|------|-----------------|-----------| -| `dnssec_zone_signed` | critical | Parent DS is published but the apex serves no DNSKEY (broken chain of trust). | -| `dnssec_dnskey_consistent` | critical | Authoritative servers disagree on the apex DNSKEY RRset. | -| `dnssec_dnskey_query_ok` | warning | At least one authoritative server failed to answer the DNSKEY query. | -| `dnssec_algorithm_allowed` | critical (forbidden) / warning (not in allowlist) | A DNSKEY uses a forbidden algorithm or an algorithm not in `allowedAlgorithms`. | -| `dnssec_algorithm_modern` | warning | The zone still uses RSA-family DNSKEYs; ECDSAP256SHA256 (13) or Ed25519 (15) recommended. | -| `dnssec_rsa_keysize` | critical (<1024) / warning (<`minRSAKeySize`) | An RSA DNSKEY has a modulus below the policy threshold. | -| `dnssec_ksk_present` | critical | No DNSKEY carries the SEP (KSK) flag while `requireSEP` is enabled. | -| `dnssec_dnskey_count` | warning | Eight or more DNSKEYs are published, bloating responses and amplification factor. | -| `dnssec_rrsig_present_dnskey` | critical | The apex DNSKEY RRset is unsigned. | -| `dnssec_rrsig_present_soa` | critical | The apex SOA RRset is unsigned. | -| `dnssec_rrsig_validity_window` | critical | An observed RRSIG is outside its `[Inception, Expiration]` window. | -| `dnssec_rrsig_freshness` | warning / critical | The closest RRSIG expires in fewer than `signatureFreshness` / `signatureFreshnessCrit` days. | -| `dnssec_denial_uses_nsec3` | warning | The zone uses NSEC for denial of existence, exposing it to trivial walking (RFC 5155 / RFC 7129). | -| `dnssec_nsec3_iterations` | warning / critical (per `nsec3IterationsSeverity`) | `NSEC3PARAM.Iterations` exceeds `nsec3IterationsMax` (RFC 9276 §3.1). | -| `dnssec_nsec3_salt_empty` | warning | `NSEC3PARAM.SaltLength` is non-zero (RFC 9276 §3.1: a salt buys no measurable protection). | -| `dnssec_nsec3_optout_only_when_signed_delegations` | info | The OPT-OUT flag is set in a leaf zone, where it serves no purpose. | -| `dnssec_denial_consistent` | critical | Authoritative servers disagree on the denial-of-existence scheme (NSEC vs NSEC3, or differing parameters). | -| `dnssec_dnskey_ttl_min` | warning | The DNSKEY TTL is below `dnskeyTTLMin`, hurting cache efficiency. | +| Code | Description | Severity | +|------------------------------------------------------|---------------------------------------------------------------------------------------------------|---------------------| +| `dnssec_zone_signed` | Detects a zone advertised as signed at the parent (DS) but no DNSKEY served at the apex. | CRITICAL | +| `dnssec_dnskey_consistent` | Verifies that every authoritative server returns the same DNSKEY RRset. | CRITICAL | +| `dnssec_dnskey_query_ok` | Verifies that every authoritative server answered the DNSKEY query. | CRITICAL | +| `dnssec_algorithm_allowed` | Rejects DNSKEYs that use a forbidden algorithm or are not in the allowed list. | CRITICAL | +| `dnssec_algorithm_modern` | Recommends ECDSAP256SHA256 (13) or Ed25519 (15) over RSA. | WARNING | +| `dnssec_rsa_keysize` | Verifies RSA DNSKEYs reach a minimum modulus size (default 2048 bits). | CRITICAL | +| `dnssec_ksk_present` | Verifies at least one DNSKEY has the SEP bit (KSK). | CRITICAL | +| `dnssec_dnskey_count` | Warns when too many DNSKEYs are published, inflating responses and amplification potential. | WARNING | +| `dnssec_rrsig_present_dnskey` | Ensures the DNSKEY RRset is signed. | CRITICAL | +| `dnssec_rrsig_present_soa` | Ensures the SOA RRset is signed. | CRITICAL | +| `dnssec_rrsig_validity_window` | Verifies that every observed RRSIG is currently within [Inception, Expiration]. | CRITICAL | +| `dnssec_rrsig_freshness` | Warns when RRSIGs are close to expiring; preemptive alert for stuck signers. | CRITICAL | +| `dnssec_denial_uses_nsec3` | Warns when the zone uses NSEC for negative answers, which makes the zone walkable (RFC 5155 / RFC 7129). | WARNING | +| `dnssec_nsec3_iterations` | Verifies that NSEC3PARAM.Iterations is at most nsec3IterationsMax (default 0, per RFC 9276 §3.1). | CRITICAL | +| `dnssec_nsec3_salt_empty` | Verifies that NSEC3PARAM.SaltLength is 0 (RFC 9276 §3.1: a salt buys no measurable protection). | WARNING | +| `dnssec_nsec3_optout_only_when_signed_delegations` | Reports informational note when the OPT-OUT flag is set on NSEC3PARAM in a leaf zone. | INFO | +| `dnssec_denial_consistent` | Verifies that every authoritative server uses the same denial-of-existence scheme. | WARNING | +| `dnssec_dnskey_ttl_min` | Warns when the DNSKEY TTL is too short to be useful for caching. | WARNING | ## License From f92ac4ebd8730504a3784a00838a2c6bd608fb7d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 30 Apr 2026 09:24:09 +0700 Subject: [PATCH 3/5] Don't classify unreachable servers as denial scheme NONE --- checker/collect.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checker/collect.go b/checker/collect.go index 486e7fd..aff9679 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -131,7 +131,7 @@ func collectFromServer(ctx context.Context, server, zone string) PerServerView { view.ProbeName = strings.TrimSuffix(probe, ".") if probeResp := authQuery(ctx, server, probe, dns.TypeA, &view, true); probeResp != nil { view.DenialKind, view.DenialRecords = classifyDenial(probeResp, view.NSEC3PARAM) - } else if len(view.DNSKEYs) == 0 { + } else if view.UDPError == "" && view.TCPError == "" && len(view.DNSKEYs) == 0 { view.DenialKind = DenialNone } From 6c5f06a3ff3a3e39ea1de226d46528928df84fbd Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 10 May 2026 19:01:37 +0800 Subject: [PATCH 4/5] Add CI/CD pipeline --- .drone-manifest.yml | 22 ++++++ .drone.yml | 187 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 .drone-manifest.yml create mode 100644 .drone.yml diff --git a/.drone-manifest.yml b/.drone-manifest.yml new file mode 100644 index 0000000..9367609 --- /dev/null +++ b/.drone-manifest.yml @@ -0,0 +1,22 @@ +image: happydomain/checker-dnssec:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}} +{{#if build.tags}} +tags: +{{#each build.tags}} + - {{this}} +{{/each}} +{{/if}} +manifests: + - image: happydomain/checker-dnssec:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64 + platform: + architecture: amd64 + os: linux + - image: happydomain/checker-dnssec:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64 + platform: + architecture: arm64 + os: linux + variant: v8 + - image: happydomain/checker-dnssec:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm + platform: + architecture: arm + os: linux + variant: v7 diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..74162ab --- /dev/null +++ b/.drone.yml @@ -0,0 +1,187 @@ +--- +kind: pipeline +type: docker +name: build-amd64 + +platform: + os: linux + arch: amd64 + +steps: + - name: checker build + image: golang:1-alpine + commands: + - apk add --no-cache git make + - make + environment: + CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" + CGO_ENABLED: 0 + when: + event: + exclude: + - tag + + - name: checker build tag + image: golang:1-alpine + commands: + - apk add --no-cache git make + - make + environment: + CHECKER_VERSION: "${DRONE_SEMVER}" + CGO_ENABLED: 0 + when: + event: + - tag + + - name: publish on Docker Hub + image: plugins/docker + settings: + repo: happydomain/checker-dnssec + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + build_args: + - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + event: + exclude: + - tag + + - name: publish on Docker Hub (tag) + image: plugins/docker + settings: + repo: happydomain/checker-dnssec + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + build_args: + - CHECKER_VERSION=${DRONE_SEMVER} + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + event: + - tag + +trigger: + branch: + exclude: + - renovate/* + event: + - cron + - push + - tag + +--- +kind: pipeline +type: docker +name: build-arm64 + +platform: + os: linux + arch: arm64 + +steps: + - name: checker build + image: golang:1-alpine + commands: + - apk add --no-cache git make + - make + environment: + CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" + CGO_ENABLED: 0 + when: + event: + exclude: + - tag + + - name: checker build tag + image: golang:1-alpine + commands: + - apk add --no-cache git make + - make + environment: + CHECKER_VERSION: "${DRONE_SEMVER}" + CGO_ENABLED: 0 + when: + event: + - tag + + - name: publish on Docker Hub + image: plugins/docker + settings: + repo: happydomain/checker-dnssec + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + build_args: + - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + event: + exclude: + - tag + + - name: publish on Docker Hub (tag) + image: plugins/docker + settings: + repo: happydomain/checker-dnssec + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + build_args: + - CHECKER_VERSION=${DRONE_SEMVER} + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + event: + - tag + +trigger: + event: + - cron + - push + - tag + +--- +kind: pipeline +name: docker-manifest + +platform: + os: linux + arch: arm64 + +steps: + - name: publish on Docker Hub + image: plugins/manifest + settings: + auto_tag: true + ignore_missing: true + spec: .drone-manifest.yml + username: + from_secret: docker_username + password: + from_secret: docker_password + +trigger: + branch: + exclude: + - renovate/* + event: + - cron + - push + - tag + +depends_on: + - build-amd64 + - build-arm64 From 81ca1810f12eee12c35f04163a73b08527e677a4 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 19 May 2026 21:51:12 +0800 Subject: [PATCH 5/5] Move per-rule user options onto their owning rules Each of the seven user options was read by exactly one rule, so expose them via CheckRuleWithOptions instead of the checker-wide UserOpts list. This keeps each rule's configuration colocated with its evaluation logic. --- checker/definition.go | 48 ------------------------------------ checker/rules_enumeration.go | 21 ++++++++++++++++ checker/rules_keys.go | 24 ++++++++++++++++++ checker/rules_signatures.go | 19 ++++++++++++++ checker/rules_ttl.go | 12 +++++++++ 5 files changed, 76 insertions(+), 48 deletions(-) diff --git a/checker/definition.go b/checker/definition.go index 1433538..76e7366 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -26,54 +26,6 @@ func Definition() *sdk.CheckerDefinition { Description: "Recursive resolver used to discover the apex name servers and to look up the parent DS. Defaults to /etc/resolv.conf.", }, }, - UserOpts: []sdk.CheckerOptionDocumentation{ - { - Id: "nsec3IterationsMax", - Type: "uint", - Label: "Maximum NSEC3 iterations", - Description: "RFC 9276 §3.1 sets the recommended ceiling at 0. Increase only if your signer cannot publish 0 yet.", - Default: defaultNSEC3IterationsMax, - }, - { - Id: "nsec3IterationsSeverity", - Type: "choice", - Label: "Severity when NSEC3 iterations exceed the ceiling", - Choices: []string{"warn", "crit"}, - Default: defaultNSEC3IterationsSeverityWarn, - Description: "Use 'crit' to enforce RFC 9276 strictly.", - }, - { - Id: "signatureFreshness", - Type: "uint", - Label: "RRSIG freshness WARN threshold (days)", - Description: "Warn when the closest RRSIG expires in fewer than this many days.", - Default: defaultSignatureFreshnessDays, - }, - { - Id: "signatureFreshnessCrit", - Type: "uint", - Label: "RRSIG freshness CRIT threshold (days)", - Default: defaultSignatureFreshnessCrit, - }, - { - Id: "minRSAKeySize", - Type: "uint", - Label: "Minimum RSA modulus size (bits)", - Default: defaultMinRSAKeySize, - }, - { - Id: "requireSEP", - Type: "bool", - Label: "Require a KSK (DNSKEY with SEP bit)", - Default: defaultRequireSEP, - }, - { - Id: "dnskeyTTLMin", - Type: "uint", - Label: "Minimum DNSKEY TTL (seconds)", - Default: defaultDNSKEYTTLMinSec, - }, - }, DomainOpts: []sdk.CheckerOptionDocumentation{ { Id: "domain_name", diff --git a/checker/rules_enumeration.go b/checker/rules_enumeration.go index 0adc1ad..ffad328 100644 --- a/checker/rules_enumeration.go +++ b/checker/rules_enumeration.go @@ -102,6 +102,27 @@ func (nsec3IterationsRule) Name() string { return "dnssec_nsec3_iterations" } func (nsec3IterationsRule) Description() string { return "Verifies that NSEC3PARAM.Iterations is at most nsec3IterationsMax (default 0, per RFC 9276 §3.1)." } +func (nsec3IterationsRule) Options() sdk.CheckerOptionsDocumentation { + return sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "nsec3IterationsMax", + Type: "uint", + Label: "Maximum NSEC3 iterations", + Description: "RFC 9276 §3.1 sets the recommended ceiling at 0. Increase only if your signer cannot publish 0 yet.", + Default: defaultNSEC3IterationsMax, + }, + { + Id: "nsec3IterationsSeverity", + Type: "choice", + Label: "Severity when NSEC3 iterations exceed the ceiling", + Choices: []string{"warn", "crit"}, + Default: defaultNSEC3IterationsSeverityWarn, + Description: "Use 'crit' to enforce RFC 9276 strictly.", + }, + }, + } +} func (nsec3IterationsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errState := loadDNSSEC(ctx, obs) diff --git a/checker/rules_keys.go b/checker/rules_keys.go index 69360bc..a3908ba 100644 --- a/checker/rules_keys.go +++ b/checker/rules_keys.go @@ -139,6 +139,18 @@ func (rsaKeySizeRule) Name() string { return "dnssec_rsa_keysize" } func (rsaKeySizeRule) Description() string { return "Verifies RSA DNSKEYs reach a minimum modulus size (default 2048 bits)." } +func (rsaKeySizeRule) Options() sdk.CheckerOptionsDocumentation { + return sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "minRSAKeySize", + Type: "uint", + Label: "Minimum RSA modulus size (bits)", + Default: defaultMinRSAKeySize, + }, + }, + } +} func (rsaKeySizeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errState := loadDNSSEC(ctx, obs) @@ -194,6 +206,18 @@ func (kskPresentRule) Name() string { return "dnssec_ksk_present" } func (kskPresentRule) Description() string { return "Verifies at least one DNSKEY has the SEP bit (KSK)." } +func (kskPresentRule) Options() sdk.CheckerOptionsDocumentation { + return sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "requireSEP", + Type: "bool", + Label: "Require a KSK (DNSKEY with SEP bit)", + Default: defaultRequireSEP, + }, + }, + } +} func (kskPresentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errState := loadDNSSEC(ctx, obs) diff --git a/checker/rules_signatures.go b/checker/rules_signatures.go index 4057d9b..f73c318 100644 --- a/checker/rules_signatures.go +++ b/checker/rules_signatures.go @@ -114,6 +114,25 @@ func (rrsigFreshnessRule) Name() string { return "dnssec_rrsig_freshness" } func (rrsigFreshnessRule) Description() string { return "Warns when RRSIGs are close to expiring; preemptive alert for stuck signers." } +func (rrsigFreshnessRule) Options() sdk.CheckerOptionsDocumentation { + return sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "signatureFreshness", + Type: "uint", + Label: "RRSIG freshness WARN threshold (days)", + Description: "Warn when the closest RRSIG expires in fewer than this many days.", + Default: defaultSignatureFreshnessDays, + }, + { + Id: "signatureFreshnessCrit", + Type: "uint", + Label: "RRSIG freshness CRIT threshold (days)", + Default: defaultSignatureFreshnessCrit, + }, + }, + } +} func (rrsigFreshnessRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errState := loadDNSSEC(ctx, obs) diff --git a/checker/rules_ttl.go b/checker/rules_ttl.go index 53e5ff9..81fea51 100644 --- a/checker/rules_ttl.go +++ b/checker/rules_ttl.go @@ -13,6 +13,18 @@ func (dnskeyTTLMinRule) Name() string { return "dnssec_dnskey_ttl_min" } func (dnskeyTTLMinRule) Description() string { return "Warns when the DNSKEY TTL is too short to be useful for caching." } +func (dnskeyTTLMinRule) Options() sdk.CheckerOptionsDocumentation { + return sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "dnskeyTTLMin", + Type: "uint", + Label: "Minimum DNSKEY TTL (seconds)", + Default: defaultDNSKEYTTLMinSec, + }, + }, + } +} func (dnskeyTTLMinRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errState := loadDNSSEC(ctx, obs)