Findings Reference
Every probe produces models.Finding records with a shared shape. This
document is the catalogue: which ProbeIDs exist, what they mean, which
sovereignty dimension they inform.
Probe-ID / rule-ID drift is build-breaking. Each ProbeID listed here is referenced by string from at least one assessor rule (
internal/assessor/wand,internal/assessor/eucsf). The integration tests ininternal/assessor/wand/integration_test.godrive real probes through real rules; renaming a ProbeID without updating the matching rule fails CI immediately rather than silently returning Onbekend on every real scan. Treat this catalogue as the contract — not a description.
Finding shape
type Finding struct {
ID string // assigned on persist
ScanID string // set by the scanner
ProbeID string // "dns.mx", "tls.issuer", ...
DimensionHint DimensionHint // optional sovereignty dimension
CriteriumHint string // optional rule-pack criterium ref
Subject string // the thing being described
Severity Severity
Attributes map[string]any // probe-specific structured data
Evidence []byte // raw source material (cert PEM, TXT record, ...)
CreatedAt time.Time
}
See pkg/models/finding.go for the canonical definition.
Severity ladder
| Severity | Meaning |
|---|---|
info |
Neutral fact. Not a problem, not praise. |
observation |
Noteworthy fact that may matter in context. |
concern |
Fact that likely bears on sovereignty posture. |
finding |
Fact the assessor should almost certainly reflect in the score. |
The ladder is deliberately coarse. Fine-grained weighting belongs to the assessor, not to the probe.
Sovereignty dimension hints
The dimension labels are shared between the wand and SEAL rule packs.
| Hint | Dimension |
|---|---|
juridisch |
Juridisch |
technologie |
Technologie |
data_ai |
Data & AI |
operationeel |
Operationeel |
mens |
Mens |
accountability |
Accountability (wand-native, no DICTU counterpart — see assessor.md) |
standards |
Standards (wand-native, no DICTU counterpart — Internet.nl import, see assessor.md) |
| (empty) | no hint — raw observation |
Scanner-level meta findings
These are produced by the orchestrator, not by any individual probe.
| ProbeID | Severity | When produced |
|---|---|---|
<probe>.timeout |
info | A probe exceeded its per-probe timeout. |
<probe>.error |
concern | A probe returned a non-timeout error. |
<probe>.panic |
concern | A probe panicked; recovered by the scanner. |
<probe> is the probe ID: dns, tls, ip, http.
Probe-level meta-finding convention
Individual probes also emit non-evidence rows that share the schema of real findings but explicitly mark their meta status. The convention is one of three attributes:
error— string error message; the row records why the probe could not produce evidence (e.g. NXDOMAIN, timeout). Set ondns.<rrtype>,http.parse_failed,tls.ct.unavailable, etc.no_answer: true— the lookup succeeded but returned zero records (e.g.dns.caaagainst a domain with no CAA policy). The row stays observable so absence is auditable.unavailable: true— the probe could not run at all on this host (e.g.ip.unavailablewhen no GeoLite2 DB is wired,inventory.<id>.unavailablewhen an inspector requires absent state).
Assessor rules SHALL skip these rows when deciding whether evidence
backs a verdict — see assessor.IsEvidenceLike in
internal/assessor/rule.go. A rule that aggregates by ProbeID
without filtering through this helper risks scoring positively on
the absence of real signal (e.g. wand.data_ai.mx_present once
counted lookup-error rows as configured mail exchangers).
DNS probe — internal/probe/dns
| ProbeID | Severity | Dimension | Attributes (non-exhaustive) |
|---|---|---|---|
dns.a |
info | — | address (IPv4); on failure: error, kind |
dns.aaaa |
info | — | address (IPv6) |
dns.mx |
observation | data_ai |
host, preference; Evidence: <pref> <host> |
dns.ns |
observation | operationeel |
host |
dns.cname |
observation | data_ai |
target (only recorded if non-apex) |
dns.txt.spf |
observation | data_ai |
record, kind: "spf" |
dns.txt.dkim |
observation | data_ai |
record, kind: "dkim" |
dns.txt.dmarc |
observation | data_ai |
record, kind: "dmarc" |
dns.txt.other |
info | data_ai |
record, kind: "other" |
dns.caa |
observation | operationeel |
flag, tag, value, inherited_from (only when the records came from an ancestor) |
dns.subdomain |
observation | — | apex_domain, source: "ct_log" \| "prefix_probe", addresses (prefix_probe only) |
dns.subdomain.wildcard |
info | — | apex_domain, source: "prefix_probe", hit_count, ips |
Subdomain sources. dns.subdomain Findings come from two passive
sources: SubjectAlternativeName entries on the apex certificate
(source: "ct_log") and a small fixed prefix sweep
(internal/probe/dns/subdomains.go, source: "prefix_probe"). When
every prefix resolves to the same IP set the probe collapses the noise
into a single dns.subdomain.wildcard Finding rather than 18 spurious
hits.
Failure variants. For dns.a, dns.mx, dns.ns, dns.txt,
dns.caa, a lookup that fails produces a finding of severity info
with error: "<string>" and kind: "nxdomain" | "timeout" |
"temporary" | "error".
CAA lookup. The Go standard library resolver does not expose CAA,
so the default adapter sends a hand-rolled UDP query (type 257) to the
system's configured nameserver — the same approach the SOA probe uses
for its own stdlib gap (internal/probe/soa/resolver.go), kept as a
separate copy per probe rather than a shared dependency. Finding
nothing on the queried name, it climbs one DNS label at a time until it
finds records or reaches the registrable domain (RFC 8659 §3; the
registrable-domain heuristic is internal/domainutil.Registrable, the
same one the scanner's NS-holder lookup uses). Records found above the
queried name carry inherited_from naming the zone they came from, and
wand.operationeel.caa_restricts_issuance's verdict text repeats that
origin. Only an empty result all the way up to the registrable domain
is recorded as "no CAA records".
Until 2026-09-21 this adapter unconditionally returned an empty result
regardless of the domain, so caa_restricts_issuance scored every
target as if it had no CAA policy — voldoende never fired, and
afhankelijk fired even for zones with real, restrictive records
(digid.nl, mijnoverheid.nl, ncsc.nl all had them). Every prior CAA
verdict is worthless; the rule's logic was fine; it was scoring
against data that was always empty by construction.
TLS probe — internal/probe/tls
| ProbeID | Severity | Dimension | Attributes |
|---|---|---|---|
tls.handshake |
concern | operationeel |
error, kind: "timeout" \| "other" — when the handshake could not be made |
tls.verify |
concern | operationeel |
verified: false, subject_cn — only when the chain did not verify |
tls.issuer |
finding | juridisch |
issuer_cn, issuer_o, issuer_country, subject_cn, serial, not_before, not_after, signature_algo, public_key_algo. Evidence: cert PEM |
tls.san |
observation | data_ai |
dns_names, ip_addresses |
tls.validity |
info | observation (expiring) | concern (expired) | operationeel |
not_before, not_after, days_left, expired?, expiring_soon? |
tls.chain |
info | operationeel |
length, intermediates (list of intermediate CNs) |
tls.ct |
observation | info (unavailable) | juridisch | — |
total_entries, issuer_counts; on failure: unavailable: true, error |
The TLS probe will first try a verified handshake; on failure it
retries with verification off so the certificate can still be
inspected. In that path it emits both tls.verify (concern) and the
usual cert-inspection findings.
IP probe — internal/probe/ip
| ProbeID | Severity | Dimension | Attributes |
|---|---|---|---|
ip.asn |
finding | juridisch |
address, asn, organisation, country |
ip.resolve |
info | — | error — hostname did not resolve |
ip.lookup |
info | — | address, error — IP present, MaxMind lookup failed |
ip.unavailable |
info | — | reason — probe started with no GeoLite2 DB |
The IP probe fails fast at startup if the GeoLite2 DB is missing or corrupt. It does not degrade silently.
HTTP probe — internal/probe/http
| ProbeID | Severity | Dimension | Attributes |
|---|---|---|---|
http.response |
info | — | status, final_url, scheme, server, powered_by |
http.security_headers |
observation | operationeel |
present (map of header→value), missing (list of header names) |
http.scheme_downgrade |
concern | operationeel |
reason — HTTPS failed, HTTP fallback succeeded |
http.third_party |
observation | technologie |
source_domain, kinds (one or more of script, link, img, iframe, source) |
http.robots_blocked |
info | — | robots_txt_fetched: true; Evidence: robots.txt body |
http.fetch_failed |
concern | — | error — neither HTTPS nor HTTP worked |
http.parse_failed |
info | — | error — response fetched but HTML parsing failed |
http.securitytxt |
observation | accountability |
status, present, parseable, contact (list), expires (RFC 3339), truncated?; Evidence: raw body |
http.securitytxt.unavailable |
info | — | reason — transport-level failure only (DNS, TLS, connect, timeout) |
http.third_party has one Finding per external host. The Subject
is the external host, not the scanned domain. Use source_domain in
Attributes to link it back.
http.securitytxt fetches https://<domain>/.well-known/security.txt
(RFC 9116 — published precisely to be fetched, so this stays inside
the passive boundary). A non-200 response (a 404 included) is a
valid observation, not an error: present: false, parseable:
false. An HTML error page served at that path (detected via
Content-Type or a <!doctype/<html sniff) is recorded as present
but parseable: false rather than parsed as if it were text. Only a
transport-level failure emits http.securitytxt.unavailable. The body
is capped at 64 KiB (truncated: true beyond that).
WHOIS / RDAP probe — internal/probe/whois
Issues GET https://rdap.org/domain/<domain> with a 5-second
timeout and walks the returned vCard array per RFC 7483. Stdlib
net/http only — no WHOIS-43 socket, no third-party SDK. Entities are
parsed recursively (entities[].entities[]) because a reseller
commonly sits nested under the registrar entity.
| ProbeID | Severity | Dimension | Attributes |
|---|---|---|---|
whois.registrant |
finding | juridisch |
country (ISO 3166-1 alpha-2, uppercase) |
whois.registrar |
info | — | name (registrar organisation) |
whois.registrant_identity |
observation | accountability |
name (or "absent"), kind (vCard KIND, e.g. individual/org, or "absent"), privacy_proxy (bool — a generic redaction placeholder, not a specific commercial service), rfc9537_redacted? (true when the RDAP response carried a redacted array) |
whois.reseller |
observation | accountability |
present (bool), name (or "absent") |
whois.status |
observation | accountability |
codes (RDAP domain status codes, [] when the registry publishes none) |
whois.expiry |
observation | accountability |
present (bool), date (RFC 3339, or "absent" when the registry publishes no expiration event — e.g. SIDN for .nl) |
whois.unavailable |
info | — | reason — emitted on any network error, non-2xx, parse error, or response with no registrant/registrar entities, so the rest of the scan continues |
Consumed by wand.juridisch.registrar_jurisdiction, which maps
EEA registrant countries to soeverein, anything outside the EEA to
afhankelijk, and absence (whois.unavailable) to onbekend.
whois.registrant_identity, whois.reseller, whois.status, and
whois.expiry feed the accountability-dimension rules and
wand.operationeel.domain_expiry — see
assessor.md.
SOA probe — internal/probe/soa
Queries the zone's SOA record (a hand-rolled SOA query, since
net.Resolver exposes no SOA lookup — the same gap the DNS probe
works around for CAA) and reports the RNAME (the zone's own
designated contact mailbox) as a contactability signal.
| ProbeID | Severity | Dimension | Attributes |
|---|---|---|---|
dns.soa |
observation | accountability |
mname, rname; on successful RNAME parse also mailbox, mailbox_domain, mailbox_domain_resolves (bool), mailbox_domain_has_mx (bool); on parse failure rname_parse_error instead |
dns.soa.unavailable |
info | — | reason — NXDOMAIN, timeout, lame delegation |
No SMTP probing. The probe checks whether the RNAME mailbox domain resolves and publishes MX only — it never opens an SMTP connection to verify delivery. Confirming delivery would cross the passive/abuse boundary; existence plus MX presence is the honest ceiling, stated as such in the rule's verdict text.
Variants probe — internal/probe/variants
Attempts all 8 apex/www × IPv4/IPv6 × http/https path combinations
and reports whether they converge on one canonical HTTPS origin.
Sequential, one hop at a time, redirect depth capped at 5 per path, a
hard budget of 24 connections per target across all 8 paths — less
traffic than one browser page load. Every hop passes the existing
SSRF guard (internal/probe/ssrf.go); a refused hop is recorded as
refused, never followed.
| ProbeID | Severity | Dimension | Attributes |
|---|---|---|---|
http.variants |
observation | operationeel |
paths (list of per-path results, see below), connections_used, connection_budget |
http.variants.unavailable |
info | — | reason — emitted once when the scan's own context budget was already exhausted before this probe ran |
Each entry in paths carries: host_part (apex/www), family
(v4/v6), scheme (http/https), status
(reachable/unreachable/refused/not_followed_budget/not_tested),
chain (the URLs visited), final_origin (when reachable), and
reason (when not_tested, always scanner_no_ipv6 — see
assessor.md).
If the scanner host has no working IPv6 route, v6 paths are recorded
as not_tested rather than attempted and reported unreachable — a
scanner-side limitation must never be charged to the target. An
allowed optimisation: once a path lands on an origin already verified
reachable in the same address family during this run, later paths
that land on the same origin stop immediately rather than re-walking
the chain; the shortcut never crosses address families, so a v6 path
is never marked reachable off the strength of a v4 verification.
NS holder Findings — internal/scanner/nsholder.go
Produced by the scanner, not a probe: one RDAP lookup per unique
registrable domain among the target's dns.ns hosts (cached per scan,
so N nameservers on one provider cost one lookup), classifying whether
that nameserver-operating domain's own RDAP registrant is
identifiable. Subject is the nameserver's registrable domain, not
the scanned target.
| ProbeID | Severity | Dimension | Attributes |
|---|---|---|---|
whois.ns_holder |
observation | accountability |
registrant ("present" | "proxied" | "absent") |
whois.ns_holder.unavailable |
info | — | reason — RDAP lookup failed, including a TLD with no RDAP service |
Consumed by wand.accountability.ns_holder_transparent.
Organisation config Findings — internal/scanner/expectedregistrant.go
config.expected_registrant records, at scan time, the organisation's
declared registrant names (organisations.expected_registrant, set
via wanderer org add --expected-registrant). It is always emitted,
even with an empty list, so its absence is never mistaken for "not
scanned yet". Recording it as a Finding rather than having the
assessor read the organisations table directly keeps the assessor a
pure function of Findings: a later re-assessment of a stored scan sees
the names as they stood when the scan ran, not what the organisation
says today.
| ProbeID | Severity | Dimension | Attributes |
|---|---|---|---|
config.expected_registrant |
info | accountability |
expected_registrant (list of names, possibly empty) |
Consumed by wand.accountability.registrant_identifiable.
Drift Findings
Drift Findings are produced by the scheduler when a new scan differs
from the previous scan of the same target. Their ProbeID starts
with drift.; their Attributes always contain
source_modus: "drift", prev_scan_id, and curr_scan_id.
| ProbeID | severity | dimension | attributes |
|---|---|---|---|
drift.baseline_established |
info | — | (first scan for a target — no comparison possible) |
drift.no_changes |
info | — | (two scans identical modulo IDs/timestamps) |
drift.tls.issuer_changed |
finding | juridisch |
prev_issuer_cn, curr_issuer_cn |
drift.tls.days_left_dropped |
concern | operationeel |
prev_days_left, curr_days_left (fired on crossing 30d) |
drift.dns.mx_set_changed |
observation | data_ai |
added: [], removed: [] |
drift.dns.ns_set_changed |
observation | operationeel |
added: [], removed: [] |
drift.ip.country_changed |
finding | juridisch |
prev_country, curr_country (per host) |
drift.http.third_party_added |
observation | technologie |
hosts: [] — newly seen subjects |
drift.http.third_party_removed |
info | technologie |
hosts: [] — subjects that disappeared |
See drift.md for the rule semantics and how the
assessor consumes drift Findings.
Inventory Findings
Inventory Findings are produced by wanderer agent running on a
host. They carry SourceModus = "inventory" (top-level field on
Finding since the inventory probe landed) so the assessor's
completeness calculation can distinguish them from perimeter data.
| ProbeID | severity | dimension | attributes |
|---|---|---|---|
inventory.systemd.service |
info | operationeel |
load_state, active_state, sub_state, description |
inventory.packages.dpkg |
info / observation | operationeel |
version, arch, status (observation when EOL) |
inventory.packages.rpm |
info / observation | operationeel |
version, arch |
inventory.docker.container |
info | operationeel |
image, image_digest, state, status, created_at, labels? |
inventory.docker.image |
info | technologie |
digest, repo_tags?, size_bytes, created_at |
inventory.nextcloud.app |
info | technologie |
version, enabled |
inventory.<id>.unavailable |
info | — | reason — inspector could not run on this host |
inventory.<id>.error |
info | — | error — inspector ran but failed mid-run |
See agent.md for how to run wanderer agent and how
inventory data raises a dimension's Completeness from partial to
complete.
Egress Findings
Egress Findings are produced by the wanderer agent's egress probe
when it identifies an external endpoint in a config file, a process
environment, or a systemd unit. They carry SourceModus = "egress".
Every non-unknown Finding includes a classifier_rule attribute
naming the rule that fired. Secrets are redacted before any value
reaches Attributes or Evidence — see egress.md and
ADR-0008 for the redaction contract.
| ProbeID | severity | dimension | attributes (in addition to config_source, config_key, value, classifier_rule, confidence) |
|---|---|---|---|
egress.object_storage |
observation | juridisch |
provider (aws/gcs/azure/generic), region (best-effort), asn/organisation/country when GeoLite2 is wired |
egress.database |
observation | juridisch |
provider (postgres/mysql/…), port |
egress.smtp |
observation | data_ai |
port |
egress.oidc |
observation | data_ai |
(host only) |
egress.log_shipper |
observation | operationeel |
(host only) |
egress.webhook |
observation | technologie |
(host only) |
egress.unknown |
info | — | URL-shaped value the classifier could not bucket |
egress.<scanner>.unconfigured |
info | — | scanner enabled but no paths/sources to read |
egress.<scanner>.error |
info | — | scanner ran but failed mid-run |
egress.host_resolution.unavailable |
info | — | emitted at most once per run when no GeoLite2 DB is wired |
Internet.nl import Findings — internal/scanner/netnlimport.go
Produced by wanderer import internetnl <file>, never by a probe: the
importer parses a netnl-findings/v1 file (exported by netnl,
the standalone internetnl-cli tool — see
Feed Internet.nl results from CI) and
persists one Finding per Internet.nl subtest, under a scan of kind
import. They carry SourceModus = "import" (models.SourceModusImport)
so the assessor's completeness calculation and the store's
FindingsForAssessment correlation (below) can tell them apart from
perimeter/inventory/egress/drift data.
| ProbeID | Severity | Dimension | Attributes |
|---|---|---|---|
internetnl.<type>.<test> |
info (passed/not_tested) | observation (info, default) | concern (warning, error) | finding (failed) |
standards |
domain, type (web|mail), test, category, status, verdict, detail (always null on the real API — see below), measured_at (RFC 3339, the whole batch's finished_date, identical for every domain in one file), score_percent, report_url |
internetnl.<type>.status |
severity of status |
standards |
domain, type, status, measured_at, score_percent, report_url — emitted instead of per-test findings when the domain measured with zero results (Internet.nl's own status: "error" case: the measurement broke, not "not delivered") |
test is the Internet.nl test name (web_dnssec_exist,
mail_starttls_tls_available, …); category is the API's own
category for it (web_dnssec, mail_starttls, …), resolved by netnl
from GET /metadata/report's hierarchy, not derivable from the
test name by a prefix rule (six RPKI tests — web_ns_rpki_exists,
mail_ns_rpki_exists, mail_mx_ns_rpki_exists, and their _valid
counterparts — have no category name as a literal prefix). status is
one of passed / failed / warning / info / not_tested /
error — the assessor scores on status; detail is carried as an
opaque, always-null field so a future API version that populates it
does not need a schema bump, and no rule may be built on it.
report_url is the Internet.nl (or self-hosted netnl-serve) report
URL, carried verbatim — Wanderer never constructs it from domain +
request ID, since a self-hosted instance's URL shape differs from
internet.nl's own.
Consumed by the six wand.standards.* rules — see
assessor.md.
Reading tips
- Group by
ProbeID's first segment for a per-probe view (the CLI output already does this). ip.asnfindings joined withhttp.third_partyfindings give you the non-EU-dependency picture — which hosts does the homepage load from, which country/ASN are those hosted in.tls.issuer.issuer_countryis a strong jurisdictional signal on its own; combine withdns.nsto see who can unilaterally affect cert issuance and DNS continuity.Evidencealways lets you audit without re-scanning. A finding is never "lost" — the raw source is retained.
Stability promise
The Finding struct and the ProbeID namespaces in this document are
intended to be stable across MVP-era releases. Adding new ProbeIDs is
non-breaking; renaming or removing an existing one is breaking and
should come with a documented migration path.