Ga naar inhoud

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 in internal/assessor/wand/integration_test.go drive 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 on dns.<rrtype>, http.parse_failed, tls.ct.unavailable, etc.
  • no_answer: true — the lookup succeeded but returned zero records (e.g. dns.caa against 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.unavailable when no GeoLite2 DB is wired, inventory.<id>.unavailable when 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.asn findings joined with http.third_party findings give you the non-EU-dependency picture — which hosts does the homepage load from, which country/ASN are those hosted in.
  • tls.issuer.issuer_country is a strong jurisdictional signal on its own; combine with dns.ns to see who can unilaterally affect cert issuance and DNS continuity.
  • Evidence always 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.