Skip to content

fix(core): logger 脱敏表按词边界匹配,不再吃掉 keys/tokens 这类普通字段 (#5573) - #5786

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5573-redact-word-boundary
Aug 6, 2026
Merged

fix(core): logger 脱敏表按词边界匹配,不再吃掉 keys/tokens 这类普通字段 (#5573)#5786
os-zhuang merged 2 commits into
mainfrom
claude/issue-5573-redact-word-boundary

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #5573

按维护者裁决(评论 5199145168)的 A 案实施:packages/core/src/logger.tsredactSensitive 由子串 includes 改为 camelCase / snake_case 分词后的词边界匹配。默认脱敏表 ['password','token','secret','key'] 本身没有动,packages/spec/src/system/logging.zod.ts 的 schema 默认值也没有动 —— 变的只是这张表怎么用

现象与实现

旧判定是 key.toLowerCase().includes(pattern),于是任何名字含有脱敏词子串的字段整块变成 ***REDACTED***:keyskeywordkeywordskeyboardmonkeytokenstokenizersecretary。读者不但丢了事实,还被告知"这里挡住了一个秘密",比字段缺失更误导。

新实现三个模块级函数 + 一个私有方法:

  • tokenizeFieldName() —— 按 camelCase / snake_case / kebab-case / 字母-数字边界分词并小写化。apiKeyapi_keyAPI_KEYx-api-key 都分成 ['api','key'];monkeykeywordtokenizer 保持单词一个。
  • singularizeWord() —— 只覆盖脱敏词汇会遇到的复数拼法(keys/tokens/secrets/passwords/passes),并且不会把 address/status 变成新词。
  • fieldWordsMatchPattern() —— 逐词比对;多词 pattern(host 配 redact: ['apiKey'])按连续词串匹配。
  • ObjectLogger.isRedactedFieldName() —— 递归里唯一的判定入口,pattern 在构造函数里分词一次。

顺带证实了一个活的 in-tree 命中(原 issue 认为"目前没有任何 in-tree 调用命中"):packages/runtime/src/dispatcher-plugin.ts:562 当年为躲开脱敏器特意把字段 key 改名成 keyedBy,但 'keyedby'.includes('key') 依然为真 —— 那条限流日志的 keyedBy 一直是 ***REDACTED***,这个 PR 之后才真正显示出限流键是什么。该文件本 PR 不需要改,行为自动恢复。

两个边角的取舍(裁决没点名,写在这里请复核)

1. 全小写连写 apikey 先实测:纯词边界下 apikey 分词只有一个词,会漏。不能用"以 key 结尾"救,因为 monkey/turkey/whiskey 也以它结尾 —— 那正是本单要去掉的误报。任何区分二者的规则都必须在某处编码词法知识,所以做成一张显式限定词表 CONCATENATED_SECRET_QUALIFIERS(api/access/refresh/client/private/session/ssh/… 共 31 个):限定词 + 脱敏词 连写算命中,别的连写不算。

  • 只认后缀连写,所以 secretarykeywordkeyboard 保持干净。
  • 表外连写(foobarkey)不脱敏。按仓库命名惯例(Prime Directive Implement ObjectStack protocol specification with Zod schemas and TypeScript interfaces #3:配置键 camelCase、机器名 snake_case)写成 fooBarKey / foo_bar_key 就通用命中 —— 全小写连写本来就是惯例外拼法,这张表只是安全网。
  • 方向上它比"例外表"(B 案)保守:B 案是从子串往回退、漏一个英文词就误报;这张表是从词边界往外加、漏一个限定词只是回到词边界基线。

2. 复数。 裁决点名 tokens 不脱敏,但 apiKeys 分词为 api+keys,keys ≠ key,纯词边界会让 apiKeys: ['sk-…'] 漏网 —— 与"不漏真秘密"冲突。按派发指示实现为:复数形变只在与其他词组合时命中。裸 keys/tokens 不脱敏(集合与计数),复合词里的 apiKeys/refresh_tokens/clientSecrets/userPasswords 照常脱敏。

两个诚实的残留,都已钉成测试而不是留给下一个读者去踩:

  • passwords / secrets 也不脱敏(裸复数规则一视同仁)。这是维护者词边界决定的直接推论,不是我引入的;要挡的 host 写 redact: [..., 'passwords'] 显式加回,已有用例。
  • tokenCountpromptTokenscompletionTokens 仍然脱敏 —— 词边界分不出词,token 作为一个词就是命中,复合复数同理。这三个在本 PR 之前也是脱敏的,没有回归,但也没被修好。再收窄意味着要给 promptapi 这两个限定词排座次,那是维护者的面,不是我能自选的,所以只钉住现状并在此点名。

PR #5572 的 pin 改判

packages/services/service-automation/src/thrown-cause-diagnostics.test.ts 里那条"原样转发 Zod issues 会被脱敏"的 pin,按裁决明文授权改判(只动这一条 it 的断言与其说明):toContain('REDACTED')not.toContain('REDACTED'),并且断言翻到正面 —— visibleIf 出现在输出里、整个 issues 结构逐字相等,而不是"因为什么都没产出所以断言过了"。

过期注释已按 PM 裁决订正(#5573 评论 5200739786,第二个提交 ccbdbff)。thrown-cause-diagnostics.ts 模块 docblock 用两条理由解释"为什么压平而不是原样转发",第一条依据的正是"按子串脱敏",本 PR 之后失效。现在:

  • 第一条 bullet 标注为finding(core): ObjectLogger 的脱敏表按子串匹配,一个叫 keys 的普通字段会被整块换成 ***REDACTED*** #5573 失效的历史理由 —— 裸 keys 不再被脱敏 —— 并写明今天判定新增字段的规则:apiKey / api_key 脱敏,keys / tokens 不脱敏。
  • 第二条(一条 Zod issue 可能带上整个被拒 input,日志记录必须有界)独自承接这个决定,仍然成立,措辞改为不再依赖上一条。所以 helper 继续压平 issue,字段名 unrecognized 保持不变。
  • LoggedCauseIssue.unrecognized 的字段注释复述了同一句已过期的 "substring redactor",一并订正(字段名改回是纯 churn,不改)。

纯注释、无行为变化:eslint 干净,thrown-cause-diagnostics.test.ts 定向复跑 Test Files 1 passed / Tests 12 passed

测试

packages/core/src/logger.test.ts 新增 redaction matches whole words, not substrings (#5573),88 个用例:

  • 真秘密矩阵 42 条全部仍脱敏 —— 脱敏词本身、camelCase、snake_case、SCREAMING_SNAKE、kebab(x-api-key)、全小写/全大写连写(apikey/APIKEY/accesstoken/clientsecret/privatekey)、复合复数(apiKeys/api_keys/accessTokens/clientSecrets/userPasswords/apikeys)。
  • 症状面 14 条不再脱敏 —— keys/keyword/keywords/keyboard/monkey/tokens/tokenizer/secretary/donkey/turkey/whiskey/hockey/keyedBy/tokenizerName
  • 嵌套用例:{ issues: [{ code: 'unrecognized_keys', keys: ['visibleIf'], path: ['nodes', 0] }] } 逐字幸存(finding(core): ObjectLogger 的脱敏表按子串匹配,一个叫 keys 的普通字段会被整块换成 ***REDACTED*** #5573 原始复现);反向的 { connector: { auth: { apiKey } } } 深层仍被打掉。
  • 裸复数 + 显式 opt-in;host 多词 pattern(redact: ['apiKey'] 命中 apiKey/api_key/apikey,而不放大到裸 key)。
pnpm --filter @objectstack/core test
  Test Files  27 passed (27)
       Tests  498 passed (498)

pnpm --filter @objectstack/service-automation test
  Test Files  64 passed (64)
       Tests  762 passed (762)

反向验证(方向先判后跑)。 预判:把 isRedactedFieldName 换回 includes 后,症状面转红、真秘密矩阵保持全绿(子串是这些拼法上的超集)。实跑与预判一致 —— 17 红,全部落在症状面(14 条 no longer redacts … + 嵌套复现 + 裸复数 + 多词 pattern),42 条真秘密矩阵与"深层嵌套仍脱敏"保持绿:

× … > no longer redacts keys / keyword / keywords / keyboard / monkey / tokens
× … > no longer redacts tokenizer / secretary / donkey / turkey / whiskey / hockey
× … > no longer redacts keyedBy / tokenizerName
× … > keeps a nested `keys` field readable — the exact #5573 repro
× … > leaves a BARE plural alone, and honours an explicit opt-in for it
× … > honours a host-configured multi-word pattern, without widening it to its parts
✓ … > still reaches a secret nested several levels down
 Test Files  1 failed (1)
      Tests  17 failed | 71 passed (88)

其中"多词 pattern"转红是新匹配器的净增覆盖:旧的 includesredact: ['apiKey'] 命不中 api_key 字段,现在能。

门禁:

pnpm check:type-check-coverage
  ✓ --self-test — 22 semantic case(s) + 11 observation case(s) hold.
  OK — 63/78 workspace packages type-checked, 15 in the DEBT ledger (358 frozen raw errors)

pnpm check:nul-bytes   → OK (scanned 5654 tracked text file(s); no raw ASCII control bytes)
pnpm check:doc-authoring → ✓ 362 files clean
eslint(改动文件)      → 无输出

packages/core / @objectstack/service-automation 都没有 typecheck script(在 #4311 的 DEBT 台账里),所以类型面的证据是上面那条比对冻结计数的 ratchet —— 计数未增长即本次改动没带进新类型错误。

changeset:.changeset/logger-redact-word-boundary.md(patch),正文含匹配语义 FROM → TO 表与影响面(host 侧自定义 redact 的行为变化:靠子串宽匹配"顺手"挡住某字段的部署需要显式写进 redact;反向收益是同一个词现在跨拼法命中)。

`ObjectLogger.redactSensitive` 的判定是 `key.toLowerCase().includes(pattern)`,
所以任何名字含有 `password`/`token`/`secret`/`key` 子串的字段,整个值都被换成
`***REDACTED***`:`keys`、`keyword`、`keyboard`、`monkey`、`tokens`、`tokenizer`、
`secretary`。读者不但丢了事实,还被告知这里挡住了一个秘密,比字段缺失更误导。
仓库里已有活的命中:dispatcher-plugin.ts 为躲开脱敏器把 `key` 改名成 `keyedBy`,
而 `'keyedby'.includes('key')` 依然为真,那条限流日志一直是 `***REDACTED***`。

按维护者裁决(#5573 评论 5199145168)的 A 案,匹配改为 camelCase / snake_case
分词后的词边界比对。默认脱敏表本身不变,spec 的 schema 默认值也不变 -- 变的只是
这张表怎么用。两个边角是显式取舍:

  - 全小写连写(`apikey`)没有边界可分,而 `monkey`/`turkey` 也以 `key` 结尾,
    任何区分二者的规则都要编码词法知识 -- 做成一张显式限定词表,`限定词+脱敏词`
    的后缀连写算命中,表外连写不算,`secretary`/`keyword` 因此保持干净。
  - 复数形变只在与其他词组合时命中:裸 `keys`/`tokens` 是集合与计数,不脱敏;
    复合词里的 `apiKeys`/`refresh_tokens` 仍是秘密,照常脱敏。

新增 88 个用例:真秘密拼法矩阵 42 条(含 camel/snake/SCREAMING/kebab/连写/复合
复数)全部仍脱敏,症状面 14 条不再脱敏,加上 #5573 的嵌套原始复现。反向验证换回
`includes` 后 17 红全部落在症状面,真秘密矩阵保持绿。`tokenCount`/`promptTokens`
仍脱敏 -- 词边界分不出词义,改动前后一致,已钉住现状而非留白。

PR #5572 落的「原样转发会被脱敏」pin 按裁决改判,断言翻到正面:`visibleIf` 出现
在输出里、整个 issues 结构逐字相等,而不是因为什么都没产出所以断言过了。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 6, 2026 5:53am

Request Review

@github-actions github-actions Bot added the size/m label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/service-automation.

26 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx (via @objectstack/core)
  • content/docs/ai/knowledge-rag.mdx (via @objectstack/core)
  • content/docs/ai/natural-language-queries.mdx (via @objectstack/core)
  • content/docs/automation/flows.mdx (via @objectstack/service-automation)
  • content/docs/automation/webhooks.mdx (via @objectstack/core)
  • content/docs/concepts/north-star.mdx (via packages/core)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/core)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/core)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/core)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/core, @objectstack/service-automation)
  • content/docs/kernel/services.mdx (via @objectstack/core)
  • content/docs/permissions/authentication.mdx (via @objectstack/core)
  • content/docs/permissions/authorization.mdx (via packages/core)
  • content/docs/plugins/anatomy.mdx (via @objectstack/core)
  • content/docs/plugins/development.mdx (via @objectstack/core)
  • content/docs/plugins/index.mdx (via @objectstack/core)
  • content/docs/plugins/packages.mdx (via @objectstack/core, @objectstack/service-automation)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/core)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/core)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/core)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/core)
  • content/docs/releases/implementation-status.mdx (via @objectstack/core, @objectstack/service-automation)
  • content/docs/releases/v12.mdx (via @objectstack/core)
  • content/docs/releases/v15.mdx (via @objectstack/core)
  • content/docs/releases/v17.mdx (via @objectstack/core)
  • content/docs/releases/v9.mdx (via @objectstack/service-automation)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…子串脱敏」说法 (#5573)

模块 docblock 用两条理由解释为什么把 Zod issue 压平而不是原样转发。第一条依据的是
`ObjectLogger` 按**子串**脱敏 —— `'keys'` 含 `'key'`,所以 `keys` 字段会渲染成
`***REDACTED***`。本 PR 把匹配改成词边界之后这条不再成立:裸 `keys` 不再被脱敏。

按 PM 裁决(#5573 评论 5200739786)就地订正:该 bullet 标注为因 #5573 **失效**的
历史理由,并写明今天判定新增字段的规则(`apiKey`/`api_key` 脱敏,`keys`/`tokens`
不脱敏);第二条(一条 issue 可能带上整个被拒 `input`,日志记录必须有界)独自承接
这个决定,仍然成立,措辞改为不再依赖上一条。

`LoggedCauseIssue.unrecognized` 的字段注释复述了同一句已过期的 "substring
redactor",一并订正。字段名保持 `unrecognized` 不变 —— 改回是纯 churn。

纯注释,无行为变化。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx
@os-zhuang
os-zhuang marked this pull request as ready for review August 6, 2026 07:08
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 46365ab Aug 6, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5573-redact-word-boundary branch August 6, 2026 07:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(core): ObjectLogger 的脱敏表按子串匹配,一个叫 keys 的普通字段会被整块换成 ***REDACTED***

2 participants