A single-binary network honeypot sensor. Deploy it on an internal segment, wait for something to touch it, and get an alert with the credentials, the prompt, the query, the container spec, or the paths the intruder tried.
Status: pre-1.0.
wispcovers all 21 of OpenCanary's protocol modules, plus 9 decoys and a honeytoken service it has none of — the coverage is complete, but it is younger and less battle-tested. See Honest comparison first.
The design rationale behind every decoy, emulator and token — the why — lives in docs/design.md, so this page can stay about what wisp does and how to run it.
A sensor gets scanned and probed for credentials while the console captures every attempt in real time. Reproduce it step by step with docs/demo.md.
wisp-demo.mp4
OpenCanary (BSD-3-Clause, by Thinkst)
proved the model, is more complete than this project, and remains a genuine
contribution to the field. wisp is not a criticism of it — it is a bet that
two things are worth redoing:
- Deployment. OpenCanary needs Python 3.10+, Twisted, Scapy, pcapy-ng, and
— for SMB — a working Samba install with a
full_auditVFS module writing to syslog for OpenCanary to tail.wispis one static binary. - Attack surface. Intruders on an internal network in 2026 reach for the Kubernetes API, the kubelet, the Docker socket, cloud IMDS, the CI server, and whatever LLM infrastructure someone stood up without auth. Nine of the decoys below exist because of that, and OpenCanary has none of them.
Everything else OpenCanary does, it currently does better, because it does it at all.
OpenCanary ships 21 protocol modules. wisp has reimplemented all 21 of them,
plus 9 decoys OpenCanary does not have.
| OpenCanary | wisp today | |
|---|---|---|
| Protocol modules | 21 | 21 + 9 new decoys |
| Still missing | — | none — all 21 |
| SMB | yes, via external Samba | native — no Samba, captures NetNTLMv2 |
| Cloud / container / CI / LLM decoys | no | yes (k8s, kubelet, docker, imds, elasticsearch, jenkins, gitlab, ollama, mcp) |
| Alerting | file, syslog, HPFeeds, email, webhook, + separate dedup daemon | JSONL, syslog, HPFeeds, email, LINE, webhook (Slack/Teams/Discord), dedup built in |
| Fleet console | none | included, self-hosted |
| Honeytokens | no — Canarytokens is a separate hosted service | DNS, HTTP, Word doc, kubeconfig, MCP — self-hosted |
| Install | Python + Twisted + Scapy (+ Samba for SMB) | one static binary |
| Platforms | Linux-first, root for several modules | anywhere Go cross-compiles |
The banner and portscan rows are the two that are not full emulators — a
connection-level catch-all, and a scan detector that correlates the events the
other decoys emit (plus a Linux packet sniffer for stealth scans). How each works
is in docs/design.md.
OpenCanary is still the more battle-tested of the two. Reach for wisp when the
Python/Samba/Scapy dependency chain is what stops you deploying a honeypot at
all, or when you want the cloud, container and LLM decoys it has none of.
| Service | Default port | What it captures |
|---|---|---|
ssh |
2222/tcp | usernames, passwords, key fingerprints, client version |
http |
8080/tcp | admin-panel credentials, probed paths, user agents |
https |
8443/tcp | the same, plus the SNI |
telnet |
2323/tcp | usernames and passwords |
ftp |
2121/tcp | usernames, passwords, post-login intent |
redis |
6379/tcp | AUTH credentials and the full command sequence |
tftp |
6969/udp | the filename, and read vs write |
ntp |
1123/udp | client requests, and monlist amplification recon |
git |
9418/tcp | the repository path, and push vs fetch |
mongodb |
27017/tcp | SCRAM proofs that crack offline, driver and app name |
mysql |
3306/tcp | native-password responses — hashcat 11200 |
mssql |
1433/tcp | the cleartext password — TDS obfuscation is reversible |
smb |
445/tcp | NetNTLMv2 hashes and the account — hashcat 5600, no Samba |
vnc |
5900/tcp | the VNC-auth challenge-response — cracks offline |
rdp |
3389/tcp | NetNTLMv2 via CredSSP/NLA — hashcat 5600 — plus the mstshash user |
sip |
5060/udp | the REGISTER digest — hashcat 11400 |
http-proxy |
3128/tcp | the tunnel target (SSRF intent) and cleartext creds |
snmp |
161/udp | the community string and OIDs — never answers |
llmnr |
outbound | a poisoner on the segment, and the address it claims |
| banner | any | first bytes sent, for ports with no emulator |
portscan |
correlation | a source sweeping the ports — stealth types on Linux |
ollama |
11434/tcp | model-list recon, and the prompts sent to your "GPU" |
k8s |
6443/tls | stolen service-account tokens, client certs |
kubelet |
10250/tls | the command run inside a pod, and the token |
docker |
2375/tcp | the container spec — host mount, Privileged, command |
imds |
169.254.169.254 | cloud role-credential theft (AWS/GCP/Azure) |
elasticsearch |
9200/tcp | the search query — the fields and the row count |
jenkins |
8081/tcp | the Groovy script, and login attempts |
gitlab |
8929/tcp | stolen glpat- tokens, and their target |
mcp |
8931/tcp | the agent, and the tool calls it made |
The last nine — ollama through mcp — are the cloud, container, CI and LLM
decoys OpenCanary has none of. Why each service captures and answers the way it
does is in docs/design.md.
Left to themselves the emulators describe a machine that cannot exist: an Ubuntu SSH daemon in front of an nginx serving a panel called "Administration", with a vsftpd underneath. Any one of those is convincing. Together they are a tell, because a real device on a real network is one product, and every port it answers says that product's name.
device:
persona: synologyThat renames the banners of ssh, http, https, ftp and telnet at once,
so an intruder who touches three ports gets three answers that agree — down to
the title on the login page and the firmware string under the form. Built in:
ubuntu (the default, so selecting it changes nothing), synology, qnap,
truenas, hp-printer.
Anything set explicitly under services: still wins, so you can take a whole
device and then correct the one banner your environment needs differently.
Two honest limits:
- It stops at the appliance services. A Synology NAS does not run a Kubernetes apiserver either, so the persona deliberately leaves the cloud, container and CI decoys alone rather than swapping a small inconsistency for a larger one. A sensor running both sets is already describing two machines; run two sensors if that matters.
- A persona with nothing to say about a port says so. A LaserJet has no
sshd, so
persona: hp-printerwithsshenabled warns at startup instead of quietly leaving an Ubuntu banner on a box whose every other port says HP. Whether to run the service is still your call.
When device.name or device.desc is set — a persona fills both in — they are
stamped onto every event, so an alert says what the box was pretending to be
without anyone having to look up the deployment. With no persona configured
neither appears, and the events are exactly the shape they always were.
Build the sensor and run it — no config needed, it runs on defaults:
go build -o wispd ./cmd/wispd
./wispdTrip it:
curl -s localhost:11434/api/generate -d '{"model":"llama3.2","prompt":"cat /etc/shadow","stream":false}'and the prompt is captured to events.jsonl:
{"service":"ollama","kind":"prompt","src_ip":"::1","dst_port":11434,"data":{"model":"llama3.2","prompt":"cat /etc/shadow"}}Every service in the table above is tripped the same way — point a client at its port. To customise ports, personas, or output:
cp wisp.example.yaml wisp.yaml
./wispd -config wisp.yamlOne Dockerfile builds both images, and both are distroless — no shell, no
package manager, nothing to pivot into. The sensor is the container most likely
to be attacked on purpose, so it carries the least.
docker build --target sensor -t wisp/sensor .docker build --target console -t wisp/console .A one-host evaluation stack (in production the console belongs somewhere other than the segment you expect to be attacked):
docker compose up --buildTwo things about running a honeypot in a container matter more than the rest:
- Use host networking on Linux. With published ports, Docker can rewrite
the client's address to the bridge gateway, and an alert saying every
intrusion came from
172.17.0.1is one nobody can act on. On Docker Desktop (macOS/Windows) that rewriting is unavoidable — map ports there and treat source IPs as unreliable. - Keep the volumes. They hold the SSH host key, the decoy TLS certificates, the console database, and the operator accounts. Key material that changes on every restart identifies the box as a honeypot to anyone who connects twice.
Both containers run as a non-root user with a read-only root filesystem, every
capability dropped, and no-new-privileges. Ports are the unprivileged
defaults, so nothing needs to be granted back.
The console image has no shell to run a health probe with, so the binary probes
itself — wisp-console healthcheck is wired up as the image's HEALTHCHECK
and is what an orchestrator will use to decide the console is alive.
Units for both halves are in deploy/systemd/, sandboxed to
the same standard as the containers: no capabilities, nothing writable but
their own state directory, a seccomp filter, and no path back to root. The
sensor parses hostile input by design, and a unit file should not depend on the
code being correct.
sudo install -m 0755 wispd /usr/local/bin/wispd
sudo useradd --system --no-create-home --shell /usr/sbin/nologin wisp
sudo install -m 0644 deploy/systemd/wispd.service /etc/systemd/system/
sudo systemctl enable --now wispdStateDirectory and ConfigurationDirectory create /var/lib/wisp and
/etc/wisp with the right ownership, so there is no chown step. Full
instructions, including the console and how to bind privileged ports without
granting the capability, are in
deploy/systemd/README.md.
Two sinks, both on by default. Console for humans:
14:22:07 ollama prompt 10.0.3.44:51188 -> :11434 model=llama3.2 path=/api/generate prompt=cat /etc/shadow
…and events.jsonl for machines — one JSON object per line, ready for Vector,
Filebeat, or a SIEM:
{"time":"2026-07-24T14:22:07.113Z","node":"wisp-01","service":"ollama","kind":"prompt","src_ip":"10.0.3.44","src_port":51188,"dst_port":11434,"data":{"model":"llama3.2","prompt":"cat /etc/shadow"}}A fleet of sensors writing to their own local files is not a monitoring system.
wisp-console is the piece that makes it one: sensors deliver over HTTPS with a
bearer token, and every alert lands in one place.
It is self-hosted and needs nothing but a writable file — SQLite via a pure-Go driver, so the console is a single static binary too.
go build -o wisp-console ./cmd/wisp-console
go build -o wispd ./cmd/wispdStart the console first — the token a sensor needs is minted here:
./wisp-console -addr :8001It prints a one-time operator password on first start (the UI login — see Signing in). In another terminal, enrol the sensor to get its token:
./wisp-console sensor add sensor-01Then point the sensor at the console and start it. The token and URL go in the environment, so there is no config file to write:
export WISP_TOKEN='wisp__...' WISP_REMOTE_URL='http://127.0.0.1:8001'
./wispdThat is the whole setup. wispd with no config runs every decoy on its defaults
and delivers to the console; open http://127.0.0.1:8001 in a browser to watch
the events arrive.
To control what the box pretends to be — a persona so every port tells one story,
or which decoys run — keep a wisp.yaml beside the binary. wispd reads it
automatically, so the command stays ./wispd; pass -config <path> only for a
file somewhere else:
device:
persona: synology # ssh/http/https/ftp/telnet all answer as Synology
services:
docker: { enabled: false } # turn off what this box would not runtoken and url can stay out of that file — left empty, they fall back to
WISP_TOKEN and WISP_REMOTE_URL. That is what lets one wisp.yaml ship to a
whole fleet in version control while each host supplies its own secret out of
band, through a systemd EnvironmentFile or a container's environment:
printf 'WISP_TOKEN=%s\n' 'wisp__...' | sudo tee /etc/wisp/wisp.env >/dev/null && sudo chmod 600 /etc/wisp/wisp.env && sudo systemctl restart wispdecho "WISP_TOKEN=wisp__..." >> .env(The two lines are the systemd unit's EnvironmentFile and a .env beside
docker-compose.yml — .gitignore both.)
Each sensor gets its own token, and the node name comes from the token, not from the request body. A sensor cannot claim to be a different one, so an operator chasing an alert is never sent to the wrong machine. Manage them with:
./wisp-console sensor list./wisp-console sensor revoke sensor-01Re-running sensor add for an existing node issues a new token and invalidates
the old one — that is both rotation and "I lost the token". Only the SHA-256 of
a token is stored, so a stolen console database yields no working credentials.
For compatibility a single shared token still works (-token, or
WISP_CONSOLE_TOKEN), but the console warns at startup: with a shared token the
node name falls back to whatever the payload claims, and any holder can forge
events attributed to any sensor.
In the console, credential events (login_password, auth_attempt, …) stand
out in red. Click any event to open its full detail — every captured field on
one page — and narrow the timeline with the search box or the sensor and
service side panels. Add ?live to the URL and the page auto-refreshes (a plain
<meta refresh>, no JavaScript) — handy for a wall display.
To reproduce the walkthrough end to end — one sensor, an attacker, the console lighting up in real time — see docs/demo.md.
The UI is behind a login, and there is no setting that turns that off. Locking ingest behind a token while leaving the dashboard open would protect nothing — the dashboard is where every captured password, prompt, and stolen token ends up.
On its first start the console creates one operator account and prints the password once:
========================================================================
CONSOLE OPERATOR CREATED — this password is shown once. Save it now.
username: admin
password: pSymBGpoDhZdiRZUMVyEeUjt
Change it later with: wisp-console user passwd admin
========================================================================
Miss it, or lose it later? It cannot be re-printed — only the hash is stored — but resetting is one command that generates a fresh password and prints it on the spot. It is safe to run while the console is up: the database is WAL-mode with a busy timeout, so the CLI and the server share it without either stopping.
./wisp-console user passwd adminTo set the password yourself instead of taking the generated one, pass
-password-stdin (minimum 12 characters):
printf '%s\n' 'your-strong-password' | ./wisp-console user passwd admin -password-stdinAdd more operators, or list them, the same way — user add also prints a
generated password once and accepts -password-stdin:
./wisp-console user add alice./wisp-console user listChanging a password signs out every session that account holds — a password is
usually changed because it leaked, and leaving the old sessions live would
defeat the point. Sessions last 12h by default (ui.session_ttl), live in the
database so a restart does not sign everyone out, and are stored as hashes, so
a stolen database yields no working access.
Sensor tokens and operator logins are separate credentials on purpose: a sensor sitting on a hostile segment can write events and nothing else. Its token will not open the UI, and a UI session will not deliver events.
Behind a reverse proxy, set ui.trust_proxy: true so the login rate limit sees
real client addresses instead of the proxy's — otherwise one attacker's failed
logins lock everybody out.
The console terminates TLS itself — no reverse proxy required. Sensors send captured credentials and their bearer tokens, and operators send a password; none of that should cross a network in the clear.
tls:
mode: self-signed # off | self-signed | file | acme
cert_file: console-cert.pem
key_file: console-key.pem
domains: ["console.internal"]self-signed is the right answer for most consoles, which live on an
internal segment with no public DNS name. The certificate is generated once and
kept — one that changed every restart could not be pinned — and its fingerprint
is printed at startup:
TLS: self-signed certificate
SHA-256 51:49:55:A1:64:D0:50:F0:DD:CE:22:40:A2:EA:D3:A6:...
Give that to the sensors, so nobody has to reach for "skip verification":
log:
remote:
url: "https://console.internal:8000"
token: "wisp_..."
fingerprint: "51:49:55:A1:..." # or ca_file: console-cert.pemA sensor with a pin refuses to deliver to anything else — including a TLS proxy
that re-signs the connection, which is a real failure mode and not a hypothetical
one (docs/design.md has the story).
insecure_skip_verify exists, is documented as a lab setting, and sends every
captured credential to whoever answers the connection.
acme obtains certificates from Let's Encrypt for a public name. It
requires accept_tos: true — agreeing to a CA's terms on an operator's behalf
is not the software's call. Port 80 is optional: with no http_addr, issuance
uses TLS-ALPN-01 over 443. Set http_addr: ":80" to serve the HTTP-01
challenge and redirect plain HTTP to HTTPS.
off is still there for a deployment that already has a proxy in front,
and the console warns loudly on every start.
The event list pages at 100 rows, and the search box matches free text against
every column including the JSON data blob — which is where the interesting
strings live. Searching hunter2 finds the login that used it; searching
/etc/shadow finds the prompt that asked for it; searching 10.0.0.9 finds
everything that address touched. Clicking a sensor, service, or source IP still
filters, and search narrows within whatever is already filtered rather than
replacing it.
Export takes the same filter as the page it is started from, as CSV or as
newline-delimited JSON — the same shape a sensor's events.jsonl has, so
anything already pointed at that will read this too.
/export.csv?q=hunter2&hours=168
One detail worth knowing about the CSV: every field in it is attacker-chosen —
a username, a probed path, a prompt. A value beginning with = is executed as
a formula when a spreadsheet opens the file, so an attacker who picks their
username carefully could get code execution on the machine of the analyst
reading the export. Those values are prefixed with an apostrophe on the way
out; the payload is still readable as evidence, it just is not run.
A sensor cannot report its own death. If someone finds it and stops it, the last thing it does is go silent — and a silent sensor looks exactly like a network where nothing is happening. The console is the only place that can tell those apart:
sensors:
silence_after: 30m
check_interval: 1mCrossing the threshold raises a sensor_silent event: stored in the timeline,
pushed through every notifier, and marked in red on the sensor list. Coming
back raises sensor_returned, because whoever was woken at 3am should not have
to guess whether it is still down. Each is reported once per transition, so a
sensor that stays down does not page anyone every minute.
This is the only alert in wisp that fires because nothing arrived, and it is the one most likely to matter: an intrusion that begins by killing the sensor is otherwise invisible.
Left alone, the database grows forever and its size is decided by whoever is
scanning your sensors — a console whose disk fills up stops recording the
intrusion that filled it. Set a policy in console.yaml:
retention:
events: 90d # maximum age; accepts d and w as well as h/m/s
max_events: 1000000 # hard cap whatever the age; oldest go first
interval: 1h # how often the policy is appliedBoth limits apply, whichever bites first. The count cap is the backstop age cannot provide: a sensor under sustained scan produces months of events in an afternoon. Purged space is returned to the filesystem — SQLite reuses freed pages but never shrinks the file on its own, so the console rebuilds it after a large enough sweep.
The default is unlimited, because silently discarding an operator's evidence would be the wrong way round — but the console warns at startup until a policy is set.
Delivery is best-effort by design: Emit never blocks a service goroutine, and
when the console is unreachable events queue and are eventually dropped. A
sensor that stops answering the network because its reporting channel stalled is
worse than one that loses telemetry — a hung service is a detectable tell.
A dashboard nobody opens is not monitoring. Copy console.example.yaml to
console.yaml to send alerts by email, LINE, or webhook (JSON,
Slack, Teams, Discord).
Two rules decide what gets sent, and both matter:
Only meaningful kinds notify. Credentials offered (login_password,
auth_attempt, …) and intent stated (prompt, tool_call, command, …).
Bare connections and version probes are stored but not pushed — they are
context for an investigation, not a reason to wake someone at 3am.
Repeats are suppressed per sensor|service|kind|source-IP for a configurable
window (15m by default). The next alert after the window says how many were
folded in: ssh login_password from 10.0.0.9 (+47 similar suppressed). A second
source IP is always a new alert — suppression must never hide a new attacker.
In a local end-to-end run, 40 stored events produced 7 notifications — a repeated telnet brute force from one source collapsed from 4 alerts to 1. Without this, a single port scan mutes your channel and the next real intrusion is missed.
LINE is there because in Taiwan and Japan that is where operations teams actually are.
A decoy waits on the network for an intruder who is already inside to touch it.
A token is the other half: a lure planted inside data — a document, a
kubeconfig, an MCP server entry — that does nothing until someone opens or uses
it, then calls home from wherever the data ended up. A firing lands in the same
console, timeline, search, export and notifications as every decoy capture — a
token_triggered event like any other. (It is the idea Thinkst's Canarytokens
popularised; OpenCanary has no token component. wisp's are self-hosted.)
Mint one from the console CLI:
./wisp-console token add -kind docx -memo "finance share"token add has to know the console's own address — it is what the planted token
calls home to — so it fails until one is set. Either put tokens.base_url in
console.yaml, or pass it on the command:
./wisp-console token add -kind docx -memo "finance share" -url https://console.example.comUse whatever address the planted data will call home from: http://127.0.0.1:8001
while you are testing on the console's own box, but a name the intruder's machine
can actually resolve for a token you really plant. A token pointing at
127.0.0.1 never fires once it leaves this host.
-memo rides on every alert the token raises, so "which lure fired" needs no
lookup. token list shows every token and its firings; token show <id>
re-prints an artifact; token disable <id> stops recording new hits.
Five kinds, by what each is planted as:
| Kind | Planted as | Fires when |
|---|---|---|
http |
a URL | it is fetched — a bookmarked admin link, a wiki page, an <img> |
dns |
a hostname | it is resolved — a config value, an allowlist, a host entry |
docx |
a Word document | it is opened; Word fetches the document's linked image |
kubeconfig |
a kubeconfig file | kubectl is first pointed at it |
mcp |
an MCP client config | an agent loads it and connects |
Everything but the DNS token rides an HTTP request to /t/<id>, so set
tokens.base_url to an address the intruder's machine can reach. The DNS token
reaches the console even where outbound HTTP is blocked, via an authoritative
server for a zone you delegate to the console:
tokens:
base_url: "https://console.example.com" # where HTTP callbacks land
dns:
enabled: true # off by default; wants port 53 + NS delegation
zone: "tokens.example.com"
addr: ":53"
answer: "127.0.0.1" # a black hole; only satisfies the resolverTest one before you plant it. The http kind is the easiest to trigger by
hand: mint it, then fetch the URL it prints, the way an intruder's tool would.
./wisp-console token add -kind http -url http://127.0.0.1:8001 -memo "test"curl http://127.0.0.1:8001/t/<id> # the URL the command above printedThe fetch lands as a token_triggered event in the timeline and on the tokens
page, exactly like a decoy capture — /t/ is public by design, since the whole
point is that an intruder trips it. A docx token fires the same way when Word
opens it; a dns token when its hostname is resolved
(dig <id>.tokens.example.com).
How the docx and dns tokens actually fire, why a token id is not a secret,
and what a callback can and cannot tell you are in
docs/design.md.
A honeypot writes a record every time a stranger touches it. An attacker who works out what it is can turn that around: hold the port open and the sensor fills its own disk and buries the console under deliveries. The sensor must not be the thing that takes down the fleet's monitoring.
Limits are on by default. All values are events per minute; a burst is how many may arrive at once:
log:
rate_limit:
enabled: true
per_source_per_minute: 60
per_source_burst: 30
high_value_per_minute: 30 # credentials and stated intent
high_value_burst: 60
global_per_minute: 600 # the whole sensor, every source
global_burst: 300Three properties matter more than the numbers:
- The first events from a new source always land. That is the alert; dropping it to save disk would be exactly backwards.
- Credentials and prompts have their own budget. A flood of bare
connections cannot crowd out the one
login_passwordthat matters — the protected list is the same one the console notifies on. - Suppression is reported, not silent. A truncated log looks like a quiet
network, and going quiet is the opposite of what a flood should look like.
Throttled sources emit a
rate_limitedevent with the tally:
{"service":"http","kind":"rate_limited","src_ip":"127.0.0.1",
"data":{"dropped":92,"duration":"1m2s","kinds":{"probe":92}}}In a local run, 100 requests in ten seconds were recorded as 8 events plus that one summary — the flood is still visible, and it costs 9 lines instead of 100.
The per-source limit is also what protects the sensor-wide budget: one flooding address can only ever spend its own allowance, so it cannot silence the rest of the network. The source table is bounded too, because an attacker rotating addresses is the same denial of service by another route.
Rate limiting stops a flood arriving faster than the disk can take it. It does not stop a year of ordinary traffic from filling the partition, and a sensor whose disk is full stops recording the intrusion that filled it — the same failure the console's retention policy prevents, at the other end of the pipe.
log:
file: events.jsonl
rotate:
max_size_mb: 100
max_files: 5On by default at those values, for the same reason the rate limiter is: a sensor
that only bounds its disk once somebody configures it is unbounded on every
deployment that matters. Set max_size_mb: -1 if logrotate or journald already
manages the file — two rotators fighting over one file is worse than either
alone.
Rotation is by rename, oldest first: events.jsonl.5 is removed, .4 becomes
.5, and the live file becomes .1, so events.jsonl is always the one to
tail. The size check happens before each write rather than after, which is
what keeps a record whole: the JSONL sink hands over one complete line at a
time, so a line always lands in exactly one file. Rotating afterwards would
leave the tail of a JSON object in one file and nothing in the next, and a
half-written object is a parse error in whatever you pointed at the log.
Events can also be published to an hpfeeds broker — the pub/sub bus honeypot operators share data over, and what OpenCanary, Cowrie and Dionaea speak when they feed a shared collector. It is here so a wisp sensor can join an existing fleet rather than sit beside one.
log:
hpfeeds:
enabled: true
addr: "broker.internal:10000"
ident: "wisp-01"
secret: "..."
channel: "wisp.events"
tls: trueThe payload is the same JSON object events.jsonl holds, so a collector pointed
at both does not need two parsers. The secret is never sent — the broker
announces a nonce and the client proves it knows the secret by hashing the two
together — but that protects the credential and nothing else: the events
themselves carry captured passwords, so tls: true belongs on for anything
leaving a network you own. It trusts a private certificate the same two ways the
console connection does, by fingerprint or CA file.
Delivery is best-effort on the same contract as the console sink. Emit never
blocks, a full queue drops events and counts them, and a broker that goes away is
reconnected to with backoff. A service goroutine held up because a collector is
slow is a service that answers the network late, and a hung service is a
detectable tell.
cmd/wispd/ sensor entry point, service wiring, signal handling
cmd/wisp-console/ console server, sensor/user/token CLI, healthcheck
internal/config/ sensor YAML config with defaults-first loading
internal/event/ the one event type every service emits
internal/ntlm/ NTLMSSP challenge + NetNTLMv2 capture, shared by smb + rdp
internal/persona/ the device this sensor claims to be, on every port
internal/sink/ console + JSONL output with rotation, remote delivery,
hpfeeds, rate limiting
internal/portscan/ scan detection: fan-out correlation everywhere, plus a
Linux AF_PACKET sniffer for stealth scans (build-tagged)
internal/tlsutil/ decoy certificates, and how a sensor trusts a console
internal/token/ honeytoken artifacts: URL, DNS name, Word doc, kubeconfig,
MCP config — rendered from a token id and the console's address
internal/service/ the Service interface
httpdecoy/ the machinery the HTTP-shaped decoys share
servicetest/ the harness every emulator is tested with
sshsvc/ OpenSSH emulation via x/crypto/ssh
httpsvc/ fake device admin login, plain and behind TLS
mongosvc/ MongoDB wire protocol and SCRAM capture
mysqlsvc/ MySQL handshake and native-password hash capture
mssqlsvc/ MSSQL/TDS handshake and cleartext LOGIN7 password capture
smbsvc/ SMB2/3 and NTLMv2 hash capture, no external Samba
vncsvc/ RFB handshake and VNC-auth challenge-response capture
rdpsvc/ RDP negotiation + CredSSP/NLA NetNTLMv2 capture over TLS
sipsvc/ SIP over UDP, REGISTER digest capture (hashcat 11400)
proxysvc/ HTTP forward proxy: tunnel target and cleartext creds
snmpsvc/ SNMP over UDP, community-string capture (hand-rolled BER)
llmnrsvc/ LLMNR poisoning detection (not a decoy)
ollamasvc/ fake Ollama inference server
k8ssvc/ Kubernetes apiserver, and the tokens aimed at it
kubeletsvc/ kubelet: pod inventory and in-pod command capture
dockersvc/ Docker Engine API and container-escape specs
imdssvc/ cloud instance metadata, AWS + GCP + Azure
elasticsvc/ open Elasticsearch and the queries run against it
jenkinssvc/ Jenkins, including the Groovy script console
gitlabsvc/ GitLab, and stolen glpat- access tokens
internal/console/ UI, auth, retention, TLS termination, token callbacks
(HTTP + an authoritative DNS server for DNS tokens)
store/ SQLite: events, sensors, operators, sessions, tokens
notify/ email, LINE, webhooks, and alert dedup
Adding a protocol means one package implementing service.Service and one line
in buildServices.
- Ports are unprivileged by default so
wispstarts without elevation. Redirect 22 → 2222 at the firewall, or map ports in Docker, to catch scans on the real ports. - Keep
hostkey.pem. A host key that changes every restart identifies the box as a honeypot to anyone who connects twice. - Never grant access. Every credential is rejected, and the HTTP service always answers "invalid username or password" — never "no such user", which would let an attacker enumerate accounts.
- This is a sensor, not a shield. It detects; it does not block. Treat any alert as a real intrusion until proven otherwise — nothing legitimate has a reason to talk to it.
Protocol coverage is complete, so the most useful contributions now are the ones that make it battle-tested: real-world hardening, a new decoy for the 2026 attack surface, or an improvement to a capture's fidelity. The checklist, and the constraints every change has to respect, are in CONTRIBUTING.md.
Security problems go through private vulnerability reporting, never a public issue: a public report on a detection tool tells the people it detects where to look, while every deployment is still vulnerable. SECURITY.md says what counts as one here — and what does not, because "the honeypot can be fingerprinted" is a documented limitation rather than a vulnerability.
The name is the will-o'-the-wisp: a light in the dark that leads you off the path. It is deliberately not Canary-anything — "Canary" and "Canarytokens" are Thinkst trademarks, and this project is an independent reimplementation rather than a fork or a successor.
BSD-3-Clause, matching OpenCanary — from which several protocol behaviours were
studied, though none of its code was copied. See LICENSE for the terms and
NOTICE for the attribution.