CIのコード実行と権限制御
イベント名だけで安全と判断せず、実行するコード・資格情報・許可操作を対応付けて確認できます。
説明
CIはコードを実行するプロセスです。外部からの変更案を試験する工程と、配布用の権限を持つ工程を同じ条件で動かすと、変更案のコードが秘密値や書込み権限に触れる場合があります。
固定の子プロセスとSQLiteの実行時権限を使います。渡す環境変数、入力文字列の引用、書込み可能な工程を順に変え、コードが実際に読める値と更新できる範囲を調べます。
イベント名だけで安全と判断せず、実行するコード・資格情報・許可操作を対応付けて確認できます。
1変更案の試験へ秘密を渡さない
子プロセスへ渡すのは固定の変更タイトルだけです。
from sd14 import *
print(child指定環境だけの子プロセスを動かす({"CHANGE_TITLE":"public change"}))DEPLOY_TOKENの存在はFalseです。コードに読む処理があっても、このプロセスへ渡されていません。
2同じコードへ秘密値を渡すと読める
公開の試料トークンを追加します。
print(child({"CHANGE_TITLE":"public change","DEPLOY_TOKEN":"PUBLIC-DEMO"同じ工程へ資格情報を追加する}))存在がTrueになります。コードが未信頼なら、ログ上で値を隠すだけではこの読取りを止められません。
3データの文字列をシェル構文へ混ぜない
固定のタイトルをシェルで連結した場合と、環境の値として渡した場合を比較します。
title = "report; printf extra"
unsafe = subprocess.run("printf "+title,shell=Trueタイトルを含む文字列をシェルで解釈する,capture_output=True,text=True,check=True)
safe = subprocess.run(["/bin/sh","-c",'printf "%s" "$CHANGE_TITLE"変数を引用して一つの値として渡す'],env={"CHANGE_TITLE":title},capture_output=True,text=True,check=True)
print(unsafe.stdout)
print(safe.stdout)連結版はreportextra、引用した値渡しは元の文字列です。CIの式展開結果をrunの構文へ直接埋め込む箇所を確認します。
4読取り工程ではDBの更新権限を拒否する
固定のrelease表を使い、DB側のauthorizerで書込みを禁止します。
db = repo()
credential(db,False)更新を許可しない実行時権限へ変える
print(db.execute("SELECT * FROM release").fetchall())
try:db.execute("UPDATE release SET version='9.9.9'")
except sqlite3.DatabaseError as e:print(str(e))読取りは成功し、更新はnot authorizedです。これは権限分離の固定試料で、GitHubトークンの実APIを検証したものではありません。
5信頼する配布工程だけに更新を許す
確定した版を反映する工程として、書込みを許可します。
credential(db,True)配布工程の書込みを許可する
with db:db.execute("UPDATE release SET version='1.0.1'")
print(db.execute("SELECT * FROM release").fetchall())版1.0.1へ更新されます。この工程が未信頼コードをcheckoutして実行しないことも、権限とセットで確認します。
6工程の終了後に同じ権限を残さない
権限を読取りへ戻し、別の版への変更を試します。
credential(db,False)不要になった書込み権限を外す
try:db.execute("UPDATE release SET version='2.0.0'")
except sqlite3.DatabaseError as e:print(str(e))
print(db.execute("SELECT * FROM release").fetchall())拒否され、1.0.1を維持します。短命な資格情報と工程ごとの権限が、不要な期間や対象への利用を減らします。
7コードの版と成果物の版を別々に記録する
工程の記録には何を実行し、どの成果物を更新したかを残します。
record = {"source_revision工程が使った固定ソース版を記録する":"public-source-7","artifact_version":db.execute("SELECT version FROM release").fetchone()[0],"permission":"read-only after publish"}
print(json.dumps(record,sort_keys=True))ソース版と配布版を対応付けました。承認したコードと実行したコードが違えば、承認済みという記録だけでは検証が足りません。
まとめ
未信頼コードへ渡す秘密と権限を減らします。
外部入力をシェル構文へ直接連結しません。
実行コードと更新した成果物の版を記録します。
操作履歴と解説を振り返れます。
Python 3.15 / 固定の公開試料
from sd14 import *
print(child({"CHANGE_TITLE":"public change"}))False public change
print(child({"CHANGE_TITLE":"public change","DEPLOY_TOKEN":"PUBLIC-DEMO"}))True public change
title = "report; printf extra"
unsafe = subprocess.run("printf "+title,shell=True,capture_output=True,text=True,check=True)
safe = subprocess.run(["/bin/sh","-c",'printf "%s" "$CHANGE_TITLE"'],env={"CHANGE_TITLE":title},capture_output=True,text=True,check=True)
print(unsafe.stdout)
print(safe.stdout)reportextra report; printf extra
db = repo()
credential(db,False)
print(db.execute("SELECT * FROM release").fetchall())
try:db.execute("UPDATE release SET version='9.9.9'")
except sqlite3.DatabaseError as e:print(str(e))[('1.0.0',)]
not authorizedcredential(db,True)
with db:db.execute("UPDATE release SET version='1.0.1'")
print(db.execute("SELECT * FROM release").fetchall())[('1.0.1',)]credential(db,False)
try:db.execute("UPDATE release SET version='2.0.0'")
except sqlite3.DatabaseError as e:print(str(e))
print(db.execute("SELECT * FROM release").fetchall())not authorized
[('1.0.1',)]record = {"source_revision":"public-source-7","artifact_version":db.execute("SELECT version FROM release").fetchone()[0],"permission":"read-only after publish"}
print(json.dumps(record,sort_keys=True)){"artifact_version": "1.0.1", "permission": "read-only after publish", "source_revision": "public-source-7"}演習完了