diff --git a/README.md b/README.md index 8f08a32..82dbd97 100644 --- a/README.md +++ b/README.md @@ -166,9 +166,15 @@ 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: +config, not in the binary. The defaults cover AWS access key IDs; any +`AWS_`/`GOOGLE_`/`GCP_`/`GCLOUD_`/`AZURE_` variable whose name carries SECRET, +KEY, TOKEN, PASSWORD or CREDENTIAL; AWS secret keys and session tokens in +`aws sts` JSON; PEM private keys, whole; and Google API keys, OAuth tokens and +client secrets. + +Plain cloud settings survive on purpose — `AWS_REGION=us-east-1` and +`AWS_PROFILE=default` reach the agent unchanged, because an agent that cannot +see your region cannot answer the question you pasted. 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 @@ -372,11 +378,14 @@ mask = true # 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', + '\b(?P(A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b', + '(?i)\b(?:AWS|GOOGLE|GCP|GCLOUD|AZURE)_\w*(?:SECRET|KEY|TOKEN|PASSWORD|CREDENTIAL)\w*\s*[=:]\s*"?(?P[^"\s,;]{8,})"?', '(?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,})', + '(?i)\b(account_key|accountkey)\b\s*[=:]\s*(?P[A-Za-z0-9/+=_.-]{16,})', + '(?s)(?P-----BEGIN[ A-Z0-9_-]{0,100}?PRIVATE KEY(?: BLOCK)?-----.*?-----END[ A-Z0-9_-]{0,100}?PRIVATE KEY(?: BLOCK)?-----)', '\b(?PAIza[0-9A-Za-z_-]{20,})\b', '\b(?Pya29\.[0-9A-Za-z_-]+)', + '\b(?PGOCSPX-[0-9A-Za-z_-]{20,})', ] [agent] diff --git a/docs/references/paste.md b/docs/references/paste.md index b397999..8031fc5 100644 --- a/docs/references/paste.md +++ b/docs/references/paste.md @@ -70,6 +70,73 @@ redact = ['(?i)"(private_key)"\s*:\s*"(?P[^"]+)"'] **Advanced Paste のような対話窓。** 変換の一覧・試し表示・履歴は、端末の仕事を超える。 +## Claude Code の検出(2026-09-17 追記) + +「Claude Code には認証情報を検知する仕組みがある」という話を追い、 +手元の実物(`~/.local/share/claude/versions/2.1.273`)の中を見た。 + +**59 個の正規表現の表**を持っており、各項目は +`{id, source, flags, confidence}` の形をしている。 +**エントロピーによる検出は無い**(`entropy` の文字列は Node の内部と、 +`maskDuplicates` の説明文「long, high-entropy secrets 向け」にしか出てこない)。 + +確信度は 2 段である。 + +| 段 | 数 | 中身 | +|---|---|---| +| `high` | 約 50 | 形で断定できるもの。`AKIA`/`ASIA`、`ghp_`、`glpat-`、`xoxb-`、`sk-ant-…AA`、`AIza`、`GOCSPX-`、`dop_v1_`、`SG.`、`npm_`、`shpat_`、`sntryu_` ほか | +| `low` | 7 | 文脈で当てるもの。`sensitive-assign`(名前 = 値)、`cloud-env-var`、`http-auth-scheme`、`loose-jwt` ほか | + +**捕獲組の 1 番が「伏せる部分」**である(設定の `extract` も同じ約束)。 +PEM の秘密鍵だけは表の外で別に見ている。 + +### 段を「データの向き」で使い分ける + +同じ表から、用途ごとに違う段を引く。ここが一番学ぶところだった。 + +```js +scan(e) → high だけ。置換せず「何が見つかったか」を返す +redactTokens(e) → high だけ +redactContext(e) → low だけ +redact(e) → redactTokens(redactContext(e)) // low を先、high を後 +redactForDisplay(e) → high だけ +``` + +**出ていくものは両方使って過剰に伏せ、画面に出すものは high だけにする。** +表示で誤爆すると、利用者が読んでいる文章のほうが壊れるからである。 +値の式には `\[REDACTED\]` 自体も含まれていて、二度通しても壊れない。 +512 文字以下は結果を覚える(上限 512 件)。 + +### どこに効いているか + +公式の記述では、効いているのは **Anthropic 宛の telemetry と feedback だけ**である。 + +> Known API key and token patterns are redacted before upload. +> **Source code, file contents, and other conversation content are uploaded as-is.** + +つまり**モデルへ送る経路には掛かっていない**。そこを守るのは利用者が宣言する +2 つ(権限の拒否規則と、サンドボックスの `mask`)である。 +termit が貼り付け口で伏せるのは、ちょうどこの空いている場所にあたる。 + +### 取ったもの + +- **クラウドの環境変数**(`cloud-env-var`)。ただし**そのままは写さない**。 + あちらは `AWS_|GOOGLE_|AZURE_` で始まる変数の値をすべて伏せるが、 + telemetry 用だから伏せすぎても誰も困らない。こちらはエージェントに読ませる経路なので、 + `AWS_REGION=us-east-1` や `AWS_PROFILE=default` まで消すと質問が成り立たない。 + **名前に SECRET/KEY/TOKEN/PASSWORD/CREDENTIAL があるものだけ**に絞った。 +- **PEM の塊**。あちらは検出専用だが、こちらは伏せる。 + 終わりの印を必ず求める(「`-----BEGIN PRIVATE KEY-----` という行で始まります」と + 書いてある文章を巻き込まないため)。 +- **`GOCSPX-`** と `A3T[A-Z0-9]`、AWS の base32 の字種 `[A-Z2-7]`。 + +### 取らなかったもの + +**`low` の段そのもの。** `password|token|cookie|authorization` まで伏せる +`sensitive-assign` は、貼り付けたコードのほうを壊す。 +termit の貼り付けは `redactForDisplay` と同じく「人とエージェントが読んで意味を取る」 +経路なので、high 寄りに留めるのが正しい。 + ## 限界(利用者に伝えるべきこと) **裸で貼った AWS のシークレットキーは捕まらない。** 40 文字の英数字に目印が無く、 diff --git a/src/config.rs b/src/config.rs index bdc80f0..538c2b2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -50,17 +50,29 @@ fn default_mask() -> bool { fn default_redact() -> Vec { [ // AWS のアクセスキー ID(長期 AKIA、一時 ASIA ほか)。 - r"\b(?P(AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16})\b", + r"\b(?P(A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b", + // クラウドの環境変数。名前に秘密を示す語があるものだけを伏せる。 + // + // Claude Code は `AWS_|GOOGLE_|AZURE_` で始まる変数の値をすべて伏せるが、 + // あれは Anthropic 宛の telemetry 用で、伏せすぎても誰も困らない。 + // こちらはエージェントに読ませる経路なので、`AWS_REGION=us-east-1` や + // `AWS_PROFILE=default` まで消すと、質問そのものが成り立たなくなる。 + r#"(?i)\b(?:AWS|GOOGLE|GCP|GCLOUD|AZURE)_\w*(?:SECRET|KEY|TOKEN|PASSWORD|CREDENTIAL)\w*\s*[=:]\s*"?(?P[^"\s,;]{8,})"?"#, // 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 文字違うだけで素通りするほうが危ない。 + // クラウド名が付かない形。Azure の接続文字列の AccountKey など。 + r"(?i)\b(account_key|accountkey)\b\s*[=:]\s*(?P[A-Za-z0-9/+=_.-]{16,})", + // PEM の秘密鍵。塊ごと伏せる。JSON に入った鍵は上の式が拾うが、 + // ファイルの中身をそのまま貼った場合はこちらが要る。 + // 終わりの印を必ず求める。無いまま末尾まで伏せると、 + // 「BEGIN …」と書いてあるだけの文章を巻き込む。 + r"(?s)(?P-----BEGIN[ A-Z0-9_-]{0,100}?PRIVATE KEY(?: BLOCK)?-----.*?-----END[ A-Z0-9_-]{0,100}?PRIVATE KEY(?: BLOCK)?-----)", + // Google の API キー、OAuth の合鍵、OAuth の client secret。 + // 長さは決め打ちにしない。1 文字違うだけで素通りするほうが危ない。 r"\b(?PAIza[0-9A-Za-z_-]{20,})\b", r"\b(?Pya29\.[0-9A-Za-z_-]+)", + r"\b(?PGOCSPX-[0-9A-Za-z_-]{20,})", ] .iter() .map(|s| s.to_string()) diff --git a/src/secret.rs b/src/secret.rs index e50f85c..755a687 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -77,17 +77,20 @@ impl Redactor { 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(), + let (from, to) = match caps.name("secret") { + Some(m) => (m.start() - whole.start(), m.end() - whole.start()), + None => (0, whole.len()), + }; + let s = whole.as_str(); + // 既に伏せてあるものを数え直さない。式は重なることがあり + // (`AWS_SECRET…=AKIA…` は語頭の式と名前の式の両方に当たる)、 + // そのたびに数えると「2 件伏せた」と嘘の件数が出る。 + if &s[from..to] == REDACTED { + return s.to_string(); } + hits += 1; + format!("{}{REDACTED}{}", &s[..from], &s[to..]) }) .into_owned(); } @@ -182,6 +185,91 @@ mod tests { assert_eq!(n, 0); } + /// `export AWS_SECRET_ACCESS_KEY=…` の形。クラウドの変数をまとめて拾う。 + #[test] + fn クラウドの環境変数の値を伏せる() { + let text = "export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n\ + export AWS_SESSION_TOKEN=FwoGZXIvYXdzEHwaDEXAMPLETOKEN123456\n\ + export AZURE_CLIENT_SECRET=Abc123~defGHIjklMNOpqrSTUvwxYZ01234\n"; + let (out, n) = default_redactor().redact(text); + assert!(out.contains("AWS_SECRET_ACCESS_KEY=[redacted]"), "{out}"); + assert!(out.contains("AWS_SESSION_TOKEN=[redacted]"), "{out}"); + assert!(out.contains("AZURE_CLIENT_SECRET=[redacted]"), "{out}"); + assert_eq!(n, 3); + } + + /// 秘密でないクラウドの設定は残す。ここが Claude Code の式と変えたところで、 + /// あちらは telemetry 用なので `AWS_REGION` まで伏せる。 + /// エージェントに読ませる経路では、これを消すと質問が成り立たない。 + #[test] + fn クラウドの設定値は残す() { + let text = "AWS_REGION=us-east-1\n\ + AWS_PROFILE=default\n\ + AWS_DEFAULT_OUTPUT=json\n\ + GOOGLE_CLOUD_PROJECT=my-project-123456\n"; + let (out, n) = default_redactor().redact(text); + assert_eq!(out, text, "設定はそのまま届く"); + assert_eq!(n, 0); + } + + /// 鍵のファイルをそのまま貼った形。塊ごと消す。 + #[test] + fn pem_の秘密鍵を塊ごと伏せる() { + let text = "ここに鍵があります\n\ + -----BEGIN RSA PRIVATE KEY-----\n\ + MIIEowIBAAKCAQEAxGZ1kQ0pS7vN8mKcZ3rTjWq\n\ + aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abc\n\ + -----END RSA PRIVATE KEY-----\n\ + これで全部です"; + let (out, n) = default_redactor().redact(text); + assert!(!out.contains("MIIEowIBAAK"), "{out}"); + assert!(!out.contains("BEGIN RSA PRIVATE KEY"), "{out}"); + // 前後の文は残る。何を聞きたかったのかが伝わらないと意味がない。 + assert!(out.contains("ここに鍵があります"), "{out}"); + assert!(out.contains("これで全部です"), "{out}"); + assert_eq!(n, 1); + } + + /// 終わりの印が無ければ伏せない。 + /// 「-----BEGIN PRIVATE KEY----- と書くと…」という文章を巻き込まないため。 + #[test] + fn 終わりの無い_pem_は伏せない() { + let text = "PEM は -----BEGIN PRIVATE KEY----- という行で始まります"; + let (out, n) = default_redactor().redact(text); + assert_eq!(out, text); + assert_eq!(n, 0); + } + + #[test] + fn google_の_client_secret_を伏せる() { + let text = "GOCSPX-abcdefghijklmnopqrstuvwxyz01"; + let (out, n) = default_redactor().redact(text); + assert_eq!(out, "[redacted]"); + assert_eq!(n, 1); + } + + /// 式が重なっても、件数は実際に伏せた数のままであること。 + /// `AWS_ACCESS_KEY_ID=AKIA…` は語頭の式と変数名の式の両方に当たる。 + #[test] + fn 重なった式で件数が増えない() { + let text = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"; + let (out, n) = default_redactor().redact(text); + assert_eq!(out, "AWS_ACCESS_KEY_ID=[redacted]"); + assert_eq!(n, 1, "1 つの秘密は 1 件と数える"); + } + + /// 二度通しても結果が変わらず、二度目は 0 件であること。 + #[test] + fn 二度伏せても変わらない() { + let r = default_redactor(); + let (once, n1) = + r.redact("aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); + let (twice, n2) = r.redact(&once); + assert_eq!(once, twice); + assert_eq!(n1, 1); + assert_eq!(n2, 0); + } + #[test] fn 式が無ければ素通りする() { let r = Redactor::new(&[]).unwrap();