Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ab58241
Redaction rules: scaffold for the expanded catalog
fylorn Oct 29, 2026
8812ece
Redaction rules: personal-information catalog
fylorn Oct 29, 2026
93a9ada
Redaction rules: expanded credential catalog and saved secrets
fylorn Oct 29, 2026
2e3e4f9
Merge remote-tracking branch 'origin/feat/redact-personal' into feat/…
fylorn Oct 29, 2026
bab1dab
Redaction rule catalog as data: names and descriptions in both languages
fylorn Oct 29, 2026
01c86fd
Redaction: two-phase scan (anchors first, then validators)
fylorn Oct 29, 2026
26b3d22
Merge remote-tracking branch 'origin/feat/redact-two-phase' into feat…
fylorn Oct 29, 2026
9f1a893
Redaction: keep a placed value unless a replacing hit covers all of it
fylorn Oct 29, 2026
2496ae0
Redaction: position rules scan in linear time
fylorn Oct 29, 2026
7a3470d
Redaction: labeled values stay inside their JSON string
fylorn Oct 29, 2026
3566374
Redaction: Slack webhook followed by punctuation is still replaced
fylorn Oct 29, 2026
30155fd
Redaction: path rules accept JSON-escaped slashes
fylorn Oct 29, 2026
11f2d83
Redaction: tab after the auth scheme; .env values with punctuation or…
fylorn Oct 29, 2026
9a29863
Redaction: a saved secret next to `.`, `-` or `_` is still found
fylorn Oct 29, 2026
3ac41f8
Credential-to-network: allow only the API hosts of sk-compat and Qian…
fylorn Oct 29, 2026
a22b277
Saved secrets: take the host of an IPv6-literal address correctly
fylorn Oct 29, 2026
6740176
Credential-to-network: a saved key keeps its provider's hosts
fylorn Oct 29, 2026
00e6b94
Saved secrets: access and control keys may go to the gateway's own ad…
fylorn Oct 29, 2026
0f0e0dc
Gateway: report a saved secret a plugin adds even if another was repo…
fylorn Oct 29, 2026
cc9fbe4
Redaction oracle: compare the two scanners on mutated samples
fylorn Oct 29, 2026
3571685
Tidy a test argument flagged by clippy
fylorn Oct 29, 2026
1c72748
Redaction: no length cap on placed values; keep failed .env scans local
fylorn Oct 29, 2026
f240774
Redaction: comma-separated Slack webhooks are each replaced
fylorn Oct 29, 2026
460205c
Redaction: tab after the header colon; Proxy-Authorization
fylorn Oct 29, 2026
ce4252d
Redaction: URL parameters after a JSON-escaped `&` or `=`
fylorn Oct 29, 2026
eb7241c
Credential-to-network: borrow a built-in rule's hosts only when it li…
fylorn Oct 29, 2026
b6f5d34
Redaction: fewer false positives from the widened .env reading
fylorn Oct 29, 2026
cac46c2
Redaction tests: regression cases from the second review
fylorn Oct 29, 2026
12657a4
Redaction prescan: cut the cost of scanning prose
fylorn Oct 29, 2026
77f93f8
Merge remote-tracking branch 'origin/perf/redact-prose' into feat/red…
fylorn Oct 29, 2026
4c23ca6
Redaction: count each backslash run once in a quoted .env value
fylorn Oct 29, 2026
fdaddc5
Redaction: .env passwords with brackets or `;keyword…` are replaced a…
fylorn Oct 29, 2026
9dbcd3b
Redaction: URL parameters the URL-token rule does not know are assign…
fylorn Oct 29, 2026
0ef2a62
Merge remote-tracking branch 'origin/fix/redact-review' into feat/red…
fylorn Oct 29, 2026
b3a1217
0.85.0: release notes, README, data source licences
fylorn Oct 29, 2026
4be853c
Keep LF in the embedded redaction data on Windows checkouts
fylorn Oct 29, 2026
ac3b935
Handover test: a write after the gate closes a wrong-token link may fail
fylorn Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ crates/tw-api/msg-codes.txt text eol=lf
# manifest 也记着这份哈希。各平台检出、编进二进制的字节必须一样
crates/tw-gateway/src/plugin/defaults/*.js text eol=lf
crates/tw-gateway/src/plugin/defaults/manifests.json text eol=lf
# 脱敏规则编进二进制的词表、数据表:编译时(const)按 `\n` 切行,Windows 检出换成 CRLF
# 会多出 `\r`,词表就对不上了
crates/tw-guard/src/redact/secrets/bip39/*.txt text eol=lf
crates/tw-guard/src/redact/personal/data/*.txt text eol=lf
crates/tw-guard/data/*.yaml text eol=lf
45 changes: 23 additions & 22 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ categories = ["network-programming", "web-programming::http-server"]

# 二进制走 CalVer,crate 走 SemVer —— 两者是两回事,卖给不同的人。
# 这里是 crate 的版本。
version = "0.84.0"
version = "0.85.0"

[workspace.dependencies]
# ── 内部 crate ───────────────────────────────────────────
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@ Documentation: [configuration reference](docs/config.md) ·
changing upstreams or models happens in the gateway, with no client change or
restart. Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and
Gemini are converted in both directions, streams included.
- **Outbound redaction.** Outbound redaction can replace API keys,
private keys and connection-string passwords with placeholders before a
request leaves, and restore them where the answer repeats them, so a relay
never sees the real values.
- **Outbound redaction.** Outbound redaction can replace credentials and
personal information with placeholders before a request leaves, and restore
them where the answer repeats them, so a relay never sees the real values.
It recognises the keys saved in ThinkWatch by value, about 60 kinds of API
keys and tokens, credentials in headers, URLs, commands and config files,
private keys and wallet seed phrases, and identity, bank and contact numbers
that can be checked by their structure.
- **Malicious tool calls are cut off.** A relay can rewrite an answer and slip
in a tool call for the client to run. Tool-call inspection can cut off an
answer whose tool call downloads and runs code, sends out environment
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ ThinkWatch Core 是 ThinkWatch 的网关引擎,由一组 Rust crate 及其构
## 主要特性

- **一次接入,随时切换**。客户端只保留一个地址和一把密钥,更换上游或模型都在网关中完成,客户端无需改配置或重启。Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 四种格式双向转换,流式输出同样适用。
- **出站脱敏**。出站脱敏可在请求发出前把 API 密钥、私钥和连接串中的口令替换为占位符,并在回答回显时还原,中转站因此看不到真实的值。
- **出站脱敏**。出站脱敏可在请求发出前把凭据和个人信息替换为占位符,并在回答回显时还原,中转站因此看不到真实的值。可识别的包括:按值识别 ThinkWatch 中保存的密钥,约 60 种 API 密钥与令牌,请求头、URL、命令行和配置文件中的凭据,私钥与钱包助记词,以及可按结构校验的证件号、银行账号和联系方式。
- **切断恶意工具调用**。中转站可以改写回答,塞入让客户端执行的工具调用。回答中的工具调用若是下载即执行、外发环境变量或凭据文件、读取私钥、读写 ThinkWatch 自己的配置、写入开机启动项或定时任务,工具调用审查可以在客户端收到完整调用之前切断回答。
- **清除隐藏指令**。屏幕上看不见的字符可以夹带模型会读取的指令,内容过滤可以在请求发出前把它们从用户消息和工具结果中删除,也可以拒绝要求模型忽略自身指令的请求。出站脱敏、工具调用审查和内容过滤出厂均为观察档,只记录检出的内容,不改变任何请求。
- **每个请求都可追溯**。每个请求连同决定其去向的规则、每次尝试、格式转换、用量、费用及价格来源、首 token 时间和生成速度一并保存。试算可以在不发出请求的情况下说明请求会被送往何处;已保存的请求可以对另一个上游重放,以便对比。
Expand Down
48 changes: 41 additions & 7 deletions crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,16 @@ pub use endpoint::{Endpoint, ErrorBody, Format, Info, Method, fill};
pub use tw_guard::content::Outcome as ContentOutcome;
pub use tw_guard::locate::{Direction as SecurityDirection, Part as HitPart};
pub use tw_guard::policy::{ContentMatch, ContentScope, Guard, Mode as GuardMode};
/// 保存的凭据是哪一项(出站脱敏的 `thinkwatch-saved-secret`)。定义在 tw-guard:企业版只依赖
/// 第一层,也要用它
pub use tw_guard::redact::rules::{SavedSource, SavedSourceKind};
pub use tw_guard::trial::{
TrialHit as SecurityTestHit, TrialRequest as SecurityTestRequest,
TrialResult as SecurityTestResult,
};
pub use tw_guard::view::{
CardNetwork, CardPrefix, GuardDetail, Matcher, RuleAction, RuleView as SecurityRuleView,
SecurityDetail,
CardNetwork, CardPrefix, GuardDetail, KindView as SecurityKindView, Matcher, RuleAction,
RuleView as SecurityRuleView, SecurityDetail,
};

/// 取值是一个固定集合的字段:线上是 slug,类型是枚举。
Expand Down Expand Up @@ -292,12 +295,27 @@ slug_enum! {
slug_enum! {
/// 出站脱敏找到的东西属于哪一类。
pub enum SecretKind {
ApiKeys = "api-keys",
/// 用户在 ThinkWatch 里保存的凭据,按值认
Saved = "saved",
AiServices = "ai-services",
DevPlatforms = "dev-platforms",
Cloud = "cloud",
Business = "business",
PrivateKeys = "private-keys",
Jwt = "jwt",
ConnStrings = "conn-strings",
/// 身份证号、银行卡号、邮箱、手机号:个人信息,不是凭据
Personal = "personal",
/// 请求头、命令行、URL 参数、机器人 Webhook、配置赋值里按位置认的凭据
InContext = "in-context",
Crypto = "crypto",
/// 以下八类是个人信息,不是凭据
IdDocuments = "id-documents",
ForeignIds = "foreign-ids",
Financial = "financial",
Contact = "contact",
Location = "location",
Vehicle = "vehicle",
Device = "device",
LabeledPersonal = "labeled-personal",
/// 内网地址
Internal = "internal",
/// 自定义规则找到的
Expand Down Expand Up @@ -1088,7 +1106,17 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt");
/// **55 起有用量历史**:`GET /summary/history`([`BucketQuery`] → [`UsageHistory`]),很长一段时间
/// 里的用量按格合计,概览上「最近一年」用。按整点另存在数据目录的 `usage.db` 里,请求库重建、
/// 请求记录过了保留期都不丢
pub const CONTROL_API_VERSION: u32 = 55;
///
/// **56 起出站脱敏规则扩充**:[`SecretKind`] 拆成服务类别和个人信息各类(`api-keys`、`personal`
/// 不再有);新增约四十条凭据规则,规则视图的 `matcher` 多了 `prefixes`(多前缀,每项带
/// `prefix`、`min_tail`、`tail`:`any` / `hex` / `alnum`)、`saved`、`assignment`(`suffixes`)、
/// `labeled`(`names`),代码实现的规则是 `builtin`(`check` 就是规则 id)。新规则
/// `thinkwatch-saved-secret` 按值认用户保存的凭据:[`SecretItem`]、[`SecurityEventView`] 和
/// 「测试…」的命中(`TrialHit`)多了 `source`([`SavedSource`]:`kind` 是 [`SavedSourceKind`]
/// 的 `upstream-key` / `upstream-header` / `access-key` / `proxy-password` / `control-key` /
/// `sync-credential`,`name` 是上游名、密钥名、代理名,控制面密钥和同步凭据是空的),这时打码
/// 后的值是空串。按位置认的(`in-context` 一类)打码只留最后四位
pub const CONTROL_API_VERSION: u32 = 56;

/// `/live` 的速度看最近多久(见 [`LiveView::tokens_per_sec`]),毫秒
pub const LIVE_RATE_WINDOW_MS: u64 = 3_000;
Expand Down Expand Up @@ -6203,6 +6231,9 @@ pub struct SecretItem {
pub replaced: bool,
/// 每一处在哪儿、当时的规则、具体做了什么(见 [`SecurityHitDetail`])
pub detail: SecurityHitDetail,
/// 是哪一项保存的凭据(规则 `thinkwatch-saved-secret`:这时 `masked` 是空的)。别的规则没有
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<SavedSource>,
}

/// 各项防护在一段时间里各留下了几条记录。
Expand Down Expand Up @@ -6300,6 +6331,9 @@ pub struct SecurityEventView {
pub rule_snapshot: RuleSnapshot,
/// 具体做了什么:换成了哪个占位符、切断的是哪个调用、客户端收到了什么
pub outcome_detail: OutcomeDetail,
/// 出站脱敏认出的是哪一项保存的凭据(规则 `thinkwatch-saved-secret`)。别的没有
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<SavedSource>,
}

/// 一条安全记录最多列几处命中([`SecurityEventView::locations`])。再多的只数个数
Expand Down Expand Up @@ -6348,7 +6382,7 @@ pub struct RuleSnapshot {
pub builtin: bool,
/// 内置规则的 id,或者自定义规则的名字
pub id: String,
/// 显示的名字:内置规则的英文名(界面按 id 查自己的名称表),自定义规则就是它的名字
/// 显示的名字:内置规则的英文名(中文名按 id 在规则视图里取 `name_zh`),自定义规则就是它的名字
pub name: String,
/// 自定义规则写的样子,原样(正则、关键词、码位)。内置规则没有
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down
14 changes: 14 additions & 0 deletions crates/tw-api/src/ts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,9 +514,23 @@ mod tests {
"matcher: Matcher",
"scope?: Array<ContentScope>",
"replacement?: string",
// 名字、说明由 core 给,中英文各一个字段
"name_zh?: string",
"why_zh?: string",
"about?: string",
"about_zh?: string",
] {
assert!(rule.contains(field), "{field}: {rule}");
}
assert!(
decl_of(&ts, "GuardDetail").contains("kinds: Array<SecurityKindView>"),
"{}",
decl_of(&ts, "GuardDetail")
);
let kind = decl_of(&ts, "SecurityKindView");
for field in ["slug: string", "name: string", "name_zh: string"] {
assert!(kind.contains(field), "{field}: {kind}");
}
assert_eq!(
decl_of(&ts, "ContentScope"),
"export type ContentScope = \"request\" | \"response\""
Expand Down
17 changes: 3 additions & 14 deletions crates/tw-config/src/credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,21 +497,10 @@ impl From<SecretResolveError> for CredentialError {
}
}

/// 地址里的主机名。和工具调用审查认目的地用的是同一个(认得 IPv6 字面量,见
/// [`tw_guard::tools::net::url_host`])
pub(crate) fn host_of(url: &str) -> String {
let h = url.trim().to_ascii_lowercase();
h.split("://")
.nth(1)
.unwrap_or(&h)
.split('/')
.next()
.unwrap_or("")
.split('@')
.next_back()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("")
.to_string()
tw_guard::tools::net::url_host(url)
}

fn is_or_under(host: &str, domain: &str) -> bool {
Expand Down
Loading
Loading