一覧SD-14-2 · 依存・ビルド・配布の防御

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 / 固定の公開試料

1
from sd14 import * print(child({"CHANGE_TITLE":"public change"}))
False
public change
2
print(child({"CHANGE_TITLE":"public change","DEPLOY_TOKEN":"PUBLIC-DEMO"}))
True
public change
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
report; printf extra
4
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 authorized
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',)]
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())
not authorized
[('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))
{"artifact_version": "1.0.1", "permission": "read-only after publish", "source_revision": "public-source-7"}

演習完了