From 142b44c150e91d2f12963497e291785b9eb744c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leo=EF=BD=9CLeoLabs=2Eme?= <7fpb5574ck@privaterelay.appleid.com> Date: Sun, 18 Oct 2026 08:06:36 +0800 Subject: [PATCH] fix(chat-export): redact PEM keys and run lab CI on changes --- .github/workflows/opc-lab.yml | 1 + labs/chat-export/README.md | 2 +- labs/chat-export/scripts/common.py | 7 ++++ labs/chat-export/tests/run.py | 54 ++++++++++++++++++++++++++++-- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/.github/workflows/opc-lab.yml b/.github/workflows/opc-lab.yml index aa7de8a..b950721 100644 --- a/.github/workflows/opc-lab.yml +++ b/.github/workflows/opc-lab.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - 'labs/opc-company/**' + - 'labs/chat-export/**' - 'scripts/verify.sh' - 'README.md' - 'README.en.md' diff --git a/labs/chat-export/README.md b/labs/chat-export/README.md index dcf4e06..c52dbc7 100644 --- a/labs/chat-export/README.md +++ b/labs/chat-export/README.md @@ -44,7 +44,7 @@ rg -l "上次那个部署问题" ~/.local/share/chat-export 丢:系统提示和注入的上下文(``、整份 AGENTS.md、环境信息)、思考过程、工具调用与结果、子 Agent 的旁支对话。不丢的话,每个会话都带一份 AGENTS.md,任何关键词一搜就命中全部会话。 -导出前会把常见凭证替换成 ``:私钥、`sk-` 开头的 API key、GitHub / AWS / Slack token、Bearer 头、`*_TOKEN=` 这类赋值、超长 base64。这是尽力而为的规则匹配,**不能保证把所有敏感内容都清掉**。导出的目录和文件都只有你本人能读写(0700 / 0600),只适合留在本机,不要提交到仓库,也不要上传。 +导出前会把常见凭证替换成 ``:私钥(包括完整 BEGIN/END 标记的多行 PEM 私钥)、`sk-` 开头的 API key、GitHub / AWS / Slack token、Bearer 头、`*_TOKEN=` 这类赋值、超长 base64。这是尽力而为的规则匹配,**不能保证把所有敏感内容都清掉**。导出的目录和文件都只有你本人能读写(0700 / 0600),只适合留在本机,不要提交到仓库,也不要上传。截断/非标准编码的密钥和标题、路径等元数据未被全面覆盖,不能将输出视为可公开分享的安全脱敏版本。 ## 踩过的坑 diff --git a/labs/chat-export/scripts/common.py b/labs/chat-export/scripts/common.py index e9e68e5..a7e9b1a 100644 --- a/labs/chat-export/scripts/common.py +++ b/labs/chat-export/scripts/common.py @@ -14,6 +14,13 @@ # Credentials are redacted before anything touches disk. Most specific patterns first. REDACTIONS = [ + # Complete multi-line PEM private keys (RSA, EC, OpenSSH, PKCS#8, encrypted). + # Stop at another BEGIN marker to avoid eating unrelated chat text. + (re.compile( + r"-----BEGIN (?P(?:[A-Z0-9-]+ )*PRIVATE KEY)-----" + r"(?:(?!-----BEGIN (?:[A-Z0-9-]+ )*PRIVATE KEY-----)[\s\S])*?" + r"-----END (?P=kind)-----" + ), ""), (re.compile(r"0x[a-fA-F0-9]{64}\b"), ""), (re.compile(r"\bsk-[A-Za-z0-9_\-]{20,}"), ""), (re.compile(r"\bghp_[A-Za-z0-9]{20,}"), ""), diff --git a/labs/chat-export/tests/run.py b/labs/chat-export/tests/run.py index 45e5475..d3fd197 100644 --- a/labs/chat-export/tests/run.py +++ b/labs/chat-export/tests/run.py @@ -10,6 +10,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import common # noqa: E402 import export_claude # noqa: E402 import export_codex # noqa: E402 import export_cursor # noqa: E402 @@ -39,6 +40,49 @@ def jsonl(path, records): path.write_text("\n".join(json.dumps(r, ensure_ascii=False) for r in records) + "\n") + +def fake_pem(kind, newline="\n"): + """Not a real key: tiny base64-encoded plain text, assembled at runtime.""" + return newline.join(( + "-----" + "BEGIN " + kind + "-----", + "QUJDREVGRw==", + "SElKS0w=", + "-----" + "END " + kind + "-----", + )) + + +def test_redaction(tmp): + # All private-key labels use complete BEGIN/END delimiters and short PEM lines. + for kind in ("PRIVATE KEY", "RSA PRIVATE KEY", "EC PRIVATE KEY", + "DSA PRIVATE KEY", "OPENSSH PRIVATE KEY", "ENCRYPTED PRIVATE KEY"): + text = "Intro\n" + fake_pem(kind) + "\nOutro" + expect(common.redact(text) == "Intro\n\nOutro", + f"PEM {kind}: redact only the key block, retain surrounding text") + + crlf = "Before\r\n" + fake_pem("ENCRYPTED PRIVATE KEY", "\r\n") + "\r\nAfter" + expect(common.redact(crlf) == "Before\r\n\r\nAfter", + "PEM: CRLF-encoded key must be redacted") + + separate = fake_pem("RSA PRIVATE KEY") + "\nkeep between\n" + fake_pem("RSA PRIVATE KEY") + expect(common.redact(separate) == "\nkeep between\n", + "PEM: separately redact adjacent keys without consuming other messages") + + orphan = "-----" + "BEGIN RSA PRIVATE KEY-----\nexample only\n" + expect(common.redact(orphan + "keep\n" + fake_pem("RSA PRIVATE KEY")) == + orphan + "keep\n", + "PEM: unpaired example must not swallow a later complete key") + + ordinary = ("普通聊天不应消失。\n多行说明继续保留。\n" + "-----BEGIN PUBLIC KEY-----\nQUJDREVGRw==\n-----END PUBLIC KEY-----") + expect(common.redact(ordinary) == ordinary, + "normal multiline text and public keys must be preserved") + + api = KEY + "\n" + "ghp_" + "b" * 30 + "\nAPI_KEY=fictional-value" + expect(common.redact(api) == + "\n\nAPI_KEY=", + "existing API-key, GitHub-token and assignment redaction must remain") + + def test_claude(tmp): jsonl(tmp / "src/proj/s1.jsonl", [ {"type": "user", "sessionId": "s1", "timestamp": "2026-10-01T00:00:00Z", "cwd": "/w", @@ -47,11 +91,15 @@ def test_claude(tmp): {"type": "text", "text": f"Use the key {KEY}"}]}}, {"type": "user", "message": {"content": [{"type": "tool_result", "content": "tool noise"}]}}, {"type": "assistant", "isSidechain": True, "message": {"content": [{"type": "text", "text": "subagent"}]}}, - {"type": "assistant", "message": {"content": [{"type": "text", "text": "Then run tests."}]}}, + {"type": "assistant", "message": {"content": [{"type": "text", "text": + "Then run tests.\n" + fake_pem("RSA PRIVATE KEY") + "\nDone."}]}}, ]) run(export_claude, ["--src", str(tmp / "src"), "--dest", str(tmp / "out")]) text = only_md(tmp / "out") - expect("How do I ship?" in text and "Then run tests." in text, "claude: user prompt and both replies kept") + expect("How do I ship?" in text and "Then run tests." in text and "Done." in text, + "claude: user prompt and both replies kept") + expect("" in text and "QUJDREVGRw==" not in text, + "claude: PEM blocks must be removed before writing Markdown") for dropped in ("injected", "hidden", "tool noise", "subagent", KEY): expect(dropped not in text, f"claude: {dropped!r} should not be exported") # Watermark: an unchanged rerun writes nothing. @@ -125,7 +173,7 @@ def test_grok(tmp): def main(): - for test in (test_claude, test_codex_rollout_keeps_final_answer, test_codex_thread_history, + for test in (test_redaction, test_claude, test_codex_rollout_keeps_final_answer, test_codex_thread_history, test_cursor_app, test_grok): with tempfile.TemporaryDirectory() as tmp: test(Path(tmp))