From cbe1482249b9949a147b568700fe61146d12721f Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:58:20 +0800 Subject: [PATCH 1/2] Security rules: groups start collapsed, heads show 7-day matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule tables (secrets & privacy, dangerous commands, content filter) now start with every group collapsed. A group head reads "name · N rules · N on · N matches in the last 7 days" and toggles on click, Enter or Space (Right expands, Left collapses); Up/Down move between heads and the rows of expanded groups. Which groups are open is remembered per protection in localStorage. Searching expands every matching group; a group collapsed during a search stays collapsed only for that search term. Built-in groups with matches in the last 7 days move to the front, each part keeping core's order; the custom group stays last. Core has no per-rule counts, so the counts come from one page (up to 500) of the protection's security log over the same 7-day window the log uses; when the window holds more than one page the count is a lower bound and is written as "N+". The page is read when the tab opens, not on every new request, so groups do not jump while being read. Groups without matches show no count. The "Built-in" label in group heads is dropped (the head format follows the spec). Co-Authored-By: Claude Opus 5.5 --- src/security/GuardTab.i18n.tsx | 11 +- src/security/GuardTab.tsx | 115 ++++++++++++--- src/security/groups.test.tsx | 260 +++++++++++++++++++++++++++++++++ src/security/rows.tsx | 23 ++- src/security/rules.ts | 94 +++++++++++- src/security/useSecurityLog.ts | 19 ++- 6 files changed, 492 insertions(+), 30 deletions(-) create mode 100644 src/security/groups.test.tsx diff --git a/src/security/GuardTab.i18n.tsx b/src/security/GuardTab.i18n.tsx index 9794cadf..a45366e8 100644 --- a/src/security/GuardTab.i18n.tsx +++ b/src/security/GuardTab.i18n.tsx @@ -31,9 +31,10 @@ 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} 条`, + /** 7 天里的日志超过一页时数的是下限,`n` 写成「120+」 */ + groupHits: (n: string) => `最近 7 天命中 ${n} 次`, /** 行内的动作选择,给读屏 */ actionFor: (name: string) => `「${name}」的动作`, /** 动作为「替换」时,行内选项写出换成什么 */ @@ -66,8 +67,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: string) => `${n} ${n === "1" ? "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..65ac28e4 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 { 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,8 +131,9 @@ export function GuardSkeleton() { * 规则的实际处理按这个顺序定(规格 §4.3):防护关闭不检查;规则停用不检查;防护处于 * 「监控」时一律仅记录(动作列照写用户设的动作,后面标「监控中」);其余按规则的动作。 * - * 内置规则按类别分组,组标题只写名字和启用条数、没有开关;自定义 - * 规则在最后一组。检索框按名字和写法筛。每一行的开关、动作就地改;点一行打开它的对话框。 + * 内置规则按类别分组,**各组默认收起**,组标题写名字、条数、启用条数和最近 7 天的命中次数, + * 没有开关;有命中的组排在内置规则的最前面,自定义规则在最后一组。检索框按名字和写法筛, + * 匹配的组自动展开。每一行的开关、动作就地改;点一行打开它的对话框。 */ export function GuardTab({ guard, @@ -143,6 +149,7 @@ export function GuardTab({ const t = useText(guardTabText); const [query, setQuery] = useState(""); const on = detail.rules.filter((r) => r.enabled).length; + const hits = useRuleHits(guard); return (
@@ -186,6 +193,7 @@ export function GuardTab({ rules={detail.rules} kinds={detail.kinds} query={query} + hits={hits} actions={actions} />
@@ -226,13 +234,18 @@ function useMenu(actions: RuleActions) { * * **动作一列三种颜色**:拒绝、切断是红的(请求或回答的结局变了),删除、替换、替换为占位符 * 是正文色(改了内容照常发出),仅记录是次要色。 + * + * **各组默认收起。**点组头、在组头上按 Enter / 空格展开或收起(→ 展开、← 收起),记在这台 + * 机器上(`readOpenGroups`);收起的组里的行不画,`↑` `↓` 在组头和展开的行之间走。检索时 + * 匹配的组一律展开,这一次检索里收起的不记。 */ -function RuleTable({ +export function RuleTable({ guard, mode, rules, kinds, query, + hits, actions, }: { guard: Guard; @@ -241,20 +254,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 +328,19 @@ 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(complete ? String(hits) : `${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/groups.test.tsx b/src/security/groups.test.tsx new file mode 100644 index 00000000..51b61035 --- /dev/null +++ b/src/security/groups.test.tsx @@ -0,0 +1,260 @@ +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 { SecurityEventView, SecurityKindView, 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 event = (rule: string, custom = false): SecurityEventView => + ({ id: 1, at_ms: 0, request_id: 1, guard: "redact", rule, custom }) as SecurityEventView; + +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("按规则数安全日志的条数;内置和自定义同名分开算", () => { + const hits = countHits({ + events: [event("anthropic-api-key"), event("anthropic-api-key"), event("客户编号", true), event("客户编号")], + more: false, + }); + expect(hits).toEqual({ counts: { "b:anthropic-api-key": 2, "c:客户编号": 1, "b:客户编号": 1 }, complete: true }); + 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("日志还有下一页:数的是下限", () => { + expect(countHits({ events: [event("passport")], more: true }).complete).toBe(false); + }); + + it("组头写条数、启用条数和命中次数;没有命中的不写次数", () => { + const html = table("", countHits({ events: [event("github-token"), event("github-token")], more: false })); + expect(heads(html)).toEqual([ + "开发平台 · 1 条 · 已开 1 条 · 最近 7 天命中 2 次", + "AI 服务 · 2 条 · 已开 1 条", + "证件 · 1 条 · 已开 1 条", + "自定义 · 1 条 · 已开 1 条", + ]); + }); + + it("不全时写成「N+」;英文界面同一句", () => { + const hits = countHits({ events: [event("passport")], more: true }); + expect(heads(table("", hits))[0]).toBe("证件 · 1 条 · 已开 1 条 · 最近 7 天命中 1+ 次"); + setLang("en"); + expect(heads(table("", hits))[0]).toBe("ID documents · 1 rule · 1 on · 1+ matches in the last 7 days"); + expect(heads(table("", countHits({ events: [event("passport")], more: false })))[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({ events: [event("passport")], more: false }); + 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/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..e68541c1 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, + SecurityEventsPage, + SecurityRuleView, +} from "@/types"; /** 规则表里的一组:内置规则按类别各一组,自定义规则在最后一组 */ export interface RuleGroup { @@ -39,6 +47,90 @@ 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 不按规则数**:这是安全日志最新的一页里数出来的。`complete` 说这一页是不是 + * 这段时间的全部 —— 不是的话每个数都只是下限,界面写成「N+」。 + */ +export interface RuleHits { + counts: Record; + complete: boolean; +} + +export function countHits(page: Pick): RuleHits { + const counts: Record = {}; + for (const e of page.events) { + const k = ruleKey({ custom: e.custom, id: e.rule }); + counts[k] = (counts[k] ?? 0) + 1; + } + return { counts, complete: !page.more }; +} + +/** 一组规则一共几条命中。还没读到是 `undefined` */ +export function hitsOf(hits: RuleHits | undefined, rules: readonly SecurityRuleView[]): number | undefined { + if (!hits) return undefined; + return rules.reduce((n, r) => n + (hits.counts[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..470dd291 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,19 @@ 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 天的安全日志,读一页(最多 + * `MAX_PAGE` 条)按规则数。**core 没有按规则的条数**,只能这样数;7 天里超过一页时数是 + * 下限(`RuleHits.complete`)。缓存里只留数出来的条数,不留整页日志。 + * + * 时间窗和日志选「7 天」时同一个起点(`windowStart`):日志按类型筛、选 7 天,条数对得上。 + * + * **只在打开这一项的页签时读,不跟着新请求重读**:组的先后按命中排,看着的时候一条新命中 + * 把组换了位置,比数字晚一会儿更糟。切走再回来就是新的。 + */ +export function useRuleHits(guard: Guard): RuleHits | undefined { + return useResource(`security-hits:${guard}`, async () => + countHits(await api.events({ guard, from_ms: windowStart(presetRange("7d")), limit: MAX_PAGE })), + ).data; +} From 88f2382fab68ae1299a79ee05feadc9b7da6e580 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:26:15 +0800 Subject: [PATCH 2/2] Pin core v0.86.0: exact rule hit counts, redaction exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core v0.86.0 (control API 57). - Group heads take their 7-day counts from GET /security/counts: exact per-rule totals (the sum of each entry's count), so the one-page log read and the "N+" lower bound are gone. - Redaction exceptions. Below the secrets & privacy rule table, a section "Exceptions · N values" lists each excepted value (masked), the rule that first found it (none for values added by hand), when it was added, and "Remove". "Add exception" opens a dialog with a hidden input and the eye toggle; it is disabled at the limit, with the limit written next to it. Without exceptions the section is one line and the button. Removing asks for confirmation (it cannot be undone without the value). - Security log: an expanded redaction hit offers "List as exception" when core marks it exceptable; the confirmation shows the masked value and the rule. Values that are exceptions now carry an "Exception" tag in the log row and the hit detail. Core's refusal (entry too old, saved secret, limit reached) is shown in the dialog. - Test: excepted values are not highlighted and read "Exception, not handled". - New endpoints are on the webview allow-list (both sides); Chinese sentences for the new message codes; regenerated bindings and screenshot fixtures; screenshot mocks answer the new endpoints. Co-Authored-By: Claude Opus 5.5 --- scripts/shots/core/en/status.json | 4 +- scripts/shots/core/zh/status.json | 4 +- scripts/shots/mock/core.ts | 16 ++ scripts/shots/mock/traffic.ts | 9 +- src-tauri/Cargo.lock | 40 ++-- src-tauri/Cargo.toml | 12 +- src-tauri/src/call.rs | 5 + src-tauri/src/notices/tests.rs | 2 + src/control.ts | 5 + src/generated/tw-api.ts | 113 ++++++++++- src/i18n/core.zh.json | 9 + src/security/Exceptions.i18n.ts | 76 ++++++++ src/security/Exceptions.tsx | 314 ++++++++++++++++++++++++++++++ src/security/GuardTab.i18n.tsx | 5 +- src/security/GuardTab.tsx | 12 +- src/security/HitDetail.tsx | 21 ++ src/security/LogTab.tsx | 5 + src/security/RuleDialog.tsx | 30 ++- src/security/SecurityPage.tsx | 84 +++++++- src/security/api.ts | 12 ++ src/security/exceptions.test.tsx | 153 +++++++++++++++ src/security/export.test.ts | 2 + src/security/groups.test.tsx | 36 ++-- src/security/labels.test.ts | 2 + src/security/marks.test.ts | 2 + src/security/needles.test.ts | 2 + src/security/redact.test.tsx | 2 + src/security/rules.ts | 28 +-- src/security/useSecurityLog.ts | 9 +- 29 files changed, 916 insertions(+), 98 deletions(-) create mode 100644 src/security/Exceptions.i18n.ts create mode 100644 src/security/Exceptions.tsx create mode 100644 src/security/exceptions.test.tsx 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 a45366e8..7226b949 100644 --- a/src/security/GuardTab.i18n.tsx +++ b/src/security/GuardTab.i18n.tsx @@ -33,8 +33,7 @@ export const guardTabText = messages( action: "动作", /** 组标题:「隐藏字符 · 3 条 · 已开 2 条 · 最近 7 天命中 5 次」 */ groupCount: (all: number, on: number) => `${all} 条 · 已开 ${on} 条`, - /** 7 天里的日志超过一页时数的是下限,`n` 写成「120+」 */ - groupHits: (n: string) => `最近 7 天命中 ${n} 次`, + groupHits: (n: number) => `最近 7 天命中 ${n} 次`, /** 行内的动作选择,给读屏 */ actionFor: (name: string) => `「${name}」的动作`, /** 动作为「替换」时,行内选项写出换成什么 */ @@ -68,7 +67,7 @@ export const guardTabText = messages( scope: "Checks", action: "Action", groupCount: (all: number, on: number) => `${plural(all, "rule", "rules")} · ${on} on`, - groupHits: (n: string) => `${n} ${n === "1" ? "match" : "matches"} in the last 7 days`, + 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 65ac28e4..6c72e0c6 100644 --- a/src/security/GuardTab.tsx +++ b/src/security/GuardTab.tsx @@ -1,4 +1,4 @@ -import { Fragment, useMemo, useState } from "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"; @@ -140,11 +140,14 @@ export function GuardTab({ detail, modePending, actions, + below, }: { guard: Guard; detail: GuardDetail; modePending: boolean; actions: RuleActions; + /** 规则表下方的一节(密钥与隐私:例外) */ + below?: ReactNode; }) { const t = useText(guardTabText); const [query, setQuery] = useState(""); @@ -196,6 +199,7 @@ export function GuardTab({ hits={hits} actions={actions} /> + {below} ); } @@ -336,7 +340,6 @@ export function RuleTable({ title={title} rules={live} hits={hitsOf(hits, live)} - complete={hits?.complete ?? true} open={expanded} onToggle={() => toggle(g.key)} /> @@ -369,7 +372,6 @@ function GroupHead({ title, rules, hits, - complete, open, onToggle, }: { @@ -377,8 +379,6 @@ function GroupHead({ title: string; rules: SecurityRuleView[]; hits: number | undefined; - /** 次数是全部的;不是的话写成「N+」 */ - complete: boolean; open: boolean; onToggle: () => void; }) { @@ -400,7 +400,7 @@ function GroupHead({ {title} {` · ${t.groupCount(rules.length, on)}`} - {hits != null && hits > 0 && ` · ${t.groupHits(complete ? String(hits) : `${hits}+`)}`} + {hits != null && hits > 0 && ` · ${t.groupHits(hits)}`} 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 index 51b61035..59d7252b 100644 --- a/src/security/groups.test.tsx +++ b/src/security/groups.test.tsx @@ -2,7 +2,7 @@ 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 { SecurityEventView, SecurityKindView, SecurityRuleView } from "@/types"; +import type { SecurityKindView, SecurityRuleHits, SecurityRuleView } from "@/types"; import { RuleTable, type RuleActions } from "./GuardTab"; import { disclosureNav } from "./rows"; import { @@ -47,8 +47,7 @@ const KINDS: SecurityKindView[] = [ { slug: "custom", name: "Custom", name_zh: "自定义" }, ]; -const event = (rule: string, custom = false): SecurityEventView => - ({ id: 1, at_ms: 0, request_id: 1, guard: "redact", rule, custom }) as SecurityEventView; +const hit = (rule: string, count: number, custom = false): SecurityRuleHits => ({ rule, custom, count }); const actions: RuleActions = { mode: () => {}, @@ -91,24 +90,21 @@ const heads = (html: string) => const ruleRows = (html: string) => (html.match(/data-row-nav=""/g)?.length ?? 0) - heads(html).length; describe("命中次数", () => { - it("按规则数安全日志的条数;内置和自定义同名分开算", () => { - const hits = countHits({ - events: [event("anthropic-api-key"), event("anthropic-api-key"), event("客户编号", true), event("客户编号")], - more: false, - }); - expect(hits).toEqual({ counts: { "b:anthropic-api-key": 2, "c:客户编号": 1, "b:客户编号": 1 }, complete: true }); + 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("日志还有下一页:数的是下限", () => { - expect(countHits({ events: [event("passport")], more: true }).complete).toBe(false); - }); - it("组头写条数、启用条数和命中次数;没有命中的不写次数", () => { - const html = table("", countHits({ events: [event("github-token"), event("github-token")], more: false })); + const html = table("", countHits([hit("github-token", 2)])); expect(heads(html)).toEqual([ "开发平台 · 1 条 · 已开 1 条 · 最近 7 天命中 2 次", "AI 服务 · 2 条 · 已开 1 条", @@ -117,12 +113,12 @@ describe("命中次数", () => { ]); }); - it("不全时写成「N+」;英文界面同一句", () => { - const hits = countHits({ events: [event("passport")], more: true }); - expect(heads(table("", hits))[0]).toBe("证件 · 1 条 · 已开 1 条 · 最近 7 天命中 1+ 次"); + it("英文界面同一句", () => { setLang("en"); - expect(heads(table("", hits))[0]).toBe("ID documents · 1 rule · 1 on · 1+ matches in the last 7 days"); - expect(heads(table("", countHits({ events: [event("passport")], more: false })))[0]).toBe( + 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", ); }); @@ -150,7 +146,7 @@ describe("组的先后", () => { it("检索时照样有命中的排在前面", () => { // 「p」筛出 AI 服务的两条(api)和证件(passport) - const hits = countHits({ events: [event("passport")], more: false }); + 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 服务", "开发平台", "自定义"]); }); 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/rules.ts b/src/security/rules.ts index e68541c1..aac59b26 100644 --- a/src/security/rules.ts +++ b/src/security/rules.ts @@ -8,7 +8,7 @@ import type { Guard, OutcomeDetail, RuleAction, - SecurityEventsPage, + SecurityRuleHits, SecurityRuleView, } from "@/types"; @@ -52,30 +52,22 @@ export const ruleKey = (r: Pick) => `${r.cust // ------------------------------------------------------------------ 组头的命中次数 -/** - * 一段时间里每条规则在安全日志里有几条(键见 `ruleKey`)。 - * - * **core 不按规则数**:这是安全日志最新的一页里数出来的。`complete` 说这一页是不是 - * 这段时间的全部 —— 不是的话每个数都只是下限,界面写成「N+」。 - */ -export interface RuleHits { - counts: Record; - complete: boolean; -} +/** 一段时间里每条规则命中了几次(键见 `ruleKey`),core 按规则数好的 */ +export type RuleHits = Record; -export function countHits(page: Pick): RuleHits { - const counts: Record = {}; - for (const e of page.events) { - const k = ruleKey({ custom: e.custom, id: e.rule }); - counts[k] = (counts[k] ?? 0) + 1; +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, complete: !page.more }; + 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.counts[ruleKey(r)] ?? 0), 0); + return rules.reduce((n, r) => n + (hits[ruleKey(r)] ?? 0), 0); } /** diff --git a/src/security/useSecurityLog.ts b/src/security/useSecurityLog.ts index 470dd291..9e0e758e 100644 --- a/src/security/useSecurityLog.ts +++ b/src/security/useSecurityLog.ts @@ -86,17 +86,14 @@ export function narrowed(only: SecurityLog, all: SecurityLog, guard: Guard): Sec } /** - * 规则表组头上「最近 7 天命中 N 次」的来源:这一项最近 7 天的安全日志,读一页(最多 - * `MAX_PAGE` 条)按规则数。**core 没有按规则的条数**,只能这样数;7 天里超过一页时数是 - * 下限(`RuleHits.complete`)。缓存里只留数出来的条数,不留整页日志。 - * - * 时间窗和日志选「7 天」时同一个起点(`windowStart`):日志按类型筛、选 7 天,条数对得上。 + * 规则表组头上「最近 7 天命中 N 次」:这一项最近 7 天每条规则的命中次数,core 按规则数好的。 + * 时间窗和日志选「7 天」时同一个起点(`windowStart`)。 * * **只在打开这一项的页签时读,不跟着新请求重读**:组的先后按命中排,看着的时候一条新命中 * 把组换了位置,比数字晚一会儿更糟。切走再回来就是新的。 */ export function useRuleHits(guard: Guard): RuleHits | undefined { return useResource(`security-hits:${guard}`, async () => - countHits(await api.events({ guard, from_ms: windowStart(presetRange("7d")), limit: MAX_PAGE })), + countHits(await api.counts(guard, windowStart(presetRange("7d")))), ).data; }