跳到正文
DEV Community· Riley Lin·· 3 小時前AI 評分62

AI 功能如何為資料外傳建立威脅模型,而非只關注模型

Threat-Model the Egress, Not the Model

AI 導讀

文章主張,AI 功能應先為每次模型呼叫建立資料外傳邊界清單,而非先比較模型。模型呼叫經過四個環節,文中建議按資料類別決定傳送、轉換或封鎖,並以 Python 閘門檢查請求;日誌只記錄雜湊值、位元組數、匹配類別和判定結果。方案以 canary 測試驗證敏感資料會被封鎖且不進入日誌;作者指出,正則檢查無法識別編碼、改寫或拆分的秘密,也不能控制供應商如何保留資料。

正文

Threat-Model the Egress, Not the Model

The thing you must defend in an AI feature is the payload, not the model, because every prompt is an outbound data transfer with your name on it. Models change weekly and pricing changes faster, so arguing about which one to call is cheap; the expensive mistake is shipping the wrong bytes. You can make that mistake once, quietly, and pay for it in an incident review months later. So the first artifact for any model-backed feature should be a boundary ledger, not a benchmark.

This matters more now that AI features are drifting into browsers and weekend side projects, where the egress path is assembled in five lines of code and never reviewed. When a call is that cheap to add, the review happens after the fact or not at all. A short, boring preflight check catches more real problems than a page of policy prose.

Name the hops before you name the model

Every model call crosses four hops, and you can only audit two of them well. The first is your own worktree and context builder, where files, issue text, and stack traces get concatenated into one string. The second is your transport code, which decides headers, retries, timeouts, and whether the request body gets written to disk for debugging. The third is the provider endpoint, which sees the full payload and may retain it under terms you cannot inspect from your side. The fourth is whatever machine actually runs the call when you do not run it yourself, and a free shared host belongs to that fourth category no matter how convenient it feels.

You control hops one and two completely, which is exactly why the ledger should start there. A free server is not a security boundary; it is someone else's machine with a clock, a hypervisor, and a log rotation policy you did not set. That does not make hosted free tiers useless, and it does make them the wrong room for customer records, session cookies, or production connection strings. Write that sentence into your design doc now, while nobody is defending a deadline.

Classify data, then decide the action

A decision table beats a list of banned words, because it forces a choice per class of data instead of per scary keyword.

Data class Examples Default action Why
Public docs, open-source source, published schemas send Already public; leakage is not the risk
Synthesized types, interfaces, errors with IDs stripped send after review Reveals shape, not data
Internal architecture notes, ticket titles, roadmap text transform Aggregates reveal more than any single line
Personal emails, user IDs, addresses, free-text support notes transform or block Regulatory and contractual exposure
Secret keys, tokens, cookies, private keys, signed URLs block Rotation cost exceeds any feature value
Regulated health, payment, government identifiers block Needs a compliance review, not a regex

The "transform" rows are the interesting ones, because transformation is real engineering and not a synonym for "hope". Tokenize identifiers deterministically per session, keep the mapping table local, and make sure the model never needs to reverse it. If the task cannot survive tokenization, you have found a feature that should not use a hosted model at all.

A preflight gate you can run in CI

The gate below is deliberately dumb: pattern matching, a size cap, and a decision record that contains no payload text. It is not a DLP system and it will not catch a paraphrased secret, but it catches the mistakes people actually make at 2 a.m.

# egress_check.py — preflight for outbound model payloads
# Reads a payload on stdin, writes a content-free decision record.
import hashlib, json, re, sys, time
from dataclasses import dataclass, asdict

RULES = [
    ("private_key", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")),
    ("aws_key",     re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
    ("bearer",      re.compile(r"\bBearer\s+[A-Za-z0-9._\-]{20,}\b")),
    ("email",       re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]{2,}\b")),
    ("private_ip",  re.compile(r"\b(?:10|192\.168|172\.(?:1[6-9]|2\d|3[01]))\.\d{1,3}\.\d{1,3}\b")),
]
BLOCK = {"private_key", "aws_key", "bearer"}
MAX_BYTES = 200_000

@dataclass
class Decision:
    ts: float
    sha256: str
    bytes: int
    classes: list
    verdict: str
    reason: str

def classify(text: str):
    hits = sorted({name for name, rx in RULES if rx.search(text)})
    if hits & BLOCK:
        return hits, "block", "secret-class token present"
    if hits:
        return hits, "transform", "identifier-class token present"
    return hits, "send", "no rule matched"

def main() -> int:
    raw = sys.stdin.buffer.read()
    text = raw.decode("utf-8", "replace")
    hits, verdict, reason = classify(text)
    if len(raw) > MAX_BYTES:
        verdict, reason = "block", f"payload over {MAX_BYTES} bytes"
    rec = Decision(time.time(), hashlib.sha256(raw).hexdigest(),
                   len(raw), hits, verdict, reason)
    with open("egress-log.jsonl", "a", encoding="utf-8") as fh:
        fh.write(json.dumps(asdict(rec)) + "\n")
    print(f"{verdict}: {reason} classes={hits or 'none'}", file=sys.stderr)
    return {"send": 0, "transform": 1, "block": 2}[verdict]

if __name__ == "__main__":
    sys.exit(main())

Wire it in front of the request builder so the string that reaches the network is the same string that reached the gate, and fail closed on anything except exit code 0. The decision record holds a hash, a byte count, matched classes, and a verdict, with no snippet and no filename. That is enough to answer "what left, when, and why did the gate allow it" during a review, and not enough to become a second copy of the leak itself.

grep -rn "requests.post\|fetch(" app/ | grep -i "model\|completion"
# for each call site: route the assembled payload through the gate
printf '%s' "$PROMPT" | python egress_check.py || exit 2

Test the gate with a canary, not with confidence

A gate that has never failed is a gate you have not tested. Put a fake credential in a fixture, run the pipeline, and assert two things at once: the call is blocked, and the log line does not contain the fixture string. The second assertion is the one people skip, and it is the one that turns a logging habit into a security property.

# test_egress.py
import json, subprocess, pathlib

CANARY = "AKIAIOSFODNN7EXAMPLE"  # fake, matches the aws_key rule

def run(payload: str):
    return subprocess.run(["python", "egress_check.py"], input=payload.encode(),
                          capture_output=True)

def test_secret_blocks_and_never_lands_in_log(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    assert run(f"deploy key: {CANARY}").returncode == 2
    log = pathlib.Path("egress-log.jsonl").read_text()
    assert CANARY not in log
    assert json.loads(log.splitlines()[-1])["verdict"] == "block"

Add a positive case for public text, one for an email that must come back as transform, and one oversized payload that must block on size alone. Rotate the canary value each quarter so the suite cannot pass by accident on a stale fixture. If your provider retains payloads beyond your contract window, the gate also gives you a defensible answer for what was exposed, because you can replay the hash list without replaying content.

Where a free tier fits, honestly

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option, and those two things are genuinely useful for this workflow in one narrow way: you can exercise the whole path, gate included, against a real endpoint without waiting on a spend approval. Practice on real transport is where the ledger earns its keep, because retries, streamed responses, and SDK defaults are exactly where stray payload copies appear. The free server is convenient for that rehearsal and it is still somebody else's machine, so keep the canary fixtures fake and the data public while you learn the shape of your own egress.

The limitations deserve plain language. A pattern gate cannot see a secret that has been base64-encoded, paraphrased into prose, or split across two messages, and it says nothing about what the provider does with a payload after the response arrives. Free tiers also change shape over time, which is another reason to keep the gate in your repository instead of in a vendor dashboard. Treat all of it as a guardrail that raises the cost of a mistake, not as proof that no mistake happened.

Who should not use this

Skip this approach if your data is regulated, if a contract names a data residency region, or if you need per-request audit evidence that a third party will accept. In those cases you need a reviewed processing agreement and an internal endpoint, and a regex gate in a shell script is not a substitute for either. Skip it too if your payloads are public by design, since the gate would only add friction to a path with nothing to protect.

For everyone else, the sequence is short and unglamorous. Draw the four hops, fill in the table for the data you actually handle, put the gate in front of the request builder, and write the canary test before the first real user touches the feature. Then, when the next model release makes you want to swap providers, the swap is a one-line change and the boundary work is already finished. If you want a cheap place to rehearse that loop, the free tier mentioned above is a reasonable starting room.

來源:DEV Community · dev.to