エージェントに渡すリポジトリを取得し直そうとして、いつもの手順が止まりました。表示されたのは detected dubious ownership の一行です。
所有者の食い違いそのものは、別の記事 に書いたとおり、書ける場所へ逃がせば片づきます。この記事で書き残すのは、その切り分けの途中で見つけた、もう一つのことです。
先に結論をお伝えします。アクセストークンを remote URL に埋め込むと、その値は .git/config という平文のファイルに残ります。そして、そのファイルの権限は、クローンを作ったときの umask 次第で、同じ共有領域の他のユーザーに読める状態になります。 私の古いクローンは、まさにその状態でした。
引っ越しの手順と、実際に動かした出力を、順に書きます。
止まった原因を探していて、別のものが出てきました
古いクローンは、共有の一時領域に、別のユーザー名義で残っていました。ls で確かめると、次のとおりです。
$ ls -ld /tmp/repos/antigravitylab.net
drwxr-xr-x 7 nobody nogroup 4096 Oct 26 04:56 /tmp/repos/antigravitylab.net
$ stat -c '%U:%G %a %n' /tmp/repos/antigravitylab.net/.git/config
nobody:nogroup 644 /tmp/repos/antigravitylab.net/.git/config
所有者は nobody です。私の uid ではないので、git status は拒否されるはずでした。ここで私は、よくある回避策を先に打ちました。
git config --global --add safe.directory '*'
すると git status は通り、「Your branch is up to date」と返しました。ところが、次の書き込みで落ちます。
error: could not lock config file .git/config: Permission denied
fatal: could not set 'remote.origin.url' to ...
error: cannot open .git/FETCH_HEAD: Permission denied
safe.directory が消してくれるのは Git の拒否だけで、ファイルシステムの権限は変わりません。読み取りは通り、書き込みだけが落ちます。表示だけ緑になって、最初の書き込みで初めて赤くなる ので、無人で動かしていると原因の位置を取り違えやすいと感じました。
この点は、前の記事の続きにあたります。ここから先が、今回の本題です。
正直に言うと、このとき私は「動いたのだから、もう大丈夫だろう」と感じかけていました。緑の表示は、安心を先に連れてきてしまうものだと気づいたのは、このあとです。
.git/config に何が入っているかを数えました
書き換えられないなら、せめて中身を確かめようと思いました。読むだけなら通ります。ただし、値は画面にもログにも出したくありません。そこで、値を読まずに「資格情報の形をした行が何行あるか」だけを数える点検スクリプトを書きました。
#!/usr/bin/env bash
# audit_remote_creds.sh <親ディレクトリ>... 資格情報入りの remote URL を持つクローンと、その config の権限を一覧する
# 資格情報そのものは出力しない。終了コード: 0=なし / 1=あり
FOUND = 0
for ROOT in " $@ " ; do
for CFG in " $ROOT "/*/.git/config ; do
[ -f " $CFG " ] || continue
# https://<何か>@host の形だけを数える(値は読まず、行数だけ得る)
N = $( grep -cE 'url *= *https://[^/@ ]+@' " $CFG " )
[ " $N " -gt 0 ] || continue
MODE = $( stat -c '%a' " $CFG " ); OWN = $( stat -c '%U' " $CFG " )
# 'others' の読み取りビット(4)が立っているか
if [ $(( 8#$MODE & 4 )) -ne 0 ]; then WORLD = "world-readable" ; else WORLD = "private" ; fi
echo "CRED $CFG owner= $OWN mode= $MODE $WORLD "
FOUND = 1
done
done
exit $FOUND
grep -c は一致した行の数しか返さないので、値は画面に出ません。stat の権限の下位 3 ビットを見れば、他のユーザーに読めるかどうかも分かります。
私のクローン置き場に当てた結果です。
$ ./audit_remote_creds.sh /tmp/repos $HOME/repos
CRED /tmp/repos/antigravitylab.net/.git/config owner=nobody mode=644 world-readable
rc=1
1 件ありました。mode=644 は、所有者以外にも読めるという意味です。 この領域に他のユーザーがいる限り、トークンを読める状態だったことになります。読まれた形跡は確認できていませんし、確認する手段も私にはありません。だからこそ、「読まれていない」とは言い切らず、入れ替える前提で動くことにしました。
引っ越し先は http.extraheader にしました
URL にトークンを入れる代わりに、リクエストごとに認証ヘッダーを渡します。git -c http.extraheader=... は、そのコマンドの間だけ有効で、設定ファイルには何も書き込みません。
まず、本当に取得できるか、不正な値のときにきちんと失敗するかを確かめました。
$ B64= $( printf 'x-access-token:%s' " $TOK " | base64 -w0 )
$ git remote set-url origin https://github.com/masakihirokawa/antigravitylab.net.git
$ git -c http.extraheader="Authorization: basic $B64 " fetch origin main
From https://github.com/masakihirokawa/antigravitylab.net
* branch main - > FETCH_HEAD
fetch_exit = 0 # 所要 0.487 秒
$ git -c http.extraheader="Authorization: basic bad" fetch origin main
fatal: unable to access '...': The requested URL returned error: 400
$ grep -c 'ghp_\|github_pat_' .git/config
0
取得は 0.487 秒で通り、わざと壊した値は 400 で止まりました。実行後の .git/config には、トークンの文字列が 0 件です。
既存クローンを壊さずに移す入口スクリプト
毎回の入口を 1 本にまとめました。やることは三つです。クローンを使い回してよいかを先に判定します。トークンは環境変数から読み、ヘッダーで渡します。そして .git/config の権限を締めます。
まず判定側です。書き込む前に、所有者と remote を見ます。
#!/usr/bin/env bash
# repo_owner_preflight.sh <repo-path> <clone-url-without-token> [fallback-root]
# 既存クローンを再利用してよいかを、書き込む前に判定する。
# 終了コード: 0=そのまま使える / 10=別ユーザー所有なので fallback へ clone / 20=壊れていて作り直し
set -u
REPO = " ${1 :? repo path } " ; URL = " ${2 :? url } " ; FALLBACK = " ${3 :- $HOME / repos } "
ME = $( id -u )
if [ ! -d " $REPO /.git" ]; then
echo "NOCLONE $REPO " ; exit 20
fi
OWNER = $( stat -c '%u' " $REPO /.git" )
if [ " $OWNER " != " $ME " ]; then
# safe.directory で git の拒否は消せるが、書き込めるようにはならない。ここで切り分ける
echo "FOREIGN owner_uid= $OWNER me= $ME -> use $FALLBACK /$( basename " $REPO ")"
exit 10
fi
# 自分の所有でも、config がロックされていると remote の付け替えで落ちる
if [ ! -w " $REPO /.git/config" ] || [ -e " $REPO /.git/index.lock" ]; then
echo "LOCKED $REPO /.git (config writable=$([ -w " $REPO /.git/config" ] && echo y || echo n))"
exit 20
fi
# 無言で別リポジトリを指している場合を弾く(資格情報は比較からも表示からも外す)
NOW = $( git -C " $REPO " remote get-url origin 2> /dev/null | sed -E 's#//[^@/]+@#//#' )
WANT = $( echo " $URL " | sed -E 's#//[^@/]+@#//#' )
[ " $NOW " = " $WANT " ] || { echo "REMOTE_MISMATCH now= $NOW want= $WANT " ; exit 20 ; }
echo "OK $REPO " ; exit 0
次に入口です。
#!/usr/bin/env bash
# sync_repo.sh <owner/name> <preferred-dir> ※トークンは環境変数 GH_TOKEN から読む
set -euo pipefail
NAME = " ${1 :? owner / name } " ; PREFERRED = " ${2 :? preferred dir } "
URL = "https://github.com/${ NAME }.git"
AUTH = "Authorization: basic $( printf 'x-access-token:%s' "${ GH_TOKEN :? GH_TOKEN is empty }" | base64 -w0 )"
HERE = "$( dirname " $0 ")"
set +e
" $HERE /repo_owner_preflight.sh" " $PREFERRED " " $URL " " $HOME /repos"
RC = $?
set -e
case " $RC " in
0 ) DIR = " $PREFERRED " ;;
10 ) DIR = " $HOME /repos/$( basename " $PREFERRED ")" ;; # 別ユーザー所有: 触らずに自分の領域へ
*) if [ ! -d " $PREFERRED /.git" ]; then DIR = " $PREFERRED " # まだ無いだけなら、そこへ作る
else DIR = " $HOME /repos/$( basename " $PREFERRED ")" ; rm -rf " $DIR " ; fi ;; # 壊れていたら自分の領域で作り直す
esac
umask 077 # 作る .git/config を自分以外に読ませない
mkdir -p "$( dirname " $DIR ")"
if [ -d " $DIR /.git" ] && [ "$( stat -c '%u' " $DIR /.git")" = "$( id -u )" ]; then
git -C " $DIR " remote set-url origin " $URL " # 過去に埋め込んだ資格情報を落とす
git -C " $DIR " -c http.extraheader=" $AUTH " fetch --depth 1 origin main
git -C " $DIR " reset --hard origin/main -q
else
git -c http.extraheader=" $AUTH " clone --depth 1 --branch main " $URL " " $DIR "
fi
chmod 600 " $DIR /.git/config" # 既存クローンの権限も締める
echo "READY $DIR (preflight rc= $RC )"
umask 077 だけでは足りません。すでにあるクローンの .git/config は、作られたときの権限のままだからです。最後の chmod 600 は、そのための一行です。
実際に出た出力
四つの場面で動かしました。
移行後の確認です。
$ stat -c '%a' $HOME/repos/antigravitylab.net/.git/config
600
$ grep -c 'ghp_\|github_pat_' $HOME/repos/antigravitylab.net/.git/config
0
落とし穴は四つありました
一つ目は、私の点検スクリプト自身の誤判定です。 最初の版は、表示用にトークンを *** で伏せた文字列同士を比べていました。比べる側は URL、比べられる側は伏せた形なので、同じリポジトリでも REMOTE_MISMATCH になりました。伏せるのは表示だけにして、比較の前に資格情報を取り除く形へ直しています。上の全文は直したあとの版です。
二つ目は、safe.directory '*' をそのまま残さないことです。 全ディレクトリを許可するので、今回のように「読めるが書けない」クローンを黙って通します。判定は、設定ではなく所有者の uid で行うほうが、原因に近い場所で止まれます。
三つ目は、reset --hard の前に fetch を挟むことです。 取得に失敗したまま reset へ進むと、古い状態を「最新」として扱ってしまいます。set -e で fetch の失敗が入口の失敗になるようにしてあります。
四つ目は、--depth 1 と履歴の読み取りの相性です。 浅い取得のクローンでは、git log で遡れる範囲が限られます。差分の確認に履歴が要る作業なら、--depth の値を見直してください。
まだ確かめていないこと
次の三点は、手元では確かめていません。
共有領域の他のユーザーが、実際にこのファイルを読んだかどうかは、確かめられていません。私には手段がないのです。
agy を無人で走らせるとき、環境変数で渡したトークンが子プロセスに引き継がれる範囲は、調べておりません。ヘッダー方式にしても、環境変数の置き場所が新しい弱点になるかもしれません。
古いクローンにあったトークンの入れ替えも、まだ済んでおりません。見つけた時点で、入れ替える前提で扱うことにしました。
次の一手
今日できることは三つです。
audit_remote_creds.sh を、自分のクローン置き場に当ててみてください。
見つかったクローンは、URL を掃除して chmod 600 を当てます。
他人所有で触れないものは放置せず、そこに書かれていたトークンを入れ替えます。
無人で動かす前の点検には、実行前に生きているかを確かめる事前点検 の考え方がそのまま使えます。設定ファイルの改変を起動時に検知する発想は、起動時の設定の整合性チェック にまとめています。書き込み先の空きを先に測る点検は、書き込み先の空き容量を測って止める記事 に書きました。
値を一度も画面に出さずに点検できる形になったので、翌朝のログを開くのが少し楽になったと感じています。