trufflehogを活用したGitHub Organizationのcredentialsスキャン

こんにちは、セキュリティチームの@sota1235です。

突然ですが、ソフトウェアエンジニアの皆さんに質問です。他者に漏らしてはいけないAPI keyやSSHのprivate keyを誤ってGitHubにpushしてしまったことはありますか?私はあります。*1

日々、スピード感を持ってものづくりに臨んでいく中で本当はcommitしてはいけないものを間違ってcommitしたり、それに気づかずにGitHubにpushしてしまうなんてことは人間がミスをする生き物である以上、誰にでも起きえる事故です。

今回はそんな事故を検知するのにtrufflehogを活用しているお話をします。

なお今回は事故を未然に予防する話には触れません。

github.com

credentialsを誤ってGitHubにpushすることのリスク

credentialsとは

この記事におけるcredentialsとはまず何か、について触れておきます。

credentialsとは「自社のメンバー含め、最低限のメンバーのみ知っていることが保証されるべき情報」です。

例えばSaaSのAPI key、Google CloudのService Account key、AWSのAccess key、SSHのprivate keyなんかはこれに該当するでしょう。

世に公表すべきでない情報と解釈してもいいと思います。

credentialsのあるべき管理方法

credentialsはその性質から「そのcredentialsを知る、もしくは直接アクセスできるメンバー数を最小にする」ことが望ましいです。

考え方としては最小権限の原則に近しいものがあり、可能性としての漏洩経路の数を減らす、漏洩時の経路の特定コストを下げる等の目的があります。

この目的を達成するため、credentials保管場所も同じく「直接アクセスできるメンバー数を最小にする」ことが望ましいです。

GitHubへのpushのリスク

これらを踏まえるとcredentialsをGitHubにpushすることは最も望ましくないミスオペのうちの1つと言えるでしょう。

public repositoryはもちろん、private repositoryであってもOrganizationの権限設定によっては全Organization memberが閲覧できる状態となります。また、メンバー以外でもそのRepositoryへのRead権限を持つPAT、GitHub App、GitHub OAuth App等も閲覧しうる状態となってしまうため基本的にはpushしてしまったら即revokeすべきとなります。

また、GitHubはその性質上、一度Pushしてしまったコードを完全に消去することは困難です(例えばブランチを消したりRepositoryを消しても特定の条件ではコードへの参照が残るケースがあります)。Pushしたコードが通常の業務フローで消滅することもほぼないためそこも注意が必要なポイントです。

ちなみにpublic repositoryへのpushはGitHubによりpushそのものを弾く設定が無料で可能です。個人開発か業務かに関わらず、有効化しておくといいでしょう。

docs.github.com

組織としてリスクを検知することを考える

きっかけ

10Xでは長らく、このリスクの能動的な探索や検知を行えていませんでした。結果として以下のような状態となっていました。

  • そもそもcredentialsの定義が人によって認識が違った
    • あるあるだと思いますがSlackのIncoming Webhook URLを機密情報と考えるメンバーもいれば、そう考えずにハードコードするメンバーもおり、それによる割れ窓が進んだりしていました
  • credentialをpushしてしまった際の対応に(おそらく)ばらつきがある
    • セキュリティチームに相談が来るパターンもありましたが、おそらく相談されずに対処が行われたパターンもあったと思います
    • その対処が本当に適切かどうかは等しい基準で評価する必要があります
  • そもそも気づいていないパターンがある
    • 今回の取り組みで創業当初にcommitされた重要なSaaSのAPI keyが見つかる等しました…

そのため、まずは現状への対処をしつつ再発防止と復旧手順の標準化を行う必要があると考えました。

ツールの選定

達成したいことは「GitHubにPushされたcredentialsをなるべく早く検知する」ことです。これを行う際にまず選択肢にあがるのがGitHub Advanced Securityです。

docs.github.com

もしこの選択肢を選べる場合はこれが一番おすすめです。なぜならそもそも検知した際にPush自体をブロックすることが可能なためです。

一方で10Xはコストの観点からこの選択肢は見送ることにしました。具体的なコストは各自で調べてみてください。

また、細かい話ではあるんですがGitHub Advanced Securityだと検知できないcredentialsが一部あり、例えばBase64エンコードされたcredentialsや圧縮されたものは検出できません(2024/11現在)。また、後述するtrruflehogがサポートするIssueやPull Requestのコメント中のcredentialsの検知はできません。

これに関しては下記の記事で詳しく書いてあるので読んでみてください。

trufflesecurity.com

ついで上がってくるのがOSSの利用です。

有名なところとしては以下が挙げられます(他にもいくつかあります)。

10Xではこの中から有識者からの推薦、preset ruleの多さ、導入のしやすさ等の観点からtrufflesecurity/trufflehogを選択しました。

trufflehogの導入手順

まずは地ならし

さっそく検知の仕組みを作りましょう!と言いたいところですが、まずは現時点でcredentialsスキャンをした場合に検知されるものがあるかどうかを洗い出し、あるのであれば対応を行う必要があります。

基本的にはスキャンして検出されたものをrevoke、ローテーションしていくだけなんですがtrufflehogの使い方に少しコツが必要だったので紹介します。

repository scan or organization scan

trruflehogはオプションでスキャンする環境を指定できます。例えばAWS S3のBucketに対してスキャンをかけるようなことも可能だったりします。

このオプションの1つとしてgithubがあるわけですが、このオプションを指定する際に特定のOrganization全体をスキャンする方法 - --orgオプションを使用する方法と単一のRespositoryをスキャンする方法 - --repoオプションを使用する方法があります。

直感的にはまず全部をスキャンしたいのであれば--orgオプションを使用するのが良いですが、Organizationのサイズ感によってはスキャン時にGitHubのAPIを叩きすぎてrate limitに引っかかるという問題があります。私は引っかかりました。

--concurrencyオプションを利用することで処理を緩めることはできるんですが、10Xの場合オプションの試行錯誤ではどうにもならなかったので以下のようなshellを書いて業務時間の合間にゆっくりとスキャンをかけるようにしました。

ちなみにこのshellはOrganizationに含まれるRepositoryの数が1000個以下に収まる前提になってるので注意してください(gh repo listを叩いてる部分)。

$GH_TOKENには自分に紐づく一時利用用のPAT(fine-grained token)を発行すると良いでしょう。

#!/bin/bash

# Usage: ./run_trufflehog.sh <organization>
# Example: ./run_trufflehog.sh my-org

# Validate input
if [ -z "$1" ]; then
  echo "Organization is required."
  exit 1
fi

ORG=$1

# Get the list of repositories from the GitHub organization including archived ones
REPOS=$(gh repo list $ORG --json name --limit 1000 --jq '.[].name')

if [ $? -ne 0 ]; then
  echo "Failed to fetch repository list."
  exit 1
fi

# Loop through each repository and run trufflehog
for repo in $REPOS; do
  echo "Scanning repository: $repo"
  trufflehog github \
    --only-verified \
    --token=$GH_TOKEN \
    --repo="https://github.com/$ORG/$repo" \
    --issue-comments --pr-comments \
    --json \
    --concurrency=6 > "results/${repo}_result.json"
  # trufflehog git --only-verified --json "https://github.com/$ORG/$repo.git" > "${repo}_result.json"

  # Wait for 1 minute to avoid GitHub rate limiting
  echo "Waiting for 1 minute to avoid rate limit..."
  sleep 60
done

echo "Completed all scans."

読めばわかるコードではあるんですがポイントを掻い摘むと以下のような感じです。

  • trufflehog実行時に--issue-comments --pr-commentsオプションを利用する
    • これによりIssueやPull Requestのコメントもスキャン対象に含みます
    • そんなとこにcredentialsないでしょーって思うかもしれませんが付与推奨です(10Xは2個、見つかりました)
    • commentの場合、編集されたケースはスキャンできないので注意してください
  • --only-verified
    • このオプションがめちゃくちゃ便利で、原理上可能な場合は検出したcredentialsがrevoke済みかどうかを検証してくれます
  • 結果はJSONに出力しておく
    • ちゃんと残さないと長い時間かけて再スキャンする羽目になります(3敗)

これでまずは現時点のスキャン結果を出力することができました。

1つ1つ対応する

弊社は当時はpublic repositoryがなかったので検出されたものに対して1秒でも早くrevokeを、という状況ではありませんでした。一方で長い間、GitHub上で残っていたものは念の為確認できる範囲の監査ログの確認や、そのcredentialsでアクセス可能なリソースの異変等は最低限確認の上、revokeやローテーションを行いました。

中には個人Slackのcredentialsなんかもあったり

ちなみに1点、注意点としては先ほど紹介した--only-verifiedオプションによりcredentialsを用いたアクセスログが発生するケースがあり、それを不正アクセスと勘違いしてしまわないように注意です。

犯人は自分だった案件

検知の要件

まずは以下で検知の仕組みを作り、運用を開始することにしました。

  • dailyでGitHub Organizationのスキャンを行う
  • スキャンの対象は直近、24時間の間に更新記録があったRepositoryに絞る
  • credentialsのcommitもしくはコメントを検知した場合はSlack通知を行う
  • 検出される結果のうち問題のないものはconfig等で通知しないように設定できる

全体像としては以下のような感じです。

ざっくり全体像

図を見ていただいた通りわかると思うのですが、要件を満たすには以下の3つを行うためのプロセスが必要です。図のなかの「?」部分に当たります。

  • trufflehog実行対象repositoryのリストアップ
  • 対象repositoryに対するtrufflehogの実行と結果のfilter
  • 結果に基づくSlack通知

このプロセスの実装時の技術スタックや手段はお好みでどうぞという感じです。10XではNode.jsを作って簡易的なscriptを実装しました。テストが書けて保守性が高ければなんでもいいと思います。

それぞれのステップの実装に関して順に触れていきます。

1. trufflehogの実行対象となるGitHub repositoryを取得

trufflehogはスキャンを行う際、過去のcommit logも遡ってスキャンを行います。

そのため、時間が経てば経つほどスキャンの対象量が増えていくためdailyスキャンが目的であれば「実行時の前日に更新があったRepositoryのみにスキャンする」ので十分ですし実行時間を短くできます。

そのためまずは上記の条件でRepositoryを取得します。Node.js(Typescript)でSDKを利用した実装例はこんな感じです。

import { Octokit } from "@octokit/rest";
import type { Endpoints } from "@octokit/types";
import { isAfter, parseISO, startOfYesterday } from "date-fns";

type GetOrgRepositoryListResponse =
  Endpoints["GET /orgs/{org}/repos"]["response"];
type Repositories = GetOrgRepositoryListResponse["data"];

export async function getRepositories(): Promise<Repositories> {
  const octokit = new Octokit({ auth: process.env.SCRIPT_GH_TOKEN });
  const repos = await octokit.paginate("GET /orgs/{org}/repos", {
    org: "your-org-name",
  });
  return repos.filter((repo) => {
    const { pushed_at: pushedAt } = repo;
    if (pushedAt === null || pushedAt === undefined) {
      return false;
    }
    const pushedAtDate = parseISO(pushedAt);
    return isAfter(pushedAtDate, startOfYesterday);
  });
}

注意点として、昨日更新されたかの判定にpushed_atを参照していますが、これだとコードは更新されてないけどIssueやPull Requestコメントが更新されたパターンを検知できません。

trufflehogはIssues, Pull Requestもスキャン対象に含められるのでコードの更新頻度が極端に少なく、Issueは活発に更新されるようなRepositoryがある場合にはIssueやPull Requestの更新を見たり、該当Repositoryをスキャン必須にするような変更が必要となります。*2

2. trufflehogの実行 / 3. credentialsのscan

取得したRepositoryに対して順にtrufflehogの実行を行います。基本的にはREADME通りに実行すれば良いですが、以下のポイントがあります。

実行時に付与するcredentialsにはPAT(fine-grained token)を利用する

2024/11現在、trufflehogはGitHub App tokenによる認証を実装していません。理由としてはサポートすることで実装が複雑化するからとのことです。

github.com

trufflehogにはOSS版の他に有料で利用できるEnterprise版があり、20名以上のOrganizationではそちらの利用が推奨されています。その採用可否はともかくとして、dailyスキャンの自動化の際には何かしらの認証情報が必要なので現状だときちんと管理されたPAT(fine-grained token)が一番セキュアかなと思います。

出力形式はJSONを指定する

trufflehogはデフォルトだとparseしづらい出力なので--jsonオプションをつけてJSON形式で結果を出力します。また、Repositoryごとに独立してtrufflehogを実行することになるため実行結果はファイル等に吐き出しておいて後で加工できるように一時保存等しておく必要があります。

スキャン結果がなければ何も出力されませんが、結果がある際は結構な出力の量になるのでscript経由等でCLI実行する場合にはstreamingで標準出力の内容を扱う必要があります。

実行間隔、並列数を適切に調整する

地ならしの部分でも触れましたが、素直にtrufflehogを実行しまくるとGitHub APIのrate limitに引っ掛かります。なので --concurrencyで並列実行数を調整しつつ、Repositoryごとのスキャンの間隔もある程度余裕を持ってsleepしておくと良いです。

もしくはrate limitはPAT(fine-grained token)でのアクセスの場合はそれぞれにかかるので複数のPAT(fine-grained token)を発行するという手もなくはないです。が、credentials管理の煩雑さの観点からあまり推奨はできないかなと思います。

4. 出力結果の取得 / 5. 出力結果の加工

全てのRepositoryに対するスキャンが完了したら結果をparseし、必要なfilter処理等を行います。

trufflehogで検知される項目は一般的には必ず対応すべきものがほとんどですが以下の2つのケースでは検知結果を無視する、というケースがあると思います。

  • trufflehogによりcredentialsの有効性が原理上確認できず、--only-verifiedをつけても検出され続けるもの
    • 例えばSSH keyは接続先の情報をtrufflehogが持ってるわけではないので有効性が確認できず、安全側に倒して検知し続けます
    • たとえSSH keyをrevokeしても検知は止まりません
  • リスク評価により、検知結果を受容できるもの
    • あまりないと思いますが、対応のコストと対処できるリスクの大きさがあまりにも見合わない、みたいなケースがありえます

これらのケースではdailyスキャンでは通知されないように処理しないとnoiseとなってしまい、alertを最終的に見なくなってしまうためあらかじめ決められたfilter処理を行います。

このfilter処理が実は難しくて、やることは出力されたJSONの配列から検知しなくていいものを落とすだけなんですが厄介なことにこのJSONの構造がundocumentedかつ、検知項目ごとにかなりkey等が可変となります。

最初の実行時に十数個の検知項目に基づく実際の出力を確認したのですが、1つ1つの検知に対してUniqueなkeyを特定するのが難しかったです。

なので一旦、10Xでは「このkeyがこのvalueだったら除外する」といった指定ができるだいぶ自由度の高いconfigを設定できるようにして運用しています。

実装イメージは以下のような感じです。

type IgnoreTargets = {
  [repoFullName: string]: {
    // 結果のJSONのうちチェックしたいkeyを指定する
    key: string;
    // keyに対して無視したい値を指定する
    value: string;
  }[];
};

const ignoreTargets: IgnoreTargets = {
  "your-org-name/your-repo-name": [
    // ここに除外する理由を詳細に書いておく
    // 例) 検出されるSSH keyはテスト用のdummyであり、漏洩してもリスクはない
    {
      key: "Redacted",
      value: "hogehoge",
    },
  ],
};

export function filterResults(
  repoFullName: string,
  results: unknown[],
): unknown[] {
  const filters = ignoreTargets[repoFullName];
  if (filters === undefined) {
    return results;
  }

  return results.filter((result) => {
    const isIgnoreTarget = filters.some((target) => {
      // @ts-ignore
      const shouldIgnore = result[target.key] === target.value;

      if (shouldIgnore) {
        console.log(`Filtered: ${target.key}=${target.value}`);
      }

      return shouldIgnore;
    });

    return !isIgnoreTarget;
  });
}

これがベストだとは思ってないんですが、どうしてもtrufflehog公式のconfig等では結果のfilterをできる機能が見つけられなかったことや出力結果と向き合って考えた結果、まずはこんな運用で落ち着いています。betterな方法をご存知の方いたらこっそり教えて欲しいです(まじで)

6. Slackに通知する

SWEであればN回実装したことあるような仕組みだと思うので詳細は省略します。

That's all! これでdailyスキャンの仕組みが完成しました 👏

trufflehogのPros / Cons

実際にこの仕組みを作り運用を開始してからそこそこの時間が経っています。その中で感じたtrufflehogのPros / Consを簡単にまとめます。

👍 簡単に使い始められる

いくつか癖はあるものの、手元で動かすだけであれば数分で利用できるくらい簡単に使い始めることができます。

ドキュメントがあまりないとはいえ、最低限のオプションに関してはREADMEを読めばわかりますし出力結果も直感的に読めると思います。

👍 デフォルトでの検出項目が豊富

競合OSSと全て比較したわけではないですが、実際の環境で実行してみた感覚としては「こんなサービスのAPI keyも検出してくれるのか!」と感じるくらい、検出項目の数が多く感じました。

またそれらに対しての--only-verfiedが本当にありがたいと思っていて、例えば「有効かどうかわからないけど検出された触ったことのないSaaSのAPI key」が検出されるとそのAPI keyの有効性を確かめる方法はあるのかを毎度、調べる必要がありますがそれをtrufflehogが肩代わりしてくれます。

これだけの項目をOSSで提供してくれていることに対して、Contributorsに深い感謝をしています。

🤔 出力結果の取り回しが不便

わざわざNode.jsのscriptを書いた理由がこれに起因するのですが、出力されるJSONの型の柔軟性が高かったり(性質上しょうがないとは思う)、出力結果にunique key等がなくfilterが難しかったり、詳細なドキュメントがなかったりしたことでやりたいことに対して少し大袈裟な仕組みを作らざるおえなかったなと感じます。

おそらくですがこの辺何も考えずに済むよ、というのが有料版のtrufflehogなのかもと思ってますし無料でここまでできることを考えると十分受容可能なデメリットだと思ってます。

😊 --only-verifiedオプションが便利

何回でも言いますがこれが本当に便利です。最初の地ならしの時に、対応後のテストとしても機能するんですよね。

検知された項目をrevoke → trufflehogを再実行して検知が消えるのを確認、というライフサイクルを回せるのは本当にありがたかったですし作業漏れの心配なく改善を進めることができました。

残りの課題

そもそもの予防

この記事では触れませんでしたが、そもそもpushしてしまわないことがベストです。

それを考えると全ての開発者のgit hooksにtrufflehogなりを実行する仕組みを作ったり等を検討する必要があります。

GitHub以外の領域での予防・検知

今回、学びだったのがGitHubのコード以外にもcredentialsが紛れ込むケースがあるということでした(少ないですがありました)。

ということは例えばタスク管理ツール等でも同じようにcredentialsを意図せずコメントする等はあると考えた方がいいと思っており、一方で対応コストを考えるとまずは文化醸成やポリシーの徹底等でお茶を濁しつつ、組織の規模感に合わせて手を打っていくのが現実的なところかなと思います。

最後に

trufflehogは利用方法や活用方法に関するリファレンスが日本語だと特に見つからなかったり、公式ドキュメントがほとんどなくissueやコードを追う必要がありました。そのためこの記事がどこかの誰かにとって役立つことを願ってます。

また、仕組みの構築の際は一部でABEJA Tech Blog: trufflehog x pre-commit & GitHub Actions で GitHubのセキュリティを強化したってばよを参考にさせていただきました。

みなさまのセキュアで快適な開発ライフを祈って🙏

*1:かなり昔の話ですし、もちろんrevoke済みです

*2:この辺をいい感じに取れる便利なAPIをご存知の方がいたら教えてください