grithdocs

Canary tokens

Decoy secrets that deny outbound calls at 9.5 - and the persistence gap you have to work around to register one.

A canary is a fake secret that has no legitimate use. Nothing should ever read it, and nothing should ever send it anywhere. If one appears in an outbound call, that is not a risk estimate - it is proof that something is exfiltrating.

grith scores a canary match at 9.5, deliberately above the 8.0 deny line, so it always denies without a prompt.

⚠️Read this before you plan around canaries

The canary registry is in-memory. grith canary add does not persist, and the CLI does not even reach the running daemon - it mutates its own throwaway registry and exits. The only durable way to register a canary on v0.3.1 is to edit config/filters/canary.toml and restart the daemon.

The persistence gap, reproduced

terminal
$ grith canary add --label probe-trap --generate
Added canary:
id:    0185dce4-df83-4a6c-bd42-004fb56cef7f
label: probe-trap
value: e8f2af1abcfc46e2b7be38e4e35c84f51d76f3f958c4f855
terminal
$ grith canary list
No canary tokens registered.

That is the real behaviour, not a bug in the example. grith canary is not one of the thin-client commands, so nothing it does is visible to the daemon that actually evaluates calls. A canary added through the REST API does reach the live daemon's registry, and is lost when the daemon restarts.

Registering one that survives

Put it in the canary filter's own config file and restart the daemon.

config/filters/canary.toml

toml
[canary]
enabled = true

[[canary.tokens]]
id = "11111111-1111-1111-1111-111111111111"
label = "aws-prod-decoy"
value = "AKIAI44QH8DHBEXAMPLE"

Then plant the value somewhere an attacker would look and nothing legitimate reads: a plausible-looking ~/.aws/credentials profile, a stale .env.production, an entry in a config file the project no longer uses. grith never plants anything for you. Placement is entirely yours, and it is the part that decides whether the trap is any good.

Where detection actually looks

The filter scans the extracted text and the JSON arguments of a call, but only for call types that can carry data off the machine:

Call typeScanned
HttpRequestMethod and URL
NetConnectAddress and port
ShellExec, ProcessSpawnCommand and arguments
FileWrite, FileAppendPath only - a canary in the filename

Every other call type returns no match immediately. In particular FileRead is not scanned, so reading a canary file is not a canary detection. The read may well be scored by other filters, and a canary living under ~/.ssh or ~/.aws will be caught by path matching, but the canary filter itself fires only on outbound calls.

That follows directly from grith not inspecting content: a canary is matched in the arguments of an outbound call, not in a payload body it never sees.

What happens on a match

Score 9.5, severity critical, rule canary-secret-detected, and a message naming the label and id. The composite is past 8.0, so the call is denied with no prompt, and the audit record carries the full breakdown.

What does not happen: there is no canary-specific alert, no automatic rotation, and no "which canary fired" view beyond the message in the filter breakdown and the audit record. Detection is wired end to end; management and alerting are not.

Generation

--generate produces 24 random bytes as 48 hex characters, built from a UUID plus XOR-shifted fill. It is not a cryptographic secret and does not need to be - a canary's job is to be unique and unmistakable, not unguessable. Nothing legitimate knows the value, so nothing legitimate can send it.

See also

Last updated: 2026-08-24Edit this page on GitHub →