Operator Guide
This document describes how to run Wanderer, interpret its output, and operate the single-tenant HTTP API. It covers the MVP only — scheduling, authentication, and the wand assessor ship as separate changes.
Install
Build from source:
make build
./bin/wanderer version
Or run directly with go run:
go run ./cmd/wanderer scan example.nl
Config file for wanderer serve
For long-running deployments — systemd, Compose, anywhere a wall
of CLI flags is awkward — point wanderer serve at a YAML config
file:
wanderer serve --config /etc/wanderer/serve.yaml
The same can be set via the WANDERER_CONFIG env var. Every
field is optional; an empty file is equivalent to no --config
at all.
# /etc/wanderer/serve.yaml
listen: "127.0.0.1:8080"
db: "/var/lib/wanderer/wanderer.db"
geoip:
asn: "/var/lib/wanderer/geoip/GeoLite2-ASN.mmdb"
country: "/var/lib/wanderer/geoip/GeoLite2-Country.mmdb"
optional: false # equivalent to --no-geoip
ui:
enabled: true
htpasswd: "/etc/wanderer/htpasswd"
oidc: # optional — Nextcloud (or any OIDC IdP) login for /ui/
provider_url: "https://cloud.example.nl"
client_id: "wanderer"
client_secret_file: "/etc/wanderer/oidc-secret"
redirect_url: "https://wanderer.example.nl/ui/oauth/callback"
# scopes: [openid, profile, email] # default; override if needed
# session_ttl: 12h # hard session lifetime
# revalidate_interval: 0s # 0 = check the IdP every request
# cookie_secure: true # set false only for local http
nextcloud: # optional — publish each completed scan into Nextcloud
enabled: true
url: "https://cloud.example.nl"
username: "wanderer-bot"
app_password_file: "/etc/wanderer/nc.token"
target_dir: "Wanderer" # relative to the bot user's Files root
schedules: "/etc/wanderer/schedules.yaml"
scan:
per_probe_timeout: 30s
budget: 2m
user_agent: "Wanderer/1.0"
allow_private_targets: false
The parser is strict: a typo like htpasswrd for htpasswd
fails the process at startup with an error naming the bad field,
never silently dropped to a default.
Setting precedence
When the same setting is provided in more than one place, the highest-precedence layer wins:
- CLI flag explicitly passed (
--addr :9090) - Environment variable explicitly set (
WANDERER_LISTEN) - YAML config value
- Hard-coded default
So an operator can lay down serve.yaml as the durable source of
truth, then override one knob from a one-off invocation without
editing the file:
wanderer serve --config /etc/wanderer/serve.yaml --addr :7070
# YAML says :8080, flag wins → server listens on :7070
A flag explicitly set to its default value (e.g.
--ui=false) still counts as "explicitly set" and overrides a
YAML ui.enabled: true.
Sample systemd unit
# /etc/systemd/system/wanderer.service
[Unit]
Description=Wanderer sovereignty monitor
After=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/wanderer serve --config /etc/wanderer/serve.yaml
Restart=on-failure
User=wanderer
Group=wanderer
[Install]
WantedBy=multi-user.target
Organisations
Wanderer groups Targets — perimeter domains and agent hosts — under
organisations. Every Target belongs to exactly one organisation.
A fresh database starts with one seeded organisation (slug
default); operators with one customer rename it to their real
handle, operators with many add organisations as needed.
First-run
wanderer scan example.nl without --organisation attaches the
scan to the seeded default organisation and emits a one-line
stderr nudge pointing at the flag and the rename command.
wanderer org rename --slug default --new-slug acme --name "ACME B.V."
That converts the seed in place — every existing Target keeps the same ID, just under the new slug + name.
Day-to-day
wanderer org add --slug customer1 --name "Customer One"
wanderer org list
wanderer org show acme
wanderer scan --organisation acme example.nl
Bulk seeding from a YAML file (idempotent — running it twice updates names but does not duplicate):
# /etc/wanderer/orgs.yaml
organisations:
- slug: customer1
name: Customer One
- slug: customer2
name: Customer Two
description: Pilot deployment
wanderer org add --from-yaml /etc/wanderer/orgs.yaml
Organisation precedence in wanderer scan
The flag --organisation <slug> wins over WANDERER_ORGANISATION
in the env, which wins over scan.organisation in serve.yaml,
which falls back to default. So an operator can lay down the
config once and only override per-invocation when they need to.
Schedules
Each entry in the schedules YAML can carry organisation: <slug>.
Entries without a slug fall back to scan.organisation in
serve.yaml, then to default. The scheduler resolves the slug at
each tick — wanderer org rename takes effect without restarting
the server.
# /etc/wanderer/schedules.yaml
schedules:
- name: customer1-daily
cron: "0 3 * * *"
target:
domain: customer1.example
organisation: customer1 # explicit override
- name: acme-internal
cron: "0 4 * * *"
target:
domain: app.acme.internal
# uses scan.organisation from serve.yaml
Agent
Hosts running wanderer agent declare their organisation in the
agent YAML:
core:
mode: local
db: /var/lib/wanderer/wanderer.db
organisation: acme
An empty organisation falls back to default; an unknown slug
fails fast at agent startup, before any Findings land.
UI
/ui/ lists every registered organisation with a target count and
a drill-in link. Per-organisation dashboards live at
/ui/orgs/{slug} — same DAR shape (Dashboard / Analysis /
Reporting) but filtered to that organisation. The Reporting page
takes an optional ?org=<slug> query parameter to filter the
cross-target view.
Nextcloud login (OIDC)
By default /ui/ is protected by HTTP Basic against the htpasswd
file. Organisations that already run a self-hosted Nextcloud can
instead accept Nextcloud as the login provider, so operators sign
in once at their Nextcloud and a disable there cuts off Wanderer
access. Configure the oidc: block (above) and install the
Nextcloud OpenID Connect (user_oidc) app's provider side, or
register Wanderer as an OIDC client in any standards-compliant IdP.
How it behaves:
- Lazy discovery. Wanderer does not contact the provider at
startup — it discovers
.well-known/openid-configurationon the first login. Sowanderer serveboots even when Nextcloud is down, and OIDC starts working the moment the provider answers. - Server-side sessions. A successful login sets an opaque,
HttpOnlycookie backed by a row in theui_sessionsSQLite table. There is no JWT in the cookie and nothing to revoke-list. - Revocation. On each request past
revalidate_interval, Wanderer re-checks the session against the provider'suserinfoendpoint (refreshing the access token first). A Nextcloud-side disable makes that check fail and the session is deleted. The default0srevalidates on every request — a disable then cuts access on the very next page load, at the cost of one userinfo call per request. Raise it (e.g.60s) to trade immediacy for fewer IdP round-trips. Note that0salso couples session survival to IdP uptime: while Nextcloud is unreachable, revalidation fails closed and live sessions are dropped — by design (fail-closed revocation), with the htpasswd break-glass as the escape hatch. A small non-zero interval rides out brief IdP blips without logging everyone out. - Break-glass with htpasswd. If both
oidc:andhtpasswd:are set, valid HTTP Basic credentials are still accepted. This is the escape hatch for an OIDC outage: when Nextcloud is unreachable, an operator with an htpasswd entry can still reach/ui/. Keep at least one htpasswd account for this reason. - The client secret never lives in YAML.
client_secret_filepoints at a file (mirroringhmac_secret_filefor the agent), readable only by the Wanderer process user.
Scope of the first wave: authentication only (any user the IdP knows can browse the read-only UI; group-based authorisation is a later wave), and a single OIDC provider per Wanderer instance.
Publish scans into Nextcloud (WebDAV)
With the nextcloud: block enabled, every completed scan drops a
JSON-LD + Markdown bundle into a Nextcloud directory over WebDAV —
so a scheduled scan becomes a shareable artefact in the customer's
Nextcloud without the screenshot-and-paste step.
Setup on the Nextcloud side:
- Create a bot user (e.g.
wanderer-bot). - Generate an app password for it (Settings → Security →
"Create new app password"). Put the token in the file named by
app_password_file, readable only by the Wanderer process user. - Pick a
target_dirrelative to that user's Files root (defaultWanderer). Share that folder with whoever should see the verdicts.
What lands, per scan:
<target_dir>/<org-slug>/<scan-id>.jsonld— machine-readable (Findings + any Assessment), durable and diffable.<target_dir>/<org-slug>/<scan-id>.md— human-readable summary that opens in Nextcloud's text app.
Behaviour and guarantees:
- Push, opt-in, one-way. Wanderer pushes after each scan; the Nextcloud side stays passive. Nothing is read back.
- Never blocks a scan. Publication runs after the scan is
persisted, with a bounded timeout and a few retries. On failure
it emits a
wanderer.nextcloud.publish.errorlog and the scan is still recordedcomplete— the operator UI stays authoritative. - Redacted before it leaves the host. Every Finding attribute goes through the ADR-0008 egress redaction (API keys, tokens, private-key blocks, URL credentials) and raw Evidence is dropped, so a published bundle carries observations, not secrets.
- Auth is an app password over HTTP Basic — no Nextcloud app to install, no OAuth token-refresh dance.
MCP
Three new methods cover the AI-agent surface:
org_list— every organisation with metadataorg_show— one organisation by slugorg_targets— Targets attached to an organisation
GeoLite2 setup
The IP probe resolves observed IPs to ASN + country via MaxMind's
GeoLite2 database. This is the input that unblocks the wand
technologie dimension and most of the juridisch dimension —
without it, every rule that depends on ip.asn returns onbekend,
and an operator looking at the assessment sees a half-blank picture.
GeoLite2 is free but the licence forbids redistribution, so Wanderer cannot ship it; you fetch it once and keep it fresh.
One-time setup
- Sign up at https://www.maxmind.com/en/geolite2/signup (free MaxMind account).
- From your account portal, generate a license key.
- Download
GeoLite2-ASN.mmdb(and optionallyGeoLite2-Country.mmdb) — either via the portal's manual download, or viageoipupdate(recommended for hosts that should stay current).
Paths Wanderer expects
| Env var | CLI flag | Required for |
|---|---|---|
WANDERER_GEOIP_ASN |
--geoip |
ASN + country |
WANDERER_GEOIP_COUNTRY |
--geoip-country |
Country only (optional; defaults to the ASN file) |
The flags apply to wanderer scan, wanderer serve, and the
agent (where wired into the egress probe). When neither is set
and the opt-out below is also absent, both wanderer scan and
wanderer serve emit one warning to stderr at startup naming
the missing flag and pointing at this guide. The scan still
completes; the warning is informational.
warning: GeoLite2 ASN database is not configured — scan will
continue with reduced assessment coverage. Pass --geoip <path>
(or set WANDERER_GEOIP_ASN), or pass --no-geoip to silence
this warning. See docs/operator.md for setup.
If the database file is configured but missing or corrupt at runtime, Wanderer fails fast at startup, never mid-scan.
Opt-out for offline labs and CI
Hosts that consciously run without ASN annotation (offline audit labs, CI smoke tests, demos) silence the warning with either:
- the
--no-geoipCLI flag, or - the
WANDERER_GEOIP_OPTIONAL=1environment variable.
Neither changes the runtime behaviour — the IP probe still emits
its single ip.unavailable info Finding and the rest of the scan
continues — the opt-out only suppresses the startup warning.
Recommended: keep GeoLite2 fresh with geoipupdate
MaxMind ships a small daemon
(geoipupdate) that
re-downloads the database on a schedule. Wanderer reads whatever
file is at the configured path, so a periodic geoipupdate run
followed by a wanderer serve SIGHUP (or process restart) is the
operational shape we recommend.
Minimal /etc/GeoIP.conf (the daemon's config, not Wanderer's):
AccountID YOUR_ACCOUNT_ID
LicenseKey YOUR_LICENSE_KEY
EditionIDs GeoLite2-ASN GeoLite2-Country
Systemd timer (preferred over crontab on systemd hosts):
# /etc/systemd/system/geoipupdate.service
[Unit]
Description=Update MaxMind GeoIP databases
[Service]
Type=oneshot
ExecStart=/usr/bin/geoipupdate
# /etc/systemd/system/geoipupdate.timer
[Unit]
Description=Daily geoipupdate
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now geoipupdate.timer
Crontab alternative (for non-systemd hosts):
# 03:00 every day, append output to a log
0 3 * * * /usr/bin/geoipupdate >> /var/log/geoipupdate.log 2>&1
Test stub for offline runs
scripts/geoip-stub.sh /tmp/stub.mmdb produces a deterministic
empty-but-valid GeoLite2-shaped mmdb so the test suite (and an
operator running smoke tests) can exercise the populated-but-empty
branch of the IP probe without a real MaxMind license. The stub
opens cleanly via the same reader Wanderer uses; every IP lookup
returns "not found" rather than erroring out.
Run a single scan
wanderer scan example.nl \
--geoip /var/lib/wanderer/GeoLite2-ASN.mmdb \
--db ./wanderer.db
Exit codes:
0— scan completed or completed partially (at least one probe produced findings)1— scan failed (domain did not resolve, every probe failed, or the store was unreachable)2— invalid arguments
Output is grouped by probe. Example:
Scan s_1729... status=complete
== dns ==
[info] dns.a subject=example.nl
address: 93.184.216.34
[observation] dns.mx subject=example.nl dim=data_ai
host: mail.example.nl.
preference: 10
...
Persisted findings live in the SQLite database (./wanderer.db by
default). You can inspect them with any SQLite client:
sqlite3 wanderer.db "SELECT probe_id, severity, subject FROM findings ORDER BY created_at DESC LIMIT 20;"
Pair with Amass for richer subdomain coverage
Wanderer's built-in subdomain discovery is intentionally light: SAN
mining from the apex certificate plus a fixed prefix sweep. For a
broader picture without bolting an active enumerator into the scanner
itself, run amass once and
feed the result in:
amass enum -passive -d example.nl -json amass.json
wanderer scan example.nl \
--geoip /var/lib/wanderer/GeoLite2-ASN.mmdb \
--amass amass.json \
--db ./wanderer.db
internal/scanner/amass.go parses the JSONL produced by amass enum
-json and merges the FQDNs into target.Related so the IP probe
resolves them in pass 2 and the assessor sees them as third parties.
The same mechanism is available over the API: POST /scans accepts an
amass_json field carrying a server-local file path (the serve
endpoint refuses inline file bodies — keep the file on the box).
CLI failures during Amass parsing are fatal at startup, not silent. A malformed JSON file aborts before the scan begins; a missing path errors immediately. This is deliberate — silent fallthrough on a flag that names a file is the kind of thing that makes a scan look fine while being effectively unenriched.
Interpret the output
Every finding carries:
ProbeID— stable identifier, e.g.tls.issuer,http.third_party.Severity—info,observation,concern,finding. This is deliberately coarse; precise scoring is the assessor's job.DimensionHint— which sovereignty dimension the finding informs, if any.Subject— the thing being described (a domain, a host, an IP).Attributes— probe-specific structured data.Evidence— raw source material (certificate PEM, verbatim DNS record) retained so a reviewer can audit without re-scanning.
Interesting starting points when looking at a fresh scan:
| Question | Probe/Finding to check |
|---|---|
| Where does mail land? | dns.mx hosts + ip.asn |
| Who issued the cert? | tls.issuer (dim: juridisch) |
| Which non-EU providers serve resources? | http.third_party + ip.asn |
| Who controls DNS continuity? | dns.ns |
| Is HTTPS configured correctly? | tls.validity, tls.verify |
Serve the HTTP API
wanderer serve \
--addr :8080 \
--db /var/lib/wanderer/wanderer.db \
--geoip /var/lib/wanderer/GeoLite2-ASN.mmdb
Endpoints:
POST /scans— body{"domain":"example.nl","related":["..."]}. Returns the finished Scan record (synchronous in the MVP).GET /scans/{id}— returns a stored Scan with all findings.GET /healthz— liveness probe.GET /metrics— Prometheus counters (seedocs/observability.md).
All errors use a structured shape:
{"error":{"code":"not_found","message":"scan not found"}}
No authentication. The MVP is intended for a trusted network (for example, run inside a Nextcloud stack alongside OpenConnector). Adding auth is a separate change — do not expose this port to the internet without a reverse proxy enforcing access control.
Configuration summary
| Flag | Env var | Default |
|---|---|---|
--addr |
WANDERER_LISTEN |
:8080 |
--db |
WANDERER_DB |
./wanderer.db |
--geoip |
WANDERER_GEOIP_ASN |
(empty) |
--geoip-country |
WANDERER_GEOIP_COUNTRY |
(fall back to ASN DB) |
--per-probe-timeout |
— | 30s |
--budget |
— | 2m |
--user-agent |
— | Wanderer/0.x |
Transit path
The transit probe traces the network path to a target and attributes
each hop — IP, reverse DNS, ASN, organisation, country, RTT — so you
can see where a target is actually hosted and which jurisdictions its
traffic crosses. It emits one transit.hop Finding per hop plus a
transit.path aggregate (target IP, hops responded, countries and
ASNs crossed). The wand.transit.eu_path rule scores the destination
hop's jurisdiction (EEA → soeverein, non-EEA → afhankelijk) and names
any non-EEA transit hops for awareness.
Notes:
- Needs
tracepathortracerouteonPATH(the unprivilegedtracepathis preferred — no root). Without either, the probe emits a singletransit.unavailableFinding rather than failing the scan. - GeoLite2 enriches the hops. Without a database, hops still carry
IP + reverse DNS (which already reveals providers, e.g.
…core.as9143.net), but ASN/country — and the EEA scoring — are omitted. - Vantage. The trace runs from the scanner. The destination-side hops (the hosting provider) are robust regardless of where the scanner sits; the middle transit hops are vantage-flavoured. An agent-modus, on-host trace (answering "where does my traffic go") is the stronger follow-up.
- Budget. Bounded by
--per-probe-timeoutand a 20-hop default; on ICMP-filtered paths a partial path is still recorded.
Hosting identity
The first of the four "who/where" signals (with Mail, DNS, and Transit),
for the front door: who hosts my service, and where? Wanderer already
collects the pieces — the apex dns.a/dns.aaaa addresses and the
ip.asn lookups the IP probe runs on them. After the scan correlates the
two, it emits one observed aggregate Finding, ip.hosting, that states it
plainly:
example.com is hosted at Hetzner (DE)
Unlike Mail and DNS there is no operator hostname to map — the apex is
the domain. The operator comes from the apex IP's ip.asn
organisation, which is already a "who"; a small in-repo normalisation
table friendly-names the common, ugly ones (HETZNER-AS → Hetzner,
AMAZON-02 → AWS, CLOUDFLARENET → Cloudflare, …) and falls back to the
raw organisation so an unlisted host still gets its honest name. The raw
ASN organisation, ASN, and address are retained in the Finding's routes
so the observed fact stands even when the friendly name is uncertain.
This is the observed layer; the wand.juridisch.apex_ip_eea rule
annotates it with the EEA-jurisdiction score and now leads its verdict
with the same operator name ("hosted at Hetzner — apex IPs in DE (EEA)"),
which is what the Hosting row of the Sovereignty overview renders.
Notes:
- No GeoLite2 degrades gracefully: with no
ip.asnthe Finding reports the hosting operator as undetermined rather than nothing at all. - Anycast apex IPs (the common case for hyperscalers / CDN-fronted origins) carry no country; the Finding names the operator with the country undetermined.
- No resolvable apex yields a Finding stating so rather than nothing.
- Apex front door only. Per-host hosting of every related name (MX, NS, third parties — each with its own twin) and rDNS/whois enrichment are separate, follow-up concerns.
Mail routing
Wanderer already collects the pieces that answer where does my mail
land? — the dns.mx hosts and the ip.asn lookups the IP probe runs
on them. After the scan correlates the two, it emits one observed
aggregate Finding, dns.mx_routing, that states it plainly:
inbound mail for example.com lands at Google Workspace (US)
The operator is resolved from a small curated table of well-known
MX-host suffixes (Google Workspace, Microsoft 365, Proton, Zoho, …)
with the ASN organisation as the fallback, so an unlisted operator
still gets a name. The raw MX host, ASN, and organisation are retained
in the Finding's routes so the observed fact stands even when the
friendly name is uncertain.
This is the observed layer; the wand.juridisch.mx_vendor_jurisdiction
rule annotates it with the EEA-jurisdiction score and now leads its
verdict with the same operator name ("mail lands at Google Workspace —
mx hosts in US (outside EEA)"), which is what the Mail row of the
Sovereignty overview renders.
Notes:
- No GeoLite2 degrades gracefully: the operator is still named from the MX-host suffix table, with the country reported as undetermined.
- No MX / null MX yields a Finding stating there is no inbound mail routing rather than nothing at all.
- Inbound only. Outbound sending infrastructure (SPF/DKIM/DMARC) is a separate, larger lead.
DNS hosting
The structural twin of mail routing, for the control plane: who runs my
authoritative DNS, and where? Wanderer already collects the pieces — the
dns.ns hosts and the ip.asn lookups the IP probe runs on them (the
scanner expands the NS hosts into Target.Related). After the scan
correlates the two, it emits one observed aggregate Finding,
dns.ns_hosting, that states it plainly:
DNS for example.com is run by Cloudflare (US)
The operator is resolved from a small curated table of well-known
NS-host suffixes (Cloudflare, AWS Route 53, Azure DNS, Google Cloud DNS,
NS1, TransIP, …) with the ASN organisation as the fallback, so an
unlisted operator still gets a name. The raw NS host, ASN, and
organisation are retained in the Finding's routes so the observed fact
stands even when the friendly name is uncertain.
This is the observed layer; the wand.juridisch.ns_vendor_jurisdiction
rule annotates it with the EEA-jurisdiction score and now leads its
verdict with the same operator name ("DNS run by Cloudflare —
authoritative DNS in US (outside EEA)"), which is what the DNS row of
the Sovereignty overview renders.
Notes:
- No GeoLite2 degrades gracefully: the operator is still named from the NS-host suffix table, with the country reported as undetermined.
- Anycast nameservers (the common case for large DNS providers) carry no country; the Finding names the operator with the country undetermined.
- No resolvable authoritative DNS yields a Finding stating so rather than nothing at all.
- Authoritative NS only. Recursive-resolver choice, DNSSEC signing posture, and registrar jurisdiction are separate, larger leads.
Web third-party origin map
The first Wave-2 signal — what a page pulls in, rather than where an
endpoint sits. Wanderer already collects the pieces: the http probe
records one http.third_party host per external resource the apex page
loads (with the kinds of resource — script, link, img, iframe), the
scanner expands those hosts into Target.Related, and the ip probe
attaches ip.asn. After the scan correlates them, it emits one observed
aggregate Finding, http.origin_map, grouped by vendor:
example.com loads fonts from Google Fonts (US), scripts from jsDelivr (US)
Each third-party host is mapped to a recognisable vendor (Google Fonts,
Google Analytics, jsDelivr, cdnjs, Meta, …) from a small in-repo
host-suffix table — the same operatorBySuffix machinery the mail and DNS
twins use — with the ASN organisation as the fallback so an unlisted host
still gets a name. Several hosts from one vendor (fonts.googleapis.com +
fonts.gstatic.com) collapse into a single entry, with the union of what
they serve and their country; the raw hosts and ASN organisations are
retained as evidence.
This is the observed layer; the wand.technologie.third_parties_eea
rule annotates it with the in/out-EEA host count and now leads its verdict
with the non-EEA vendor names — the export surface — when there are
any ("loads from Google Fonts (non-EEA) — 3 of 5 third-party hosts resolve
in the EEA"). An all-EEA page keeps its clean "all N hosts in the EEA"
with no scary lead. The Third parties row of the Sovereignty overview
renders the result.
Notes:
- No GeoLite2 degrades gracefully: the vendors are still named from the host suffix table, with the country reported as undetermined.
- Vendor jurisdiction is the rule's call. The map records each vendor's observed country; whether that is non-EEA is decided by the rule (which owns the EEA membership table), not the scanner.
- No third parties on a fetched page yields a Finding stating the page loads nothing external, rather than nothing at all.
- Served HTML only. The probe parses the served markup, so
script-injected third parties are missed. Per-subpage crawling,
CSP/
connect-srcanalysis, and runtime request capture are separate, larger leads.
CDN / front detection
The hosting-identity signal reads the apex IP's ASN organisation and says
"hosted at Cloudflare (US)". For a CDN-fronted site that is the edge,
not the origin: the apex IP belongs to the CDN, every request (and the
TLS handshake) terminates at the edge, and the real origin is masked
behind it. Wanderer already collects what tells the two apart — the apex
ip.asn organisation, the http.response server header, and the
tls.issuer — so after the scan it emits one observed aggregate Finding,
http.cdn_front:
discord.com's apex is fronted by Cloudflare (US)
The edge is detected from a small, conservative signature table keyed on
the ASN organisation and the server header (a header match raises
confidence over org alone): Cloudflare, Fastly, Akamai, Amazon CloudFront,
Vercel, Netlify, Sucuri, BunnyCDN, Imperva, …. The Finding records which
signal(s) fired (asn, server) and the raw values as evidence, so the
fact stands even when the friendly name is uncertain. A site behind no
known edge reads "no CDN/edge front detected — apex served directly".
This is the observed layer; the wand.technologie.no_us_hyperscaler
rule annotates it with the US-hyperscaler-reach score and now leads its
verdict with the named front when the apex is fronted ("apex fronted by
Cloudflare (US); US hyperscaler in path: CLOUDFLARENET"), which is what
the CDN / hyperscaler row of the Sovereignty overview renders.
Notes:
- No GeoLite2 still names the edge from the server header, with the country reported as undetermined.
- Anycast edge IPs (the common case for big CDNs) carry no country; the Finding names the edge with the country undetermined.
- Conservative by design. The table only claims a front where the signal is a strong tell, to avoid false "fronted by" labels; an unknown edge reads as served-directly rather than guessing.
- Apex front only. Origin de-masking (the IP behind the edge) and WAF/bot-management detection are separate, follow-up leads.
TLS-chain geography
The certificate-issuer rule scores where a cert was issued ("cert issued
in US (outside EEA)") but never names who issued it — the Certificate
Authority that controls the site's cryptographic identity and can revoke
or refuse to renew it. Wanderer already collects the pieces — tls.issuer
(issuer organisation, common name, country) and tls.chain (the presented
intermediates) — so after the scan it emits one observed aggregate
Finding, tls.chain_geography:
the TLS certificate for w3.org is issued by Google Trust Services (US);
chain ← Google Trust Services (US) ← Google Trust Services (US)
The CA is named from a small in-repo table of issuer org/CN substrings
(Let's Encrypt, DigiCert, Sectigo, GlobalSign, Google Trust Services,
Amazon, …) with the raw issuer org/CN as the fallback. The tls.chain
probe is enriched to record each intermediate's organisation and country
(read from the certificates the handshake already presented — passive, no
new request), so the chain geography is stated per link.
This is the observed layer; the wand.juridisch.cert_issuer_eea rule
annotates it with the EEA-issuer score and now leads its verdict with the
named CA ("issued by Let's Encrypt — cert issued in US (outside EEA)").
The certificate also gains its own Certificate row in the Sovereignty
overview, alongside the other who/where flows.
Notes:
- Issuer country absent (many CA certs omit it) still names the CA with "jurisdiction undetermined"; the CA brand itself is the practical jurisdiction tell.
- Unrecognised issuer falls back to the raw issuer organisation (or common name), so the observed fact always stands.
- Presented chain only. TLS handshakes usually omit the root, so the chain is stated as served. Full root-store / trust-path analysis, CAA-vs-actual-issuer cross-checks, and CT-log monitoring are separate, larger leads.
Package vendor jurisdiction
Both package inspectors now emit a vendor / maintainer attribute:
inventory.packages.rpmincludes thevendorattribute, populated from rpm's%{VENDOR}tag (e.g."Fedora Project","Red Hat, Inc.","Microsoft Corporation")inventory.packages.dpkgincludes themaintainerattribute, populated from dpkg's${Maintainer}field (e.g."Bash Maintainers <bash@packages.debian.org>")
One rule classifies the host's package origin:
wand.host.eu_package_origin— reads both probe families, classifies each finding's vendor / maintainer againstinternal/assessor/package_vendors.yaml. Scores:afhankelijkon any US-tied vendor (Red Hat / Fedora, Microsoft, Oracle, Canonical-UK, Datadog, etc.)soevereinwhen every classified package resolves to an EU-tied vendor (SUSE / openSUSE, …)voldoendewhen no US hits AND not every classified package is EU-tied (mixed or unclassified — no red flag, no positive call)onbekendwithout inventory.packages.* findings
Bare maintainer values without a parseable email fall through
as unknown jurisdiction. Locally-built RPMs with
Vendor: (none) are skipped at the agent.
Container image sovereignty
When the Docker inspector is enabled the agent emits two finding families:
inventory.docker.image— every image present on disk, withrepo_tagscarrying the human-readable refsinventory.docker.container— every running container, with theimageattribute naming the ref it was started from
Three rules score this surface against
internal/assessor/container_registries.yaml:
wand.docker.images_us_registry— afhankelijk on any image whose registry resolves to a US-headquartered registry (docker.io,gcr.io,ghcr.io,mcr.microsoft.com,public.ecr.aws,quay.io,registry.access.redhat.com,registry.suse.com)wand.docker.containers_us_registry— same shape, scoped to actually-running containers (a stronger live signal)eucsf.sov6.container_supply_chain— SEAL roll-up combining both shapes
A bare image name (nginx:1.27) or the library shorthand
(library/nginx:1.27) is the Docker Engine's default for
docker.io/library/... — the rule classifies these as a
docker.io hit and flags the implicit resolution in the verdict.
EU self-hosted registries (harbor.example.de/...) are
sovereign by default: they do not appear on the YAML list,
so they do not fire the rule.
Nextcloud inspector
wanderer agent ships a Nextcloud inspector that scores the
sovereignty posture of the Nextcloud instance running on the
agent host. Enable it in wanderer-agent.yaml:
inspectors:
nextcloud:
enabled: true
occ_path: /var/www/nextcloud/occ
run_as: www-data
The inspector shells out to occ four times per tick:
occ command |
ProbeID family | What it reads |
|---|---|---|
app:list --output=json |
inventory.nextcloud.app |
every enabled / disabled app + version |
status --output=json |
inventory.nextcloud.version |
Nextcloud version + supported flag |
config:list system --output=json |
inventory.nextcloud.trusted_domain + .objectstore |
trusted domains, S3 backends |
user_oidc:provider --output=json |
inventory.nextcloud.oidc_provider |
OIDC IdP list, with issuer_host + geoip |
If the user_oidc app is absent, the inspector falls back to
detecting alternatives (oidc_login, social_login,
user_saml) and emits one
inventory.nextcloud.oidc.unavailable Finding naming the
alternative — so an operator sees "we can't probe because
you're on social_login" rather than "Wanderer doesn't work".
Sovereignty rules
Three rules score the Nextcloud surface:
wand.nextcloud.objectstore_eu— afhankelijk when any S3 backend resolves to a non-EEA country (the inspector enriches with geoip when configured)wand.nextcloud.oidc_provider_eu— afhankelijk when any IdP's issuer URL resolves to a non-EEA jurisdictioneucsf.sov6.nextcloud_supply_chain— SEAL analogue rolling both signals into one observation
Supported Nextcloud majors: 28, 29, 30. Older versions parse
but emit supported: false in the version Finding so an
operator sees the contract mismatch.
Playwright UI smoke layer
The Playwright suite at tests/playwright/ covers the read-only
UI. It runs against three hermetic SQLite fixtures (one per
scenario) seeded by internal/fixtures, not against an
operator's hand-rolled /tmp/wanderer-demo.db. A fresh checkout
runs end-to-end with:
make playwright-install # once: installs node deps + chromium
make playwright # builds binary, seeds fixtures, runs specs
Scenarios
| Scenario | DB | Used by |
|---|---|---|
baseline |
tests/playwright/fixtures/baseline.db |
DAR layering + reporting catalogue |
agent-host |
tests/playwright/fixtures/agent-host.db |
host-rule + nextcloud-as-target deep dives |
empty-org |
tests/playwright/fixtures/empty-org.db |
empty-state copy on every UI surface |
Each scenario boots its own wanderer serve instance on a
different port (8281/8282/8283). The Playwright config pins each
spec file to one scenario via testMatch.
Adding a scenario
- Add
internal/fixtures/<name>.gowith an exportedBuild<Name>(ctx, *store.Store) errorfunction. - Register it in
internal/fixtures/seed.go'sScenariosmap. - Add a Makefile line (under
playwright-fixture) that emits the DB. - Add a project block + a
webServerentry intests/playwright/playwright.config.ts.
The seeder uses the public store API + wand.DefaultRules() /
eucsf.DefaultRules(), so a schema change or a rule rename that
breaks a fixture surfaces at make playwright-fixture build
time — before Playwright ever opens a browser.
Separate from tests/playwright/fixtures/agent-host.yaml
The YAML config file at the same path is not part of the
fixture seeder. It is a stop-gap for running
./bin/wanderer agent --once --config tests/playwright/fixtures/agent-host.yaml
against your real laptop's /tmp/wanderer-demo.db if you want
to smoke test the host rule against actual occ /
/proc / /etc/systemd/ state. The hermetic
agent-host scenario above is what Playwright reads against.
Troubleshooting
ip: asn DB: no such file or directory— the GeoLite2 path is wrong. Fail-fast is intentional; the probe will not start without a readable database file.tls.ctshowsunavailable— crt.sh rate-limited you or was unreachable. The rest of the TLS probe is unaffected.http.robots_blocked— the target'srobots.txtdisallows/for your User-Agent. Wanderer honours it; use a different User-Agent if you have authorisation.