🔍

サプライチェーン攻撃対策の「実効」を継続検証するGitHub監査基盤を内製した話

に公開

はじめに

こんにちは、スマートラウンドの@shonansurvivors です。

近年、サプライチェーン攻撃のニュースを目にする機会が増えてきました。弊社でも各種の対策を打ってきたのですが、その「実効」を継続的に保証する仕組みが手薄でした。

本記事では、その実効監査のために内製した社内監査基盤の設計思想と、なぜ既存ツールではなく自作したのか、そしてどのようなチェック処理を書いているのかをご紹介します。

要約

  • 弊社ではpnpm minimumReleaseAge / GitHub ActionsのSHA pinning / Takumi Guardなど、サプライチェーン対策を段階的に導入してきました
  • ところが「新規リポジトリで未設定だった」「既存リポジトリ配下に後から追加されたnpm / Pythonプロジェクトのサブツリーは未設定だった」「設定はしているがpnpm/npmのバージョンが古く、minimumReleaseAgeが実は機能していなかった」といった「設定 ≠ 実効」のリスクを横串で検証する仕組みが無く、PythonベースのGitHub設定の継続監査基盤を内製しました
  • ghqrやGitHub Advanced Securityも評価しましたが、スコープや運用ガバナンスの仕組み、細粒度の文脈判断の難しさから自作を選択しています

サプライチェーン攻撃とGitHub侵害リスクの高まり

2024年以降、ソフトウェア開発者を狙ったサプライチェーン攻撃が継続して話題に上がっています。npm / PyPIへの悪性パッケージ混入、CI/CDパイプライン経由のシークレット流出、開発者アカウントへのフィッシング等による組織への侵入など。どれも他人事として済ませにくく、「いざという時に自社のGitHubは本当に守られているか」を改めて点検する必要性を強く感じる状況です。

そして点検していくと、こうした攻撃への対策は、ひとつの決定打で片付くものではなく、複数の層を組み合わせて積み上げていくしかない、ということが見えてきます。

弊社が積み上げてきたサプライチェーン攻撃対策(主なもの)

弊社が現時点で導入している主な施策を、本記事の後半で踏み込む観点を中心にピックアップして列挙します。これだけが対策の全てではなく、組織レベルの基本的な対策はこの他にも一通り導入しています。

依存パッケージの取得・更新まわり

  • Takumi Guard(GMO Flatt Security提供のnpm/pip registry proxy)の利用。npm.flatt.tech/pypi.flatt.techを経由することで、悪性パッケージの混入を取得手前で防ぎます。
  • pnpmのminimumReleaseAge / uvのexclude-newer。依存パッケージが公開されてから一定日数経過するまでインストールしない設定で、公開直後の悪性版を発見前に踏むリスクを下げます。
  • このほか、Renovate経由の自動更新PRにも同等の防御を効かせる、Dependabotで脆弱性アラートと自動セキュリティ更新を有効にする、などを併用しています

GitHub Actions / CIまわり

  • GitHub Actionsのカスタムアクションをcommit SHAで固定@v1のようなタグ指定ではなく、@<40 hex sha>まで指定します。タグは公開元で指す先を書き換えられるため、過去にレビュー済みのバージョンを確実に固定する目的です。
  • GITHUB_TOKENの権限を最小化permissions: read-allのような広めの指定は避けて、各jobで必要な権限だけを個別に明示する(不要ならpermissions: {})方針です。
  • このほか、pull_request_targetの利用制限とスクリプトインジェクション防止などを併用しています

組織・権限・シークレットまわり

  • 2FA強制mainブランチのbranch protectionメンバーのデフォルト権限の最小化といった組織レベルの基本設定
  • GitHub App / OAuth App / PATの継続棚卸し(インストール状況、全リポジトリへの高権限を持つAppの抽出、組織が承認しているfine-grained PATの一覧化と棚卸しなど)
  • GitGuardian によるシークレットの常時検知と、gitleaks による定期監査の組み合わせ

ここまで挙げた施策は、弊社が時間をかけて少しずつ積み上げてきたものです。ひとつひとつは公開記事も多く、導入の最初の一歩は思ったよりも踏み出しやすかったのですが、苦戦したのはそこから先でした。それぞれの施策を、導入時には「効いている」ことを確認できたものの、その後の継続状態は誰も保証していないのです。レビュー文化があっても、次のような落とし穴は残ります。

  • 新規リポジトリを作るたびに、.npmrcのregistry設定や各jobのpermissions指定が抜けていても気付きにくい。レビューで漏れを指摘するには「あるべき項目をすべて記憶している人」が必要になる。
  • pnpm-workspace.yamlminimumReleaseAgeが書いてあっても、package.jsonpackageManagerで指定しているpnpmのバージョンが古くて実は機能していない(後述)、というようなケースは、設定ファイルを目で見るレビューでは見抜けない

これらを毎週1回全リポジトリを目視で点検するような運用で潰し続けるのは到底現実的ではありません。入れた施策の実効を継続的に維持し続けられる自信が正直なところ持てなかった、というのが本音で、それが、本記事で紹介する社内監査基盤を内製することになった直接の動機です。

なぜ既存ツールではなく自作したか

最初は当然、既存のOSS / SaaSを組み合わせて解決できないかを検討しました。GitHubのセキュリティpostureを管理するために特に検討に値する既存ツールは、以下の2つです。

  • ghqr(Microsoft): GitHub Enterprise / Organization / Repositoryの設定をベストプラクティス基準で評価するGo製CLI。Markdown / Excel / JSONでレポートを出力します。
  • GitHub Advanced Security / Security Overview: GitHub純正の有料機能群。secret scanning / Dependabot / code scanningなどを束ねた可視化ダッシュボードです。

結論としては、これらをそのまま採用するのは難しいと判断しました。

ghqrとの棲み分け

ghqrを採用しなかった理由は2つあります。

1つ目は、ghqrが「現在のポスチャーをスキャンしてレポートにするCLI」であり、設計思想がスナップショット型であるためです。本基盤の心臓部であるIssueの自動upsert / auto-close / Slack通知 / 例外のgit集中管理 / 期限切れ例外の自動再評価 / 人による棚卸しのリマインダーといった、継続検証と運用ガバナンスの仕組みは、別途用意する必要があります。

2つ目は、後述する「細粒度の文脈判断」を要するチェックが、汎用ツールではそもそも表現しづらいことです。

仮にghqrを採り入れるとしたら、その出力(JSONなど)を本基盤のIssue起票やSlack通知につなげるためのアダプタ層を別途用意することになります。しかし、外部ツールの仕様変更に合わせてアダプタを保守し続けるよりも、ghqrが公開しているチェック仕様を参考にしつつ、自分の都合の良いインターフェースで実装してしまう方が、AIコーディングを活用できる環境においては総合的にラクだと感じます。

GHASのスコープが部分的

GitHub Advanced SecurityのSecurity Overviewは強力ですが、secret scanning / Dependabot / code scanningの活用状況の可視化が中心です。弊社のユースケースで重点的に見たい「新規リポジトリで.npmrcのregistry設定が抜けていないか」「packageManagerの宣言がminimumReleaseAgeの要件を満たしているか」のような細かい監査は、GHASのスコープには含まれません。

細粒度の文脈判断が難しい

これが個人的に一番大きな理由でした。「設定があっても実効していない」を見抜く判定は、思いのほか込み入っています。

たとえばnpmのmin-release-age設定は、npm v11.10.0以上でないと機能しません。さらにpackage.jsonengines.npmで「npm 11.10.0以上」を宣言していたとしても、それはnpmのデフォルトでは警告のみで通り抜けてしまうため、古いnpmでもinstallは通ってしまいます。古いnpmはこの設定そのものを読まないため、release_age防御は気付かないうちに無効化されたままになります(npmのバージョンによっては未知のキーとして警告が出ますが、いずれにせよinstallは通ります)。

これを防ぐには、.npmrc: engine-strict=trueを二重に揃える必要があります。つまり「engines.npmで最小バージョンを宣言する」かつ「engine-strict=trueで警告をEBADENGINEによるinstall失敗に格上げする」が両方揃って、はじめてrelease_ageが実効します。

このような「設定の存在 × 実効条件 × プロジェクト固有の事情」を組み合わせた判断は、汎用ツールのポリシーDSLでは表現しづらく、書けても保守が辛くなりがちです。Pythonの関数としてif文で書いた方が、結局はメンテしやすいと考えました。

自作で得たもの

これらを総合して、1 check = 1 Pythonファイル(10〜100行程度)で実装し、共通ヘルパーでボイラープレートを最小化する自作の道を選びました。意思決定の経緯はADRとして最初から記録しています。

リポジトリの構造はこんな感じになっています。

.
├── checks/
│   ├── _common/             # gh.py, finding.py, exceptions.py, targets.py など共通ヘルパー
│   ├── org/                 # require_2fa.py, members_can_change_repo_visibility.py, ...
│   ├── repo/                # package_manager_hardening.py, renovate_minimum_release_age.py, ...
│   ├── workflow/            # action_unpinned.py, default_workflow_permissions.py, ...
│   ├── apps/                # high_privilege_installed.py, installation_inventory.py, ...
│   ├── pat/                 # fine_grained_owner_active.py
│   └── ...                  # branch/, secrets/, webhook/, member/, meta/
├── policy/
│   ├── exceptions.yaml      # 例外の集中管理(後述)
│   ├── reminders.yaml       # 棚卸しリマインダー定義(後述)
│   └── config.yaml
├── scripts/
│   ├── emit_issues.py       # findingsをGitHub Issueにupsert / auto-closeする
│   ├── emit_reminders.py    # Layer Bの棚卸しリマインダーIssueを自動起票する
│   └── notify_slack.py      # findingsをSlackに通知する
├── .github/workflows/
│   ├── weekday-audit.yml    # 平日に走るdaily audit
│   ├── monthly-audit.yml    # 月次の重めのaudit + 月次ハートビート
│   └── audit-reminders.yml  # Layer Bリマインダーを起票する定期実行ワークフロー
├── docs/adr/                # 設計判断の記録(例: 0001-cspm-vs-snapshot-diff.md)
└── audit.py                 # check群を実行するオーケストレーター

GitHubの監査対象カテゴリ(org / repo / workflow / branch protection / apps / pat / secrets / webhookなど)ごとにディレクトリを分けて、その中に1ファイル1checkで実装しています。共通ヘルパーはchecks/_common/に置き、GitHub APIアクセスやfinding生成、例外マッチなどのボイラープレートをまとめています。.github/workflows/配下の定期実行ワークフローがオーケストレーターaudit.pyを呼び出し、その結果をscripts/配下のスクリプトがIssueやSlackに流す、というシンプルな構成です。

設計の核: ルール + 例外 + PRレビューによる承認

本基盤の構造は、本質的には以下の3点セットです。

  1. ルール (check):「あるべきposture」をPythonの関数として表現
  2. 例外 (exceptions YAML): 個別事情で許容するものをfingerprint + 理由 + 期限 + 承認PR付きで管理
  3. PRレビューによる承認: ルールの追加も例外の追加も、必ずPR経由でレビューを通す

そして基盤は大きく2層で構成しています。

Layer A: 継続drift検知

Layer Aは機械判定可能な領域を担当します。

  • 実行: GitHub Actionsのワークフローを日次・月次・四半期に定期実行し、checkを動かしています。GitHub APIへのアクセスは専用のGitHub AppをOrganizationに導入し、そのinstallation tokenで叩く形にしています(個人のPATは使いません)。
  • 冪等性: findingはfingerprintで識別し、同じ違反は1つのIssueに集約。修正されると自動でcloseされます。
  • 通知: 違反検知時はGitHub IssueとSlack。違反0件の月も月次のハートビートを出すことで、ワークフローの定期実行自体が止まっていることに気付けない事態を防いでいます。

例外管理: ルールと例外で運用すると例外は必ず発生する

ルールベースの監査機構を作って運用してみるとよく分かりますが、例外は必ず発生します。「このリポジトリは別SaaSで代替している」「移行作業中の古いリポジトリで、packageManager関連のcheckを移行完了まで期間限定で許容している」など、業務的に許容すべきfindingは必ず出てきます。

問題は、それをどう統治するかです。コードに# noqa的に埋め込むと、なぜそれが許容なのかが永遠に分からなくなります。別システムで管理すると、gitの履歴から追えなくなります。そこでpolicy/exceptions.yamlという形で例外を一元管理し、以下を必須にしました(架空データで再構成しています)。

# 各 entry は finding fingerprint で match し、suppressed として扱う。
# 必須フィールド:
#   - fingerprint: sha256(check_id + canonical(target)) の先頭 16-64 文字
#   - reason: なぜ例外なのかの説明
#   - approved_by_pr: 承認 PR 番号
#   - expires_at: 期限切れ日 (YYYY-MM-DD)。期限なし例外は禁止

- fingerprint: "abc123def456"
  reason: "AI coding agent App の install。重要 repo は既に許可済で marginal risk 小。AI 特有の prompt context リスクがあるため期限短めで定期再評価"
  approved_by_pr: 42
  expires_at: "2026-11-04"
  external_refs:
    - "https://docs.example-ai.dev/"
  note: |
    再評価ポイント:
    1. 提供元の prompt log retention policy 再確認
    2. 「AI が指示外 repo を読んだ」事案の有無
    3. 業界 default の動向

ポイントはreason / approved_by_pr / expires_atを必須にして、期限なし例外を技術的に禁止していることです。オーケストレーターはexpires_atを超えたエントリを自動で無効化し、対応するfindingを再度通知します。これにより「いつの間にか永続化したignore」が原理的に発生しません。

また、noteフィールドには「次回再評価する時に確認すべきポイント」を書き残せるようにしてあります。例外を入れる時に未来の自分(または後任)への申し送りを残せるのが、運用上の予想外の恩恵でした。

チェック処理の実際: pnpmのバージョン要件を例に

ここからは、実際に弊社で動いているcheckのうち、「設定の存在 ≠ 実効」というメッセージを最も端的に示せるrepo/package_manager_hardeningを抜粋してご紹介します。

このcheckは、.npmrc / pnpm-workspace.yaml / pyproject.toml等を読み、registry / minimumReleaseAge相当 / ignore_scripts相当が適切に設定されていてかつ実効する状態かを検証します。中でも記事として一番伝えたいのが、パッケージマネージャのバージョンによって設定が知らないうちに無効化されるパターンの検出ロジックです。

pnpmのminimumReleaseAgeはv10.16.0以上で機能します。それより古いpnpmは、設定が書いてあっても無視します(なおpnpm v11以降は既定で1440分=1日相当が入りますが、strictに効かせるには明示的な宣言を残しておくのが安全です)。ここまではnpmと同じ構造ですが、pnpmの場合はnpmのengine-strict相当の追加の強制は不要です。理由は、Corepackが有効化されている前提では、package.jsonpackageManager: "pnpm@10.16.0"のようなCorepack pinが、バージョン不一致でinstallを失敗させる強制力を持つためです。つまり「packageManager宣言が事実上の強制として機能する」状態を作れば、minimumReleaseAgeも実効する、というロジックになります。

checkの中ではこれを以下のように表現しています(説明用に抜粋・簡略化したものです)。

PNPM_MIN_RELEASE_AGE_SUPPORT = (10, 16, 0)

# pnpm の `minimumReleaseAge` / `.npmrc: minimum-release-age` は v10.16.0 以降。
# `package.json` の `packageManager` (Corepack pin) または `engines.pnpm` で
# minimum pnpm version が 10.16.0 以上と宣言されていなければ release_age を
# missing 扱い。npm と違い engine-strict 相当の追加 enforcement は要求しない:
# Corepack pin が version mismatch で install を失敗させる強制力を持つため、
# 宣言が事実上の強制として機能する。
pnpm_min = _get_pkg_json_pm_min(pkg_json_text, "pnpm")
if pnpm_min is None or pnpm_min < PNPM_MIN_RELEASE_AGE_SUPPORT:
    have["release_age"] = False

なお、npmの場合も同じ思想で、NPM_MIN_RELEASE_AGE_SUPPORT = (11, 10, 0)engines.npm / engine-strict=trueの二重要件を並列に判定しています。

加えて、値の型妥当性も検証します。たとえばmin-release-age=7dのように書くと、JavaScript / npm側ではNumber("7d") === NaNになり、警告も出ないまま無効化されてしまいます。これではキーが存在していても実効していないので、invalid_valuesとしてevidenceに記録した上でmissing扱いにします。

そしてこのcheckは同時に、Takumi Guard registryの設定(https://npm.flatt.tech/ / https://pypi.flatt.tech/)が.npmrc / pyproject.toml等にコミットされているかも検査します。registryを経由しているかどうかは、サプライチェーン防御の最初の関門だからです。

ここでもやはり「設定があっても実効していない」パターンに引っかかります。たとえば.npmrcにTakumi Guardのregistryが書かれていても、それが@scope:registryのような特定スコープ限定の指定で、デフォルトのregistryが上書きされていなければ、本体パッケージはpublic registryから取得されてしまいます。Pythonのuvでも、pyproject.toml[[tool.uv.index]]にTakumi Guardのindexは書いてあるがdefault = trueが抜けているために補助扱いになり、デフォルトのindexとしてはPyPIが使われ続けてしまう、というケースがあります。

そこで以下のように、「デフォルトのregistryとしてTakumi Guardが効いているか」まで踏み込んで判定しています(こちらも説明用に簡略化したものです)。

# .npmrc から「default registry」を抽出して Takumi Guard 判定。
# `registry=https://...` はデフォルト registry の指定。
# `@scope:registry=https://...` はスコープ限定なので default 扱いしない。
def _npmrc_default_registry(npmrc_text: str) -> str | None:
    for line in npmrc_text.splitlines():
        m = _INI_LINE_RE.match(line)
        if not m:
            continue
        key, value = m.group(1), m.group(2).strip()
        if key == "registry":  # スコープ限定 (@scope:registry) は対象外
            return value
    return None


default = _npmrc_default_registry(npmrc_text)
have["registry"] = default is not None and default.startswith(TAKUMI_NPM_REGISTRY)

uv側も同様に、[[tool.uv.index]]を走査してdefault = trueかつurlがTakumi Guardのものになっているindexが1件以上存在することを確認します。書いてあるだけでは合格にしない、という判定がポイントです。

ご覧のとおり、一つのcheckは驚くほどシンプルで、汎用ポリシーDSLでは難しい細やかな判定もPythonのif文で素直に表現できます。新しいcheckの追加コストが低いため、運用しているなかで気付いた監査観点を、その都度数十行のPythonでcheckとして追加していけるようになりました。

個別のcheckが同一リポで重なるとき

実運用で本当に怖いのは、こうした「設定 ≠ 実効」のパターンが、1つのリポジトリに複数同時に併存しているケースです。弊社でも個別のcheckを書き始めたきっかけは、似たような重なりに気付かされた経験でした。架空のリポジトリexample-webを例に、サプライチェーン攻撃の「取得 → ビルド」の各層で落とし穴が踏まれている状況を考えてみます。

あるべき設定 実際の状態
取得 .npmrcのデフォルトregistryをTakumi Guardに @example:registryのスコープ限定指定のみ。本体パッケージはpublic registryから取得
ビルド minimumReleaseAgeをpnpm v10.16.0以上で実効させる pnpm-workspace.yamlminimumReleaseAgeは書いてあるが、package.jsonpackageManagerの指定が無く、CIでは古いpnpmが走っている

それぞれ単体では、設定ファイルを目で追うだけだとレビューですり抜けてしまう類の見落としです。本基盤を回すと、ある朝こんなIssueがまとめて2件立ちます。

  • [repo/package_manager_hardening] example-web: Takumi Guard registryがデフォルトregistryとして設定されていません
  • [repo/package_manager_hardening] example-web: pnpm minimumReleaseAgeが宣言バージョンで実効しません(packageManager未指定)

どちらも「設定ファイルには書いてある(あるいは書こうとしたが抜けた)が、運用上は実効していない」という構造です。横串で継続検証する仕組みがなければ、こうした落とし穴は次の侵害事例が話題になるまで気付かれない可能性があります。

CIによるシフトレフトとの関係

ここまで挙げてきた落とし穴の多くは、pnpm-workspace.yaml.npmrcの整合性をlintする形でCI上で事前に弾く運用も可能です。実際、弊社でもGitHub Actionsのカスタムアクションのcommit SHA pinningはpinactをCIに組み込んで強制しており、シフトレフト的にはそれが第一選択肢になります。

それでも事後の継続監査を残しているのは、(1) 新規リポジトリの作成タイミングでCIワークフロー自体が抜けたケース、(2) CIではそもそも見られない組織レベル設定(OAuth App / PAT / branch protectionなど)、を横串で同時に観察したいためです。CIによるシフトレフトと事後の継続監査は、二重の網として相補的に機能していると考えています。

Layer B: 人による定期棚卸し

ここまでは機械判定できるLayer Aの話でしたが、ルールベースの自動判定だけでは完結しない領域もあります。

  • 外部コラボレーターの棚卸し(誰がまだアクセスを持っていて、本当に必要か)
  • OAuth App / GitHub Appのインストール状況の再評価
  • PAT(Personal Access Token)の現役状況確認

こうした「人が見ないと判断できない」項目には、Layer Bとしてリマインダー用のIssueを自動起票する仕組みを別途用意しました。policy/reminders.yamlで棚卸し項目とその実施頻度(年次・半年・四半期)を定義しておくと、期日になると自動でGitHub Issueが立ち、担当者にアサインされます。

スキーマはこんな形です(架空データで再構成しています)。

- id: "outside-collaborator-review"
  title: "外部コラボレーターの定期棚卸し"
  cadence: "quarterly"          # quarterly / biannual / annual
  assignees: ["@platform-sre"]
  next_due: "2026-06-01"
  description: |
    各リポジトリの outside collaborator 一覧を改めて確認し、
    日常のオフボーディングで取りこぼした権限や、
    業務状況の変化で不要になった権限が残っていないかをチェック

- id: "github-app-installation-review"
  title: "GitHub App / OAuth App のインストール棚卸し"
  cadence: "biannual"
  assignees: ["@platform-sre"]
  next_due: "2026-07-15"

期限を超過するとエスカレーションのワークフローがチーム宛のメンションで再通知する仕組みも入れてあり、「棚卸しはやったつもりで実は飛ばされていた」が起きにくい構造にしています。

得られた効果と学び

立ち上げて運用を始めてから、実感している効果は主に3つです。

設定driftの即時検出と自動クローズ: 設定がズレた瞬間にIssueが立ち、修正すると次の定期実行で自動でcloseされます。Issueの活動だけ見ていれば、いま何がズレていて何が直ったかが分かります。

期限管理による自動再評価: expires_atを必須にしたことで、「とりあえずignoreして、そのまま永続化」が物理的に起きません。期限切れになれば自動的に再評価対象に戻り、もう一度承認PRを通す必要が出ます。

拡張コストの低さ: 新しいcheckの追加コストが非常に低く、気になることがあれば数十行のPythonで1つcheckを書いてPRを出すだけです。運用しながら気付いた監査観点を、運用負荷を増やさずチェック対象に組み込み続けられるのは、内製を選んだことの大きな成果だと感じています。

学びとしては、いくつか挙げられます。

ADRを最初から書く価値: 「なぜルール+例外モデルを選んだか」「なぜこのcheckは機械判定側に書くか」といった意思決定を最初から残しておくと、後で議論が再燃した時に立ち戻れます。少し時間が経って自分が書いたADRを読み返した時に、当時の判断を支持できるかどうかを冷静に確認できるのも良いところです。

期限なし例外を禁止したことの副次効果: expires_atの必須化は「永久放置の防止」のためでしたが、運用してみると、例外を追加するときに「いつまでなら許容できるか / いつ再評価するか」を考える習慣ができ、判断の質そのものが上がりました。

Layer A / Layer Bの分離を最初から明示したこと: 「これは機械判定できる範囲か、それとも人が見るべき範囲か」を新しいcheckを書くたびに問い直す習慣ができ、無理にすべて自動化しようとして破綻するパターンを避けられています。

おわりに

以上、サプライチェーン攻撃対策の「実効」を継続検証する社内基盤の設計と運用のご紹介でした。

本記事が、サプライチェーン攻撃対策に取り組まれている方の参考になれば幸いです。

スマートラウンド テックブログ

Discussion