サブモジュール経由で発生するソースコード漏えいの落とし穴と対策

TL;DR: サブモジュールは独立した Git リポジトリです。サブモジュール配下で origin を「自分が書き込める Public リポジトリ」に向けたまま push すると、その中身は即時に公開されます。さらに親リポジトリの push.recurseSubmodules や pre-push フックの実装次第では、親で git push しただけでサブモジュールが push され、意図せず漏えいが起こり得ます。

背景 モノレポやサイト構築(Hugo など)で外部テーマ/ライブラリを submodule として取り込むのは一般的です。しかし、サブモジュールは“別リポジトリ”そのものであり、配下で git remote set-url origin … を変えれば、単独で push できます。この性質と、親の push 時の挙動(push.recurseSubmodules、pre-push フック、CI スクリプト)が組み合わさると、気づかないうちに第三者の Public リポジトリへコードが押し出される危険があります。

よくある漏えいシナリオ flowchart TB A[開発者が親リポで作業] –> B[サブモジュール内で修正] B –> C[サブモジュール内の origin を自分の Public リポに変更] C –> D{親で git push} D –>|on-demand/フックが push| E[サブモジュールも push] E –> F[Public リポに中身が公開]

トリガーA: サブモジュール内で origin を書き込み可能な Public リポジトリに設定

トリガーB: 親で git push した際に

push.recurseSubmodules=on-demand で「参照コミットがリモートに無ければサブモジュールも push」

あるいは pre-push フックが git submodule foreach などで push を試みる

すぐできる被害最小化(即時ロック) 親リポジトリでサブモジュール push を止める:

親リポで: サブモジュールを push 対象にしない

git config push.recurseSubmodules no

push のドライラン癖付け

git push –dry-run origin main

各サブモジュールで push を物理的に無効化(安全策):

ルートで実行: すべてのサブモジュールの pushURL を無効化

(fetch URL はそのまま)

git config -f .gitmodules –get-regexp ‘^submodule..*.path$’ | while read -r _ path; do echo “Locking $path” git -C “$path” remote set-url –push origin DISABLED || true hookdir="$(git -C “$path” rev-parse –git-dir)/hooks" mkdir -p “$hookdir” cat > “$hookdir/pre-push” «‘SH’ #!/bin/sh echo “ERROR: Pushing from this submodule is disabled.” >&2 exit 1 SH chmod +x “$hookdir/pre-push” done

これでサブモジュール直下での git push は常に失敗し、誤操作やフック経由の push も止まります。

恒久対策(レイヤ別)

  1. 設定レイヤ(Git) 親リポ: git config push.recurseSubmodules no をデフォルトに

サブモジュール URL の整合性: git submodule sync –recursive

危険 URL のブロック(親の pre-push フック):

.git/hooks/pre-push (親リポ)

#!/bin/sh set -e url=$(git remote get-url –push origin 2>/dev/null || echo “”) case “$url” in git@github.com:your-org/|https://github.com/your-org/) ;; *) echo “ERROR: Blocked push to non-allowed remote: $url” >&2; exit 1;; esac

  1. リポジトリレイヤ(GitHub 側) Private 化(フォークやミラーも)

Protected Branch(強制 push 禁止、PR 必須、レビュー必須)

Secret Scanning / Push Protection を有効化

  1. CI レイヤ(検知) GitHub Actionsで誤 push を検知・ブロック: name: guard-visibility on: [push] jobs: check: runs-on: ubuntu-latest steps:

    • name: Fail if repo is public if: ${{ !github.event.repository.private }} run: | echo “Repository is public. Aborting.” >&2 exit 1
    • uses: actions/checkout@v4
    • name: Allowlist push target run: | url=$(git remote get-url –push origin || true) case “$url” in git@github.com:your-org/|https://github.com/your-org/) ;; *) echo “Blocked remote: $url” >&2; exit 1;; esac
  2. 運用レイヤ(人の習慣) git remote -v/git remote get-url –push origin をpush 前に確認

git push –dry-run を毎回実行

サブモジュールに変更を加える場合は 必ずフォークを Private で作る → .gitmodules をフォーク URL に変更 → PR

兆候と検知ポイント git push 時に 予期しないリモート URL がログに出る(例: 第三者のテーマ作者のリポ)

pre-push / CI ログに サブモジュールでの push 成功の痕跡

GitHub の 監査ログ/Contributors に意図しない push 履歴

調査ワンライナー:

親の push 先

git remote -v | sed -n ‘/origin/p’

すべてのサブモジュールの fetch/push 先一覧

git config -f .gitmodules –get-regexp ‘^submodule..*.path$’ | while read -r _ path; do echo “[$path]”; git -C “$path” remote -v; echo done

よくある質問(FAQ) Q. リポジトリが Public か Private かで差はある? A. 公開可否と書き込み権限は別です。Public でも あなたに書き込み権限がなければ push できず、403 で止まります。逆に Public で書き込める先に向いていれば、即座に誰でも読める状態になります。 Q. –no-verify を付けても止まらないのは? A. –no-verify は フックの実行抑止だけです。origin の向き先が第三者リポであれば素通りしてしまうため、URL ガード(pre-push 内の URL 判定)と pushURL の無効化 を合わせ技で使います。 Q. サブモジュールの “detached HEAD” は問題? A. サブモジュールとしては 正常です。問題は どこに push され得るか です。

ベストプラクティス:Read‑Only 運用 + PR フロー 結論: 外部のサブモジュールは read‑only に保ち、変更が必要になったら「別途 clone(またはフォーク)→ 修正 → 上流へ PR」。親リポには 参照コミットだけ を更新します。

  1. Read‑Only の基本設定(親とサブモジュール)

親: サブモジュールを push 対象にしない

git config push.recurseSubmodules no

サブモジュール側: push を物理的に無効化(誤操作防止)

git -C remote set-url –push origin DISABLED cat > “$(git -C rev-parse –git-dir)/hooks/pre-push” «‘SH’ #!/bin/sh echo “ERROR: Pushing from this submodule is disabled.” >&2 exit 1 SH chmod +x “$(git -C rev-parse –git-dir)/hooks/pre-push”

URL の整合を保つ(.gitmodules → ローカルへ反映)

git submodule sync –recursive

.gitmodules は原典(upstream)URLのまま。サブモジュール直下では push しない運用にします。 2) 変更が必要になったときの手順(別途 clone → PR)

1) 自分の作業ディレクトリで、上流のリポジトリをフォーク or 直接 clone

※ 機微が混じる可能性があるならフォークは Private 推奨

例: フォークを clone

git clone git@github.com:/.git cd

git remote add upstream https://github.com//.git

git checkout -b fix/your-change

… 変更 …

git commit -s -m “fix:

” git push -u origin HEAD

GitHub で origin→upstream への PR を作成

PR が upstream にマージ された後、親リポで参照コミットを更新します: cd /path/to/parent

サブモジュールで最新を取得

git -C fetch –tags –all

目的のコミット(またはタグ)へ移動

git -C checkout

親にポインタ更新を記録

git add git commit -m “chore(submodule): bump to <sha/tag>” git push

※ .gitmodules に branch = main を設定していれば、git submodule update –remote で「そのブランチの最新へ」上げることも可能です(明示運用推奨)。 3) どうしても PR を待てない場合(代替案) オーバーレイ: 親の layouts/ / assets/ 等で上書き(Hugo などで有効)。

パッチ適用: 親レポに patches/ を置き、git apply/git am を CI/ローカルで当てる。サブモジュール自体は upstream のまま。

いずれの方法でも、サブモジュール配下からの push は不要です。

まとめ サブモジュールは独立リポ。origin が Public かつ書き込み可であれば即漏えい。

親の push.recurseSubmodules=no、サブモジュール pushURL 無効化、pre-push の URL Allowlist で 多層防御 を。

CI と運用ルールで最後の見張りを置いて、ヒューマンエラーを吸収する。

付録: チェックリスト(配布用) 親: git remote -v で push 先を確認

親: git config push.recurseSubmodules が no

サブモジュール: remote -v で pushURL が DISABLED(または Private)

親 .git/hooks/pre-push に URL Allowlist 実装

GitHub: Private + Protected Branch + Secret Scanning

CI: 可視性チェック + リモート Allowlist

運用: git push –dry-run を習慣化

本記事のサンプルスクリプトは 自己責任でお試しください。組織のポリシーに合わせて、許可ドメインやブランチ名、CI 条件を調整してください。

技術ネタ、趣味や備忘録などを書いているブログです
Hugo で構築されています。
テーマ StackJimmy によって設計されています。