Back to Portfolio

Case Study · 04 · OSS · MIT

mask-pipe

ターミナル出力中のシークレットを、画面に出る前に パイプでマスクする Go 製の単一バイナリ CLI

Problem

手元のターミナルからも、シークレットは画面の外へ漏れる

手元の端末は「信頼できる空間」だと感じやすい。でも開発者が意識しない経路から、シークレットは頻繁に画面の外へ漏れる。画面共有 中の envdocker-compose configペアプロ で同期される tmux / Live Share の出力、何千行も残る スクロールバック、そして AI アシスタントに貼り付けた ログに混ざる資格情報。録画された asciinema もディスクにシークレットを残す。

既存ツールはこのベクトルを守らない。secretlint は コミット前のファイル、TruffleHog は リポジトリ/ログの事後検出、GitHub add-maskCI ログ専用。どれも「ローカルの実行時 stdout が、いま画面に出る瞬間」は守ってくれない。その瞬間を埋める道具が手元になかったので、自分で作った。

Approach

パイプを一本挟み、表示前に行単位でマスクする

位置づけは明快にした ——「TruffleHog はコードを、secretlint はコミットを守る。mask-pipe は画面を守る」。競合ではなく、コミット前スキャナと事後検出の間を埋める補完として設計している。使い方は <command> | mask-pipe の一本のパイプだけ。stdin を行単位で読み、シークレットらしき部分をマスクして即 flush するので、tail -f のようなストリーミング用途でも遅延しない。

マスク対象は、設定なしで効く 8 つの高精度な組み込みパターン(GitHub PAT / トークン、AWS アクセスキー・シークレットキー、Stripe キー、JWT、DB URL のパスワード、PEM 秘密鍵)。方針は 再現率より精度を優先 している。誤検知を出すくらいなら検出漏れを許す、という割り切りにした。たとえば AWS シークレットキーは裸の 40 文字をマッチさせず、aws_secret_access_key= という 文脈キーがある場合だけ キャプチャ部を伏せる。各パターンが match/no-match 例を 5 件以上抱え、それをテストで縛ることで精度を担保している。

難所は PEM 秘密鍵 だった。20〜50 行に渡るブロックは全体を見ないとマスク判断ができないが、全入力をメモリに溜めると tail -f がハングする(ストリーミング保証違反)。そこで begin/end マーカーを検出した時 だけ バッファリングモードに入り、単一行パターンの遅延はゼロのまま複数行だけ溜める方式にした。安全上限(100 行 / 64KB)を超えたら 未加工のまま flush(fail-open) し、不正な入力でデータが消えないようにしている。

「全部自動でマスク」も検討したが、子プロセスの stdout にフィルタを挟むと子が pipe(非 TTY)を見て、色消失・TUI 破綻・isatty() の false 化を招く。これは Unix の本質的制約で、1Password op run が実証済みの失敗でもある。だから 明示パイプを既定 とし、LD_PRELOAD や zsh hook による自動ラップは ADR で明確に却下した。何がマスクされるかは --dry-run で破壊せず確認でき、list-patterns / doctor で設定と環境を点検できる。

リアルタイム・パイプフィルタ

stdin を行単位で読み、処理後すぐ flush。command | mask-pipe の一本で、ストリーミング出力でも遅延なくマスクする。

8 つの組み込みパターン

GitHub / AWS / Stripe / JWT / DB URL / PEM 鍵を設定不要で検出。「先頭 4 + * + 末尾 4」で長さを保ったままマスクする。

精度優先のパターン設計

裸の形式マッチを避け、文脈キーや URL 構造を要求。各定義に match/no-match 例を 5 件以上添え、テストで精度を縛る。

複数行バッファリング

PEM 鍵は begin/end マーカー検出時だけバッファ。単一行の遅延ゼロを保ち、上限超過時は fail-open でデータ消失を防ぐ。

TOML 設定

組み込みパターンの ON/OFF、独自正規表現([[custom]])、誤検知除外([[allowlist]])、マスク文字を上書き。ロード時に検証する。

dry-run / 診断サブコマンド

--dry-run で何が検出されるかを破壊せず確認。list-patterns / doctor / version --json で設定と環境を点検できる。

Language
Go 1.23 (CGO_ENABLED=0 / 単一バイナリ・依存ランタイムなし)
Dependencies
標準ライブラリ中心 + BurntSushi/toml のみ (ADR で正当化)
Core
internal/filter — 行単位ストリーム + 複数行バッファリング
Testing
標準 testing (テスト 47 / ベンチ 5、CI で -race + カバレッジ)
Release
goreleaser (darwin/linux/windows × amd64/arm64)
Distribution
Homebrew tap · go install · GitHub Releases バイナリ
License
MIT (public repo)

Result

末尾にパイプを一本足すだけで使える

c12o-dev/mask-pipe として MIT で公開し、v0.1.1 をリリース済み。Homebrew tap・go install・GitHub Releases のバイナリ で配布している。env | mask-pipedocker logs -f app 2>&1 | mask-pipekubectl logs pod | mask-pipe --show-tail 0 のように、いつものコマンドの末尾にパイプを一本足すだけで、画面共有・ペアプロ・AI 貼り付けの漏洩経路を表示前に塞げる。

クロスコンパイル(darwin/linux/windows × amd64/arm64)と CI(build → -race test → vet → gofmt)を備え、約 1,800 行・テスト 47 件のコンパクトな単一バイナリに収めた。テレメトリなし・ローカル完結・外部依存はひとつだけ。単機能の軽量バイナリに保つことを優先し、依存を足すときは ADR に理由を残すようにしている。

Learnings

作って分かったこと

精度優先は「文脈要求 + 例による検証」で実装に落ちる

裸の形式マッチは誤検知を量産する。aws_secret_access_key= のような文脈キーや URL 構造を要求し、match/no-match 例をテストで縛る。「精度を上げる」が思想で終わらず、パターン定義の具体的な形になる。

ストリーミング保証は、機能追加の制約になる

複数行マスクを「単一行の遅延ゼロ」を壊さずに足すには、対象を限定したバッファリングと fail-open が要る。性能保証を先に決めておくと、後から載せる機能の設計が自動的に絞られる。

Unix の制約は、実装バグではなく前提として扱う

自動マスクは子プロセスの TTY を壊す。これを実装の不具合ではなく Unix の前提と捉え、明示パイプを既定にした。自動ラップの誘惑を ADR で却下したことで、op run が踏んだ失敗を避けられた。

競合ではなく「補完」として位置づける

TruffleHog / secretlint と競うのではなく「守る対象=画面」「タイミング=リアルタイム」と置いた。何をしないかを決めたことで、8 パターンという小さなスコープと単一バイナリに収まった。