WAF 阻断请求后,安全工程师常把日志粘贴给 AI 模型分析,但日志中残留的 Cookie、Bearer token、session id 会被模型读取——阻断不等于脱敏,信任边界已悄然转移。
WAF 完成了它的工作。请求从未抵达应用层。于是我把拦截日志复制给了一个编码模型,问了一个合理的问题:这是 SQLi,还是噪声误报?
模型回答了。同时它也收到了 Cookie 请求头、Bearer token,以及挂在查询字符串里的 session id。攻击者从未到达源站,但我还是把 session 甩给了提示词 API 后面的任何东西。眼熟吗?
这是一个实验演练,不是生产事故报告。没有真实租户受影响。但这种失效模式是真实存在的:拦截请求的控制措施不会自动产生一个净化后的产物。如果你粘贴的是产物,你就移动了信任边界。
粘贴才是特权操作
WAF 位于客户端到源站的路径上。你的剪贴板位于工程师到模型的路径上。这不是同一个边界。混淆它们,就是把被拦截的 cookie 变成提示词。
flowchart LR
Client -->|HTTPS request| WAF
WAF -->|allow / block| Origin
WAF -->|block event| LogStore
LogStore -->|export / copy| Engineer
Engineer -->|prompt pack| ModelRuntime
大声问出那个丑陋的问题。谁被允许查看 Cookie?WAF 进程,也许还有 SIEM,也许还有已经有生产权限的 on-call 工程师。远程补全 API 不在那个名单上。共享 GPU 盒子也不在那个名单上,即使没有人为此给你开账单。
在这次演练中,我把三个事实视为不可协商的:
拦截日志是恶意输入。它们包含攻击者载荷和浏览器附加的任何东西。
查询字符串是伪装成 URL 参数的请求头。Token 出现在那里是因为 2019 年有人偷懒,客户端从未打过补丁。
"我只粘贴了一行"不是控制措施。一行就足够了。
评审包允许包含什么
我想要模型帮我分类规则触发事件。我不想要它重建一个会话。所以提示词包使用白名单,而不是一段"请小心使用"的空话。
这个 fixture 允许的字段:
ts、method、path(不含查询字符串)、status、rule_id、action、latency_ms、proto
X-Api-Key / X-Auth-Token
查询字符串中的 access_token、id_token、refresh_token、sessionid、sid
原始 URL,仍带有 ?
白名单从未命名的额外 JSON 键
为什么拒绝额外键?因为 WAF 导出器喜欢"有用"的字段。raw_request。header_map。decoded_body。多一个额外键,你的白名单就成了演戏。
Source IP 敏感吗?有时候是。我故意把它放在白名单之外。如果你的 IR runbook 需要它,且模型运行时与 SIEM 处于同一信任区域,你可以加回来。这是一个策略选择。不是默认值。
实验 fixture,固定版本并标注
固定运行时:Python 3.12。本模板未针对真实 WAF 执行。它是提示词包的回归门。如果你把它指向生产日志,你自己负责脱敏策略,不是这篇文章。
正向 fixture——必须失败。结构化事件加上一行原始混合风格。两者都泄露。
# fixtures/positive.log
{"ts":"2026-09-11T02:14:08Z","method":"POST","path":"/login?access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJsYWIifQ.labfixturetoken","status":403,"rule_id":"sql-1","action":"block","latency_ms":12,"cookie":"session=abc"}
02:14:08 POST /admin HTTP/1.1 403 Authorization: Bearer lab-token-32chars Cookie: sid=1
负向 fixture——必须通过。同样的攻击类别。包内无凭证。
# fixtures/negative.log
{"ts":"2026-09-11T02:14:08Z","method":"POST","path":"/login","status":403,"rule_id":"sql-1","action":"block","latency_ms":12,"proto":"HTTP/1.1"}
正向文件的预期失败证据:非零退出码,至少一个发现命名为 json_keys_outside_allowlist、path_contains_query、access_token_qs、cookie_header、authorization_header 或 jwt_like。负向文件的预期证据:退出码 0 且输出 ok。
#!/usr/bin/env python3
"""Refuse to pack WAF/block logs that still contain secrets. Lab fixture."""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
DENY_PATTERNS = [
("authorization_header", re.compile(r"(?i)authorization\s*[:=]\s*bearer\s+\S+")),
("cookie_header", re.compile(r"(?i)\bcookie\s*[:=]\s*\S+")),
("set_cookie", re.compile(r"(?i)set-cookie\s*[:=]")),
("jwt_like", re.compile(
r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"
)),
("access_token_qs", re.compile(
r"(?i)(access_token|id_token|refresh_token|sessionid|sid)="
)),
("api_key_header", re.compile(r"(?i)(x-api-key|x-auth-token)\s*[:=]\s*\S+")),
]
ALLOW_KEYS = {
"ts", "method", "path", "status", "rule_id", "action", "latency_ms", "proto",
}
def findings_in_text(blob: str) -> list[tuple[str, str]]:
hits: list[tuple[str, str]] = []
for name, cre in DENY_PATTERNS:
match = cre.search(blob)
if match:
hits.append((name, match.group(0)[:80]))
return hits
def findings_in_line(line: str) -> list[tuple[str, str]]:
try:
obj = json.loads(line)
except json.JSONDecodeError:
return findings_in_text(line)
if not isinstance(obj, dict):
return findings_in_text(line)
hits: list[tuple[str, str]] = []
extra = sorted(set(obj) - ALLOW_KEYS)
if extra:
hits.append(("json_keys_outside_allowlist", ",".join(extra)))
packed = " ".join(str(obj.get(k, "")) for k in ALLOW_KEYS if k in obj)
hits.extend(findings_in_text(packed))
path = obj.get("path")
if isinstance(path, str) and "?" in path:
hits.append(("path_contains_query", path[:80]))
return hits
def scan(path: Path) -> list[tuple[int, str, str]]:
out: list[tuple[int, str, str]] = []
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if not line.strip() or line.startswith("#"):
continue
for name, snippet in findings_in_line(line):
out.append((i, name, snippet))
return out
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--input", type=Path, required=True)
parser.add_argument("--expect", choices=("pass", "fail"), required=True)
args = parser.parse_args()
hits = scan(args.input)
if hits:
for line_no, name, snippet in hits:
print(f"FAIL line={line_no} rule={name} snippet={snippet!r}")
failed = True
else:
print("ok")
failed = False
if args.expect == "fail" and not failed:
print("expected findings, got a clean pack")
return 2
if args.expect == "pass" and failed:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
不要从争论模型供应商开始。先证明这个门是关闭失效的。
把脚本保存为 waf_log_prompt_gate.py,两个 fixture 放在同目录。
先跑负向路径。如果这个失败了,你的白名单写错了,不是模型的问题。
跑正向路径。如果这个通过了,门在撒谎,你还会再粘贴 cookie。
只有到那时才导出一个真实的拦截事件,脱敏到白名单,然后用 --expect pass 重新跑。
保留原始事件在 SIEM。模型永远不需要原始版本。
python3 --version # expect Python 3.12.x
python3 waf_log_prompt_gate.py --input fixtures/negative.log --expect pass
python3 waf_log_prompt_gate.py --input fixtures/positive.log --expect fail
如果第 3 步打印了 expected findings, got a clean pack,停下来。你没有检测器。你只是 wiki 里的一个注释。
预防、检测、恢复
注意少了什么:"让模型忽略 secrets"。那不是控制措施。模型不会执行你的隐私策略。它们只是补全文本。
自托管推理移动了边界。它没有删除边界。
远程模型 API 是显而易见的泄露点。然后人们切换到桌下的一台机器就放松了。为什么?因为"这是我们的 GPU"感觉像是一个安全属性。它不是。它只是一个位置。
磁盘快照、共享操作员访问、提示词缓存,以及下一个实习生的 tmux session 仍然能看到那个包。如果你用编码助手审查 WAF 事件,在字节离开日志存储之前就做脱敏。运行时应该只收到白名单化的 JSON,不要其他东西。
披露:本文是 MonkeyCode 产品推广的一部分。MonkeyCode 是一个开源 AI 开发平台,提供免费模型访问和免费服务器选项。我在这里关心它的原因只有一个:你可以在自己运营的基础设施上保留对净化后拦截事件的评审。这不允许在提示词中出现原始 Cookie。如果本文中的门失效了,不要发送那个文件,包括发送到你喜欢的那台服务器。
自托管选项是一个部署决策。不变式仍然是:"提示词包中不得包含 session 材料。"
局限性,以及谁应该跳过这个
Regex 不是 HTTP 解析器。编码后的 cookie、分片请求头、分块请求体、gzip 和 protobuf 导出器都会绕过这些模式。多部分上传也会。请求头中的同形异义词也会。如果你的 WAF 把二进制对象写入对象存储,这个脚本救不了你。
白名单也故意丢弃了取证细节。path 加 query 可能是整个事件的全部。如果你在积极进行 IR,且需要在模型评审的同一窗口中获得原始字节,不要用编码模型作为查看器。用 SIEM、用有访问控制的工单、用已经持有生产凭证的人。
如果以下情况,跳过这种方法:
你不拥有日志管道(你将与导出格式无休止地斗争)
你需要在模型评审的同一窗口中进行完整 PCAP 或请求体重建
你的"WAF"是你无法在下载前剥离的 CDN dashboard
你想要一个让泄露变得安全的魔法提示词
这个 fixture 不能证明供应商在你数据上训练过模型。它只能证明你差点把 session 交给了他们。这是不同的主张。把它们分开。
哪一层拥有这个不变量?
我想要导出按钮成为执行点。CI 对提示词包文件的检查是回归测试。模型运行时是最后一道关卡,而最后关卡会失效。
所以这里是我真正想在评审中回答的边界问题:WAF 控制台是否拒绝复制被拒绝的字段,还是我们继续依赖工程师在剪贴板已经丢失之后运行一个 Python 门?
如果你已经在评估自托管编码工作区,把这个门放在提示词包第一次补全之前——包括在免费服务器上。有趣的部分不是产品。而是你拦截的那个 cookie 是否还坐在你即将粘贴的那个 JSON 里。