Skip to content

docs(backend): say what CANARYTOKENS_API_URL is for and what empty costs - #1938

Merged
Xore merged 4 commits into
mainfrom
canarytokens-env-1487
Aug 25, 2026
Merged

Xore merged 4 commits into
mainfrom
canarytokens-env-1487

Conversation

@Xore

@Xore Xore commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Every honeytoken component was live and healthy — the self-hosted Canarytokens platform, the adapter, the fired-event pipeline, the store, the page — while token creation answered 503 canarytoken creation is not configured on this host. CANARYTOKENS_API_URL had never been set on the host, so Compose interpolated the empty default and canarytokens::create short-circuited.

Nothing about that is visible from the dashboard: the tokens page lists, the types endpoint answers, the platform serves. Only creation is off.

It was never set because this file described it as optional, defaults to '' if unset and nothing more — while HONEYFS_IMPLANT_URL, directly below, carries a paragraph explaining where the service lives and why. That paragraph exists because that variable had already been mis-set once, in the sibling stack's env where nothing read it. This gives the same treatment to the one that hadn't had its turn yet.

Fixed live and verified end to end

The variable is now set on the homeserver and the backend recreated. All five items of #1487 exercised against the live fleet:

# item evidence
1 dashboard-driven token creation POST /api/v1/canarytokens → 200, real token id + URL, persisted to dashboard-canarytokens-v1 (6 rows, newest first)
2 implant into a honeypot file present under cowrie's bind-mounted honeyfs and indexed in fs.pickle at /opt/backup/…, which is what makes it visible to ls/cat rather than just bytes on disk
3 credential provisioning + rotation POST /credentials/{id}/rotate → 200, new password, rotated_at set — and the file on disk carries the rotated value with a matching mtime
4 visibility GET /api/v1/credentials returns target, real path, username and current password
5 link a token to a credential POST /credentials/{id}/link-token → 200, linked_token_id set

Confirmed cowrie itself reads the implanted file from its honeyfs mount and returns the rotated contents.

Worth noting for anyone repeating this: fs.pickle's node format puts contents at index 7. Walking it at the wrong index reports the file as absent when it is indexed correctly — the first check here said "NOT FOUND" for exactly that reason.

Refs #1487

Xore added 4 commits August 25, 2026 14:46
…e to write

The lab's whole premise is that a theme is reviewed against real captured
data rather than fixtures -- both previous design refreshes were run that
way and that is what shipped. The harness that made it possible went with
the Go dashboard in cb77cdf, so the pattern branding/design-lab/ documents
has not been executable since.

The Go version got its safety from constructing the server with nil
write-services. frontend-next has no such handle: it reaches data over HTTP
through two bases, and one of them is write-capable by definition. So the
guarantee is made at that seam instead --

  BACKEND_URL         a gate forwarding GET/HEAD, refusing the rest with 405
  BACKEND_MOUNTED_URL nothing at all; every request 503s

-- which is stronger than what it replaces, because it is enforced per
request rather than by remembering to pass nil.

That is not a formality. The Rust tier really exposes stores.rs's
generic_delete, preferences.rs's put, the honeyfs implant writer and the
canarytoken minter, so a reviewer clicking through a variant would
otherwise delete real documents and mint real tokens against live
infrastructure. lab.test.mjs drives the actual harness against a recording
stand-in backend and asserts a write never arrives; it runs in CI, because
the guarantee is the point of the tool and a silent regression would be
indistinguishable from it working.

Variant stylesheets are symlinked into the dev server's public/ tree, so a
variant layers on the vendored theme.css with no rebuild and no change to
the shipped vite config -- and saving the source file is enough to see it.
Ports are the ones the lab has always used, so the review log's links keep
resolving.

Verified end to end against the homeserver's real backend: the page renders
cowrie, dionaea, wordpot, galah and suricata with live states, carries both
stylesheets in the right order, and the gate refuses a delete without it
reaching Elasticsearch.

Booting it also surfaced that routeShape.test.ts was being scanned as a
route file and warned on every dev-server start; renamed to the generator's
ignore prefix, which vitest's own include pattern is unaffected by.

Closes #1828
…t have

Chasing #1566's "near-duplicate rows" on /ml-anomalies turned up why they
looked the way they did: the list was not in any order at all.

Store pages sort with `unmapped_type` set, so a store whose index does not
exist yet returns an empty list instead of an error. The same setting means
a field the documents do not have sorts every hit as null rather than
failing -- and nothing anywhere says so. Three stores shipped that way:
ml-anomalies asked for `timestamp` where the worker writes `@timestamp`,
auth-events asked for `last_seen` where Keycloak writes `@timestamp`, and
static-analysis asked for `Analysis.GeneratedUTC`, which its documents do
not carry in any spelling -- they hold only Analysis and Fingerprint, and no
time field whatsoever.

Measured on the live index, the first page of /ml-anomalies read 20:58,
20:58, 20:59, 20:59, 20:59, 20:57, 20:57, 20:58. The newest document was
third by luck.

Worse than the disorder: an all-null sort is one enormous tie, and
`from`/`size` over a tie leaves the order inside it undefined. Paging could
hand back the same document twice and never show another. So every store
sort now carries `_doc` as a tiebreak -- which the numeric sorts needed
anyway, since campaigns-by-score and attackers-by-events tie constantly --
and each declares its field's real type, because an `unmapped_type` that
disagrees with the mapping is the same silent no-op for keyword sorts.

CI cannot see Elasticsearch, so scripts/check-store-sorts.py is the guard:
it checks all 23 declared sort fields against a live mapping dump. Run
against the homeserver it passes, and re-introducing either bug class fails
it with the reason.

With the order fixed, the duplicates #1566 actually reported are adjacent
for the first time, so they can be folded. 66% of the 7,305 live anomalies
sit in an (address, same second) bucket with siblings; the worst single
burst is 48 rows for one IP inside one second, every one scoring exactly
0.8000. Consecutive rows from one address in one second scoring within 0.01
now collapse to one row badged with what it stands for.

Acknowledging a folded row acknowledges all of it -- otherwise it would
return as open on the next refresh and the button would look broken while
behaving exactly as written -- and the status badge reports "3 of 48 open"
rather than speaking only for whichever row represents the run.

Folding is deliberately page-local and says so in the badge: doing it in
the Rust tier would change the shape of the shared /api/v1/store endpoint
for every other consumer, and a burst longer than one page still splits at
the boundary.

Also verified while here, and already correct: page-title casing is
uniformly sentence case across all 27 headers, and /campaigns renders 7
columns rather than 15 -- the rest are behind `detail: true`.

Closes #1566
… into

Leaflet sizes its tile grid once, against whatever the container measured
at construction time, and the map is built before the card has settled at
its final width. Measured at an 1854px viewport: six 256px tile columns
laid out from the wrong origin for a 1442px container, leaving a 92px
strip of bare card down the right-hand edge. It reads as a rendering
fault rather than as ocean, which is how #1565 reported it.

invalidateSize() re-measures and recomputes the grid. A ResizeObserver
repeats that whenever the card changes width, because a fixed zoom covers
a fixed pixel width and the card's width is not fixed -- a window resize
puts the strip straight back otherwise.

Verified on a rendered page against live data, at two widths. Before, at
1442px: tiles ended 92px short on the right. After: every edge overflows
the container (-75 left, -19 right, -179 top, -69 bottom) and the whole
world is still in frame, Australia and southern Africa included. Narrowed
to a 488px card the observer holds it (-552/-496/-249/-139).

Fitting the world to *cover* the card was the other candidate and is
worse: it zooms to 3 and crops the southern hemisphere, trading data for
coverage. This keeps zoom 2 and simply stops lying to leaflet about how
big the card is.

Closes #1565
Every other honeytoken component was live and healthy -- the Canarytokens
platform, the adapter, the fired-event pipeline, the store, the page --
while token creation answered 503 "not configured", because this one
variable was never set on the host.

It was never set because this file described it as "optional, defaults to
'' if unset" and nothing more, while the variable directly below it got a
paragraph explaining where the service lives and why. HONEYFS_IMPLANT_URL
had already been mis-set once, in the sibling stack's env where nothing
read it, and the fix for that was the explanation it now carries.

So this one gets the same: where the service is published, that it is
WireGuard-only, and that leaving it empty disables token creation quietly
rather than loudly.

Refs #1487
@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@Xore
Xore merged commit 553353a into main Aug 25, 2026
89 checks passed
@Xore
Xore deleted the canarytokens-env-1487 branch August 25, 2026 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant