From 652674187bfe2e854858cc94db72764511885133 Mon Sep 17 00:00:00 2001 From: tkc Date: Thu, 17 Sep 2026 11:55:29 +0900 Subject: [PATCH] feat: redact cloud credentials on paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying a block of logs, JSON or ~/.aws/credentials to ask an agent about it carries any live key straight into the model's context. ⌘V now replaces what looks like a cloud credential with [redacted] before the text reaches the session, keeping the surrounding text so the agent still sees what you meant to show it. The rules are regexes in `[paste] redact`, not knowledge in the binary. The part named `secret` is what gets replaced, so the name and quotes around it survive and a mistyped replacement can't eat the context. A broken regex is refused at startup, naming its index, rather than failing silently at paste time. Taken from iTerm2, which puts an ordered transform pipeline behind paste (sanitizePasteEvent: in iTermPasteHelper.m) and ships regex substitution as a tool with no rules of its own. termit ships cloud defaults too, because a tool nobody writes rules for protects nobody. Two things changed on the way: the redacted span is a named capture rather than iTerm2's pattern+substitution pair, and the escape hatch is ⌥⌘V -- iTerm2's Advanced Paste slot -- which avoids the Cmd+Shift combination this codebase already had to work around once. regex was already linked via env_logger, so making it a direct dependency costs 33KB of binary and no compile time. Rust's regex has no backtracking, so a user-supplied pattern cannot hang the terminal. Scope is the user's own paste. ⌘C and OSC 52 are untouched: rewriting a copy would silently break selecting a key on screen in order to use it. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + Cargo.toml | 4 + README.md | 46 ++++++++- docs/references/paste.md | 86 ++++++++++++++++ src/config.rs | 107 ++++++++++++++++++++ src/input.rs | 36 +++++++ src/main.rs | 40 ++++++++ src/secret.rs | 211 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 docs/references/paste.md create mode 100644 src/secret.rs diff --git a/Cargo.lock b/Cargo.lock index d42f5a4..b32d2e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2384,6 +2384,7 @@ dependencies = [ "memchr", "pollster", "portable-pty", + "regex", "rusqlite", "rustc-hash 2.1.3", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4443d2c..18b68df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,10 @@ description = "エージェント向けの軽量ターミナル" [dependencies] # 普通の文字のあいだを飛ばすため。OSC の走査に使う。 memchr = "2" +# 貼り付けで伏せる場所を指す式に使う。env_logger が既に連れてきているので、 +# 直接使っても組み立ての費用は増えない。後戻りしない実装なので、 +# 利用者の書いた式で端末が固まることがない。 +regex = "1" alacritty_terminal = "0.26" portable-pty = "0.9" libc = "0.2" diff --git a/README.md b/README.md index 4f9d06a..8f08a32 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,8 @@ takes nothing, because neither shells nor agents use Cmd. | `⌘[` / `⌘]` | Select the previous / next session | | `⌘1`…`⌘9`, then `⌘A` `⌘G` `⌘J` `⌘L` `⌘O` `⌘P` `⌘S` `⌘T` `⌘U` `⌘X` `⌘Y` `⌘Z` | Jump to that session | | `⌘W` | Close the session (stops it if it is still running) | -| `⌘C` / `⌘V` | Copy / paste | +| `⌘C` / `⌘V` | Copy / paste (paste redacts credentials — see below) | +| `⌥⌘V` | Paste unchanged, without redacting | | `⌘=` / `⌘-` | Font size | | `Shift+PageUp` / `PageDown` | Scroll a page | | Wheel / two fingers | Scroll the scrollback; hold `Shift` to keep it from the program | @@ -148,6 +149,34 @@ know what any agent looks like — it matches the phrases and title characters listed under `[agent]` in your config, and you can change them when an agent's UI changes. +**Pasting credentials.** `⌘V` scans the clipboard and replaces anything that +looks like a cloud credential with `[redacted]` before it reaches the session. +This is aimed at one accident: you copy a block of logs, JSON or `~/.aws/credentials` +to ask an agent about it, and a live key rides along into the model's context. +The surrounding text is kept, so the agent still sees what you meant to show it: + +``` +aws_secret_access_key = [redacted] +"private_key": "[redacted]" +``` + +The bottom bar says `pasted with 2 secrets redacted — ⌥⌘V pastes it unchanged`, +so it never happens silently, and `⌥⌘V` gives you the real thing when you +actually want it — typing a key into `aws configure`, say. `⌘C` is untouched: +copying out of termit gives you exactly what is on screen. + +What counts as a credential lives in `[agent]`'s neighbour `[paste]` in your +config, not in the binary. The defaults cover AWS access key IDs, AWS secret +keys and session tokens (bare or in `aws sts` JSON), GCP service-account +private keys, and Google API keys and OAuth tokens. Two limits worth knowing: + +- **A bare AWS secret key cannot be detected.** It is 40 characters of base64 + with no marker; a rule that catches it also catches passwords, hashes and + git SHAs. It is caught when it appears next to its name, which is how it + arrives in a credentials file or an API response. +- Broad words like `password` and `token` are deliberately **not** in the + defaults. They would fire on the code you paste for review and damage it. + **Dropping files.** Drag a file onto the window and its path is typed into the session, followed by a space, so several files dropped together line up as arguments. Paths that need it are quoted for the shell, so spaces and quotes @@ -336,6 +365,20 @@ restore_sessions = true # rebuild the session list on the next start program = "/bin/zsh" args = ["-l"] +[paste] +# Redact credentials on ⌘V. ⌥⌘V always pastes unchanged. +mask = true +# Each rule is a regex. The part named `secret` is what gets replaced, so the +# name and the quotes around it survive; a rule with no `secret` group replaces +# the whole match. A broken regex is reported at startup, not at paste time. +redact = [ + '\b(?P(AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16})\b', + '(?i)"(aws_secret_access_key|secretaccesskey|sessiontoken|private_key|client_secret)"\s*:\s*"(?P[^"]+)"', + '(?i)\b(aws_secret_access_key|aws_session_token|account_key)\b\s*[=:]\s*(?P[A-Za-z0-9/+=_.-]{16,})', + '\b(?PAIza[0-9A-Za-z_-]{20,})\b', + '\b(?Pya29\.[0-9A-Za-z_-]+)', +] + [agent] # The dot turns green while the title starts with one of these, even when the # program has stopped printing. Agents spin one of them while they think. @@ -404,6 +447,7 @@ The detailed design record is in Japanese. - [`docs/superpowers/specs/2026-09-08-agent-terminal-design.md`](docs/superpowers/specs/2026-09-08-agent-terminal-design.md) — the specification - [`docs/performance.md`](docs/performance.md) — where the time actually goes, measured - [`docs/references/performance-techniques.md`](docs/references/performance-techniques.md) — techniques taken from other terminals, each marked adopted, rejected with the measurement, or still open +- [`docs/references/paste.md`](docs/references/paste.md) — what iTerm2 does at the paste boundary, and which half of it termit took - [`docs/references/agent-state.md`](docs/references/agent-state.md) — how other tools tell a working agent from one that is waiting for you, and which parts of that termit adopted - [`docs/references/sandbox.md`](docs/references/sandbox.md) — how agents are sandboxed elsewhere, what Apple's `container` measured at, and what termit deliberately leaves outside - [`docs/references/scrollback.md`](docs/references/scrollback.md) — how five other implementations handle scrollback, and which parts were copied diff --git a/docs/references/paste.md b/docs/references/paste.md new file mode 100644 index 0000000..b397999 --- /dev/null +++ b/docs/references/paste.md @@ -0,0 +1,86 @@ +# 貼り付け口で何をするか + +作成日:2026-09-17 + +「クラウドの認証情報をエージェントに貼ってしまう」事故を防ぎたい、という求めに対し、 +貼り付け口を一番作り込んでいる iTerm2 を読んだ記録。 + +## iTerm2 の作り + +`sources/Pasting/iTermPasteHelper.m` の `sanitizePasteEvent:` が中心にある。 +**変換を一本の関数に順番に並べ、旗(`iTermPasteFlags`)で選ぶ**形である。 + +| 順 | 変換 | 旗 | +|---|---|---| +| 1 | 改行を消す/`\r` にそろえる | `RemovingNewlines` / `SanitizingNewlines` | +| 2 | Unicode の約物を ASCII に寄せる | `ConvertUnicodePunctuation` | +| 3 | 危険な制御コードを落とす | `RemovingUnsafeControlCodes` | +| 4 | タブを空白か `^V` に | `tabTransform` | +| 5 | シェルに解釈される字を退避 | `EscapeSpecialCharacters` | +| 6 | **正規表現で置換** | `UseRegexSubstitution` | +| 7 | base64 に包む | `Base64Encode` | + +`PasteEvent` は `originalString`(元)と `string`(変換後)を別々に持つ。 + +読み取れることが 3 つある。 + +**端末が貼り付けを書き換えるのは、例外ではなく普通である。** 制御コードを落とすのも +改行をそろえるのも、貼り付け攻撃と事故を防ぐためで、ブラケット貼り付け(`?2004`)が +存在する理由と同じである。認証情報を伏せるのは、この並びに 1 つ足すことにあたる。 + +**秘密の検出規則は 1 つも積んでいない。** 代わりに `regex` と `substitution` という +**道具だけ**を出し、中身は利用者が Advanced Paste やキー割り当てで与える。 + +**Advanced Paste は ⌥⌘V である**(⌘⇧V ではない)。 + +## termit が取ったもの + +**正規表現の置換。** 自前の照合器(名前つき/語頭の 2 種類)を考えていたが、やめた。 +`regex 1.13.1` は `env_logger` 経由で既に依存木にあり(`cargo tree -i regex`)、 +直接使っても組み立ての費用が増えない。Rust の `regex` は後戻りしない実装なので、 +利用者の書いた式で端末が固まることがない(PCRE と違い ReDoS が原理的に無い)。 +概念が 1 つで済み、gitleaks などの式をそのまま持ち込める。 + +**⌥⌘V。** 逃げ道(伏せずに貼る)をここに置いた。当初は ⌘⇧V を考えていたが、 +このコードには「Shift の同時押しが届かない環境があるため ⌘⇧R の代わりに ⌘I も受ける」 +という前例がある。⌥⌘V なら Shift を使わないので、その心配ごと消える。 + +**一本の変換の並び。** ⌘V は `伏せる → ブラケットに包む → PTY` になった。 + +## termit が変えたもの + +**伏せる範囲は `secret` という名前の捕獲組で指す。** iTerm2 は「式+置換文字列」の組で、 +`$1` などを使って書く。termit は式の中で `(?P…)` と印を付け、 +**その部分だけ**を `[redacted]` に替える。名前や引用符が自動的に残るので、 +置換文字列を書き間違えて文脈ごと消す、ということが起きない。 + +```toml +redact = ['(?i)"(private_key)"\s*:\s*"(?P[^"]+)"'] +``` + +**既定の式を持つ。** iTerm2 は道具だけを配るが、termit はクラウドの認証情報に絞った +5 つの式を既定で持つ。道具だけ配っても、書く人がいなければ誰も守られない。 +ただし式は設定側にあり、実行ファイルの中に「AWS の鍵の形」は無い。 + +## 取らなかったもの + +**⌘C 側とプログラムからの書き込み(OSC 52)。** 利用者の貼り付けだけにした。 +守りたいのが「エージェントに食わせない」ことだからで、⌘C を書き換えると +「画面の鍵を選んで写して使う」という正当な用途が黙って壊れる。 + +**Advanced Paste のような対話窓。** 変換の一覧・試し表示・履歴は、端末の仕事を超える。 + +## 限界(利用者に伝えるべきこと) + +**裸で貼った AWS のシークレットキーは捕まらない。** 40 文字の英数字に目印が無く、 +これを捕まえる式はパスワード・ハッシュ・base64・git の SHA を軒並み巻き込む。 +gitleaks などの既存ツールも同じ理由で文脈(名前)に頼っている。 +捕まるのは「名前とセットのとき」と「`AKIA` などの目印があるとき」である。 + +**`password` や `token` は既定に入れない。** エージェントに貼るコードの変数名に当たり、 +貼った内容のほうが壊れる。守るために貼り付けを壊しては、機能を切られて終わる。 + +## 参考 + +- [gnachman/iTerm2](https://github.com/gnachman/iTerm2) — `sources/Pasting/iTermPasteHelper.m`、`sources/Pasting/PasteEvent.h` +- [gitleaks](https://github.com/gitleaks/gitleaks) — クラウドの認証情報の式の書き方 diff --git a/src/config.rs b/src/config.rs index f008616..bdc80f0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,9 +19,63 @@ pub struct Config { #[serde(default)] pub agent: AgentConfig, #[serde(default)] + pub paste: PasteConfig, + #[serde(default)] pub profile: BTreeMap, } +/// 貼り付けるときの扱い。 +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PasteConfig { + /// 認証情報らしき値を伏せてから貼り付けるか。 + #[serde(default = "default_mask")] + pub mask: bool, + /// 伏せる場所を指す式。`secret` と名付けた組があれば、そこだけを伏せる。 + /// + /// 何を伏せるかはここにしかない。termit の実行ファイルの中に + /// 「AWS の鍵の形」は無く、相手の形が変わればこの表を直す。 + #[serde(default = "default_redact")] + pub redact: Vec, +} + +fn default_mask() -> bool { + true +} + +/// 既定で伏せるもの。クラウドの認証情報に絞る。 +/// +/// `password` や `token` のような広い語は入れない。エージェントへ貼る +/// コードの中の変数名に当たってしまい、貼った内容のほうが壊れる。 +fn default_redact() -> Vec { + [ + // AWS のアクセスキー ID(長期 AKIA、一時 ASIA ほか)。 + r"\b(?P(AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16})\b", + // JSON の中の値。aws sts の出力と、GCP のサービスアカウントの鍵。 + // 閉じ引用符まで取るので、鍵の中の改行(\n)も丸ごと伏せる。 + r#"(?i)"(aws_secret_access_key|secretaccesskey|sessiontoken|private_key|client_secret)"\s*:\s*"(?P[^"]+)""#, + // 裸の値。~/.aws/credentials と export の形。 + r"(?i)\b(aws_secret_access_key|aws_session_token|account_key)\b\s*[=:]\s*(?P[A-Za-z0-9/+=_.-]{16,})", + // Google の API キーと OAuth の合鍵。 + // 長さは決め打ちにしない。実物は AIza に続けて 35 文字だが、 + // そこが 1 文字違うだけで素通りするほうが危ない。 + r"\b(?PAIza[0-9A-Za-z_-]{20,})\b", + r"\b(?Pya29\.[0-9A-Za-z_-]+)", + ] + .iter() + .map(|s| s.to_string()) + .collect() +} + +impl Default for PasteConfig { + fn default() -> Self { + Self { + mask: default_mask(), + redact: default_redact(), + } + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct WindowConfig { @@ -320,6 +374,9 @@ impl Config { } } } + if let Err(e) = crate::secret::Redactor::new(&self.paste.redact) { + return Err(ConfigError::Invalid(e.to_string())); + } if self.agent.blocked_lines == 0 || self.agent.blocked_lines > 200 { return Err(ConfigError::Invalid(format!( "agent.blocked_lines must be between 1 and 200 (got {})", @@ -760,6 +817,56 @@ blocked_lines = 12 assert!(c.agent.blocked_lines > 0); } + #[test] + fn readme_の_paste_設定を読める() { + let toml = r#" +[paste] +mask = true +redact = ['(?PAKIA[0-9A-Z]{16})'] +"#; + let c: Config = toml::from_str(toml).unwrap(); + c.validate().unwrap(); + assert!(c.paste.mask); + assert_eq!(c.paste.redact.len(), 1); + } + + /// README に載せた式と、実際に配る既定値がずれていないこと。 + /// + /// 利用者はあれを写して自分の設定を作る。ずれていれば、 + /// 書いてあるとおりにしたのに守られない、ということが起きる。 + #[test] + fn readme_の式は既定値と同じ() { + let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md")) + .expect("README を読める"); + // 本文にも `[paste]` と書いてあるので、行として独立したものだけを拾う。 + let block = readme + .split("\n[paste]\n") + .nth(1) + .and_then(|s| s.split_once("redact = [")) + // 式の中にも `]` が出るので、行頭の `]` を表の終わりとする。 + .map(|(_, rest)| rest.split_once("\n]").expect("表が閉じている").0) + .expect("README に [paste] の例がある"); + let listed: Vec = block + .lines() + .map(str::trim) + .filter(|l| l.starts_with('\'')) + .map(|l| l.trim_end_matches(',').trim_matches('\'').to_string()) + .collect(); + assert_eq!(listed, PasteConfig::default().redact); + } + + /// 壊れた式は起動時に断る。貼り付けてから気づくのでは遅い。 + #[test] + fn 壊れた式のある設定を拒む() { + let toml = r#" +[paste] +redact = ["[unclosed"] +"#; + let c: Config = toml::from_str(toml).unwrap(); + let e = c.validate().unwrap_err(); + assert!(format!("{e}").contains("paste.redact[0]"), "{e}"); + } + #[test] fn 待ちの行数が範囲外なら拒む() { let toml = r#" diff --git a/src/input.rs b/src/input.rs index 06bf9fe..3b1ee5f 100644 --- a/src/input.rs +++ b/src/input.rs @@ -20,6 +20,11 @@ pub enum Action { ToggleSidebar, Copy, Paste, + /// 伏せずに、クリップボードのまま貼り付ける。 + /// + /// `[paste] mask` が効いていると、認証情報らしき値が伏せられる。 + /// `aws configure` に本物を渡したいときの逃げ道。 + PasteRaw, /// 画面とスクロールバックを消し、プロンプトを出し直す。 ClearScreen, /// 画面とスクロールバックの中を探す。 @@ -116,6 +121,12 @@ fn action_from_char(key: &Key, mods: ModifiersState) -> Option { _ => None, }; } + // ⌥⌘ の枝。伏せずに貼るためだけに使う。 + // iTerm2 が Advanced Paste に使っている枠で、Shift を使わないので + // 「Shift の同時押しが届かない」環境でも通る。 + if mods.super_key() && mods.alt_key() && !mods.control_key() { + return (c.as_str() == "v").then_some(Action::PasteRaw); + } // Cmd 側。Shift を併用する組み合わせは ⌘⇧R だけに限る。 if mods.super_key() && !mods.control_key() && !mods.alt_key() { if mods.shift_key() { @@ -170,6 +181,9 @@ fn action_from_physical(physical: PhysicalKey, mods: ModifiersState) -> Option None, }; } + if mods.super_key() && mods.alt_key() && !mods.control_key() { + return (code == KeyCode::KeyV).then_some(Action::PasteRaw); + } if mods.super_key() && !mods.control_key() && !mods.alt_key() { let command = match code { KeyCode::KeyN => Some(Action::NewSession), @@ -498,6 +512,28 @@ mod tests { assert_eq!(action_for(&ch("["), phys, cmd), Some(Action::SelectPrev)); } + /// ⌥⌘V は伏せずに貼る。Shift を使わないので、届かない環境の心配がない。 + #[test] + fn 伏せずに貼る組み合わせを受ける() { + let phys = PhysicalKey::Code(KeyCode::KeyV); + let alt_cmd = ModifiersState::SUPER | ModifiersState::ALT; + assert_eq!(action_for(&ch("v"), phys, alt_cmd), Some(Action::PasteRaw)); + // ⌥ を離せば、ふだんの貼り付け(伏せるほう)に戻る。 + assert_eq!( + action_for(&ch("v"), phys, ModifiersState::SUPER), + Some(Action::Paste) + ); + // 文字が取れない配列でも物理キーで通る。 + assert_eq!( + action_for(&Key::Dead(None), phys, alt_cmd), + Some(Action::PasteRaw) + ); + // ⌥⌘ に別のキーを足しても、何も起こさない。 + // ⌘C(写す)が ⌥ を足したせいで別の意味になる、ということがない。 + let phys_c = PhysicalKey::Code(KeyCode::KeyC); + assert_eq!(action_for(&ch("c"), phys_c, alt_cmd), None); + } + #[test] fn 物理キーでも照合できる() { // 文字が取れない配列でも、物理キーの位置で組み合わせが届く。 diff --git a/src/main.rs b/src/main.rs index 7d87bff..e614486 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod pty; mod rect; mod render; mod search; +mod secret; mod session; mod state; mod term; @@ -126,6 +127,18 @@ fn main() { } }; + // 貼り付けで伏せる式は、ここで 1 度だけ組み立てる。 + // 設定の検査も同じものを通しているので、ここまで来れば必ず成功する。 + let redactor = match secret::Redactor::new(&config.paste.redact) { + Ok(r) if config.paste.mask => r, + // 伏せない設定なら、式を持たない。判定そのものが走らなくなる。 + Ok(_) => secret::Redactor::default(), + Err(e) => { + eprintln!("termit: {e}"); + std::process::exit(1); + } + }; + let event_loop = EventLoop::::with_user_event() .build() .expect("イベントループを作れない"); @@ -143,6 +156,7 @@ fn main() { .then(Counters::default), resize_quiet_since: None, agent_state_at: None, + redactor, }; if let Err(e) = event_loop.run_app(&mut app) { eprintln!("termit: {e}"); @@ -418,6 +432,8 @@ struct App { resize_quiet_since: Option, /// 返事待ちを最後に調べた時刻。毎フレーム画面を読まないための間隔。 agent_state_at: Option, + /// 貼り付けで伏せる式。起動時に 1 度だけ組み立てる。 + redactor: crate::secret::Redactor, } /// 画面の割り付け。すべてセル単位で扱う。 @@ -1017,9 +1033,27 @@ impl App { } } Action::Paste => { + if let Some(text) = clipboard::paste() { + // 認証情報らしき値を伏せてから渡す。伏せたことは必ず出す。 + // 黙って書き換えると、貼ったものが違う理由が分からない。 + // 伏せない設定なら、大きなクリップボードを写し取らない。 + let (text, hits) = if self.redactor.is_empty() { + (text, 0) + } else { + self.redactor.redact(&text) + }; + if let Some(s) = state.manager.selected() { + let mode = *s.term.lock().mode(); + s.pty.write(bracketed(&text, mode)); + } + state.status = (hits > 0).then(|| redacted_notice(hits)); + } + } + Action::PasteRaw => { if let (Some(s), Some(text)) = (state.manager.selected(), clipboard::paste()) { let mode = *s.term.lock().mode(); s.pty.write(bracketed(&text, mode)); + state.status = None; } } Action::ClearScreen => { @@ -2027,6 +2061,12 @@ mod sidebar { /// 文字を使う。全画面 UI は選んだ行を書き直した時点で選択を捨てるため、 /// これが無いと、選べているのに何も写らないという形になる。 /// 控えは持ち主のセッションでだけ使う。 +/// 伏せたことを知らせる文。⌥⌘V が逃げ道であることも併せて出す。 +fn redacted_notice(hits: usize) -> String { + let what = if hits == 1 { "secret" } else { "secrets" }; + format!("pasted with {hits} {what} redacted — ⌥⌘V pastes it unchanged") +} + fn copy_text( live: Option, picked: Option<&(crate::session::SessionId, String)>, diff --git a/src/secret.rs b/src/secret.rs new file mode 100644 index 0000000..e50f85c --- /dev/null +++ b/src/secret.rs @@ -0,0 +1,211 @@ +//! 貼り付ける文字列から、認証情報らしき値を伏せる。 +//! +//! 何を伏せるかは設定(`[paste] redact`)にある。ここにあるのは +//! 「式に当てはめて、`secret` と名付けた部分を置き換える」という手続きだけで、 +//! AWS や Google の鍵の形は 1 つも書かれていない。相手の形が変われば設定を直す。 +//! +//! iTerm2 も同じ考え方で、貼り付けに正規表現の置換を持たせている +//! (`iTermPasteHelper.m` の `sanitizePasteEvent:`)。違いは、あちらが +//! 道具だけを配るのに対し、termit は既定の式を持つことである。 +//! 道具だけ配っても、書く人がいなければ誰も守られない。 + +use regex::{Captures, Regex}; + +/// 伏せた跡に置く文字列。 +/// +/// 読む側(エージェント)に「消されている」と分かる形にする。 +/// 伏せ字だけだと、その文字が値そのものだと解釈されることがある。 +const REDACTED: &str = "[redacted]"; + +/// 設定の式をまとめて持つ。起動時に 1 度だけ組み立てる。 +#[derive(Debug, Default)] +pub struct Redactor { + rules: Vec, +} + +/// 組み立てに失敗した式と、その理由。 +#[derive(Debug)] +pub struct BadPattern { + /// 設定の `redact` の中で何番目か(0 から数える)。 + pub index: usize, + pub pattern: String, + pub message: String, +} + +impl std::fmt::Display for BadPattern { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "paste.redact[{}] is not a valid regex: {}\n {}", + self.index, self.pattern, self.message + ) + } +} + +impl Redactor { + /// 式を組み立てる。1 つでも壊れていれば、その場所を言って失敗する。 + pub fn new(patterns: &[String]) -> Result { + let mut rules = Vec::with_capacity(patterns.len()); + for (index, pattern) in patterns.iter().enumerate() { + match Regex::new(pattern) { + Ok(re) => rules.push(re), + Err(e) => { + return Err(BadPattern { + index, + pattern: pattern.clone(), + message: e.to_string(), + }) + } + } + } + Ok(Self { rules }) + } + + /// 伏せる式を 1 つも持たないか。 + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } + + /// 伏せた文字列と、伏せた件数を返す。 + /// + /// 式に `secret` という名前の組があれば、**その部分だけ**を置き換える。 + /// 名前や引用符は残るので、貼り付けた先には形が伝わる。 + /// 組が無ければ、当たった全体を置き換える。 + pub fn redact(&self, text: &str) -> (String, usize) { + let mut out = text.to_string(); + let mut hits = 0usize; + for re in &self.rules { + out = re + .replace_all(&out, |caps: &Captures| { + hits += 1; + let whole = caps.get(0).expect("当たり全体は必ずある"); + match caps.name("secret") { + Some(m) => { + let s = whole.as_str(); + let from = m.start() - whole.start(); + let to = m.end() - whole.start(); + format!("{}{REDACTED}{}", &s[..from], &s[to..]) + } + None => REDACTED.to_string(), + } + }) + .into_owned(); + } + (out, hits) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::PasteConfig; + + /// 既定の式で試す。設定の既定値そのものが正しいことを確かめたい。 + fn default_redactor() -> Redactor { + Redactor::new(&PasteConfig::default().redact).expect("既定の式は組み立てられる") + } + + #[test] + fn 既定の式はすべて組み立てられる() { + let r = default_redactor(); + assert!(!r.is_empty()); + } + + /// ~/.aws/credentials をそのまま貼った形。 + #[test] + fn aws_の認証情報ファイルの値を伏せる() { + let text = "[default]\n\ + aws_access_key_id = AKIAIOSFODNN7EXAMPLE\n\ + aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n"; + let (out, n) = default_redactor().redact(text); + // 名前は残り、値だけが消える。貼った先に形は伝わる。 + assert!(out.contains("aws_secret_access_key = [redacted]"), "{out}"); + assert!(out.contains("aws_access_key_id = [redacted]"), "{out}"); + assert!(!out.contains("wJalrXUtnFEMI"), "{out}"); + assert!(!out.contains("AKIAIOSFODNN7EXAMPLE"), "{out}"); + assert_eq!(n, 2); + } + + /// aws sts assume-role の出力を貼った形。 + #[test] + fn sts_の_json_の値を伏せる() { + let text = r#"{"Credentials": {"AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "SessionToken": "FwoGZXIvYXdzEHwaDEXAMPLETOKEN123456"}}"#; + let (out, _) = default_redactor().redact(text); + assert!(out.contains(r#""SecretAccessKey": "[redacted]""#), "{out}"); + assert!(out.contains(r#""SessionToken": "[redacted]""#), "{out}"); + // キー ID は語頭で分かるので、名前が付いていなくても消える。 + assert!(!out.contains("ASIAIOSFODNN7EXAMPLE"), "{out}"); + assert!(!out.contains("wJalrXUtnFEMI"), "{out}"); + assert!(!out.contains("FwoGZXIvYXdz"), "{out}"); + } + + /// GCP のサービスアカウントの鍵。値の中に改行(\n)が入るので、 + /// 「形が秘密らしいか」で絞ると通り抜ける。閉じ引用符まで取る。 + #[test] + fn gcp_のサービスアカウントの鍵を伏せる() { + let text = r#"{"type": "service_account", "project_id": "my-proj", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BA\n-----END PRIVATE KEY-----\n", + "client_email": "svc@my-proj.iam.gserviceaccount.com"}"#; + let (out, n) = default_redactor().redact(text); + assert!(out.contains(r#""private_key": "[redacted]""#), "{out}"); + assert!(!out.contains("MIIEvQIBADAN"), "{out}"); + assert!(!out.contains("BEGIN PRIVATE KEY"), "{out}"); + // 秘密でないものは残す。相手に文脈が伝わらないと質問にならない。 + assert!(out.contains(r#""project_id": "my-proj""#), "{out}"); + assert!(out.contains("svc@my-proj.iam.gserviceaccount.com"), "{out}"); + assert_eq!(n, 1); + } + + #[test] + fn google_の_api_キーと_oauth_を伏せる() { + // 実物と同じ長さ(AIza に続けて 35 文字)。 + let text = "curl 'https://x/v1?key=AIzaSyD_abcdefghijklmnopqrstuvwxyz01234' \ + -H 'Authorization: Bearer ya29.a0AfH6SMBexampletoken_-123'"; + let (out, n) = default_redactor().redact(text); + assert!(!out.contains("AIzaSyD_abcdefg"), "{out}"); + assert!(!out.contains("ya29.a0AfH6SMB"), "{out}"); + assert_eq!(n, 2); + } + + /// エージェントに貼るコードを壊さないこと。これが誤爆の本命である。 + #[test] + fn 秘密でないコードはそのまま貼る() { + let text = "let api_key = config.get(\"API_KEY\")?;\n\ + token = os.environ['TOKEN']\n\ + password = prompt()\n\ + // AKIA is the prefix of an AWS key id\n\ + aws_secret_access_key = None\n"; + let (out, n) = default_redactor().redact(text); + assert_eq!(out, text, "貼った内容が変わってはいけない"); + assert_eq!(n, 0); + } + + #[test] + fn 式が無ければ素通りする() { + let r = Redactor::new(&[]).unwrap(); + assert!(r.is_empty()); + let text = "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + let (out, n) = r.redact(text); + assert_eq!(out, text); + assert_eq!(n, 0); + } + + /// `secret` の組が無い式は、当たり全体を伏せる。 + #[test] + fn 組の無い式は当たり全体を伏せる() { + let r = Redactor::new(&[r"hunter2".to_string()]).unwrap(); + let (out, n) = r.redact("pw is hunter2 ok"); + assert_eq!(out, "pw is [redacted] ok"); + assert_eq!(n, 1); + } + + /// 壊れた式は、何番目かを言って断る。起動時に気づけるようにする。 + #[test] + fn 壊れた式は場所を言って断る() { + let e = Redactor::new(&["ok".to_string(), "[unclosed".to_string()]).unwrap_err(); + assert_eq!(e.index, 1); + assert!(e.to_string().contains("paste.redact[1]"), "{e}"); + } +}