Assessor
The assessor turns the Findings produced by a scan into a structured
Assessment: per-dimension score, a completeness flag, and a list of
per-rule rationales that cite the Findings they drew their verdict
from.
This document covers: running the assessor, reading the output, and how to extend the wand rule set.
Inspired by
Wanderer's first-party rule pack is named wand (Wanderer-NL). Its rule semantics were inspired by the Dutch government's publicly available Toetsingsinstrument Soevereiniteit Clouddiensten, published by DICTU (Dienst ICT Uitvoering, EZK). The rule authoring, the implementation, and the ongoing maintenance are MWest2020's; DICTU does not endorse, certify, or otherwise sanction Wanderer or the wand pack. See ADR-0011 for the rationale behind the rename.
Running it
From the CLI:
wanderer assess <scan-id> [--framework wand|eucsf|both] [--format text|markdown|json] [--db wanderer.db] [--persist=false]
--frameworkdefaults towand.eucsfruns the EU Cybersecurity Framework / SEAL pack instead;bothruns both packs and persists one Assessment record per framework, tagged onAssessment.Framework.dictuis accepted as a deprecated alias forwandfor one release (it prints a stderr deprecation warning).--formatdefaults totext. Markdown is the intended operator- facing format; JSON is for tooling.--persist=falsesuppresses the side-effect of writing the assessment to the store — useful for dry runs.
Over HTTP:
curl -XPOST http://localhost:8080/scans/<scan-id>/assessments
curl http://localhost:8080/assessments/<assessment-id>
POST always persists and always returns the assessment it just
produced. Each POST creates a new record — previous assessments for
the same scan remain retrievable by their own IDs.
Reading a score
Each wand dimension receives one of:
| Score | Meaning |
|---|---|
soeverein |
The best verdict the rule set issues for this dimension. |
voldoende |
Adequate. No red flags, but not actively sovereign. |
afhankelijk |
Low sovereignty — dependency on a non-sovereign party. |
onbekend |
No evidence-backed verdict could be reached. |
The dimension score is the worst score across every
evidence-backed rule in that dimension. One rule scoring afhankelijk
drags the whole dimension down even if four others scored soeverein
— because one material dependency is one material dependency.
Next to the score, a Completeness flag tells you how much of the
rule set was answerable:
| Completeness | Meaning |
|---|---|
complete |
Every rule in this dimension had evidence to work with. |
partial |
Some rules evaluated; others did not because data was absent. |
incomplete |
No rule in this dimension had any evidence. |
A dimension with no rules at all (for example, mens in the current
rule set) is rendered as n/a in the summary table.
Rule set (MVP)
The wand rule set today is deliberately small. Most rules are Go
functions in internal/assessor/wand/rules.go; the accountability
rules live in internal/assessor/wand/accountability_rules.go.
| Rule ID | Dimension |
|---|---|
wand.juridisch.cert_issuer_eea |
juridisch |
wand.juridisch.apex_ip_eea |
juridisch |
wand.juridisch.mx_vendor_jurisdiction |
juridisch |
wand.juridisch.registrar_jurisdiction |
juridisch |
wand.operationeel.cert_validity |
operationeel |
wand.operationeel.dns_redundancy |
operationeel |
wand.operationeel.caa_restricts_issuance |
operationeel |
wand.operationeel.domain_expiry |
operationeel |
wand.operationeel.variant_convergence |
operationeel |
wand.technologie.third_parties_eea |
technologie |
wand.technologie.no_us_hyperscaler |
technologie |
wand.data_ai.mx_present |
data_ai |
wand.data_ai.oidc_federation |
data_ai |
wand.accountability.registrant_identifiable |
accountability |
wand.accountability.no_reseller |
accountability |
wand.accountability.soa_rname |
accountability |
wand.accountability.securitytxt |
accountability |
wand.accountability.ns_holder_transparent |
accountability |
wand.standards.dnssec |
standards |
wand.standards.mail_auth |
standards |
wand.standards.starttls_dane |
standards |
wand.standards.ipv6 |
standards |
wand.standards.rpki |
standards |
wand.standards.tls_config |
standards |
The oidc_federation rule always returns no evidence until the
egress probe lands — it is listed so the reader sees the future
coverage the dimension is waiting on.
The mens dimension has no rules. It appears in the output as
onbekend (n/a). This is explicit, not an omission: the MVP scanner
observes perimeter posture, not human processes.
The accountability dimension
accountability is a wand-native dimension: it has no DICTU
counterpart in the Toetsingsinstrument Soevereiniteit
Clouddiensten, and none is expected — accountability (who is
answerable for a piece of infrastructure, and can you actually reach
them) is a gap in the DICTU instrument itself, not an oversight in
wand's coverage of it. models.DimensionAccountability and the
dimension list assessor.WandDimensions (the DICTU five plus
accountability, replacing the earlier DICTUDimensions name) make
this explicit. Callers iterate WandDimensions; none may assume a
fixed length or that every entry maps to a DICTU criterium.
The five wand.accountability.* rules answer, in plain language:
registrant_identifiable— is the RDAP registrant identifiable, and does it match a name the organisation declared (organisations.expected_registrant, set withwanderer org add --expected-registrant NAME, repeatable)?no_reseller— is the registrar relationship direct, with no reseller layer in between?soa_rname— does the zone's SOA RNAME mailbox domain exist and accept mail (existence and MX presence only — delivery itself is never probed; SMTP verification would cross the passive/abuse boundary)?securitytxt— does the target publish a parseable, RFC 9116/.well-known/security.txtwith a currentContactandExpires?ns_holder_transparent— is the organisation behind each authoritative nameserver identifiable via RDAP?
Two related rules score under operationeel instead, because they
are continuity properties, not accountability ones: domain_expiry
(will the registration lapse unexpectedly) and variant_convergence
(do all eight apex/www × IPv4/IPv6 × http/https paths converge on one
canonical HTTPS origin).
All seven rules' answer-sheet copy — question, verdict text per
outcome, remediation line — lives in one Dutch string table,
internal/assessor/wand/accountability_nl.yaml, keyed by rule ID. A
load-time test fails when a rule lacks an entry or a template names a
parameter the rule does not supply. There is no i18n machinery: a
second language is a second table.
Two YAML lists, embedded and loaded the same way as
package_vendors.yaml, back registrant_identifiable:
internal/assessor/wand/registry_redaction.yaml— per-TLD registries that redact registrant data for every domain under that TLD as blanket policy (seeded withnl: SIDN). Checked first, independent of the RDAP vcard content, because on such a TLD a "REDACTED FOR PRIVACY" vcard is the registry's policy, not the registrant's choice.internal/assessor/wand/privacy_proxies.yaml— commercial proxy services ("Domains By Proxy", "Whois Privacy Protection Service", …) that publish themselves as the registrant. A miss on this list is acceptable: an undetected proxy still surfaces as identifiable-but-unmatched via the expected-registrant name comparison.
For .nl targets, registrant_identifiable and domain_expiry
render n.v.t. — SIDN publishes neither registrant data nor an
expiration event for any .nl domain, so two of the five
accountability questions are structurally unanswerable there, not a
probe failure. See
Accountability: the passive ceiling
for why Wanderer does not work around this.
The standards dimension
standards is a second wand-native dimension — no DICTU
counterpart, same as accountability — that scores the Dutch
comply-or-explain standards list (Forum Standaardisatie) as measured
by Internet.nl: DNSSEC, SPF/DKIM/DMARC, STARTTLS/DANE, IPv6, RPKI,
and TLS configuration. Wanderer does not reimplement any of these
probes itself; see
Permanent non-goals: what Wanderer will never probe itself
for why, and
Feed Internet.nl results from CI for how
to get a measurement into Wanderer.
The dimension has no evidence at all until an operator runs
wanderer import internetnl <findings-file> — a file import, not
a probe. internal/scanner/netnlimport.go parses a netnl-findings/v1
document (produced by the standalone netnl tool) and persists
internetnl.<type>.<test> Findings under a scan of kind import,
tagged SourceModus = "import" (models.SourceModusImport) — see
findings.md
for the Finding shape. No network call happens on Wanderer's side; all
probing is Internet.nl's own, under its own measurement policy.
The six wand.standards.* rules
(internal/assessor/wand/standards_rules.go) each map one or two
Internet.nl API categories onto a verdict, verbatim — never
recomputing Internet.nl's own percentage score (the "clever valkuil"
design.md warns against: weights change between Internet.nl releases,
and a home-grown score would disagree with the public report the
target's owner can open in a browser):
| Rule ID | Internet.nl categor(y/ies) |
|---|---|
wand.standards.dnssec |
web_dnssec, mail_dnssec |
wand.standards.mail_auth |
mail_auth |
wand.standards.starttls_dane |
mail_starttls |
wand.standards.ipv6 |
web_ipv6, mail_ipv6 |
wand.standards.rpki |
web_rpki, mail_rpki (includes the nameserver-RPKI subtests the API itself groups under the same category) |
wand.standards.tls_config |
web_https |
Verdict mapping, per rule, from the relevant category's per-test
status values:
- every relevant test
passed→ soeverein - a genuine mix of passed and failed/warning → voldoende
- tested and none passed → afhankelijk
- no relevant Finding, or every relevant test
not_tested/error→ onbekend, reasonnot_measured - every relevant Finding older than
standards.max_age(default 30 days) → onbekend, reasonmeasurement_stale
not_tested and error never count as measured evidence and never
score positively: a domain with no mail server configured at all
returns mostly not_tested for its mail-security tests, and scoring
that as sovereign would be the single most dangerous mistake this
dimension could make — "never measured" must never read as "measured
and fine". error (the measurement itself broke, e.g. an Internet.nl
worker crashed on that test) is likewise never treated as a failure of
the target.
Web and mail measurements for the same target import as two separate
import-kind scans (one type: "web", one type: "mail"); a
perimeter scan carries neither on its own. Store.FindingsForAssessment
(internal/store/netnlimport.go) correlates a target's latest web and
latest mail import alongside whichever scan is actually being
assessed, so wand.assess/wanderer assess/the API/the MCP tools all
see both halves of the evidence regardless of which scan kind they
were called against — a fresh import of one type supersedes only that
type's findings, never the other's.
Every standards rule declares standards.max_age as a Threshold
(StandardsMaxAgeDays / StandardsMaxAge,
internal/assessor/wand/standards_rules.go) so the staleness boundary
is visible on the rule page, not only in code.
Reason codes
A RuleResult (and the models.Rationale it becomes) may carry an
optional Reason — a machine-readable code explaining why a rule
could not produce an ordinary evidence-backed verdict. The mechanism
is generic: any pack, any dimension, may use it. Accountability is
its first user.
A rationale that carries a reason always scores onbekend on the
existing four-value scale — a reason never introduces a fifth score —
but the code additionally classifies how the dimension aggregation
should treat it:
| Class | Renders as | Dimension aggregation |
|---|---|---|
structural |
"n.v.t." + the code's sentence | Excluded from both the worst-score computation and the completeness denominator. |
gap |
"onbekend" + the code's sentence | Counts as a missing observation, same as an evidence-less rule. |
A dimension whose every registered rule carries a structural reason
is marked NotApplicable on its DimensionScore — explicit, not
merely derivable by re-scanning the rationale list — and is left out
of the target's overall score, the same way an all-onbekend
dimension already was. The overview names how many dimensions the
overall score was computed over (e.g. "over 5 van 6 dimensies") so a
.nl fleet with a structurally n.v.t. sub-slice of accountability
never reads as silently averaged in.
Each code also names a subject:
target(the default) — the verdict says something about the scanned domain.scanner— the verdict says something about Wanderer's own environment, never the target. A scanner-subject reason must never render as a property of the target; the UI shows it as an operator environment warning next to the report instead (see thescanner_no_ipv6row below).
The codes seeded by this change:
| Code | Class | Subject | Used by |
|---|---|---|---|
registry_redacted |
structural |
target |
registrant_identifiable, on a TLD listed in registry_redaction.yaml. |
not_published_by_registry |
structural |
target |
domain_expiry, when RDAP answered but published no expiration event. |
probe_unavailable |
gap |
target |
Any accountability/operationeel rule backed only by a *.unavailable finding. |
scanner_no_ipv6 |
structural |
scanner |
variant_convergence, for v6 paths the scanner host could not test because it has no working IPv6 route. |
not_measured |
gap |
target |
Every wand.standards.* rule, when no relevant internetnl.* Finding exists or every relevant test came back not_tested/error (no import yet, or Internet.nl never ran that measurement). |
measurement_stale |
gap |
target |
Every wand.standards.* rule, when the relevant internetnl.* Finding(s) are all older than standards.max_age (default 30 days). |
internal/assessor/reason.go holds the single registry mapping every
code to its class and subject, across every pack. Emitting a code
that is not registered there is a programming error: ReasonInfo
panics rather than silently defaulting a class, so an unregistered
code fails a test immediately instead of shipping a wrong render.
EU CSF / SEAL framework
internal/assessor/eucsf ships the SEAL pack — a five-level
sovereignty scale (SEAL0–SEAL4) over the same Findings the wand pack
consumes. The two packs share no rule code; --framework both runs
each independently and persists one Assessment per framework.
| Level | Meaning |
|---|---|
| SEAL0 | No evidence-backed verdict could be produced (no relevant Findings). |
| SEAL1 | Verdict that fails the framework outright — clear non-EU exposure. |
| SEAL2 | Verdict with notable dependence on a non-EU party. |
| SEAL3 | Verdict that is adequate — minor or low-impact dependencies only. |
| SEAL4 | Full sovereignty — every checked surface is EU-resident. |
The MVP rules are intentionally narrow:
| Rule ID | What it checks |
|---|---|
eucsf.sov2.cert_issuer_eu |
The leaf certificate's issuer country is in the EEA (uses tls.issuer.issuer_country). |
eucsf.sov2.apex_jurisdiction |
The apex domain's resolved IPs sit in EEA jurisdictions (uses ip.asn.country joined to dns.a/aaaa). |
eucsf.sov3.mx_jurisdiction |
All MX hosts resolve to EEA jurisdictions (uses dns.mx + ip.asn). |
eucsf.sov4.operational_eu |
DNS authority and TLS termination are operated from EEA-resident infrastructure. |
eucsf.sov6.no_us_hyperscaler |
Apex and discovered third parties are not hosted on a US hyperscaler (AWS / GCP / Azure / Cloudflare). |
The SEAL level for a dimension is the worst rule outcome that contributed to it; absent evidence collapses to SEAL0 rather than silently inflating the score. This mirrors the wand "worst wins" collapse — a reader who sees SEAL3 knows every rule is at SEAL3 or better, not "on average".
A rule that consults Findings the scanner did not produce returns
SEAL0 with a Verdict naming what was missing. Operators who want
to lift a SEAL0 verdict to a real one need to look at the missing
ProbeID rather than re-running the assessor.
ADR-0009 records the wand/SEAL dual-framework choice; the rationale is that the two stakeholder groups (Dutch sovereignty reviewers and EU CSF reviewers) ask the same evidence questions but want different answer shapes, and we prefer to ship the second shape rather than translate at read time.
Evidence and auditability
Every rationale entry cites one or more Finding.ID values in its
Evidence field. The markdown report renders these as:
Evidence: f_abc, f_def
A reader who wants to verify the verdict can pull those findings out
of the scan and inspect their Attributes and Evidence fields — the
raw source material the probe captured. Two reviewers running the
assessor on the same stored scan will get the same verdicts,
rationales, and evidence lists, modulo assessment ID and timestamp.
Who owns a threshold
A Threshold on a Rule is for a number the rule's own Match
compares against (domain_expiry, dns_redundancy): the same named
constant feeds both the comparison and the declared Threshold, so a
test can catch the two drifting apart.
Some rules only read a judgement flag a probe already set —
cert_validity's expiring_soon, variant_convergence's per-path
status. There the probe owns the boundary: the 30-day window lives
in internal/probe/tls, the 8 paths and 24-connection budget in
internal/probe/variants. The probe carries that number on the
Finding alongside the flag (expiring_soon_threshold, path_count,
connection_budget), so a reader can trace the verdict to a concrete
number without the assessor importing the probe package — it stays a
pure consumer of models.Finding. The rule still declares a
Threshold for the rule page, reusing the same rendering as a
rule-owned one, but its Explanation names the observation as the one
applying it, and the value is a hand-synced mirror of the probe's
constant (kept in sync by hand, same as variantStatusReachable and
reasonScannerNoIPv6 already are across this boundary) — Match
never compares against it, so it cannot be verified the way
domain_expiry's can.
The rejected alternative was having the probe report only raw data
(days_left) and moving the 30-day decision into each rule's Match.
That is a cleaner separation on paper, but it pushes the same decision
into every future rule that reads a probe's judgement, and it breaks
any already-stored Finding that assumed the probe had already decided.
Extending the rule set
Rules are Go functions, not a DSL. To add one:
- Add a function to
internal/assessor/wand/rules.gothat returns anassessor.Rulewith a stableID(prefixwand.<dimension>.), aDimension, a one-lineDescription, aRationale, and aMatchfunction. - Wire it into
DefaultRules. - Add a test in
rules_test.go. The pattern is: fabricate the minimal set of Findings the rule needs, callr.Match, assert onScoreandEvidence.
Rules MUST be total. On missing attributes, return a no-evidence
result (Score: onbekend, empty Evidence, Verdict explaining
what was missing) rather than panicking. The engine's panic recovery
is a safety net, not a design.
When a rule genuinely does not apply to the target (not merely "no
evidence yet"), set RuleResult.Reason to a code registered in
internal/assessor/reason.go instead of inventing a fifth score —
see "Reason codes" above. Adding a new reason code means adding it to
reasonRegistry with its class and subject; an unregistered code
panics at rule-evaluation time, which the accompanying test must
catch.
See ADR-0004 for why rules are Go functions and not a hot-reloadable DSL.