Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Data Loss Prevention (DLP)

Canister’s L7 egress proxy includes a built-in DLP layer that scans outbound HTTP traffic for credential patterns and enforces per-detector domain scoping. Even when a sandboxed process has filesystem access to credential files (because the user wants npm, gh, or aws to keep working), DLP makes it structurally impossible for those credentials to leak to unauthorised destinations.

Table of Contents


Threat Model

A sandboxed process typically has filesystem access to credential-bearing files — intentionally, because the user wants their package managers and CLI tools to keep working against private registries. That process is potentially:

  • Untrusted — a build script, post-install hook, or LLM-generated command running with read access to ~/.npmrc, ~/.aws/credentials, the GitHub keyring, etc.
  • Trusted-but-buggy — telemetry code that accidentally serialises environment variables containing tokens.
  • Trusted-but-compromised — a supply-chain attack inside an otherwise reputable dependency.

DLP’s goal: even when a credential is readable, it cannot leave the sandbox via HTTP(S) unless flowing to an explicitly authorised destination for that credential’s service.

Pattern-based detection has one structural blind spot: a process can encrypt a secret before exfiltrating it, and the ciphertext matches no detector. The fake-secret swap closes this for env-var secrets by ensuring the sandbox never holds the real value in the first place — it carries a fake, and the proxy substitutes the real value only on egress to an authorised host. Encrypt-then-exfiltrate then leaks only the useless fake.

In scope:

  • HTTP/1.1 and HTTP/2 request headers, bodies, trailers
  • URI query parameters and path segments
  • Bodies wrapped in gzip / deflate / brotli
  • Multi-layer encoded payloads (base64 / hex / percent), up to 32 levels
  • DNS-label exfiltration via high-entropy hostname labels
  • Slow byte-at-a-time exfiltration via cumulative entropy budgeting

Out of scope:

  • Covert timing channels
  • In-memory key extraction
  • Filesystem-write exfiltration to shared/CWD mounts
  • Pixel-level steganography in image payloads
  • Plain CONNECT (L4) tunnels — DLP forces interception when enabled, so any traffic that bypasses interception (e.g. non-HTTP protocols) is denied rather than inspected.

Architecture

DLP lives in the standalone can-dlp crate so it can be reused by both the proxy and the sandbox (for canary generation) without pulling proxy dependencies into the sandbox crate.

crates/can-dlp/
  src/
    detectors.rs      — DetectorId enum, compiled RegexSet, Finding
    scopes.rs         — per-detector domain matching (built-in + extras)
    decode.rs         — base64/hex/percent recursion, up to N layers
    decompress.rs     — gzip/deflate/brotli body decompression
    normalize.rs      — whitespace/unicode normalisation before scanning
    entropy.rs        — Shannon entropy + SessionEntropyBudget
    canary.rs         — fake credential generation
    scanner.rs        — DlpScanner: orchestrates the full pipeline
    error.rs          — DlpError (thiserror)

The DlpConfig serde struct lives in can-policy (next to NetworkConfig) to avoid a can-dlp → can-policy circular dependency.

Activation chain:

recipe / manifest [network.dlp]
        │
        ▼
NetworkConfig::dlp (Option<DlpConfig>)
        │
        ▼
ProxyServer constructed with DlpScanner + SessionEntropyBudget
        │
        ▼
Per-request: scan headers + URI + (decompressed, decoded) body

When DLP is enabled, the proxy forces interception of all traffic. The passthrough path (which is opaque to the proxy) is disabled because it would bypass scanning.


Detectors and Scope Model

Each detector has hardcoded home domains baked into the binary. Tokens can only flow to their home service — even if a [[host]] block permits the destination, a GitHub PAT bound for registry.npmjs.org is blocked.

DetectorPatternBuilt-in home domainsDefault action
github_patgh[pousr]_[A-Za-z0-9]{36} and github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59}github.com, *.github.comblock
npm_tokennpm_[A-Za-z0-9]{36}registry.npmjs.orgblock
aws_access_keyAKIA[A-Z0-9]{16}*.amazonaws.comblock
slack_tokenxox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[A-Za-z0-9]{24}*.slack.comblock
ssh_private_key-----BEGIN (RSA|EC|OPENSSH|DSA )?PRIVATE KEY-----none — always blockblock
bearer_tokenBearer\s+[A-Za-z0-9\-._~+/]{20,}=*(requires explicit allow_credentials = ["bearer_token"] on a host)block
generic_high_entropySliding window, Shannon entropy > 4.5, 20+ chars(warn only)warn (promoted to block in --strict)
canary_tokenExact match against injected fake credentialsnone — always blockblock (error log)

Enforcement rules:

  1. Known-service tokens (github_pat, npm_token, aws_access_key, slack_token) — destination must be in the detector’s home domains or in a [[host]] block whose allow_credentials includes the detector id. Mismatched service → 451 block.
  2. bearer_token — generic; requires explicit per-host opt-in via allow_credentials = ["bearer_token"]. No implicit scope.
  3. ssh_private_key and canary_token — no legitimate HTTP destination; always blocked.
  4. generic_high_entropy — too noisy to scope; always warn, blocks only in --strict.

The shipped service contracts under recipes/services/*.toml (github.toml, npm-registry.toml, …) already include the right allow_credentials for their detector — composing recipes = ["npm", "gh"] (which pull the right host contracts in) produces the right behaviour: npm tokens can only reach npmjs.org, GitHub PATs can only reach GitHub.

Extending scopes for self-hosted services

Self-hosted services (GitHub Enterprise, private npm registries) extend the built-in scopes via extra_scopes:

[network.dlp]
enabled = true

[network.dlp.extra_scopes]
github_pat = ["github.corp.example.com"]
npm_token = ["npm.internal.example.com"]

Extras are unioned with the built-in domains. They never replace or narrow them, so a self-hosted override cannot accidentally weaken the default scope for the public service.


Scan Pipeline

Per request, the proxy runs:

1. Headers (Authorization, Cookie, Proxy-Authorization, X-*)
   → scan_text → token detected? scope check
2. URI (full reconstructed authority + path + query)
   → scan_text → token detected? scope check
3. Body
   a. Read Content-Encoding header
   b. Decompress (gzip / deflate / brotli) if configured
   c. Run encoding chain recursion (base64 / hex / percent)
   d. Pattern match each layer against PatternSet
4. For every finding:
   - canary    → BLOCK + error! log (zero false positives)
   - ssh key   → BLOCK
   - scoped    → BLOCK if destination not in home/extras
   - bearer    → BLOCK unless the destination's `[[host]]` block lists `"bearer_token"` in `allow_credentials`
   - generic   → WARN (BLOCK in --strict)
5. Session entropy budget update; BLOCK if exceeded.
6. Build response:
   - On allow: forward upstream with `update_content_length()` if body
     was buffered.
   - On block: 451 + `x-canister-error: dlp-blocked` +
     `x-canister-dlp-detector: <name>`.
   - On monitor-mode warn: forward upstream + add
     `x-canister-dlp-warning` so the sandboxed process can observe what
     would have been blocked.

DLP forces request body buffering within the existing max_buffered_body_bytes cap. A streaming scan would miss tokens that straddle chunk boundaries; the cap (default 8 MiB) prevents memory abuse.


Encoding Chain Recursion

decode.rs walks every layer of base64 / base64url / hex / percent-encoding up to max_decode_depth (default 32). At each layer the scanner attempts all decoders; any that produces output different from its input is recursed into. All decoded layers are matched against PatternSet, so:

  • Authorization: Bearer dGVzdA== (Bearer test) is matched at the original layer.
  • body={"x":"Z2hwX0FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ=="} (a base64-wrapped GitHub PAT) is caught at depth 1.
  • base64(base64(token)) is caught at depth 2.
  • Garbage / malformed encoding at any layer is fail-closed: the original bytes are scanned as-is and the recursion stops on that branch — never silently skipped.

The depth cap is a fuse against adversarially deep nesting designed to exhaust CPU.


Content Decompression

decompress.rs inspects the Content-Encoding header and inflates gzip / deflate / brotli bodies before scanning. This is gated by network.dlp.decompress (default true).

Malformed or truncated compressed bodies fail the request rather than being forwarded unscanned — fail-closed.


DNS Entropy Check

Independently of HTTP scanning, the proxy applies a Shannon-entropy check to the destination hostname before resolving it. Each DNS label (the parts between the dots) is scored; if any label exceeds dns_entropy_threshold (default 4.5) the request is blocked with dlp-blocked + dns-entropy reason. This catches the classic DNS exfiltration pattern: <base64-of-secret>.attacker.example where the high-entropy subdomain is the payload.

The check runs even on CONNECT tunnels (before resolution), so it applies regardless of L7 protocol.


Session Entropy Budget

A sandbox session keeps a SessionEntropyBudget (default 8192 bytes). After each request scan, the count of high-entropy bytes (Shannon entropy > 4.0 in any 32-byte sliding window) is recorded against the budget. When the budget is exhausted, further requests are blocked.

This catches slow exfiltration: a credential split across many small requests, each individually below the per-request entropy threshold but collectively well above plausible legitimate traffic patterns.

The budget is per ProxyServer instance, which is one per sandbox session — it resets when the sandbox exits.


Canary Tokens

When canary_tokens = true (default when DLP is enabled), the sandbox generates one fake credential per detector that has a CanarySpec in the registry and exports them as environment variables into the sandboxed process:

Env varShape
CANISTER_CANARY_GITHUB_PATghp_ + 36 random alphanumeric
CANISTER_CANARY_NPM_TOKENnpm_ + 36 random alphanumeric
CANISTER_CANARY_AWS_ACCESS_KEYAKIA + 16 random uppercase
CANISTER_CANARY_OPENAI_KEYsk- + 48 random alphanumeric
CANISTER_CANARY_ANTHROPIC_KEYsk-ant-api03- + 32 random alphanumeric
CANISTER_CANARY_SLACK_TOKENxoxb-…-…- + 24 random alphanumeric
CANISTER_CANARY_STRIPE_KEYsk_live_ + 32 random alphanumeric

Adding a canary is a single registry edit (canary: Some(CanarySpec { … }) in registry.rs); the generator, the scanner’s substring set, and the registry-driven tests all read from that one slice.

The same exact strings are registered as the highest-priority pattern in the DlpScanner. Any outbound appearance of one of these strings is proof of exfiltration — no legitimate process produces these values, because they don’t authenticate to anything. The detection is always block and is logged at error! level with full request context.

Canary tokens are an active honeypot: they create traffic that a malicious process is incentivised to scoop up (env vars are the first thing credential thieves enumerate) and which the proxy treats as a high-confidence trip wire. They are injected under synthetic CANISTER_CANARY_* names and are never swapped for a real value — that is the job of the fake-secret swap, which is a distinct mechanism.


Fake-Secret Swap

Canary tokens are pure tripwires — they don’t authenticate to anything, so a tool that actually needs GITHUB_TOKEN would break if handed one. The fake-secret swap makes a fake functional: the sandbox runs with a fake under the real env-var name, and the proxy substitutes the real value back in on authorised egress.

[network.dlp]
fake_secrets = [{ env = "GITHUB_TOKEN", credential = "github_pat" }]

Lifecycle:

  1. Capture & generate (parent). Before the sandbox fork, the parent reads the real value from the host env, generates a fake matching the named credential’s pattern (the same generator the canaries use), and records a fake → real map. The real value is never placed in the child environment — only the fake, under the real name (any env_passthrough copy is stripped first).
  2. Detect (proxy). The sandbox carries only the fake. Because the fake matches the credential’s regex (ghp_…), the existing scanner blocks it on egress to any host not authorised for that credential — exactly as it would the real one.
  3. Swap (proxy). Just before forwarding to a host that is authorised for the credential (its home domain or a [[host]] allow_credentials entry), the proxy replaces the fake with the real value in request headers and the buffered body, fixing Content-Length if the length changed.

The authorisation gate is the same allow_credentials / home-domain scope the scanner uses, and it is checked independently of enforcement mode — so --monitor (which downgrades blocks to warnings) never causes a real secret to be swapped toward an unauthorised host.

Why this defeats encrypt-then-exfiltrate: the real secret materialises only inside the proxy, only for authorised destinations, in plaintext the proxy itself constructs. A tool that base64s, gzips, or encrypts its GITHUB_TOKEN and POSTs it to evil.com is shipping the fake — the proxy finds no matching substring to swap, and the blob is worthless.

Trust. fake_secrets routes a real credential to authorised hosts, so it is a credential-trust escalation: an unpinned (untrusted) recipe has its fake_secrets dropped at load, exactly like allow_credentials. The shipped service recipes are pinned by checksum, so their fake_secrets take effect; user-authored scope belongs in the project canister.toml.

Scope (v1). Only env-var secrets whose credential has a generatable pattern (github_pat, npm_token, openai_key, anthropic_key, slack_token, stripe_key). Opaque/patternless secrets and secrets delivered by other means (files, args) are out of scope, as is swapping inside a streamed body above the proxy’s buffered-body cap (headers are always covered). AWS is deferred — its secret-access-key has no pattern, so faking only the AKIA… access-key-id would be misleading.


Enforcement Modes

DLP integrates with the existing sandbox enforcement modes rather than introducing a separate kill switch.

ModeDLP enabled?generic_high_entropyBlock action
DefaultPer recipe enabled = truewarn451
--monitorAs configuredwarn (logged)Not blocked — request forwarded with x-canister-dlp-warning header
--strictImplicitly enabled when egress = "proxy"promoted to block451
  • Default: DLP runs if the recipe enables it; violations are 451.
  • --monitor: DLP findings are logged at warn! level with full detector / host / fingerprint detail but requests still go through. Mirrors how monitor mode handles seccomp and filesystem checks. Use this to dry-run a new policy before flipping it on.
  • --strict: DLP is implicitly enabled even without dlp.enabled = true, provided the recipe uses egress = "proxy" (strict mode requires DLP-grade enforcement). generic_high_entropy is promoted from warn to block.

No new flags or kill switches were added — --strict plus recipe config cover the same activation surface as a dedicated enable knob.


Response Headers and Status Codes

OutcomeStatusHeaders
Token detected, blocked451 Unavailable For Legal Reasonsx-canister-error: dlp-blocked, x-canister-dlp-detector: <name>
Token detected, monitor mode(upstream status)x-canister-dlp-warning: <name>
DNS-label entropy block451x-canister-error: dlp-blocked, x-canister-dlp-reason: dns-entropy
Session budget exhausted451x-canister-error: dlp-blocked, x-canister-dlp-reason: session-budget

451 is used so DLP blocks are distinguishable from upstream 403s. The detector name is exposed in the header so the sandboxed process / calling tool can produce a sensible error message.


Configuration

Full schema (all fields optional; defaults shown):

[network.dlp]
enabled = false                   # implicit true under --strict + proxy
canary_tokens = true              # default when DLP is enabled
max_decode_depth = 32             # encoding chain recursion cap
decompress = true                 # gzip/deflate/brotli before scan
dns_entropy_threshold = 4.5       # Shannon entropy per DNS label
session_entropy_budget = 8192     # cumulative high-entropy bytes/session

# Env-var secrets to fake-and-swap (see Fake-Secret Swap above). Each
# entry names the env var the real secret arrives in and the credential
# detector that classifies it (governs both fake generation and swap
# authorisation).
fake_secrets = [
  { env = "GITHUB_TOKEN", credential = "github_pat" },
]

Merge semantics

When recipes / manifests are merged left-to-right (base.toml → auto-detected → explicit -r → manifest overrides), each field uses:

FieldMerge ruleRationale
enabledOR (any Some(true) wins)Security escalation, never reversed
canary_tokensORSame
fake_secretsunion by env (overlay credential wins)One fake per var; layers add, never conflict
extra_scopesper-detector domain unionNever narrows
max_decode_depthlast-Some-winsNumeric tuning
decompresslast-Some-wins
dns_entropy_thresholdlast-Some-wins
session_entropy_budgetlast-Some-wins

This guarantees a downstream recipe can never disable DLP that an upstream recipe enabled, and can never shrink the scope set.

Where to put it

  • Project-level: [network.dlp] in canister.toml enables DLP for every sandbox in the project.
  • Per-sandbox: same key under [sandbox.<name>.network.dlp].
  • Recipe-level: drop a [network.dlp] block into a custom recipe. The shipped service recipes (github, npm-registry, openai, anthropic, slack, stripe) ship [network.dlp] fake_secrets so their tokens are faked-and-swapped by default; they are pinned by checksum, so the credential scope survives the untrusted-recipe gate. A user-authored (unpinned) recipe’s fake_secrets and allow_credentials are dropped at load — put project-specific scope in canister.toml instead.

Limitations

  • Pattern coverage is finite. A novel credential shape (a vendor introducing a new prefix) won’t be caught until a detector is added. generic_high_entropy is the catch-all, but its warn-by-default posture means it’s only fatal in --strict.
  • Body buffering ceiling. Requests above max_buffered_body_bytes (default 8 MiB) are rejected with 413 Payload Too Large rather than forwarded unscanned. This is fail-closed by design, but it limits the protocol shapes DLP can cover (large file uploads need a higher cap or a different egress path).
  • TLS interception is required. DLP relies on the proxy’s MITM CA; it does not inspect end-to-end-pinned TLS (e.g. when the sandboxed process pins its own cert). Such traffic fails to handshake under the proxy, which is the same fail-closed posture.
  • No regex on raw binary. Detectors operate on UTF-8 text after decompression and decoding. Binary protocols carrying credentials outside text fields (e.g. proprietary RPC over HTTP) need a custom detector or a different egress strategy.