Exfiltration guard and canaries
canaryStop 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.
exfiltration-guardFree in your app, Apache-2.0Status 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.
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: alertimport { 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 }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.
canary:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-EXFIL-0006 | Checkmainsdk check, before deploy | An 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-0003 | Checkmainsdk check, before deploy | canaries is set but nothing is planted in that env, or a model named under records doesn't exist |
MAIN-EXFIL-0001 | RuntimeError responses and logs | See the spec |
MAIN-EXFIL-0002 | RuntimeError responses and logs | See the spec |
MAIN-EXFIL-0003 | RuntimeError responses and logs | See the spec |
MAIN-EXFIL-0004 | RuntimeError responses and logs | See the spec |
MAIN-EXFIL-0005 | RuntimeError responses and logs | See the spec |
MAIN-CANARY-0001 | RuntimeError responses and logs | See the spec |
MAIN-CANARY-0002 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| exfiltration and canaries sections in main.yaml with their check rules | Phase 1 | 0.4 | exfiltration-guard#schema |
| Known-value matcher with chunked scanning | Phase 1 | 0.4 | exfiltration-guard#matcher |
| Outbound secret and labelled-value check | Phase 1 | 0.4 | exfiltration-guard#outbound |
| Response check | Phase 1 | 0.4 | exfiltration-guard#responses |
| mainsdk canary plant and status | Phase 1 | 0.4 | exfiltration-guard#canary-plant |
| canary.touched event | Phase 1 | 0.4 | exfiltration-guard#canary-touch |
| Canary API key and secret | Phase 1 | 0.4 | exfiltration-guard#canary-keys |
| observe | Phase 1 | 0.4 | exfiltration-guard#modes |
Quick start
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: alertimport { 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.
| Key | What it does |
|---|---|
exfiltration.mode | observe logs a would-block, warn logs and writes a security event, enforce blocks. Defaults to enforce |
exfiltration.outbound.secrets | block (default) or allow: a resolved secret sent to a host that is not its provider |
exfiltration.outbound.labels | The labels checked on outbound calls: personal, sensitive, child, phi. All four by default |
exfiltration.outbound.receivers | Hosts and the labels each may receive |
exfiltration.outbound.secret-hosts | More hosts a named secret may go to |
exfiltration.responses.secrets | block (default) or allow for a resolved secret in a response |
exfiltration.responses.pan | block (default) or allow for a card number in a response |
exfiltration.responses.bulk | Most records per label one response may carry |
exfiltration.responses.actor-window | window (one hour by default) and the most records per label one actor may read in it |
exfiltration.scan.max | The largest body that is scanned. 1MB by default |
canaries.records | Canary records per model, such as { users: 2 } |
canaries.keys | true also plants a decoy API key and secret. Off by default |
canaries.on-touch | alert (default) or block when a canary is touched |
What you get
| Call | What 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
| Where | What it stops | Code |
|---|---|---|
| Outbound call | A 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-hosts | MAIN-EXFIL-0001 |
| Outbound call | Labelled 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 themselves | MAIN-EXFIL-0002 |
| Response | More labelled records than responses.bulk allows (or than the action's own bulk), or than one actor may read over actor-window | MAIN-EXFIL-0003 |
| Response | A resolved secret, or a card number that passes the Luhn check and has a known network prefix: replaced with a 500 | MAIN-EXFIL-0004 |
Any body over scan.max | The rest is not scanned; logged once a minute per route | MAIN-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.
mainsdk canary plant --env staging
mainsdk canary status --env staging --jsonmainsdk 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-0001MAIN-CANARY-0002MAIN-CANARY-0003MAIN-EXFIL-0001MAIN-EXFIL-0002MAIN-EXFIL-0003MAIN-EXFIL-0004MAIN-EXFIL-0005MAIN-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.
| Driver | Provider | |
|---|---|---|
builtin | Built 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?