DEV Community

jaryn
jaryn

Posted on

Build a Canary Regression Test for Model-Bound Webhook Data

The 02:14 ticket

Last month a support engineer opened a ticket that looked like a hundred others: one tenant's webhook deliveries were failing, and she wanted a model to explain why. So she copied the failed delivery record — status code, endpoint, body, and the X-Signature header — into a chat box and asked, "what is wrong here?"

The answer was fine. The paste was not. That header is derived from a per-tenant signing secret, and it now lived in a vendor transcript, a browser cache, and a ticket that three people could read.

Nothing exploded. No failed request, no 500, no alarm. That is the worst kind of incident: a secret that crossed a boundary nobody drew.

So let's draw it — and then prove it with a test.

The boundary is not the network

Most "don't send secrets to models" advice is a vibe. The useful version is a specific question: which fields are allowed past the prompt assembler, and what fails the build when someone widens that list?

Boundary What crosses Failure mode Control
Log store → prompt assembler Structured event records Whole-record serialization drags in secrets Field allowlist projection
Assembler → model endpoint Rendered prompt Retention you don't control HMAC pseudonyms + shape-only values
Model output → ticket Free text Regurgitated secret in a readable place Output scan before write

Note what is not on that list: "tell the model not to repeat secrets." A system prompt is a preference, not an enforcement layer. Enforcement lives in code that runs before the bytes leave your process.

Step 1 — Classify data, not payloads

Start with a table somebody can argue with. Mine, for webhook delivery records:

Class Example field Send raw? Why
Secret signature, authorization, cookie Never Rotating a tenant secret is a customer-visible event
Identifier endpoint, host, bucket Never Names your internal topology and tenant mapping
Personal data customer_email, phone Tokenized Correlation is useful; identity is not
Free text body, error, stack Synthesized Where the leaks actually hide
Telemetry event, status_code, duration_ms Yes The reason you called a model at all

Argue about the rows now, in review, with names attached. Not at 02:14.

Step 2 — Project with a default-deny allowlist

Denylists lose because they need you to predict every new field. Allowlists lose nothing but convenience, and convenience is exactly what you want to spend.

# payload_policy.py — starting template, not a validated policy
import hashlib, hmac, re

RULES = [
    (r"(?i)^(event|event_id|status|status_code|attempt|duration_ms|timestamp)$", "allow"),
    (r"(?i)(secret|token|password|signature|cookie|authorization|api_?key)",      "drop"),
    (r"(?i)(host|hostname|endpoint|bucket|arn|ip_addr)",                         "drop"),
    (r"(?i)(email|phone|address|full_?name|card)",                               "tokenize"),
    (r"(?i)(body|payload|message|error|stack|trace)",                            "synthesize"),
]
DEFAULT = "drop"

def classify(key: str) -> str:
    for pattern, action in RULES:
        if re.search(pattern, key):
            return action
    return DEFAULT

def shape(value):
    if isinstance(value, str):
        return f"<str:{len(value)}>"
    if isinstance(value, bool) or value is None:
        return value
    if isinstance(value, (int, float)):
        return 0
    return "<obj>"

def project(record: dict, hmac_key: bytes) -> dict:
    out = {}
    for key, value in record.items():
        action = classify(key)
        if action == "drop":
            continue
        if isinstance(value, dict):
            out[key] = project(value, hmac_key)
        elif action == "allow":
            out[key] = value
        elif action == "tokenize":
            digest = hmac.new(hmac_key, str(value).encode(), hashlib.sha256).hexdigest()
            out[key] = "tok_" + digest[:16]
        else:
            out[key] = shape(value)
    return out
Enter fullscreen mode Exit fullscreen mode

Two details matter more than the code. First, the HMAC key lives outside the prompt path, so pseudonyms correlate within a tenant but resist dictionary attack — an unkeyed hash of an email is reversible in seconds. Second, shape() preserves length and type; that leaks a little, and it is the price of the model still being able to say "this body looks truncated."

Step 3 — Synthesize what the model needs to see

Here is the tradeoff people skip: aggressive redaction produces prompts that are safe and useless. If you replace an error string with [REDACTED], the model can tell you nothing about it.

So keep the skeleton. Replace values with typed placeholders, keep key names, keep ordering, keep booleans. A stack trace becomes file=<str:41> line=0 fn=<str:18> — still enough to spot a pattern like "every failure is in the same function," which is usually the actual question behind the ticket.

Step 4 — Two fixtures, not one

A redaction test that only asserts absence will pass when your projection drops everything. You need a negative control too.

# test_payload_policy.py — pytest, no network
from payload_policy import project

KEY = b"test-only-key-not-a-secret"
CANARY = "CANARY-{}-8f3a1c"

def render(record):
    return repr(project(record, KEY))

def sample():
    return {
        "event": "webhook.delivery_failed",
        "status_code": 502,
        "signature": "sha256=" + CANARY.format("signature"),
        "endpoint": CANARY.format("endpoint"),
        "customer_email": CANARY.format("email") + "@example.test",
        "body": '{"order":"' + CANARY.format("body") + '"}',
    }

def test_canaries_never_reach_the_prompt():
    out = render(sample())
    for field in ("signature", "endpoint", "body"):
        assert CANARY.format(field) not in out, field

def test_useful_fields_survive():
    out = render(sample())
    assert "webhook.delivery_failed" in out   # over-redaction guard
    assert "502" in out
    assert "tok_" in out                      # tokenized, not dropped

def test_pseudonyms_correlate_but_do_not_collide():
    a = project({"customer_email": "a@example.test"}, KEY)["customer_email"]
    b = project({"customer_email": "a@example.test"}, KEY)["customer_email"]
    c = project({"customer_email": "b@example.test"}, KEY)["customer_email"]
    assert a == b and a != c

def test_unknown_field_defaults_to_drop():
    out = render({"some_new_field": CANARY.format("mystery")})
    assert "some_new_field" not in out
Enter fullscreen mode Exit fullscreen mode

That last test is the one that keeps working after the refactor. Every new field a service adds lands in drop until somebody writes a rule and a reason.

Step 5 — Run it against a model you are allowed to break

The hard part of this workflow is not the regex, it is measuring whether your projection destroyed the signal. You need a model endpoint and a throwaway environment, and you should never use production records for that measurement.

This is where MonkeyCode's free model access and free server option are relevant: the operator states that free model access (currently advertised as a 10M-token allowance) and a free server option are available, which is enough to stand up an isolated project, point the same fixture at a real model, and compare answers from raw versus projected payloads. Treat those terms as operator-supplied and check the current page before you rely on them — allowances and server options change, and I have not benchmarked throughput or hardware here.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The measurement is simple. Take twenty synthetic records that produce a known correct diagnosis, run each through the model twice — once raw, once projected — and score whether the diagnosis survives. If accuracy collapses, your synthesize rules are too aggressive. If nothing changes, your drop rules are probably not firing.

Step 6 — Detect, then recover

Prevention fails eventually, so make failure loud. Scan rendered prompts before they are persisted, with deliberately dumb heuristics:

# scan a prompt log directory for shapes that should never be stored
grep -rEn 'CANARY-|sha256=[0-9a-f]{64}|Bearer [A-Za-z0-9._-]{20,}|AKIA[0-9A-Z]{16}' ./prompt-logs \
  && echo 'LEAK: rotate before continuing' && exit 1
Enter fullscreen mode Exit fullscreen mode

A pattern match is not proof of a leak, and a clean scan is not proof of safety. It is a tripwire on the specific mistake you already made once.

Found in prompt log Rotate Notify
Signing secret All tenant webhook secrets in that scope Tenant owner, within the contract window
Session cookie Invalidate sessions for the affected scope Security on-call
Tokenized identifier only Nothing Nobody — but log the near miss

What this does not cover

Be honest about the edges. Key-name classification breaks the moment a customer pastes a secret into a free-text body field — you get shape-only values, which helps, but not extraction. Canaries only catch values you planted. Self-hosting the model removes network egress but not the prompt log on your own disk. And if your contract requires sending raw customer content to a processor, this gate is the wrong tool; go fix the contract first.

Who should not use this

  • Teams with no prompt log store: you cannot detect what you do not keep, and you cannot keep what you have not classified.
  • Anyone without a rotation runbook: redaction without recovery is theater.
  • Teams sending regulated data where legal review has not signed off on the synthetic-value approach.

The boundary question worth taking to your next review: which of these invariants belongs in CI as a canary test, which belongs in the assembler as an allowlist, and which one are you still pretending a system prompt will enforce?

Top comments (0)