MainSDK
Search⌘K
Interfaces

Security and privacy

Exfiltration guard and canaries

canary

Stop a stolen secret or a bulk copy of personal data leaving your app, and plant records and keys that raise an alarm when an intruder touches them.

Building for 0.4ships as a beta APIPhase 1 · Core, public betaSpec exfiltration-guardFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

canary stops a stolen secret or a bulk copy of personal data leaving your app, and plants records and keys that raise an alarm when an intruder touches them.

The guard is off until main.yaml has an exfiltration or canaries section. Once on, it adds one in-memory scan to outbound calls and responses.

At a glance

exfiltration:
  mode: enforce
  outbound:
    secrets: block
    receivers: { api.partner-crm.com: [personal] }
  responses:
    pan: block
    bulk: { personal: 500 }
    actor-window: { window: 1h, personal: 5000 }
canaries:
  records: { users: 2 }
  keys: true
  on-touch: alert

Calls

  • canary.plant(model, { count? })
  • canary.plantAll()
  • canary.status()

Guarantees

Whatever the driver.

  • A resolved secret leaves only for its own provider, in any common encoding, and the value is never logged
  • A response carrying a secret or a card number becomes a 500 with no value in it
  • A canary touch raises one critical security event and the reader is not told
  • Never in the request path beyond one in-memory scan; fail static

Drivers

1 drivers. Select one to see its line in main.yaml.

main.yamlBuilt in (an Aho-Corasick matcher on the standard library) (default)
canary:
  driver: builtin

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-EXFIL-0006Checkmainsdk check, before deployAn exfiltration value is invalid: a receiver not in network.allow or any driver's hosts, an unknown label, a bulk limit below 1, or an unknown key
MAIN-CANARY-0003Checkmainsdk check, before deploycanaries is set but nothing is planted in that env, or a model named under records doesn't exist
MAIN-EXFIL-0001RuntimeError responses and logsSee the spec
MAIN-EXFIL-0002RuntimeError responses and logsSee the spec
MAIN-EXFIL-0003RuntimeError responses and logsSee the spec
MAIN-EXFIL-0004RuntimeError responses and logsSee the spec
MAIN-EXFIL-0005RuntimeError responses and logsSee the spec
MAIN-CANARY-0001RuntimeError responses and logsSee the spec
MAIN-CANARY-0002RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
exfiltration and canaries sections in main.yaml with their check rulesPhase 10.4exfiltration-guard#schema
Known-value matcher with chunked scanningPhase 10.4exfiltration-guard#matcher
Outbound secret and labelled-value checkPhase 10.4exfiltration-guard#outbound
Response checkPhase 10.4exfiltration-guard#responses
mainsdk canary plant and statusPhase 10.4exfiltration-guard#canary-plant
canary.touched eventPhase 10.4exfiltration-guard#canary-touch
Canary API key and secretPhase 10.4exfiltration-guard#canary-keys
observePhase 10.4exfiltration-guard#modes

Quick start

main.yaml
exfiltration:
  mode: enforce
  outbound:
    secrets: block
    receivers: { api.partner-crm.com: [personal] }
  responses:
    pan: block
    bulk: { personal: 500 }
    actor-window: { window: 1h, personal: 5000 }
canaries:
  records: { users: 2 }
  keys: true
  on-touch: alert
app/example.ts
import { canary } from "mainsdk/canary";

// plant two canary users in each region's canary tenant, once; counts only, never ids
await canary.plant("users", { count: 2 });
const { records } = await canary.status();   // { users: 4 }

Configuration

The Quick start block above shows every section. Unknown keys and invalid values fail mainsdk check with MAIN-EXFIL-0006.

KeyWhat it does
exfiltration.modeobserve logs a would-block, warn logs and writes a security event, enforce blocks. Defaults to enforce
exfiltration.outbound.secretsblock (default) or allow: a resolved secret sent to a host that is not its provider
exfiltration.outbound.labelsThe labels checked on outbound calls: personal, sensitive, child, phi. All four by default
exfiltration.outbound.receiversHosts and the labels each may receive
exfiltration.outbound.secret-hostsMore hosts a named secret may go to
exfiltration.responses.secretsblock (default) or allow for a resolved secret in a response
exfiltration.responses.panblock (default) or allow for a card number in a response
exfiltration.responses.bulkMost records per label one response may carry
exfiltration.responses.actor-windowwindow (one hour by default) and the most records per label one actor may read in it
exfiltration.scan.maxThe largest body that is scanned. 1MB by default
canaries.recordsCanary records per model, such as { users: 2 }
canaries.keystrue also plants a decoy API key and secret. Off by default
canaries.on-touchalert (default) or block when a canary is touched

What you get

CallWhat it does
canary.plant(model, { count? })Plants canary records of one model in each region's canary tenant, up to count (default from canaries.records). Planting twice adds nothing. Returns how many it added, never ids.
canary.plantAll()Plants every model under canaries.records, and with keys: true the canary API key and the MAINSDK_CANARY_TOKEN secret.
canary.status()Counts only: canary records per model across regions, whether keys are planted, and when one was last touched.

What it always does

  • A resolved secret leaves only for its own provider, in any common encoding, and the value is never logged
  • A response carrying a secret or a card number becomes a 500 with no value in it
  • A canary touch raises one critical security event and the reader is not told
  • Never in the request path beyond one in-memory scan; fail static

What the guard checks

WhereWhat it stopsCode
Outbound callA resolved secret in the URL, headers or body (raw, base64, base64url or URL-encoded, also split across chunks) sent to a host that is not that secret's own provider. List more hosts under outbound.secret-hostsMAIN-EXFIL-0001
Outbound callLabelled values read in this request (personal, sensitive, child, phi) sent to a host not listed under receivers for that label. Drivers that need them, such as email, declare that themselvesMAIN-EXFIL-0002
ResponseMore labelled records than responses.bulk allows (or than the action's own bulk), or than one actor may read over actor-windowMAIN-EXFIL-0003
ResponseA resolved secret, or a card number that passes the Luhn check and has a known network prefix: replaced with a 500MAIN-EXFIL-0004
Any body over scan.maxThe rest is not scanned; logged once a minute per routeMAIN-EXFIL-0005

mode is observe (log a would-block), warn (log and write a security event) or enforce (block). A push of main.exfiltration.mode can only move toward enforce; anything looser is refused with MAIN-CONFIG-0021.

Canaries

Canary records belong to a canary owner in each region (canary-us, canary-eu, canary-in), so an owner-scoped query never returns one and raises nothing. A cross-owner read, an unscoped support query or a dump that returns one raises a single critical canary.touched event with MAIN-CANARY-0001; the response is unchanged unless on-touch: block. The registry of ids is kept as HMAC-SHA256 values under MAINSDK_CANARY_KEY from secrets, so a reader of the database cannot tell which rows are canaries. canary.status and the CLI show counts only.

With keys: true, plant also stores a decoy API key as MAINSDK_CANARY_TOKEN, referenced by no code. Presenting it to apikeys.verify is refused like an unknown key (MAIN-APIKEY-0002) and raises canary.key_used with MAIN-CANARY-0002; so does sending it out or returning it.

text
mainsdk canary plant --env staging
mainsdk canary status --env staging --json

mainsdk check reports MAIN-EXFIL-0006 for an invalid exfiltration value and MAIN-CANARY-0003 for a model that does not exist or a canary set with nothing planted in the env.

Errors and checks

mainsdk check raises MAIN-EXFIL-0006 and MAIN-CANARY-0003; the rest are logged or returned at run time.

  • MAIN-CANARY-0001
  • MAIN-CANARY-0002
  • MAIN-CANARY-0003
  • MAIN-EXFIL-0001
  • MAIN-EXFIL-0002
  • MAIN-EXFIL-0003
  • MAIN-EXFIL-0004
  • MAIN-EXFIL-0005
  • MAIN-EXFIL-0006

Each code's cause and fix is in check codes.

Swapping drivers

There is one driver, so there is nothing to swap today. The calls and main.yaml sections do not depend on it.

DriverProvider
builtinBuilt in (an Aho-Corasick matcher on the standard library)default

Testing

@mainsdk/testing turns the guard on with an empty canaries section, and its attack simulator can drive a canary touch, all offline with the built-in matcher. See Test your app.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet