2019/05/26(Sun)
○[Windows] PowerShellで生活するために - New-TemporaryFileコマンドレット編(その1)
ちょいと前回のサンプルにNew-TemporaryFileコマンドレットが登場したので、Import-Csv/Export-Csvの話より先に書いておく。
@UNIXのmktemp(1)とは何か
毎回WindowsカテゴリなのにタッパーウェアことUNIXの話をしている気がするけどまあええわ、タッパーウェアにおいて一時用ディレクトリである/tmpや/var/tmpは誰でも読み書きが可能な「激アツスリーセブン!ジャンバリ大開放!!」パーミッションとなってる。
$ ls -ld /tmp
drwxrwxrwt+ 1 tnozaki None 0 May 25 23:47 /tmp
$ ls -ld /var/tmp
drwxrwxrwt+ 1 tnozaki None 0 Apr 14 10:26 /var/tmp
今時Cygwinなのでオーナーとグループが変だが気にするな、ここに作業用のファイルやディレクトリを作成する場合、情報漏洩やシンボリックリンク攻撃などのセキュリティ事故を避けるよう注意してコーディングする必要がある。
まぁ詳しい話はここはタッパーウェア通販サイトでもパチスロ情報誌でもないので セキュアコーディングガイドでも読んどけ、 最近のOSは「per user tmp」すなわちユーザー毎に一時ディレクトリが用意されているのでセキュリティについては昔よりマシにはなってるけど、機密性がわずかにマシになっただけでプログラミングには変わらず注意が必要なことに変わりはない。
シェルからならたいていの環境にはmktemp(1)コマンドが用意されているので、これを使って作業用ファイル・ディレクトリを作成すればおk。
作業用ファイルなら
$ f=`mktemp -p /tmp unko.XXXXXX`
$ ls -l $f
-rw------- 1 tnozaki None 0 May 25 23:46 /tmp/unko.CrhtWx
$ rm $f
作業用ディレクトリであれば
$ d=`mktemp -d -p /tmp unko.XXXXXX`
$ ls -dl $d
drwx------+ 1 tnozaki None 0 May 25 23:47 /tmp/unko.ygEwOO
$ rmdir $d
どちらのケースでも
- XXXXXXの部分が乱数を元に生成された英数字62種に置換えられてるので、62^6通りのファイル名が生成 → 予測不可能なファイル名
- パーミッションもファイルなら0600、ディレクトリなら0700で他のユーザーからは読み書きできない → 適切なアクセスコントロール
- 作成したファイル・ディレクトリは既に存在したものを上書きしてないことが保証される → 競合状態の回避
というさっきのセキュアコーディングガイドで触れられている原則が、このコマンドを使うだけで保証できる訳。
ちなみにmktemp(1)の内部ではC APIである
- mkstemp(3) … 作業用ファイル作成
- mkdtemp(3) … 作業用ディレクトリ作成
が呼ばれているはずだ、以下はオレオレN6のコードより。
134 if (dflag) {
135 if (mkdtemp(name) == NULL) {
...
144 } else {
145 fd = mkstemp(name);
...
156 }
なおコマンドと同名のmktemp(3)は設計ミスで危険な関数なので決して使ってはならない、POSIX:2008でめでたく抹殺されました。
ちなみにSolarisとかAIXなんかの商用UNIXしか経験の無いシェルスクリプトコーダーには、わりと最近までmktemp(1)が存在しなかった関係上「プログラム名 + プロセスID + 日付時刻」みたいなザルなコード書く輩が多いという印象がある。
$ f=`date +"/tmp/$0-$$-%Y-%m-%d_%H:%M:%S"`
$ touch $f
$ ls -l $f
-rw-r--r-- 1 tnozaki None 0 5月 26 00:55 /tmp/unko.sh-1362-2019-05-26_00:55:57
$ rm $f
こーゆーのみかけたら見次第殺。
だからshebang含めて3行以上のシェルスクリプトは書きたくない読みたくないのだよ心臓が止まりそうになる、なぜこれじゃダメなのかはもうお判りですね?
@PowerShellにおけるmktemp(1)の代替品
これはPowerShellも同様の無関心さだったようで、mktemp(1)に該当するコマンドレットであるNew-TemporaryFileは5.0になってようやく実装された始末。
$tmpfile = New-TemporaryFile
それ以前の環境であれば、.Net FrameworkのSystem.IO.Path::GetTempFileNameを使うしかない。
$tmpfile = [System.IO.Path]::GetTempFileName()
ちなみにNew-TemporaryFileの実装( Microsoft.PowerShell.Commands.NewTemporaryFileCommand)もGetTempFileNameを呼出してるだけなのだ。
16 public class NewTemporaryFileCommand : Cmdlet
17 {
...
29 filePath = Path.GetTempFileName();
...
そして.Net Frameworkの System.IO.Pathのコードを読むと
166 public static string GetTempFileName()
167 {
...
176 uint result = Interop.Kernel32.GetTempFileNameW(
177 ref tempPathBuilder.GetPinnableReference(), "tmp", 0, ref builder.GetPinnableReference());
と、Windows APIのFileAPI.hにある GetTempFileNameWを呼んでるだけなので、詳しい挙動はそっちを参照ってことですな。
しかし困ったことに作業用ファイルの作成はこいつら使えばいいんだけど、作業用ディレクトリを作成する方法については未だにPowerShellにも.Net FrameworkにもWindows APIにもご用意されていないのだ、ファッキン。
@よくあるまちがい
ちなみに作業用ディレクトリを掘れといわれてワンワンワン、セキュリティに理解の浅いプログラマー未満のやらかしがちな失敗は
- System.IO.Path::GetRandomFileNameを使ってランダムな名前で作業用ディレクトリを掘ればいい
$tmpdir = [System.IO.Path]::GetRandomFileName() New-Item -ItemType Directory $tmpdir
- 作業用ファイルを作成した後に削除し再び同名で作業用ディレクトリを掘ればいい
$tmpdir = New-TemporaryFile Remove-Item $tmpdir New-Item -ItemType Directory $tmpdir
みたいなコードを書いてしまいがちなんだけど、これどちらもNew-Itemするまでのわずかな時間に「TOCTTOU(Time Of Check To Time Of Use)」と呼ばれる競合状態が発生する可能性があるのでアウトなのだ、TOCTTOUについては 過去回で説明を書いてるので今回は省略。
@本当にそのコードって安全?
んで話脱線するけども、前回Import-Csvがパイプからファイル読めないから作業用ファイルを経由するというサンプル書いたけど
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$tmpfile = New-TemporaryFile
Invoke-WebRequest -Method Get -Uri 'https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv' -OutFile $tmpfile
Import-Csv -Encoding OEM $tmpfile | Where-Object { ([DateTime]$_.{国民の祝日・休日月日}).Year -eq 2019 } | ForEach-Object {
Write-Host $_.{国民の祝日・休日月日}
}
Remove-Item -Force $tmpfile
これだってパラノイアこじらせると怪しいコードに見えてくるのだ、Invoke-WebRequestの-OutFileの挙動がリダイレクトの「>」つまり上書き相当なら問題ないけれど、これが「削除 → 新規作成」という内部動作しとったらやはりTOCTTOUが発生する可能性がある。
なので安心したいので念の為に仕様を確認しておこう、PowerShellのコア部分はMITライセンス下のオープンソースなのでさっさとソースを読むことにする。
- Microsoft.PowerShell.Commands.InvokeWebRequestCommand
16 public class InvokeWebRequestCommand : WebRequestPSCmdlet 17 { ... 32 internal override void ProcessResponse(HttpResponseMessage response) 33 { ... 52 if (ShouldSaveToOutFile) 53 { 54 StreamHelper.SaveStreamToFile(responseStream, QualifiedOutFile, this); 55 }
- Microsoft.PowerShell.Commands.WebRequestPSCmdlet
87 public abstract partial class WebRequestPSCmdlet : PSCmdlet 88 { ... 369 [Parameter] 370 public virtual string OutFile { get; set; } ... 683 internal bool ShouldSaveToOutFile 684 { 685 get { return (!string.IsNullOrEmpty(OutFile)); } 686 } ...
- Microsoft.PowerShell.Commands.StreamHelper
261 internal static class StreamHelper 262 { ... 326 internal static void SaveStreamToFile(Stream stream, string filePath, PSCmdlet cmdlet) 327 { ... 338 using (FileStream output = File.Create(filePath)) 339 { 340 WriteToStream(stream, output, cmdlet); 341 } ...
このあたりのコードをざっと読むと
- Invoke-WebRequestコマンドレットの実体はInvokeWebRequestCommandクラスである
- InvokeWebRequestCommandクラスはWebRequestPSCmdltクラスを継承してる
- Invoke-WebRequestの-OutFileオプションに指定された引数はWebRequestPSCmdltクラスのOutFileプロパティに格納されている
- OutFileプロパティが空でない場合、StreamHelperクラスのSaveStreamToFileメソッドが呼ばれる
- SaveStreamToFileメソッドの中ではSystem.IO.File::Createメソッドが呼ばれる
- Createメソッドの仕様としてはすでにファイルが存在する場合は上書きモードになる
ということなのでTOCTTOU問題は回避できるので一安心ですな。
だいぶ脱線した、これもストリームに流れるのがテキストでなくオブジェクトなので迂闊にリダイレクトが使えないPowerShellのクソデザインがそもそも悪手なのだ、テキストなら「>」か「>>」使えれば一目瞭然なんだよな、だから 最初に門倉元投手の言葉を借りて「PowerShellだけはやめとけよ」といいたくなるのもお分かりいただけるだろうか。
@次回
作業用ディレクトリを作成する方法なんだけどこれはもう自分で実装するより他にないのだ、セキュリティのためには少なくとも
- 予測不可能なファイル名
- 適切なパーミッションを設定する
- 既に存在するディレクトリとは絶対に被ってはならない
は必須になるのだけれど、これをどう実現したものか説明しようと思う。