Two of our paid verification handlers answered a question they had not been able to ask. Both looked like this inside:
let ns = null;
try { ns = await dns.resolveNs(domain); } catch (e) { /* nothing */ }
return { ok: true, legit: Boolean(whois && ns && mx), ns, mx, whois };
The intent is reasonable: if the lookup throws, treat the record as absent. The code has one operator doing two jobs. A DNS response code carries more than "yes" and "no": ENOTFOUND means the name does not exist, ENODATA means the name exists but has no record of that type โ those are answers. ESERVFAIL, ETIMEOUT, and EREFUSED mean the resolver did not give us an answer at all. The catch flattened all five into null, and null flowed into legit: false.
So a domain that was briefly unanswerable produced the same fields and the same verdict as a domain that does not exist:
whois: null ns: null mx: null dmarc: null legit: false
Same for domain-audit, which additionally folded TLS into the same shape. These are $3 calls whose product is a judgement. The judgement was being made by the resolver's weather.
A third handler, osint-pack, built its risk flags like this:
if (!results.ssl.authorized) flags.push('untrusted-cert');
But the certificate object never carried an authorized field. !undefined is true, so every domain with a perfectly valid certificate was flagged untrusted-cert and scored at least medium. The 402 response advertised the bug in its own output example: a valid example.com certificate, riskFlags: ["untrusted-cert"], riskScore: "medium". The shop window was showing the defect.
Both shapes are the same mistake at different depths: a value that was never observed was allowed to decide a boolean. null and undefined are not evidence; they are the absence of evidence. Code that treats them as a negative answer will produce confident false negatives, which are the expensive kind โ the caller pays for the answer.
The fix is to keep the third state instead of collapsing it:
ENOTFOUND/ENODATA, or an RDAP 404 (the registry answered: no such object);ESERVFAIL, ETIMEOUT, EREFUSED, a 429, a 5xx. Neither a yes nor a no.Concretely, both handlers now collect the failures they could not classify:
{ "ok": true,
"check": {
"whois": { ... },
"ns": ["elliott.ns.cloudflare.com", "hera.ns.cloudflare.com"],
"mx": null,
"legit": null // unknown, not false
},
"unresolved": ["mx"] } // the lookup that did not answer
legit is null when the inputs required for the judgement were not all observed. unresolved names which lookups never answered. A caller can now distinguish "this domain is not a company" from "ask again"; the second one is cheap to retry and the first one should not be invented. Only ENOTFOUND and ENODATA reach the negative branch; an RDAP 404 stays a definitive no.
osint-pack now carries authorized: s.authorized on the certificate object, so the flag can only fire on an observed false. It also gained an upstream-state signal: a transient DNS failure now surfaces as a dns-unavailable flag and riskScore: "unknown" instead of a confident clean โ the same rule in the other direction. An unanswered lookup must not produce a positive verdict either.
Nine more handlers had a smaller version of it. Before fetching a caller-supplied host, they resolve it to reject private addresses. On a resolution failure they returned:
{ "ok": false, "error": "internal address blocked" }
That sentence is a security finding about the caller. A resolver timeout is not. The private-address branch is unchanged โ 127.0.0.1 still says internal address blocked โ but a failure to resolve now says host did not resolve: โฆ with retryable: true. Nothing about the protection changed; only the claim about why it fired.
We already had a guard for exactly this class of bug: sixteen handlers that take a domain and report DNS verdicts. Its population rule was "handlers that only do DNS". company-check, domain-audit, and osint-pack also do RDAP, TLS, or HTTP, so the rule excluded them โ the three handlers with the bug were outside the population that checked for it.
The guard now has a second class: handlers that require a DNS module and also use ssrf, tls, or fetch. Population 16 โ 22 (17 pure, 5 mixed); controls 12 โ 25. Before the fix it named the two handlers by id. After it, the same command over the deployed bytes is clean.
The population rule is part of the predicate. A guard that only inspects the pure cases will report clean on the mixed ones, and the mixed ones are where the composition bug lives.
Anywhere a network result is coerced into a boolean, ask which failures the coercion is allowed to swallow. A timeout, a rate limit, and a 5xx are not answers. If the response cannot say "I did not ask", it will eventually say "no" on someone else's behalf โ and when that response costs $3, the false negative is the product.
The same three-state discipline is what we wrote about in The Monitor That Reported Itself, one layer up: there the reporter could not say "I did not measure this". Here the service could not say "I did not ask". The state is the same; the altitude is different.
Related: the monitor that reported itself ยท the error class that was thrown away ยท signature valid is not authorized