diff --git a/scripts/shots/core/en/status.json b/scripts/shots/core/en/status.json index 2e9c7bab..549916b9 100644 --- a/scripts/shots/core/en/status.json +++ b/scripts/shots/core/en/status.json @@ -1,5 +1,5 @@ { - "api_version": 56, + "api_version": 57, "clients": 4, "config_path": "", "draining": 0, @@ -16,5 +16,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.85.0" + "version": "0.86.0" } diff --git a/scripts/shots/core/zh/status.json b/scripts/shots/core/zh/status.json index 2e9c7bab..549916b9 100644 --- a/scripts/shots/core/zh/status.json +++ b/scripts/shots/core/zh/status.json @@ -1,5 +1,5 @@ { - "api_version": 56, + "api_version": 57, "clients": 4, "config_path": "", "draining": 0, @@ -16,5 +16,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.85.0" + "version": "0.86.0" } diff --git a/scripts/shots/mock/core.ts b/scripts/shots/mock/core.ts index 54f9158f..82d4c488 100644 --- a/scripts/shots/mock/core.ts +++ b/scripts/shots/mock/core.ts @@ -220,6 +220,22 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { for (const e of window) by_outcome[e.action] += 1; return { events: clone(xs.slice(0, limit)), more: xs.length > limit, total: window.length, by_outcome }; }, + SecurityRuleCounts: (req) => { + // 各条记录的 count 加起来,按规则 + const by = new Map(); + for (const e of SEC_EVENTS) { + if (e.guard !== req.guard || (req.since_ms != null && e.at_ms < req.since_ms)) continue; + const k = `${e.custom ? "c" : "b"}:${e.rule}`; + const x = by.get(k) ?? { rule: e.rule, custom: e.custom, count: 0 }; + x.count += e.count; + by.set(k, x); + } + return [...by.values()]; + }, + RedactExceptions: () => ({ items: [], max: 500 }), + AddRedactException: refuse, + ExceptSecurityEvent: refuse, + RemoveRedactException: refuse, SetSecurityMode: refuse, ToggleBuiltinRule: refuse, SetBuiltinRuleAction: refuse, diff --git a/scripts/shots/mock/traffic.ts b/scripts/shots/mock/traffic.ts index ee2d13b1..adbb6af7 100644 --- a/scripts/shots/mock/traffic.ts +++ b/scripts/shots/mock/traffic.ts @@ -549,8 +549,11 @@ function generate() { // ───────────────────────────────────────── 安全日志 type Detail = "direction" | "locations" | "more_locations" | "rule_snapshot" | "outcome_detail"; -type Hit = Omit & - Partial>; +type Hit = Omit< + SecurityEventView, + "id" | "at_ms" | "request_id" | "provider" | "client" | "model" | "client_hint" | "peer" | "key_masked" | "excepted" | "exceptable" | Detail +> & + Partial>; /** * 一次命中的细节,照 core 的写法补:脱敏在发出的请求里、占位符 `<>`;工具调用在 @@ -609,6 +612,8 @@ function securityLog() { client_hint: row.client_hint ?? null, peer: null, key_masked: row.key_masked ?? null, + excepted: false, + exceptable: false, ...hit, ...detailOf(hit), }; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1ca0a0b7..ec0ac28c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -1206,7 +1206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3699,7 +3699,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3755,7 +3755,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4909,7 +4909,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5438,8 +5438,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.85.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.85.0#7134a31380ca2b3784079e3f3e0d2f7c64ec967b" +version = "0.86.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" dependencies = [ "serde", "serde_json", @@ -5450,8 +5450,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.85.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.85.0#7134a31380ca2b3784079e3f3e0d2f7c64ec967b" +version = "0.86.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" dependencies = [ "memchr", "serde", @@ -5460,8 +5460,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.85.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.85.0#7134a31380ca2b3784079e3f3e0d2f7c64ec967b" +version = "0.86.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" dependencies = [ "aho-corasick", "base64 0.22.1", @@ -5480,8 +5480,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.85.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.85.0#7134a31380ca2b3784079e3f3e0d2f7c64ec967b" +version = "0.86.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" dependencies = [ "serde", "serde_json", @@ -5507,8 +5507,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.85.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.85.0#7134a31380ca2b3784079e3f3e0d2f7c64ec967b" +version = "0.86.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" dependencies = [ "serde", "ts-rs", @@ -5516,8 +5516,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.85.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.85.0#7134a31380ca2b3784079e3f3e0d2f7c64ec967b" +version = "0.86.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" dependencies = [ "libc", "notify", @@ -5527,8 +5527,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.85.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.85.0#7134a31380ca2b3784079e3f3e0d2f7c64ec967b" +version = "0.86.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" dependencies = [ "saphyr-parser", "thiserror 2.0.21", @@ -5992,7 +5992,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6b682562..8cf4c927 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.85.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.85.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.85.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.85.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.85.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.85.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index 95e30745..7624c96d 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -147,6 +147,7 @@ webview_endpoints![ // 安全 Security, SecurityEvents, + SecurityRuleCounts, SetSecurityMode, ToggleBuiltinRule, SetBuiltinRuleAction, @@ -155,6 +156,10 @@ webview_endpoints![ UpdateCustomRule, DeleteCustomRule, TestSecurity, + RedactExceptions, + AddRedactException, + ExceptSecurityEvent, + RemoveRedactException, // 账号登录(发起和取消是命令:要开浏览器、要记住这一次) ChatgptLoginStatus, ChatgptUsage, diff --git a/src-tauri/src/notices/tests.rs b/src-tauri/src/notices/tests.rs index 8666b7f5..90954c16 100644 --- a/src-tauri/src/notices/tests.rs +++ b/src-tauri/src/notices/tests.rs @@ -960,6 +960,7 @@ fn secrets(provider: &str, items: &[(&str, bool)]) -> tw_api::Event { count: 1, replaced: !*recorded, source: None, + value_sha256: None, detail: hit_detail( tw_api::SecurityDirection::Request, rule, @@ -1036,6 +1037,7 @@ fn a_saved_secret_hit_names_where_it_is_saved_and_nothing_of_the_value() { count: 2, replaced: false, source: Some(source), + value_sha256: None, detail: hit_detail( tw_api::SecurityDirection::Request, "thinkwatch-saved-secret", diff --git a/src/control.ts b/src/control.ts index 495de758..eb21614d 100644 --- a/src/control.ts +++ b/src/control.ts @@ -100,6 +100,7 @@ export const WEBVIEW_ENDPOINTS = [ "DeletePriceSheet", "Security", "SecurityEvents", + "SecurityRuleCounts", "SetSecurityMode", "ToggleBuiltinRule", "SetBuiltinRuleAction", @@ -108,6 +109,10 @@ export const WEBVIEW_ENDPOINTS = [ "UpdateCustomRule", "DeleteCustomRule", "TestSecurity", + "RedactExceptions", + "AddRedactException", + "ExceptSecurityEvent", + "RemoveRedactException", "ChatgptLoginStatus", "ChatgptUsage", "ChatgptResets", diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 69aaaad4..f9428025 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 56; +export const CONTROL_API_VERSION = 57; /** * 手动中止(`POST /request/{id}/abort`、`POST /sessions/{id}/abort`)叫停了哪几个请求。 @@ -1832,6 +1832,22 @@ plugin_name: string, */ request_id?: number | null, message: Msg, at_ms: number, } | { "kind": "events_dropped", id: number, count: number, at_ms: number, } | { "kind": "sync_status", id: number, view: SyncView, at_ms: number, } | { "kind": "gateway_keys_changed", id: number, names: Array, at_ms: number, } | { "kind": "client_prefs_changed", id: number, at_ms: number, }; +/** + * 手动加入一个例外值(`POST /security/exceptions`):还没出现在日志里的测试值。 + * + * **原值只在这个请求里**:core 收到就算哈希,不写进配置、日志、事件和回答。两头的空白去掉 + */ +export type ExceptionAdd = { value: string, base_version?: string | null, }; + +/** + * 加入了例外(或者它本来就在:这时 `version` 是没改动的那一版)。 + */ +export type ExceptionAdded = { version: string, +/** + * 例外表里的那一项 + */ +exception: RedactException, }; + /** * `POST /config/export`。 */ @@ -4259,6 +4275,45 @@ export type RatioView = { */ median: number, samples: number, }; +/** + * 一个例外值。**只存哈希和打码后的样子,不存原值**:例外表随配置导出、同步、备份。 + * + * 规则是保存的凭据(`thinkwatch-saved-secret`)的读得进来、**但不生效**:保存的凭据本就该换, + * 界面上也不给它「列为例外」。不当成配置错误 —— 那样的话一份手改过的配置会整份读不进来, + * 而它要的效果(不生效)本来就是对的。 + */ +export type RedactException = { +/** + * 值(解开转义之后的样子)的 SHA-256,64 个小写十六进制字符 + */ +sha256: string, +/** + * 加入时打码后的样子(和安全日志里的一样),只给人看是哪一个 + */ +masked: string, +/** + * 最先认出它的规则:内置规则的 id 或者自定义规则的名字。手动加入、没有规则认得它时是空的 + */ +rule?: string, +/** + * 加入的时刻,毫秒 + */ +added_at: number, }; + +/** + * 出站脱敏的例外表(`GET /security/exceptions`):按配置里的先后(先加的在前)。 + */ +export type RedactExceptions = { +/** + * 每一项是配置里的那一项原样:哈希、打码后的样子、最先认出它的规则、加入的时刻。移出例外 + * 按 `sha256` + */ +items: Array, +/** + * 最多几个([`tw_guard::policy::EXCEPTIONS_MAX`])。满了「添加例外」置灰 + */ +max: number, }; + /** * 配置里引用了某个上游的一处。 */ @@ -5144,7 +5199,21 @@ outcome_detail: OutcomeDetail, /** * 出站脱敏认出的是哪一项保存的凭据(规则 `thinkwatch-saved-secret`)。别的没有 */ -source?: SavedSource | null, }; +source?: SavedSource | null, +/** + * 出站脱敏:这个值此刻在例外表里(`security.redact.exceptions`),界面上标「例外」。 + * 读的时候按现在的例外表算,不是记下时的 + */ +excepted: boolean, +/** + * 出站脱敏:这一条能「列为例外」(`POST /security/events/{id}/exception`)。保存的凭据、 + * 几处重叠合成的一段、别的防护的记录不能;已经是例外的也是 false。 + * + * **这两样只对最近的记录算得出**:值的哈希不进请求库,只在 core 的内存里留最近两万行 + * (重启就没了)。更早的记录两样都是 false —— 界面上不给按钮也不标「例外」,那个值可以 + * 粘贴进「添加例外」手动加入 + */ +exceptable: boolean, }; /** * 安全日志的一页。**按时间倒序**,`more` 说后面还有没有。 @@ -5239,6 +5308,29 @@ stripped: number, */ blocked: number, }; +/** + * 一条规则命中了几次([`SecurityRuleHitsQuery`])。 + */ +export type SecurityRuleHits = { +/** + * 内置规则的 id,或者自定义规则的名字 + */ +rule: string, custom: boolean, +/** + * 这段时间里各条记录的 `count` 加起来([`SecurityEventView::count`]),不论做了什么 + */ +count: number, }; + +/** + * `GET /security/counts`:一项防护从某个时刻起每条规则命中了几次(规则表的组头「最近 7 天 + * 命中 N 次」)。 + */ +export type SecurityRuleHitsQuery = { guard: Guard, +/** + * 从这个时刻起(毫秒,含)。不给是全部 + */ +since_ms?: number | null, }; + /** * 一条规则。 */ @@ -5337,7 +5429,12 @@ action?: RuleAction | null, /** * 出站脱敏认出的是一项保存的凭据(`thinkwatch-saved-secret`):是哪一项。这时 `excerpt` 是空的 */ -source?: SavedSource | null, }; +source?: SavedSource | null, +/** + * 出站脱敏:这个值是例外(`security.redact.exceptions`),规则认得出、但不换也不记, + * 界面上写「例外,不处理」。这时 `action` 是空的,`output` 里也原样留着它 + */ +excepted: boolean, }; /** * 拿一段文本试一试。 @@ -6563,6 +6660,7 @@ export const ENDPOINTS = { DeletePriceSheet: { method: "DELETE", path: "/pricing/sheets/{name}", params: ["name"], format: "json" }, Security: { method: "GET", path: "/security", params: [], format: "json" }, SecurityEvents: { method: "GET", path: "/security/events", params: [], format: "json" }, + SecurityRuleCounts: { method: "GET", path: "/security/counts", params: [], format: "json" }, SetSecurityMode: { method: "PUT", path: "/security/{guard}/mode", params: ["guard"], format: "json" }, ToggleBuiltinRule: { method: "PUT", path: "/security/{guard}/builtin/{id}", params: ["guard", "id"], format: "json" }, SetBuiltinRuleAction: { method: "PUT", path: "/security/{guard}/builtin/{id}/action", params: ["guard", "id"], format: "json" }, @@ -6571,6 +6669,10 @@ export const ENDPOINTS = { UpdateCustomRule: { method: "PUT", path: "/security/{guard}/custom/{name}", params: ["guard", "name"], format: "json" }, DeleteCustomRule: { method: "DELETE", path: "/security/{guard}/custom/{name}", params: ["guard", "name"], format: "json" }, TestSecurity: { method: "POST", path: "/security/{guard}/test", params: ["guard"], format: "json" }, + RedactExceptions: { method: "GET", path: "/security/exceptions", params: [], format: "json" }, + AddRedactException: { method: "POST", path: "/security/exceptions", params: [], format: "json" }, + ExceptSecurityEvent: { method: "POST", path: "/security/events/{id}/exception", params: ["id"], format: "json" }, + RemoveRedactException: { method: "DELETE", path: "/security/exceptions/{sha256}", params: ["sha256"], format: "json" }, Plugins: { method: "GET", path: "/plugins", params: [], format: "json" }, PluginInspect: { method: "POST", path: "/plugin-inspect", params: [], format: "json" }, PluginRewrite: { method: "POST", path: "/plugin-rewrite", params: [], format: "json" }, @@ -6716,6 +6818,7 @@ export type Endpoints = { DeletePriceSheet: { req: BaseVersion; res: ConfigWritten }; Security: { req: null; res: SecurityDetail }; SecurityEvents: { req: SecurityEventsQuery; res: SecurityEventsPage }; + SecurityRuleCounts: { req: SecurityRuleHitsQuery; res: Array }; SetSecurityMode: { req: ModeSave; res: ConfigWritten }; ToggleBuiltinRule: { req: RuleToggle; res: ConfigWritten }; SetBuiltinRuleAction: { req: ActionSave; res: ConfigWritten }; @@ -6724,6 +6827,10 @@ export type Endpoints = { UpdateCustomRule: { req: CustomRuleSave; res: ConfigWritten }; DeleteCustomRule: { req: BaseVersion; res: ConfigWritten }; TestSecurity: { req: SecurityTestRequest; res: SecurityTestResult }; + RedactExceptions: { req: null; res: RedactExceptions }; + AddRedactException: { req: ExceptionAdd; res: ExceptionAdded }; + ExceptSecurityEvent: { req: BaseVersion; res: ExceptionAdded }; + RemoveRedactException: { req: BaseVersion; res: ConfigWritten }; Plugins: { req: null; res: Array }; PluginInspect: { req: PluginSource; res: PluginInspection }; PluginRewrite: { req: PluginRewriteRequest; res: PluginSource }; diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index 8c83f924..518236d4 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -530,6 +530,12 @@ "security.rule_scope_empty": "内容规则须至少检查请求或回答中的一项。", "security.rule_scope_block": "检查回答的规则不能拒绝:发现命中时,回答已在发送途中。请选择删除、替换或仅记录。", "security.rule_scope_codepoints": "码位只在请求中检查,检查回答的规则须按「包含」或「正则」匹配。", + "security.unknown_event": "安全日志中没有第 {id} 条记录,可能已超过保留期。", + "security.exception_not_exceptable": "只有密钥与隐私找到的值可以列为例外;保存的密钥、由几处重叠命中合成的一段不能列为例外。", + "security.exception_forgotten": "这条记录较早,无法从日志列为例外。请将这个值粘贴到「添加例外」中加入。", + "security.exceptions_full": "例外已有 {max} 个,达到上限。请先移出一个再添加。", + "security.exception_value_empty": "值为空。", + "security.exception_saved_secret": "这个值是 ThinkWatch 中保存的密钥,保存的密钥始终替换,不能列为例外。", "control.session_not_found": "未找到会话 {id}。", "control.session_not_running": "会话 {id} 没有正在进行的请求。", "control.client_unknown": "未知的客户端「{client}」。", @@ -684,6 +690,9 @@ "config.base_url_userinfo": "上游「{upstream}」的接口地址在主机前带有用户名或密码。凭据请改写在 key 或 headers 中。", "config.zero_concurrency": "网关密钥「{key}」的 max_concurrent 为 0,使用它的请求会一直等待。不限制并发时请删除 max_concurrent。", "config.provider_concurrency_range": "上游「{upstream}」的 max_concurrent 为 {value},须在 1 到 {max} 之间。不限制并发时请删除 max_concurrent。", + "config.redact_exceptions_too_many": "security.redact.exceptions 中有 {count} 个值,最多 {max} 个。", + "config.redact_exception_hash_bad": "security.redact.exceptions 中的例外「{sha256}」不是 SHA-256:须为 64 个小写十六进制字符。", + "config.redact_exception_duplicate": "例外「{sha256}」在 security.redact.exceptions 中出现了两次。", "config.key_limit_empty": "网关密钥「{key}」的一条用量上限没有写明计量。limits 中的每一条须写 requests、tokens、cost 之一。", "config.key_limit_two_measures": "网关密钥「{key}」的一条用量上限同时写了 {measures:limit_measures}。每一条只能写 requests、tokens、cost 之一,请分成多条。", "config.key_limit_not_positive": "网关密钥「{key}」的一条用量上限({measure:limit_measure},每{per:limit_per})为 {value},须大于 0。", diff --git a/src/security/Exceptions.i18n.ts b/src/security/Exceptions.i18n.ts new file mode 100644 index 00000000..216d141d --- /dev/null +++ b/src/security/Exceptions.i18n.ts @@ -0,0 +1,76 @@ +import { messages } from "@/i18n"; + +const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; + +/** 密钥与隐私的例外值:规则表下方一节、添加、移出,以及安全日志里「列为例外」 */ +export const exceptionsText = messages( + { + /** 节标题:「例外 · 3 个值」 */ + title: "例外", + count: (n: number) => `${n} 个值`, + /** 没有例外时这一节只有这一句和「添加例外」 */ + none: "无例外值。被误认的值可在安全日志中列为例外,或在此添加。", + add: "添加例外", + /** 满了:「添加例外」置灰,旁边写明原因 */ + full: (max: number) => `已达上限 ${max} 个`, + value: "值", + rule: "首次认出的规则", + /** 确认对话框里的「规则」一行 */ + ruleLabel: "规则", + addedAt: "加入时间", + remove: "移出例外", + removeFor: (masked: string) => `将 ${masked} 移出例外`, + /** 手动加入的值没有规则 */ + noRule: "—", + + addTitle: "添加例外", + addDesc: "此值今后在任何规则下都不替换,也不记录。", + addLabel: "值", + addPlaceholder: "粘贴要列为例外的值", + addFailed: "未能添加", + + exceptTitle: "列为例外", + exceptDesc: "此值今后不再替换,也不再记录。", + exceptFailed: "未能列为例外", + + removeTitle: "移出例外", + removeDesc: "此值今后照常替换并记录。", + removeFailed: "未能移出", + + /** 日志、命中详情里的标签 */ + tag: "例外", + /** 「测试…」里例外的值 */ + trial: "例外,不处理", + }, + { + title: "Exceptions", + count: (n: number) => plural(n, "value", "values"), + none: "No exceptions. A value matched by mistake can be listed as an exception from the security log, or added here.", + add: "Add exception", + full: (max: number) => `Limit of ${max} reached`, + value: "Value", + rule: "First found by", + ruleLabel: "Rule", + addedAt: "Added", + remove: "Remove", + removeFor: (masked: string) => `Remove ${masked} from the exceptions`, + noRule: "—", + + addTitle: "Add exception", + addDesc: "This value is no longer replaced or recorded under any rule.", + addLabel: "Value", + addPlaceholder: "Paste the value to except", + addFailed: "Not added", + + exceptTitle: "List as exception", + exceptDesc: "This value is no longer replaced or recorded.", + exceptFailed: "Not listed as an exception", + + removeTitle: "Remove from exceptions", + removeDesc: "This value is replaced and recorded again.", + removeFailed: "Not removed", + + tag: "Exception", + trial: "Exception, not handled", + }, +); diff --git a/src/security/Exceptions.tsx b/src/security/Exceptions.tsx new file mode 100644 index 00000000..5714d8cd --- /dev/null +++ b/src/security/Exceptions.tsx @@ -0,0 +1,314 @@ +import { useState, type ReactNode } from "react"; +import { PlusIcon } from "lucide-react"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogConfirm, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/ui/alert-dialog"; +import { Badge } from "@/ui/badge"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { Label } from "@/ui/label"; +import { rowMotion, usePresentList } from "@/ui/motion"; +import { PageSection } from "@/ui/page"; +import { SecretInput } from "@/ui/secret-input"; +import { Loadable, TableSkeleton } from "@/ui/states"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; +import { whenMinute } from "@/format"; +import type { Resource } from "@/lib/resource"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import type { RedactException, RedactExceptions, SecurityEventView } from "@/types"; +import { ruleName } from "./labels"; +import { exceptionsText } from "./Exceptions.i18n"; + +/** + * 密钥与隐私的例外值:某个值反复被误认时,只把这个值列为例外,规则照常工作。**例外跨规则**: + * 列入之后这个值在任何规则下都不替换、不记录。 + * + * 界面上只有打码后的样子(core 给的);原值只在「添加例外」的输入框里,交给 core 之后不留。 + */ + +/** 「例外 · N 个值」一节,在规则表下方。没有例外时只有一句和「添加例外」 */ +export function ExceptionsSection({ + r, + onAdd, + onRemove, +}: { + r: Resource; + onAdd: () => void; + onRemove: (item: RedactException) => void; +}) { + const t = useText(exceptionsText); + const data = r.data; + const n = data?.items.length ?? 0; + const full = data !== undefined && n >= data.max; + return ( + + {t.title} + {n > 0 && · {t.count(n)}} + + } + actions={ + data && ( + <> + {full && {t.full(data.max)}} + + + ) + } + > + }> + {(d) => + d.items.length === 0 ? ( +

{t.none}

+ ) : ( + + ) + } +
+
+ ); +} + +/** 例外表:值(打码)、最先认出它的规则、加入时间、「移出例外」。先加的在前,和配置里一样 */ +export function ExceptionTable({ + items, + onRemove, +}: { + items: RedactException[]; + onRemove: (item: RedactException) => void; +}) { + const t = useText(exceptionsText); + const shown = usePresentList(items, (x) => x.sha256); + return ( + + + + + + + + + + {t.value} + {t.rule} + {t.addedAt} + + + + + {shown.map(({ item: x, key, presence }) => ( + + + {x.masked} + + + {x.rule ? ruleName("redact", x.rule) : t.noRule} + + {whenMinute(x.added_at)} + + + + + ))} + +
+ ); +} + +/** + * 跑一个会失败的写操作:失败时对话框留着,把 core 的话显示在里面;成功时由调用方关掉。 + */ +function useRun() { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + async function run(go: () => Promise) { + setBusy(true); + setError(null); + try { + await go(); + } catch (e) { + setError(errorText(e)); + setBusy(false); + } + } + return { busy, error, run }; +} + +/** + * 手动添加一个例外值:还没出现在日志里的测试值。**值只在这个输入框里**,默认隐藏、小眼睛 + * 切换;交给 core 之后对话框关掉,值随之丢掉。 + */ +export function AddExceptionDialog({ onAdd, onClose }: { onAdd: (value: string) => Promise; onClose: () => void }) { + const t = useText(exceptionsText); + const common = useText(commonText); + const [value, setValue] = useState(""); + const { busy, error, run } = useRun(); + const empty = value.trim().length === 0; + const submit = () => { + if (!empty && !busy) void run(() => onAdd(value)); + }; + return ( + !o && !busy && onClose()}> + + + {t.addTitle} + {t.addDesc} + +
{ + e.preventDefault(); + submit(); + }} + > + + setValue(e.target.value)} + autoFocus + /> + + + {error} + + + + + +
+
+ ); +} + +/** 确认对话框里的「值」「规则」两行 */ +function Facts({ rows }: { rows: [string, ReactNode][] }) { + return ( +
+ {rows.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ ); +} + +/** + * 把安全日志的一条列为例外。写明此值今后不再替换、不再记录,给出打码后的值和规则名。 + * 记录太早、无法从日志列入时,core 的话里说了改用「添加例外」,原样显示在对话框里 + */ +export function ExceptDialog({ + e, + onConfirm, + onClose, +}: { + e: SecurityEventView; + onConfirm: () => Promise; + onClose: () => void; +}) { + const t = useText(exceptionsText); + const common = useText(commonText); + const { busy, error, run } = useRun(); + return ( + !o && !busy && onClose()}> + + + {t.exceptTitle} + {t.exceptDesc} + + {e.excerpt}], + [t.ruleLabel, ruleName(e.guard, e.rule, e.custom, e.rule_snapshot.name)], + ]} + /> + + {error} + + + {common.cancel} + void run(onConfirm)}> + {t.exceptTitle} + + + + + ); +} + +/** + * 移出例外。**移出之后不能撤销**:配置里只留着值的哈希,要再列入得重新粘贴原值,所以先确认 + */ +export function RemoveExceptionDialog({ + item, + onRemove, + onClose, +}: { + item: RedactException; + onRemove: () => Promise; + onClose: () => void; +}) { + const t = useText(exceptionsText); + const common = useText(commonText); + const { busy, error, run } = useRun(); + return ( + !o && !busy && onClose()}> + + + {t.removeTitle} + {t.removeDesc} + + {item.masked}], + ...(item.rule ? [[t.ruleLabel, ruleName("redact", item.rule)] as [string, ReactNode]] : []), + ]} + /> + + {error} + + + {common.cancel} + void run(onRemove)}> + {t.remove} + + + + + ); +} + +/** 日志、命中详情里「例外」的标签:这个值此刻是例外 */ +export function ExceptedTag() { + const t = useText(exceptionsText); + return {t.tag}; +} diff --git a/src/security/GuardTab.i18n.tsx b/src/security/GuardTab.i18n.tsx index 9794cadf..7226b949 100644 --- a/src/security/GuardTab.i18n.tsx +++ b/src/security/GuardTab.i18n.tsx @@ -31,9 +31,9 @@ export const guardTabText = messages( scope: "检查对象", /** 规则在「开启」状态下做什么 */ action: "动作", - /** 组标题:「隐藏字符 · 内置 · 已启用 2 条,共 3 条」 */ - builtin: "内置", - groupCount: (on: number, all: number) => `已启用 ${on} 条,共 ${all} 条`, + /** 组标题:「隐藏字符 · 3 条 · 已开 2 条 · 最近 7 天命中 5 次」 */ + groupCount: (all: number, on: number) => `${all} 条 · 已开 ${on} 条`, + groupHits: (n: number) => `最近 7 天命中 ${n} 次`, /** 行内的动作选择,给读屏 */ actionFor: (name: string) => `「${name}」的动作`, /** 动作为「替换」时,行内选项写出换成什么 */ @@ -66,8 +66,8 @@ export const guardTabText = messages( match: "Match", scope: "Checks", action: "Action", - builtin: "Built-in", - groupCount: (on: number, all: number) => `${on} of ${all} on`, + groupCount: (all: number, on: number) => `${plural(all, "rule", "rules")} · ${on} on`, + groupHits: (n: number) => `${plural(n, "match", "matches")} in the last 7 days`, actionFor: (name: string) => `Action for “${name}”`, replaceWith: (text: string) => `Replace with ${text}`, turnOn: "Turn on", diff --git a/src/security/GuardTab.tsx b/src/security/GuardTab.tsx index dd7aefab..6c72e0c6 100644 --- a/src/security/GuardTab.tsx +++ b/src/security/GuardTab.tsx @@ -1,5 +1,5 @@ -import { Fragment, useMemo, useState } from "react"; -import { FlaskConicalIcon, PlusIcon, SearchIcon } from "lucide-react"; +import { Fragment, useMemo, useState, type ReactNode } from "react"; +import { ChevronRightIcon, FlaskConicalIcon, PlusIcon, SearchIcon } from "lucide-react"; import { Badge } from "@/ui/badge"; import { Button } from "@/ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/ui/input-group"; @@ -26,12 +26,20 @@ import { actionAllowed, actionOf, DEFAULT_REPLACEMENT, + groupOpen, groupRules, + hitsFirst, + hitsOf, + readOpenGroups, + ruleKey, ruleMatches, scopeChoice, scopeOf, + writeOpenGroups, + type RuleHits, } from "./rules"; -import { ROW_FOCUS, rowNav, stop } from "./rows"; +import { disclosureNav, ROW_FOCUS, rowNav, stop } from "./rows"; +import { useRuleHits } from "./useSecurityLog"; /** 状态的先后,和规格的示意一致:开启、监控、关闭 */ const MODES: readonly GuardMode[] = ["enforce", "observe", "off"]; @@ -53,9 +61,6 @@ export interface RuleActions { test: () => void; } -/** 规则在表里的键:内置和自定义可以同名 */ -const keyOf = (r: SecurityRuleView) => `${r.custom ? "c" : "b"}:${r.id}`; - /** * 一项防护的状态:开启、监控、关闭,右边一句当前状态下命中之后怎样,以及启用了几条规则。 * 圆点和页签上的同色。 @@ -126,23 +131,28 @@ export function GuardSkeleton() { * 规则的实际处理按这个顺序定(规格 §4.3):防护关闭不检查;规则停用不检查;防护处于 * 「监控」时一律仅记录(动作列照写用户设的动作,后面标「监控中」);其余按规则的动作。 * - * 内置规则按类别分组,组标题只写名字和启用条数、没有开关;自定义 - * 规则在最后一组。检索框按名字和写法筛。每一行的开关、动作就地改;点一行打开它的对话框。 + * 内置规则按类别分组,**各组默认收起**,组标题写名字、条数、启用条数和最近 7 天的命中次数, + * 没有开关;有命中的组排在内置规则的最前面,自定义规则在最后一组。检索框按名字和写法筛, + * 匹配的组自动展开。每一行的开关、动作就地改;点一行打开它的对话框。 */ export function GuardTab({ guard, detail, modePending, actions, + below, }: { guard: Guard; detail: GuardDetail; modePending: boolean; actions: RuleActions; + /** 规则表下方的一节(密钥与隐私:例外) */ + below?: ReactNode; }) { const t = useText(guardTabText); const [query, setQuery] = useState(""); const on = detail.rules.filter((r) => r.enabled).length; + const hits = useRuleHits(guard); return (
@@ -186,8 +196,10 @@ export function GuardTab({ rules={detail.rules} kinds={detail.kinds} query={query} + hits={hits} actions={actions} /> + {below}
); } @@ -226,13 +238,18 @@ function useMenu(actions: RuleActions) { * * **动作一列三种颜色**:拒绝、切断是红的(请求或回答的结局变了),删除、替换、替换为占位符 * 是正文色(改了内容照常发出),仅记录是次要色。 + * + * **各组默认收起。**点组头、在组头上按 Enter / 空格展开或收起(→ 展开、← 收起),记在这台 + * 机器上(`readOpenGroups`);收起的组里的行不画,`↑` `↓` 在组头和展开的行之间走。检索时 + * 匹配的组一律展开,这一次检索里收起的不记。 */ -function RuleTable({ +export function RuleTable({ guard, mode, rules, kinds, query, + hits, actions, }: { guard: Guard; @@ -241,20 +258,47 @@ function RuleTable({ /** 分组和分组名,core 给的(旧的 core 不给) */ kinds: SecurityKindView[] | undefined; query: string; + /** 最近 7 天每条规则的命中次数。还没读到、读不到是 `undefined`:组头不写次数,组照 core 的顺序 */ + hits: RuleHits | undefined; actions: RuleActions; }) { const t = useText(guardTabText); const menu = useMenu(actions); const content = guard === "content"; const span = content ? 6 : 5; + const searching = query.trim().length > 0; + const [open, setOpen] = useState(() => readOpenGroups(guard)); + // 这一次检索里收起的组。换了检索词就不算了 + const [closed, setClosed] = useState<{ query: string; keys: ReadonlySet }>({ query, keys: new Set() }); + const closedInSearch = closed.query === query ? closed.keys : new Set(); + const toggle = (key: string) => { + if (searching) { + const keys = new Set(closedInSearch); + if (!keys.delete(key)) keys.add(key); + setClosed({ query, keys }); + return; + } + const next = new Set(open); + if (!next.delete(key)) next.add(key); + setOpen(next); + writeOpenGroups(guard, next); + }; // 淡入淡出按全部规则算:检索筛掉的行直接不画,不播淡出 - const present = usePresentList(rules, keyOf); + const present = usePresentList(rules, ruleKey); + /* + **组的先后按全部规则的命中定,不按筛出来的**:一边打字一边换位置,眼睛跟不上。 + */ + const hitKinds = useMemo(() => { + const ks = new Set(); + for (const g of groupRules(rules, (r) => r)) if ((hitsOf(hits, g.rows) ?? 0) > 0) ks.add(g.key); + return ks; + }, [rules, hits]); const groups = useMemo(() => { const shown = present.filter( ({ item: r, presence }) => presence === "exit" || ruleMatches(r, viewName(r), query), ); - return groupRules(shown, (p) => p.item); - }, [present, guard, query]); + return hitsFirst(groupRules(shown, (p) => p.item), (g) => hitKinds.has(g.key)); + }, [present, guard, query, hitKinds]); return ( @@ -288,15 +332,18 @@ function RuleTable({ const live = g.rows.filter((p) => p.presence !== "exit").map((p) => p.item); // 分组名由 core 给(`kinds`),按界面语言取 const title = kindName(kinds, g.key); + const expanded = groupOpen(g.key, searching, open, closedInSearch); return ( toggle(g.key)} /> - {g.rows.map(({ item: r, key, presence }) => ( + {expanded && g.rows.map(({ item: r, key, presence }) => ( void; }) { const t = useText(guardTabText); const on = rules.filter((r) => r.enabled).length; return ( - - - {title} - {builtin && ` · ${t.builtin}`} - {` · ${t.groupCount(on, rules.length)}`} + + + + + + {title} + {` · ${t.groupCount(rules.length, on)}`} + {hits != null && hits > 0 && ` · ${t.groupHits(hits)}`} + + ); } +/** 组头的键盘焦点:和规则行一样左边一道竖线(底色本来就深一档,不再加深) */ +const HEAD_FOCUS = + "outline-none focus-visible:bg-surface focus-visible:shadow-[inset_2px_0_0_0_color-mix(in_oklab,var(--foreground)_60%,transparent)]"; + /** 一行规则:点它、按 Enter 打开,右键和行尾是同一份菜单 */ function RuleRow({ guard, diff --git a/src/security/HitDetail.tsx b/src/security/HitDetail.tsx index 13137500..48ba2887 100644 --- a/src/security/HitDetail.tsx +++ b/src/security/HitDetail.tsx @@ -12,6 +12,8 @@ import { useRuleCatalog } from "./catalog"; import { Code, matchingOf, partLabel, snapshotName } from "./labels"; import { securityLabelsText } from "./labels.i18n"; import { hitDetailText } from "./HitDetail.i18n"; +import { ExceptedTag } from "./Exceptions"; +import { exceptionsText } from "./Exceptions.i18n"; /** 一开始列几处。其余的「展开全部」 */ const FIRST = 3; @@ -36,12 +38,16 @@ const LINK = * * `onOpenRequest` 不给就不画「请求」一行(请求详情里就是这一条请求);会话那一行 * 只在这次请求属于某次会话、`onOpenSession` 给了时才有。 + * + * 密钥与隐私的命中:这个值此刻是例外时标「例外」;能列为例外(`exceptable`)、又给了 + * `onExcept` 时,处置一行下面有「列为例外」。不能列的不给按钮,也不解释为什么。 */ export function HitDetail({ e, stacked = false, onOpenRequest, onOpenSession, + onExcept, className, id, }: { @@ -49,11 +55,14 @@ export function HitDetail({ stacked?: boolean; onOpenRequest?: (requestId: number) => void; onOpenSession?: (session: string) => void; + /** 列为例外(打开确认对话框)。不给就没有这个按钮 */ + onExcept?: () => void; className?: string; id?: string; }) { const t = useText(hitDetailText); const lt = useText(securityLabelsText); + const xt = useText(exceptionsText); // 规则的中文名按 id 到规则目录里查(快照里只有英文名) useRuleCatalog(); const where = lt.directions[e.direction]; @@ -78,6 +87,18 @@ export function HitDetail({ + {e.excepted && ( + + + + )} + {onExcept && e.exceptable && ( + + + + )} {onOpenRequest && ( diff --git a/src/security/LogTab.tsx b/src/security/LogTab.tsx index bf7fb0c8..c138f912 100644 --- a/src/security/LogTab.tsx +++ b/src/security/LogTab.tsx @@ -30,6 +30,7 @@ import type { Guard, SecurityDetail, SecurityEventView } from "@/types"; import { api } from "./api"; import { fetchAll, fileName, toCsv, toJson, type ExportFormat } from "./export"; import { HitDetail } from "./HitDetail"; +import { ExceptedTag } from "./Exceptions"; import { ActionBadge, byCodepoints, clock, dayHead, dayKey, EventDetail, ruleName, whereOf } from "./labels"; import { securityLabelsText } from "./labels.i18n"; import { logTabText } from "./LogTab.i18n"; @@ -44,6 +45,8 @@ export interface LogActions { disableRule: (guard: Guard, id: string, custom: boolean) => void; /** 切到某一项防护的标签(空状态里的入口) */ showGuard: (guard: Guard) => void; + /** 把这一条的值列为例外(打开确认对话框)。只给 `exceptable` 的那几条 */ + except: (e: SecurityEventView) => void; } /** @@ -400,6 +403,7 @@ function LogTable({
{name} {e.custom && {lt.custom}} + {e.excepted && } {where && · {where}}
{/* 值只剩头尾:日志截一张图就能带出去 */} @@ -454,6 +458,7 @@ function LogTable({ e={e} onOpenRequest={onOpen} onOpenSession={openSession} + onExcept={() => actions.except(e)} className="sticky left-4 w-[calc(100cqw-2rem)] max-w-[60rem] animate-in fade-in-0 duration-(--motion-fast)" />
diff --git a/src/security/RuleDialog.tsx b/src/security/RuleDialog.tsx index b4f53e67..5eca4df3 100644 --- a/src/security/RuleDialog.tsx +++ b/src/security/RuleDialog.tsx @@ -48,6 +48,7 @@ import { catalogKinds } from "./catalog"; import { kindName, modeName, RuleMatchText, ruleName, ruleWhy, viewName } from "./labels"; import { securityLabelsText } from "./labels.i18n"; import { ruleDialogText } from "./RuleDialog.i18n"; +import { exceptionsText } from "./Exceptions.i18n"; import { actionAllowed, actionOf, @@ -276,9 +277,12 @@ function TrialBox({ action, output = true, refusal, + listExcepted = true, }: { trial: Trial; sample: string; + /** 例外的值逐个写一行「例外,不处理」。「测试…」的命中表里已经写了,不重复 */ + listExcepted?: boolean; /** 标成什么颜色。不给就按每一处自己的动作 */ action?: RuleAction; /** 显示处理后的样子(core 给了的话) */ @@ -287,6 +291,7 @@ function TrialBox({ refusal?: string; }) { const t = useText(ruleDialogText); + const xt = useText(exceptionsText); if (trial.state === "idle") return null; if (trial.state === "failed") { return ( @@ -297,7 +302,9 @@ function TrialBox({ } const done = trial.state === "done" ? trial : null; const hits: SecurityTestHit[] = done?.hits ?? []; - const bad = hits.some((h) => tone(action ?? h.action) === "bad"); + // 例外的值照样认得出,但不处理:不标出来,也不算进颜色 + const handled = hits.filter((h) => !h.excepted); + const bad = handled.some((h) => tone(action ?? h.action) === "bad"); const refused = refusal != null && done?.refused === true; const sent = output && !refused ? (done?.output ?? null) : null; return ( @@ -305,7 +312,7 @@ function TrialBox({

{!done ? : hits.length > 0 ? t.hits(hits.length) : t.noHit} @@ -313,9 +320,17 @@ function TrialBox({ {hits.length > 0 && ( ({ start: h.start, end: h.end, tone: tone(action ?? h.action) }))} + marks={handled.map((h) => ({ start: h.start, end: h.end, tone: tone(action ?? h.action) }))} /> )} + {listExcepted && + hits + .filter((h) => h.excepted) + .map((h, i) => ( +

+ {h.excerpt} · {xt.trial} +

+ ))} {refused &&

{refusal}

} {sent != null && (
@@ -817,6 +832,7 @@ export function TestDialog({ guard, onClose }: { guard: Guard; onClose: () => vo const [sample, setSample] = useState(""); const [side, setSide] = useState("request"); const content = guard === "content"; + const xt = useText(exceptionsText); const trial = useTrial(guard, sample, { scope: content ? side : undefined }); const hits = trial.state === "done" ? trial.hits : []; @@ -835,6 +851,7 @@ export function TestDialog({ guard, onClose }: { guard: Guard; onClose: () => vo {hits.length > 0 && ( @@ -869,9 +886,12 @@ export function TestDialog({ guard, onClose }: { guard: Guard; onClose: () => vo · {t.line(lineOf(sample, h.start))} - {lt.ruleActions[a] ?? a} + {h.excepted ? xt.trial : (lt.ruleActions[a] ?? a)} ); diff --git a/src/security/SecurityPage.tsx b/src/security/SecurityPage.tsx index fc143f71..252f5d63 100644 --- a/src/security/SecurityPage.tsx +++ b/src/security/SecurityPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useResource } from "@/lib/resource"; +import { invalidate, useResource } from "@/lib/resource"; import { AnimatedNumber } from "@/ui/motion"; import { notify, undoable } from "@/ui/notify"; import { Page, PageHeader, SummaryItem } from "@/ui/page"; @@ -11,12 +11,22 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui/tabs"; import { useText } from "@/i18n"; import { GUARDS } from "@/types"; import { api, type RuleSave } from "./api"; -import type { ConfigWritten, Guard, GuardMode, RuleAction, SecurityDetail, SecurityRuleView } from "@/types"; +import type { + Guard, + GuardMode, + RedactException, + RedactExceptions, + RuleAction, + SecurityDetail, + SecurityEventView, + SecurityRuleView, +} from "@/types"; import { actionOf, scopeOf } from "./rules"; import { GuardSkeleton, GuardTab, type RuleActions } from "./GuardTab"; import { modeName, modeTone, OUTCOMES, outcomeTone, viewName } from "./labels"; import { securityLabelsText } from "./labels.i18n"; import { LogTab, type LogActions } from "./LogTab"; +import { AddExceptionDialog, ExceptDialog, ExceptionsSection, RemoveExceptionDialog } from "./Exceptions"; import { BuiltinRuleDialog, DeleteRuleDialog, patternOf, RuleDialog, TestDialog, type RuleSeed } from "./RuleDialog"; import { securityPageText } from "./SecurityPage.i18n"; import { narrowed, useSecurityLog, type SecurityLog } from "./useSecurityLog"; @@ -36,7 +46,10 @@ type DialogState = | { kind: "rule"; guard: Guard; editing: SecurityRuleView | null; seed?: RuleSeed } | { kind: "builtin"; guard: Guard; rule: SecurityRuleView } | { kind: "test"; guard: Guard } - | { kind: "delete"; guard: Guard; rule: SecurityRuleView }; + | { kind: "delete"; guard: Guard; rule: SecurityRuleView } + | { kind: "except"; event: SecurityEventView } + | { kind: "addException" } + | { kind: "removeException"; item: RedactException }; /** 自定义规则现在的样子,改一处(启停、动作)时其余原样带回去 */ function saveOf(guard: Guard, r: SecurityRuleView, enabled: boolean, action = actionOf(guard, r)): RuleSave { @@ -93,6 +106,10 @@ export default function SecurityPage({ const lt = useText(securityLabelsText); const [tab, setTab] = useState("log"); const detail = useResource("security-detail", () => api.detail(), { deps: [configVersion] }); + // 密钥与隐私的例外值:在配置里,配置一变就重读 + const exceptions = useResource("security-exceptions", () => api.exceptions(), { + deps: [configVersion], + }); // 日志的区间放在这一层:页头的命中次数和日志是同一次读取 const [range, setRange] = useRange("tw-security-range", "1d"); /** 日志「类型」列头上筛着的那一项。离开这一页就回到全部 */ @@ -128,7 +145,7 @@ export default function SecurityPage({ version.current = configVersion; }, [configVersion]); const queue = useRef>(Promise.resolve()); - const write = useCallback((run: (base: string) => Promise) => { + const write = useCallback((run: (base: string) => Promise) => { const p = queue.current .then(() => run(version.current)) .then((w) => { @@ -260,7 +277,18 @@ export default function SecurityPage({ test: () => setDialog({ kind: "test", guard }), }); + /** + * 例外变了:例外表、日志里的「例外」标签和「列为例外」按钮都跟着变(日志读的时候按现在的 + * 例外表算),规则表组头的命中次数不变 + */ + const exceptionsChanged = () => { + settle(); + void exceptions.reload(); + invalidate("security-log:"); + }; + const logActions: LogActions = { + except: (event) => setDialog({ kind: "except", event }), viewRule: (guard, id, custom) => { const r = find(guard, id, custom); if (!r) return; @@ -333,7 +361,21 @@ export default function SecurityPage({ } errorTitle={t.loadFailed}> {(data) => ( - + setDialog({ kind: "addException" })} + onRemove={(item) => setDialog({ kind: "removeException", item })} + /> + ) + } + /> )} @@ -368,6 +410,38 @@ export default function SecurityPage({ }} /> )} + {dialog?.kind === "except" && ( + setDialog(null)} + onConfirm={async () => { + await write((base) => api.exceptEvent(dialog.event.id, base)); + setDialog(null); + exceptionsChanged(); + }} + /> + )} + {dialog?.kind === "addException" && ( + setDialog(null)} + onAdd={async (value) => { + await write((base) => api.addException(value, base)); + setDialog(null); + exceptionsChanged(); + }} + /> + )} + {dialog?.kind === "removeException" && ( + setDialog(null)} + onRemove={async () => { + await write((base) => api.removeException(dialog.item.sha256, base)); + setDialog(null); + exceptionsChanged(); + }} + /> + )} {dialog?.kind === "test" && setDialog(null)} />} {dialog?.kind === "delete" && ( call("Security", null), /** 安全日志的一页。`guard` 不给就是全部;`before` 翻页 */ events: (q: SecurityEventsQuery) => call("SecurityEvents", q), + /** 一项防护从某个时刻起每条规则命中了几次(各条记录的 `count` 加起来) */ + counts: (guard: Guard, sinceMs: number) => call("SecurityRuleCounts", { guard, since_ms: sinceMs }), + /** 密钥与隐私的例外值,和最多几个 */ + exceptions: () => call("RedactExceptions", null), + /** 手动加入一个例外值。**原值只在这一个请求里**,不进任何日志、缓存 */ + addException: (value: string, baseVersion: string) => + call("AddRedactException", { value, base_version: baseVersion }), + /** 把安全日志的一条列为例外(按那一条的号) */ + exceptEvent: (id: number, baseVersion: string) => + call("ExceptSecurityEvent", { base_version: baseVersion }, String(id)), + removeException: (sha256: string, baseVersion: string) => + call("RemoveRedactException", { base_version: baseVersion }, sha256), /** * 导出的文件:弹系统的「存储」对话框,`name` 是默认文件名,存到用户选的地方。返回存到的 * 路径;用户取消了是 `null`。**对话框和写文件都在 Rust 这一侧**(`export::save_export`), diff --git a/src/security/exceptions.test.tsx b/src/security/exceptions.test.tsx new file mode 100644 index 00000000..d2b904b2 --- /dev/null +++ b/src/security/exceptions.test.tsx @@ -0,0 +1,153 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setLang } from "@/i18n"; +import type { Resource } from "@/lib/resource"; +import { TooltipRoot } from "@/ui/tip"; +import type { RedactException, RedactExceptions, SecurityEventView } from "@/types"; +import { api } from "./api"; +import { ExceptionsSection } from "./Exceptions"; +import { HitDetail } from "./HitDetail"; + +const call = vi.fn(); +vi.mock("@/control", () => ({ call: (...a: unknown[]) => call(...a) })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +// 命中详情挂着规则目录(取数的钩子在服务端渲染里用不了):这里只看按钮和标签 +vi.mock("./catalog", async (importOriginal) => ({ + ...(await importOriginal()), + useRuleCatalog: () => undefined, +})); + +/** + * 密钥与隐私的例外值(规格 D5):规则表下方「例外 · N 个值」一节,日志命中详情里的 + * 「列为例外」和「例外」标签,以及界面交给 core 的调用。 + */ + +const item = (x: Partial): RedactException => ({ + sha256: "a".repeat(64), + masked: "sk-ant-api03-…Q3xA", + rule: "anthropic-api-key", + added_at: new Date(2026, 9, 29, 9, 5).getTime(), + ...x, +}); + +const resource = (data: RedactExceptions | undefined): Resource => + ({ data, error: undefined, loading: data === undefined, refreshing: false, reload: async () => data, mutate: () => () => {} }) as Resource; + +const section = (data: RedactExceptions | undefined) => + renderToStaticMarkup( + + {}} onRemove={() => {}} /> + , + ); +const plain = (html: string) => html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); + +const event = (x: Partial): SecurityEventView => ({ + id: 7, + at_ms: 0, + request_id: 48123, + guard: "redact", + rule: "anthropic-api-key", + custom: false, + action: "replaced", + provider: "anthropic", + client: "claude-code", + model: "claude-sonnet-5", + excerpt: "sk-ant-api03-…Q3xA", + count: 1, + direction: "request", + locations: [], + more_locations: 0, + rule_snapshot: { builtin: true, id: "anthropic-api-key", name: "Anthropic API key", core_version: "0.86.0" }, + outcome_detail: { action: "replaced", placeholders: ["<>"] }, + excepted: false, + exceptable: false, + ...x, +}); + +beforeEach(() => { + setLang("zh"); + call.mockReset(); +}); + +describe("例外一节", () => { + it("没有例外:一句说明和「添加例外」", () => { + const text = plain(section({ items: [], max: 500 })); + expect(text).toContain("无例外值。"); + expect(text).toContain("添加例外"); + expect(text).not.toContain("个值"); + expect(text).not.toContain("首次认出的规则"); + }); + + it("有例外:标题写个数,表里是掩码后的值、规则名、加入时间和「移出例外」;手动加入的没有规则", () => { + const html = section({ items: [item({}), item({ sha256: "b".repeat(64), masked: "test…1234", rule: undefined })], max: 500 }); + const text = plain(html); + expect(text).toContain("例外 · 2 个值"); + expect(html.match(/data-slot="exception"/g)).toHaveLength(2); + expect(text).toContain("sk-ant-api03-…Q3xA"); + // 规则目录没取到时写 id + expect(text).toContain("anthropic-api-key"); + expect(text).toContain("test…1234 —"); + expect(text).toContain("移出例外"); + // 只有掩码后的值,没有哈希 + expect(html).not.toContain("a".repeat(64)); + }); + + it("满了:「添加例外」置灰,旁边写明原因", () => { + const html = section({ items: [item({})], max: 1 }); + expect(plain(html)).toContain("已达上限 1 个"); + expect(html).toMatch(/]*disabled=""[^>]*>.*?添加例外/); + }); + + it("尚未读到:不画按钮", () => { + expect(plain(section(undefined))).not.toContain("添加例外"); + }); + + it("英文界面", () => { + setLang("en"); + const text = plain(section({ items: [item({})], max: 500 })); + expect(text).toContain("Exceptions · 1 value"); + expect(text).toContain("First found by"); + expect(text).toContain("Remove"); + }); +}); + +describe("命中详情", () => { + const detail = (e: SecurityEventView, onExcept?: () => void) => + plain( + renderToStaticMarkup( + + + , + ), + ); + + it("能列为例外的才有「列为例外」", () => { + expect(detail(event({ exceptable: true }), () => {})).toContain("列为例外"); + expect(detail(event({ exceptable: false }), () => {})).not.toContain("列为例外"); + // 请求详情里不给入口 + expect(detail(event({ exceptable: true }))).not.toContain("列为例外"); + }); + + it("此刻是例外的标「例外」", () => { + expect(detail(event({ excepted: true }))).toMatch(/例外/); + expect(detail(event({}))).not.toMatch(/例外/); + }); +}); + +describe("交给 core 的调用", () => { + it("命中次数、例外的列出、加入、从日志列入、移出", async () => { + call.mockResolvedValue(null); + await api.counts("redact", 1000); + await api.exceptions(); + await api.addException("sk-test-value", "v1"); + await api.exceptEvent(42, "v2"); + await api.removeException("c".repeat(64), "v3"); + expect(call.mock.calls).toEqual([ + ["SecurityRuleCounts", { guard: "redact", since_ms: 1000 }], + ["RedactExceptions", null], + ["AddRedactException", { value: "sk-test-value", base_version: "v1" }], + ["ExceptSecurityEvent", { base_version: "v2" }, "42"], + ["RemoveRedactException", { base_version: "v3" }, "c".repeat(64)], + ]); + }); +}); diff --git a/src/security/export.test.ts b/src/security/export.test.ts index 76c080a5..a74835bf 100644 --- a/src/security/export.test.ts +++ b/src/security/export.test.ts @@ -67,6 +67,8 @@ const hit = (x: Partial): SecurityEventView => ({ more_locations: 0, rule_snapshot: { builtin: true, id: "anthropic-api-key", name: "Anthropic API key", core_version: "0.68.0" }, outcome_detail: { action: "replaced", placeholders: ["<>"] }, + excepted: false, + exceptable: false, ...x, }); diff --git a/src/security/groups.test.tsx b/src/security/groups.test.tsx new file mode 100644 index 00000000..59d7252b --- /dev/null +++ b/src/security/groups.test.tsx @@ -0,0 +1,256 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setLang } from "@/i18n"; +import { TooltipRoot } from "@/ui/tip"; +import type { SecurityKindView, SecurityRuleHits, SecurityRuleView } from "@/types"; +import { RuleTable, type RuleActions } from "./GuardTab"; +import { disclosureNav } from "./rows"; +import { + countHits, + groupOpen, + groupRules, + hitsFirst, + hitsOf, + readOpenGroups, + writeOpenGroups, + type RuleHits, +} from "./rules"; + +/** + * 规则表的分组(规格 D6):默认收起,组头写条数、启用条数和最近 7 天的命中次数,有命中的组 + * 排在内置规则最前,检索时匹配的组展开,组头用键盘展开收起。 + */ + +const rule = (x: Partial): SecurityRuleView => ({ + id: "r", + custom: false, + name: "Rule", + kind: "ai-services", + matcher: { kind: "prefix", prefix: "sk-", min_tail: 20 }, + enabled: true, + on_by_default: true, + ...x, +}); + +const RULES = [ + rule({ id: "anthropic-api-key", name: "Anthropic API key", name_zh: "Anthropic API 密钥", kind: "ai-services" }), + rule({ id: "openai-api-key", name: "OpenAI API key", name_zh: "OpenAI API 密钥", kind: "ai-services", enabled: false }), + rule({ id: "github-token", name: "GitHub token", name_zh: "GitHub 令牌", kind: "dev-platforms" }), + rule({ id: "passport", name: "Passport number", name_zh: "护照号码", kind: "id-documents" }), + rule({ id: "客户编号", name: "客户编号", custom: true, kind: "custom" }), +]; + +const KINDS: SecurityKindView[] = [ + { slug: "ai-services", name: "AI services", name_zh: "AI 服务" }, + { slug: "dev-platforms", name: "Developer platforms", name_zh: "开发平台" }, + { slug: "id-documents", name: "ID documents", name_zh: "证件" }, + { slug: "custom", name: "Custom", name_zh: "自定义" }, +]; + +const hit = (rule: string, count: number, custom = false): SecurityRuleHits => ({ rule, custom, count }); + +const actions: RuleActions = { + mode: () => {}, + toggle: () => {}, + setAction: () => {}, + pending: () => false, + open: () => {}, + remove: () => {}, + create: () => {}, + test: () => {}, +}; + +/** 一张内存里的 localStorage:测试环境没有 window */ +function fakeStorage() { + const m = new Map(); + vi.stubGlobal("window", { + localStorage: { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => void m.set(k, v), + }, + }); + return m; +} + +afterEach(() => vi.unstubAllGlobals()); +beforeEach(() => setLang("zh")); + +function table(query = "", hits?: RuleHits) { + return renderToStaticMarkup( + + + , + ); +} + +/** 组头一行一个,按先后:「AI 服务 · 2 条 · 已开 1 条」 */ +const heads = (html: string) => + [...html.matchAll(/data-slot="rule-group"[^>]*>(.*?)<\/tr>/g)].map((m) => m[1]!.replace(/<[^>]+>/g, "")); +/** 画出来的规则行(不含组头)有几行 */ +const ruleRows = (html: string) => (html.match(/data-row-nav=""/g)?.length ?? 0) - heads(html).length; + +describe("命中次数", () => { + it("按规则取 core 数好的次数;内置和自定义同名分开算", () => { + const hits = countHits([ + hit("anthropic-api-key", 2), + hit("客户编号", 1, true), + hit("客户编号", 4), + ]); + expect(hits).toEqual({ "b:anthropic-api-key": 2, "c:客户编号": 1, "b:客户编号": 4 }); + expect(hitsOf(hits, RULES.slice(0, 2))).toBe(2); + expect(hitsOf(hits, [RULES[4]!])).toBe(1); + expect(hitsOf(hits, [RULES[2]!])).toBe(0); + expect(hitsOf(undefined, RULES)).toBeUndefined(); + }); + + it("组头写条数、启用条数和命中次数;没有命中的不写次数", () => { + const html = table("", countHits([hit("github-token", 2)])); + expect(heads(html)).toEqual([ + "开发平台 · 1 条 · 已开 1 条 · 最近 7 天命中 2 次", + "AI 服务 · 2 条 · 已开 1 条", + "证件 · 1 条 · 已开 1 条", + "自定义 · 1 条 · 已开 1 条", + ]); + }); + + it("英文界面同一句", () => { + setLang("en"); + expect(heads(table("", countHits([hit("passport", 3)])))[0]).toBe( + "ID documents · 1 rule · 1 on · 3 matches in the last 7 days", + ); + expect(heads(table("", countHits([hit("passport", 1)])))[0]).toBe( + "ID documents · 1 rule · 1 on · 1 match in the last 7 days", + ); + }); +}); + +describe("组的先后", () => { + it("有命中的内置组排在最前,两部分各自照 core 的顺序,自定义的在最后", () => { + const groups = groupRules( + [ + rule({ id: "a", kind: "k1" }), + rule({ id: "b", kind: "k2" }), + rule({ id: "c", kind: "k3" }), + rule({ id: "d", kind: "k4" }), + rule({ id: "e", custom: true, kind: "custom" }), + ], + (r) => r, + ); + const hit = new Set(["k3", "k2", "custom"]); + expect(hitsFirst(groups, (g) => hit.has(g.key)).map((g) => g.key)).toEqual(["k2", "k3", "k1", "k4", "custom"]); + }); + + it("次数尚未读到:照 core 的顺序", () => { + expect(heads(table()).map((h) => h.split(" · ")[0])).toEqual(["AI 服务", "开发平台", "证件", "自定义"]); + }); + + it("检索时照样有命中的排在前面", () => { + // 「p」筛出 AI 服务的两条(api)和证件(passport) + const hits = countHits([hit("passport", 1)]); + expect(heads(table("p", hits)).map((h) => h.split(" · ")[0])).toEqual(["证件", "AI 服务"]); + expect(heads(table("", hits)).map((h) => h.split(" · ")[0])).toEqual(["证件", "AI 服务", "开发平台", "自定义"]); + }); +}); + +describe("展开与收起", () => { + it("默认全部收起:只画组头", () => { + fakeStorage(); + const html = table(); + expect(heads(html)).toHaveLength(4); + expect(ruleRows(html)).toBe(0); + expect(html.match(/aria-expanded="false"/g)).toHaveLength(4); + }); + + it("记下的展开着的组按防护各一份", () => { + const m = fakeStorage(); + writeOpenGroups("redact", new Set(["ai-services"])); + expect(m.get("security.groups.redact")).toBe('["ai-services"]'); + expect(readOpenGroups("redact")).toEqual(new Set(["ai-services"])); + expect(readOpenGroups("content")).toEqual(new Set()); + const html = table(); + expect(ruleRows(html)).toBe(2); + expect(html).toContain("Anthropic API 密钥"); + expect(html).not.toContain("GitHub 令牌"); + }); + + it("存的东西坏了、或者读不到:照默认收起", () => { + const m = fakeStorage(); + m.set("security.groups.redact", "{oops"); + expect(readOpenGroups("redact")).toEqual(new Set()); + m.set("security.groups.redact", '{"a":1}'); + expect(readOpenGroups("redact")).toEqual(new Set()); + vi.unstubAllGlobals(); + // 没有 window(隐私窗口里 localStorage 会抛错,一样) + expect(readOpenGroups("redact")).toEqual(new Set()); + expect(() => writeOpenGroups("redact", new Set(["x"]))).not.toThrow(); + }); + + it("检索时匹配的组自动展开,筛掉的行不画", () => { + fakeStorage(); + const html = table("令牌"); + expect(heads(html)).toEqual(["开发平台 · 1 条 · 已开 1 条"]); + expect(html).toContain('aria-expanded="true"'); + expect(ruleRows(html)).toBe(1); + expect(html).toContain("GitHub 令牌"); + }); + + it("检索时收起的组只在这一次检索里算;不检索时按记下的", () => { + const open = new Set(["a"]); + const closed = new Set(["b"]); + expect(groupOpen("a", false, open, closed)).toBe(true); + expect(groupOpen("b", false, open, closed)).toBe(false); + expect(groupOpen("b", true, open, closed)).toBe(false); + expect(groupOpen("c", true, open, closed)).toBe(true); + }); +}); + +describe("组头的键盘", () => { + /** 一次按键:组头自己收到的(不是行里的控件) */ + function press(props: ReturnType, key: string, rows: object[] = []) { + const self = { focus: vi.fn(), closest: () => ({ querySelectorAll: () => rows }) }; + const e = { key, target: self, currentTarget: self, preventDefault: vi.fn() }; + props.onKeyDown(e as never); + return { self, e }; + } + + it("Enter、空格展开收起;→ 只展开、← 只收起", () => { + const toggle = vi.fn(); + const closed = disclosureNav(false, toggle); + expect(closed["aria-expanded"]).toBe(false); + expect(closed["data-row-nav"]).toBe(""); + expect(closed.tabIndex).toBe(0); + press(closed, "Enter"); + press(closed, " "); + press(closed, "ArrowRight"); + press(closed, "ArrowLeft"); + expect(toggle).toHaveBeenCalledTimes(3); + toggle.mockClear(); + const open = disclosureNav(true, toggle); + press(open, "ArrowRight"); + expect(toggle).not.toHaveBeenCalled(); + press(open, "ArrowLeft"); + expect(toggle).toHaveBeenCalledTimes(1); + }); + + it("↑ ↓ 在组头和画出来的行之间走:收起的组里的行不在其中", () => { + const toggle = vi.fn(); + const props = disclosureNav(false, toggle); + const next = { focus: vi.fn() }; + const self = { focus: vi.fn(), closest: () => ({ querySelectorAll: () => [self, next] }) }; + const e = { key: "ArrowDown", target: self, currentTarget: self, preventDefault: vi.fn() }; + props.onKeyDown(e as never); + expect(next.focus).toHaveBeenCalled(); + expect(toggle).not.toHaveBeenCalled(); + // 收起时表里能走到的只有四个组头 + fakeStorage(); + expect(table().match(/data-row-nav=""/g)).toHaveLength(4); + }); + + it("行里的控件自己处理按键", () => { + const toggle = vi.fn(); + const props = disclosureNav(false, toggle); + const e = { key: "Enter", target: {}, currentTarget: {}, preventDefault: vi.fn() }; + props.onKeyDown(e as never); + expect(toggle).not.toHaveBeenCalled(); + }); +}); diff --git a/src/security/labels.test.ts b/src/security/labels.test.ts index e2c1c86f..2363a367 100644 --- a/src/security/labels.test.ts +++ b/src/security/labels.test.ts @@ -103,6 +103,8 @@ const hit = (x: Partial): SecurityEventView => ({ more_locations: 0, rule_snapshot: { builtin: true, id: "unicode-tags", name: "Unicode tags", matching: "codepoints", core_version: "0.68.0" }, outcome_detail: { action: "stripped", segments: 1 }, + excepted: false, + exceptable: false, ...x, }); diff --git a/src/security/marks.test.ts b/src/security/marks.test.ts index 0f79f8a7..c1b8c625 100644 --- a/src/security/marks.test.ts +++ b/src/security/marks.test.ts @@ -20,6 +20,8 @@ const ev = (x: Partial): SecurityEventView => ({ more_locations: 0, rule_snapshot: { builtin: true, id: "anthropic-api-key", name: "Anthropic API key", core_version: "0.68.0" }, outcome_detail: { action: "recorded" }, + excepted: false, + exceptable: false, ...x, }); diff --git a/src/security/needles.test.ts b/src/security/needles.test.ts index 0f94acbd..537f4833 100644 --- a/src/security/needles.test.ts +++ b/src/security/needles.test.ts @@ -30,6 +30,8 @@ const hit = (x: Partial): SecurityEventView => ({ more_locations: 0, rule_snapshot: { builtin: true, id: "aws-access-key-id", name: "AWS access key", core_version: "0.68.0" }, outcome_detail: { action: "recorded" }, + excepted: false, + exceptable: false, ...x, }); diff --git a/src/security/redact.test.tsx b/src/security/redact.test.tsx index a9f3b5d5..28e1a159 100644 --- a/src/security/redact.test.tsx +++ b/src/security/redact.test.tsx @@ -206,6 +206,8 @@ const hit = (x: Partial): SecurityEventView => ({ more_locations: 0, rule_snapshot: { builtin: true, id: "thinkwatch-saved-secret", name: "Credentials saved in ThinkWatch", core_version: "0.85.0" }, outcome_detail: { action: "replaced", placeholders: ["<>"] }, + excepted: false, + exceptable: false, ...x, }); diff --git a/src/security/rows.tsx b/src/security/rows.tsx index a615726b..b640bb59 100644 --- a/src/security/rows.tsx +++ b/src/security/rows.tsx @@ -5,7 +5,8 @@ import { TableCell, TableRow } from "@/ui/table"; * 表格行的共同做法:安全页的日志和规则表、MCP 页的几张表都用。 * * · 点一行打开它的详情(有详情的那几张表); - * · 行能用 Tab 聚焦,`↑` `↓` 在行之间走(跳过组头),`Enter` / 空格打开; + * · 行能用 Tab 聚焦,`↑` `↓` 在行之间走,`Enter` / 空格打开。日志的日期组头(`GroupRow`)不在其中; + * 规则表能展开收起的组头在其中,`Enter` / 空格展开收起,收起的组里的行不画、也就走不到; * · 键盘聚焦的那一行左边一道竖线、底色深一档,鼠标点的不画(`focus-visible`)。 */ @@ -38,6 +39,26 @@ export function rowNav(open?: () => void) { }; } +/** + * 能展开收起的组头:和 `rowNav` 一样能走到、`↑` `↓` 接着走,Enter / 空格展开或收起; + * 另外 `→` 展开、`←` 收起(已经是那样就不动) + */ +export function disclosureNav(open: boolean, toggle: () => void) { + const nav = rowNav(toggle); + return { + ...nav, + "aria-expanded": open, + onKeyDown: (e: KeyboardEvent) => { + if (e.target === e.currentTarget && (e.key === "ArrowRight" || e.key === "ArrowLeft")) { + e.preventDefault(); + if ((e.key === "ArrowRight") !== open) toggle(); + return; + } + nav.onKeyDown(e); + }, + }; +} + /** 行里的开关、按钮那一格:点它不算点这一行 */ export const stop = { onClick: (e: { stopPropagation: () => void }) => e.stopPropagation() }; diff --git a/src/security/rules.ts b/src/security/rules.ts index 31bc9ff2..aac59b26 100644 --- a/src/security/rules.ts +++ b/src/security/rules.ts @@ -2,7 +2,15 @@ * 规则表和规则对话框里不画东西的那部分:分组、检索、内容规则 * 「检查对象」和「动作」「匹配方式」的联动。拆出来是为了能单独测。 */ -import type { ContentMatch, ContentScope, Guard, OutcomeDetail, RuleAction, SecurityRuleView } from "@/types"; +import type { + ContentMatch, + ContentScope, + Guard, + OutcomeDetail, + RuleAction, + SecurityRuleHits, + SecurityRuleView, +} from "@/types"; /** 规则表里的一组:内置规则按类别各一组,自定义规则在最后一组 */ export interface RuleGroup { @@ -39,6 +47,82 @@ export function groupRules(rows: readonly T[], ruleOf: (row: T) => SecurityRu return out; } +/** 规则的键:内置和自定义可以同名。安全日志的一条按同样的写法认出是哪条规则 */ +export const ruleKey = (r: Pick) => `${r.custom ? "c" : "b"}:${r.id}`; + +// ------------------------------------------------------------------ 组头的命中次数 + +/** 一段时间里每条规则命中了几次(键见 `ruleKey`),core 按规则数好的 */ +export type RuleHits = Record; + +export function countHits(rows: readonly Pick[]): RuleHits { + const counts: RuleHits = {}; + for (const h of rows) { + const k = ruleKey({ custom: h.custom, id: h.rule }); + counts[k] = (counts[k] ?? 0) + h.count; + } + return counts; +} + +/** 一组规则一共几条命中。还没读到是 `undefined` */ +export function hitsOf(hits: RuleHits | undefined, rules: readonly SecurityRuleView[]): number | undefined { + if (!hits) return undefined; + return rules.reduce((n, r) => n + (hits[ruleKey(r)] ?? 0), 0); +} + +/** + * 有命中的组排在内置规则的最前面,其余照 core 的顺序(两部分里各自不变);自定义的那一组 + * 照旧在最后。命中次数还没读到时原样不动 + */ +export function hitsFirst(groups: readonly RuleGroup[], hit: (g: RuleGroup) => boolean): RuleGroup[] { + const builtin = groups.filter((g) => !g.custom); + return [ + ...builtin.filter(hit), + ...builtin.filter((g) => !hit(g)), + ...groups.filter((g) => g.custom), + ]; +} + +// ------------------------------------------------------------------ 组的展开 + +/** + * 规则表里哪几组展开着。**默认全部收起**;展开、收起是看的人自己的选择,按防护各记一份、 + * 记在这台机器上。存的是展开着的那几组 + */ +const openKey = (guard: Guard) => `security.groups.${guard}`; + +export function readOpenGroups(guard: Guard): Set { + try { + const raw = window.localStorage.getItem(openKey(guard)); + const keys: unknown = raw ? JSON.parse(raw) : []; + return new Set(Array.isArray(keys) ? keys.filter((k): k is string => typeof k === "string") : []); + } catch { + // 隐私窗口、禁用站点数据、存的东西坏了:照默认全部收起 + return new Set(); + } +} + +export function writeOpenGroups(guard: Guard, open: ReadonlySet) { + try { + window.localStorage.setItem(openKey(guard), JSON.stringify([...open])); + } catch { + // 记不住只是下次要再展开一次 + } +} + +/** + * 一组现在展开没有。**检索时匹配的组一律展开**(筛出来的行要看得见),除非这一次检索里 + * 又被收起了(`closedInSearch`,换了检索词就清掉);不检索时按记下的 + */ +export function groupOpen( + key: string, + searching: boolean, + open: ReadonlySet, + closedInSearch: ReadonlySet, +): boolean { + return searching ? !closedInSearch.has(key) : open.has(key); +} + /** * 一条规则写的是什么,拿来检索:正则、要找的文字、码位、前缀、域名后缀、变量名和字段名。代码里做的检查 * 和没有写法的内置规则(PEM、JWT…)是空的,只按名字找得到 diff --git a/src/security/useSecurityLog.ts b/src/security/useSecurityLog.ts index 18810e68..9e0e758e 100644 --- a/src/security/useSecurityLog.ts +++ b/src/security/useSecurityLog.ts @@ -1,9 +1,10 @@ import { useRef } from "react"; import { useResource, type Resource } from "@/lib/resource"; import { usePending } from "@/ui/notify"; -import { windowStart, type Range } from "@/ui/range"; +import { presetRange, windowStart, type Range } from "@/ui/range"; import type { Guard, SecurityEventsPage } from "@/types"; import { api } from "./api"; +import { countHits, type RuleHits } from "./rules"; /** 一次读多少条。**一屏半** —— 再多就是替用户翻他不会看的那几页 */ const PAGE = 100; @@ -83,3 +84,16 @@ export function narrowed(only: SecurityLog, all: SecurityLog, guard: Guard): Sec const events = page.events.filter((e) => e.guard === guard); return { ...only, r: { ...only.r, data: { ...page, events }, loading: false } }; } + +/** + * 规则表组头上「最近 7 天命中 N 次」:这一项最近 7 天每条规则的命中次数,core 按规则数好的。 + * 时间窗和日志选「7 天」时同一个起点(`windowStart`)。 + * + * **只在打开这一项的页签时读,不跟着新请求重读**:组的先后按命中排,看着的时候一条新命中 + * 把组换了位置,比数字晚一会儿更糟。切走再回来就是新的。 + */ +export function useRuleHits(guard: Guard): RuleHits | undefined { + return useResource(`security-hits:${guard}`, async () => + countHits(await api.counts(guard, windowStart(presetRange("7d")))), + ).data; +}