DEV Community

marcorossi4891
marcorossi4891

Posted on

Rebuilding DNS Audit Evidence — Why History Needs More Than a Live Zone

Short answer: use application events for complete history and live DNS reads for independent proof; neither source can replace the other.

A live DNS zone answers what is published now. Application logs answer what your service attempted to publish. An audit trail that needs historical completeness must reconcile both, because neither source is a complete ledger by itself.

The practical choice is an append-only change record as the primary evidence, with periodic live-zone reads as an independent observation. Treat the read as a snapshot, not as a time machine. That distinction prevents a surprisingly common audit error: presenting today's MX or TXT record as proof of what existed during last quarter's incident.

What can application logs and live zone reads prove for DNS?

An application log can prove that a request entered your control plane, including the actor, intent, validation result, and response. It cannot prove that the authoritative name server served the intended RRset. A timeout, rejected update, stale deployment, or an operator changing the zone outside the application can break that assumption.

A live read has the opposite boundary. It proves what a resolver observed at a particular time and vantage point. DNS caching means that observation is affected by TTL and resolver behavior; a positive answer can remain cached after the zone has changed, while a negative answer has its own cache rules (RFC 2308). A single read also says nothing about who requested the change.

For mail domains, this matters beyond the MX record. DMARC policy is published in a TXT record at _dmarc.<domain> and is evaluated alongside SPF and DKIM alignment (RFC 7489). If an auditor asks why a message stream was treated as failing policy on a given date, the answer needs the policy version and the change event, not just the current TXT value.

Where reconciliation fails in real systems

The first trap is treating a successful API response as publication. The response is an application fact. Publication is a DNS fact. Keep both, with separate timestamps and identifiers.

The second trap is collapsing repeated values. Suppose a deployment writes the same MX RRset twice, then changes its TTL, then rolls back. A value-only table loses the sequence and makes a later rollback look like an original state. Store the RRset, TTL, owner name, type, class, and an event sequence; hash the canonical representation so a replay can detect accidental mutation.

I initially expected a daily live read to fill the gaps. It did not. A deletion and recreation inside one sampling window can leave no visible trace, even though the application log contains two distinct decisions. The read is still useful, but as a checkpoint that can expose drift between intended and observed state.

Short gaps matter.

A defensible evidence model

Use an event record for intent and an observation record for reality. The event should include a monotonic sequence, request identifier, actor or workload identity, normalized owner name, RRset before and after, validation outcome, and the authoritative update result. The observation should include query time, resolver or authoritative endpoint, response code, answer section, TTL, and the event sequence known to be latest at read time.

The awkward cases deserve more storage, not less. Imagine sequence 418 changes an MX target, the update service returns success, and a read from one authoritative server still returns the old RRset while another returns the new one. Record both responses, their server identities, and the exact query times; then keep the event in a pending state until the policy window closes. If sequence 419 is a rollback during that window, do not overwrite 418 or “fix” the report by selecting the newest answer. Auditors need to see the propagation interval and the competing observations, because that interval explains why a message-handling decision could differ by resolver. This is also where a raw response pays off: a normalized row cannot preserve an unexpected status code or an empty authority section that changes the interpretation.

Keep the uncertainty visible.

Here is a compact Python shape for the reconciliation key. It deliberately keeps transport details out of the audit contract.

from dataclasses import dataclass
from hashlib import sha256
import json

@dataclass(frozen=True)
class ZoneObservation:
    owner: str
    rrtype: str
    rrset: tuple[str, ...]
    ttl: int
    observed_at: str
    source: str

    def fingerprint(self) -> str:
        payload = {
            "owner": self.owner.rstrip(".").lower(),
            "rrtype": self.rrtype.upper(),
            "rrset": sorted(self.rrset),
            "ttl": self.ttl,
        }
        encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
        return sha256(encoded).hexdigest()
Enter fullscreen mode Exit fullscreen mode

The reconciliation job compares fingerprints and sequence ranges, then classifies the result: observed-and-accounted-for, accounted-but-not-observed-yet, observed-without-an-application-event, or indeterminate because the read was served from a cache. Do not silently turn the last two classes into success. They are work queues for investigation.

Which signal should drive an audit decision?

For history completeness, the append-only event stream is the spine. It supports ordering, actor attribution, and replay. Live reads provide independent corroboration and catch out-of-band edits, failed propagation, and records that never matched the requested state.

For a point-in-time claim, preserve the raw response and query context. A normalized database row is convenient for dashboards, but it is not enough evidence when response codes, authority flags, or DNSSEC-related data affect interpretation. Keep retention aligned with the compliance period, and make deletion of evidence a separately authorized event.

A useful operational rule is to alert on missing pairs, not on mismatched strings alone. Every accepted change should eventually have an observation within a defined propagation window; every unexpected observation should have an owning queue and a disposition. The window is a policy choice that should be tested against the zone's TTLs and resolver behavior, not copied from a vendor default.

That design has a real cost: retaining raw responses and immutable events consumes storage and creates access-control work. A small team may accept coarser sampling for low-risk domains, while a regulated mail boundary may require every change and a tighter observation window. The trade-off is evidence strength versus operational overhead, not a choice between a “good” and “bad” product.

Start in shadow mode. Capture events and reads without changing enforcement, then measure sequence gaps, duplicate writes, and observations that arrive after the expected window. Backfill only from sources whose timestamp semantics are known; label imported records as reconstructed rather than original.

During migration, freeze the canonicalization rules. Lowercase owner names, normalize the terminal dot, sort RRset members, and preserve the original wire or text form beside the normalized form. Changing normalization halfway through creates false differences that look like DNS drift.

The end state is intentionally boring: an immutable event stream, sampled authoritative observations, explicit uncertainty, and a report that can show both what the control plane decided and what DNS served. That is stronger audit evidence than either application logs or live reads presented alone.

Sources

Top comments (0)