Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<secret>(AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16})\b',
'\b(?P<secret>(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<secret>[^"\s,;]{8,})"?',
'(?i)"(aws_secret_access_key|secretaccesskey|sessiontoken|private_key|client_secret)"\s*:\s*"(?P<secret>[^"]+)"',
'(?i)\b(aws_secret_access_key|aws_session_token|account_key)\b\s*[=:]\s*(?P<secret>[A-Za-z0-9/+=_.-]{16,})',
'(?i)\b(account_key|accountkey)\b\s*[=:]\s*(?P<secret>[A-Za-z0-9/+=_.-]{16,})',
'(?s)(?P<secret>-----BEGIN[ A-Z0-9_-]{0,100}?PRIVATE KEY(?: BLOCK)?-----.*?-----END[ A-Z0-9_-]{0,100}?PRIVATE KEY(?: BLOCK)?-----)',
'\b(?P<secret>AIza[0-9A-Za-z_-]{20,})\b',
'\b(?P<secret>ya29\.[0-9A-Za-z_-]+)',
'\b(?P<secret>GOCSPX-[0-9A-Za-z_-]{20,})',
]

[agent]
Expand Down
67 changes: 67 additions & 0 deletions docs/references/paste.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,73 @@ redact = ['(?i)"(private_key)"\s*:\s*"(?P<secret>[^"]+)"']

**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 文字の英数字に目印が無く、
Expand Down
24 changes: 18 additions & 6 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,29 @@ fn default_mask() -> bool {
fn default_redact() -> Vec<String> {
[
// AWS のアクセスキー ID(長期 AKIA、一時 ASIA ほか)。
r"\b(?P<secret>(AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16})\b",
r"\b(?P<secret>(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<secret>[^"\s,;]{8,})"?"#,
// JSON の中の値。aws sts の出力と、GCP のサービスアカウントの鍵。
// 閉じ引用符まで取るので、鍵の中の改行(\n)も丸ごと伏せる。
r#"(?i)"(aws_secret_access_key|secretaccesskey|sessiontoken|private_key|client_secret)"\s*:\s*"(?P<secret>[^"]+)""#,
// 裸の値。~/.aws/credentials と export の形。
r"(?i)\b(aws_secret_access_key|aws_session_token|account_key)\b\s*[=:]\s*(?P<secret>[A-Za-z0-9/+=_.-]{16,})",
// Google の API キーと OAuth の合鍵。
// 長さは決め打ちにしない。実物は AIza に続けて 35 文字だが、
// そこが 1 文字違うだけで素通りするほうが危ない。
// クラウド名が付かない形。Azure の接続文字列の AccountKey など。
r"(?i)\b(account_key|accountkey)\b\s*[=:]\s*(?P<secret>[A-Za-z0-9/+=_.-]{16,})",
// PEM の秘密鍵。塊ごと伏せる。JSON に入った鍵は上の式が拾うが、
// ファイルの中身をそのまま貼った場合はこちらが要る。
// 終わりの印を必ず求める。無いまま末尾まで伏せると、
// 「BEGIN …」と書いてあるだけの文章を巻き込む。
r"(?s)(?P<secret>-----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(?P<secret>AIza[0-9A-Za-z_-]{20,})\b",
r"\b(?P<secret>ya29\.[0-9A-Za-z_-]+)",
r"\b(?P<secret>GOCSPX-[0-9A-Za-z_-]{20,})",
]
.iter()
.map(|s| s.to_string())
Expand Down
106 changes: 97 additions & 9 deletions src/secret.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
Expand Down
Loading