2021-02-18

2020-11-16

技術ノート

eyecatch
イベントログを CSV 形式に変換して管理するユースケースを紹介します。最終的には実行ログ出力、ログローテートを含んだ一式を自動化します。

はじめに

日々のオペレーションで、Windows Server のイベントログを目視確認して、見慣れないログがあったらエスカレーションする、という運用を考えてみます。これは、手動でやるとなると面倒な作業です。チェックシート通りにサーバーに RDP して、怪しいログをメモして、次のサーバーに RDP して。繰り返し同じことやって最後にサマリして・・・こういう作業は真っ先に自動化対象に上げられるでしょう。監視ソリューションを導入していて、Windows Server のイベントログもあらかじめ登録してあって、クリティカルなものを自動でメール発報する、といった体制が整っていれば今回の話はまったく必要ないのですが、なかには死活監視だけは導入しているがログ監視ソリューションまでは導入しておらず、手動でイベントログ等をチェックしているといったケースもあると思います。今回はそういう時に作業をある程度自動化するというシチュエーションを想定しています。要件は以下とします。

  • 複数サーバーに(RDP や MMC コンソールで)アクセスする手間を極力減らしたい
  • 確認結果を一定期間保持しておきたい
  • 実行ログも残しておきたい
  • 確認結果やログは自動でローテーションしたい

今回はこのようなライトな要件で、ログを集約および解析するようなソリューションを構築するといった話ではありません(今後試してみたいネタではありますが)

使っている関数

なにはともあれ、Windows なので PowerShell で実装することにします。私の環境は以下ですが、Windows 10であれば問題ありません。

検証済の動作環境

OS モジュール
Windows 10 Pro 2004 Windows PowerShell 5.1 (.NET Framework 4.8)

今回は PowerShell 関数を組み合わせて作りますが、自作 PowerShell 関数は以下のリポジトリで管理しています。

そして、今回紹介するスクリプトに関しては以下のリポジトリで管理しています。

では見ていきましょう!

前提

今回紹介するスクリプトで使う関数はすべて、GitHub リポジトリpowershell-functionsに入っています。今回はこのリポジトリをクローンし、サブフォルダの中にある各種 .ps1 ファイルをドットソースで読み込んで利用する方式を想定しています。

フォルダ構成が以下だとして、


# フォルダ構成

├─powershell-functions
│  └─scripts
└─usecase-winevent
   └─scripts # --- ココが $PSScriptRoot になる
       ├─data
       │  └─yyyy-MM-dd
       └─log

こんな感じで読み込みます。


Push-Location -Path $PSScriptRoot

$FunctionsDir = "..\..\powershell-functions\scripts"
Get-ChildItem -Path $FunctionsDir | ForEach-Object -Process { .$_.FullName }

これでオリジナル関数がすべて使えるようになりました。あとはメインスクリプトに適宜組み込めばよいでしょう。

イベントログの取得

メイン関数Backup-Eventlogです。

スクリプト

### Requires -Version 5.1 ###

<#
.Synopsis
Optimize Windows Eventlog for CSV format

.DESCRIPTION
Optimize Windows Eventlog for CSV Format:
 - Divided Timecreated into Date and Time
 - Convert from UserId to UserName,
 - Compress the Message to One Line

.EXAMPLE
$Params = @{ ComputerName = "localhost"; LogName = "System", "Application"; Level = 1,2,3; Recently = 24 }
Backup-Eventlog @Params

.EXAMPLE
$Params = @{ ComputerName = "localhost"; LogName = "Security"; Level = 0; Recently = 24; EventId = 4625 }
Backup-Eventlog @Params

.EXAMPLE
$Params = @{ LogName = "System", "Application"; Level = 1,2,3; Recently = 24 }
"127.0.0.1" | Backup-Eventlog @Params

.EXAMPLE
$Params = @{ ComputerName = "localhost"; Level = 1,2,3; Recently = 24 }
"System", "Application" | Backup-Eventlog @Params

.NOTES
Author: nekrassov01
#>

Function Backup-Eventlog
{
    [OutputType([System.Object])]
    [CmdletBinding()]
    Param
    (
        [Parameter(
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Mandatory = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]$ComputerName = "localhost",

        [Parameter(
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Mandatory = $true
        )]
        [ValidateSet(
            "Application",
            "System",
            "Security"
        )]
        [string[]]$LogName = @("Application","System"),

        [Parameter(
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Mandatory = $true
        )]
        [ValidateNotNullOrEmpty()]
        [int[]]$Level = @(1,2,3),

        [Parameter(
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Mandatory = $true
        )]
        [ValidateNotNullOrEmpty()]
        [int32]$Recently = 1,

        [Parameter(
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Mandatory = $false
        )]
        [int32[]]$EventId
    )

    Begin
    {
        # 結果を格納する配列を作る
        $Result = New-Object -TypeName System.Collections.ArrayList
    }

    Process
    {
        # Get-WinEvent に渡す連想配列を定義する
        $FilterHashTable = @{
            LogName   = $LogName
            Level     = $Level
            StartTime = (Get-Date).AddHours(-$Recently)
        }

        # EventId パラメータが指定されている場合は連想配列に Add する
        If($EventId)
        {
            $FilterHashTable.Add("Id", $EventId)
        }

        # Get-WinEvent の結果を1つずつ処理する
        Get-WinEvent -ComputerName $ComputerName -FilterHashTable $FilterHashTable | ForEach-Object -Process {

            # カラムを定義して PSCustomObject を初期化する
            $Columns = @("ComputerName","LogName","LevelId","Level","EventId","Date","Time","Source","Keyword","Opcode","Task","User","Sid","Message")
            $Obj = New-Object -TypeName PSCustomObject | Select-Object $Columns

            # PSCustomObject のプロパティを定義する
            $Obj."ComputerName" = If($Null -ne $_.MachineName        ){[string]$_.MachineName}
            $Obj."LogName"      = If($Null -ne $_.LogName            ){[string]$_.LogName}
            $Obj."LevelId"      = If($Null -ne $_.Level              ){[string]$_.Level}
            $Obj."Level"        = If($Null -ne $_.LevelDisplayName   ){[string]$_.LevelDisplayName}
            $Obj."EventId"      = If($Null -ne $_.Id                 ){[string]$_.Id}
            $Obj."Date"         = If($Null -ne $_.TimeCreated        ){[string]$_.TimeCreated.ToString("yyyy-MM-dd")}
            $Obj."Time"         = If($Null -ne $_.TimeCreated        ){[string]$_.TimeCreated.ToString("HH:mm:ss")}
            $Obj."Source"       = If($Null -ne $_.ProviderName       ){[string]$_.ProviderName}
            $Obj."Keyword"      = If($Null -ne $_.KeywordsDisplayName){[string]$_.KeywordsDisplayName}
            $Obj."Opcode"       = If($Null -ne $_.OpcodeDisplayName  ){[string]$_.OpcodeDisplayName}
            $Obj."Task"         = If($Null -ne $_.TaskDisplayName    ){[string]$_.TaskDisplayName}
            $Obj."User"         = If($Null -ne $_.UserId             ){Try{[string]$_.UserId.Translate([System.Security.Principal.NTAccount])}Catch{}}
            $Obj."Sid"          = If($Null -ne $_.UserId             ){[string]$_.UserId}
            $Obj."Message"      = If($Null -ne $_.Message            ){[string]$_.Message.Replace("`r`n","`n").Replace("`r","`n").Replace("`n"," ").Replace("`t"," ")}

            # 結果格納用の配列にオブジェクトを加算する
            $Result += $Obj
        }
    }

    End
    {
        # オブジェクト配列を返す
        return $Result
    }
}

Get-WinEventコマンドレットをこねくり回して、CSV で出せるように戻り値を加工しただけです。要点は以下です。

要点

  • SID からユーザー名を取れる場合は取る
  • CSV に最適化させたいので Message は1行に圧縮する
SID からユーザー名を取れる場合は取る

これは、SID だけだと誰なのかわからないので、ユーザー名が取得できるなら取得して CSV にマッピングしましょうというものです。以下の部分です。


If($Null -ne $_.UserId){Try{[string]$_.UserId.Translate([System.Security.Principal.NTAccount])}Catch{}}

Translateメソッドを使って、[System.Security.Principal.NTAccount]型に変換しています。変換できる場合とできない場合について説明します。

SID からユーザー名に変換できる場合とできない場合

条件 可否
ローカルコンピューターのローカルアカウント できる
ローカルコンピューターのドメインアカウント できる
リモートコンピューターのローカルアカウント できない
リモートコンピューターのドメインアカウント できる
すでに削除されたアカウント できない

リモートコンピューターのローカルアカウントが対象の場合に SID からユーザー名に変換できない理由は、ローカルコンピューターのローカルアカウント A とリモートコンピューターのローカルアカウント A が別人扱いになるからです。Get-WinEventは RPC プロトコルを使ってリモートコンピューターに処理を実行しますが、その際にローカル側の情報でリモート先の SID を解決しようとするため、一致せずに失敗します。これを回避するには、以下のようにGet-WMIObjectコマンドレットを使って取得する方法が考えられます。


Try
{
    [string]$_.UserId.Translate([System.Security.Principal.NTAccount]).Value
}
Catch
{
    (Get-WmiObject -Class Win32_UserAccount -ComputerName "RemoteComputer").Caption
}

とはいえ、これはあまりお勧めできません。その理由は、第一に、Get-WMIObjectGet-WinEventと同様に RPC プロトコルでリモートコンピューターと通信するのですが、この場合はGet-WinEventでリモートコンピューターに対してループ処理を回している中でGet-WMIObjectコマンドレットを使うことになるので、同種の通信が二重で発生してしまい、パフォーマンス面で無駄が多くなるためです。そしてなによりダサいからです。第二に、Win32_UserAccountクラスにアクセスすると関連する全ユーザーアカウントを列挙しますが、ドメイン環境の場合はすべてのドメインユーザーを列挙するのでめちゃくちゃ時間がかかります。こんな特殊なケースのためだけにコストをかけても仕方がないので、変換できなかった場合はそっとしておくのが無難です。ということで「ユーザー名を取得するが失敗した場合は深追いしない」という仕様になっています。

※ちなみにドメインアカウントの場合は前述の通り、別のコンピューターであっても同一ユーザーとして扱われ、SID も同一となります。このため問題なくTranslateされます

CSV に最適化させたいので Message は1行に圧縮する

Message を1行に圧縮しますが、replaceメソッドをチェーンしまくって対応します。イベントログのメッセージにはいろんなメタ文字が入っているので、できる限り想定して無害な文字列に変換します。


If($Null -ne $_.Message){[string]$_.Message.Replace("`r`n","`n").Replace("`r","`n").Replace("`n"," ").Replace("`t"," ")}

流れとしてはこんな感じ。

  1. CRLF を LF に変換
  2. CR を LF に変換
  3. LF をスペースに変換
  4. タブ文字をスペースに変換

あとで復元したい場合は、スペースではなく、セミコロンとか無害な文字列にしておくのでもいいかもしれません。

フォルダの構成

ベースとなるフォルダ構成を準備しますが、フォルダが故意に消されても自動で再現してから処理するようにします。記述を簡単にするための関数を用意しています。

スクリプト

### Requires -Version 5.1 ###

<#
.Synopsis
Build Directory structure

.DESCRIPTION
Build Directory structure

.EXAMPLE
New-FolderConstruction -Path "folder-1", "folder-2" -Root "C:\Work"

.EXAMPLE
"folder-1", "folder-2" | New-FolderConstruction -Root "C:\Work"

.NOTES
Author: nekrassov01
#>

Function New-FolderConstruction
{
    [OutputType([System.IO.FileInfo])]
    [CmdletBinding()]
    Param
    (
        [Parameter(
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Mandatory = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string[]]$Path,

        [Parameter(
            Mandatory = $false
        )]
        [ValidateNotNullOrEmpty()]
        [string]$Root = $PSScriptRoot
    )

    Begin
    {
        # 結果を格納する配列を作る
        $Result = New-Object -TypeName System.Collections.ArrayList
    }

    Process
    {
        # 文字列として渡されたフォルダ名を1つずつ処理する
        $Path | ForEach-Object -Process {

            # 出力を入れるための PSCustomObject を初期化する
            $Obj = New-Object -TypeName PSCustomObject | Select-Object -Property "FullName", "Result"

            # ルートに指定されたディレクトリと渡されたフォルダ名を結合して絶対パスを作る
            $Target = Join-Path -Path $Root -ChildPath $_
            $Obj."FullName" = $Target

            # フォルダを作成する(すでに存在していてもエラーにしない)
            New-Item -Path $Target -ItemType Directory -Force | Out-Null

            # 直前のコマンド結果に応じて結果を格納する
            If($?)
            {
                $Obj."Result" = "Success"
            }
            Else
            {
                $Obj."Result" = "Error"
            }

            # 結果格納用の配列にオブジェクトを加算する
            $Result += $Obj
        }
    }

    End
    {
        # オブジェクト配列を返す
        return $Result
    }
}

こんな感じで使っています。


$Directories = @(
    "data"
    "data\$((Get-Date).ToString("yyyy-MM-dd"))-Level-1"
    "log"
)

$Directories = New-FolderConstruction -Path $Directories -Root $PSScriptRoot

# 後から使うので変数に入れておく
$DataDir  = $Directories[0].FullName
$MonthDir = $Directories[1].FullName
$LogDir   = $Directories[2].FullName

過去ファイルのローテーション

古い CSV ファイルやログファイルのローテーションも自動化したいので、そのための関数を用意しています。

スクリプト

### Requires -Version 5.1 ###

<#
.Synopsis
Delete Old Folders and Files

.DESCRIPTION
Delete Old Folders and Files:
 - Select Property "CreationTime" or "LastWriteTime"

.EXAMPLE
Remove-PastItem -Path "C:\Work\test-1", "C:\Work\test-2" -Day 90

.EXAMPLE
"C:\Work\test-1", "C:\Work\test-2" | Remove-PastItem -Day 90

.EXAMPLE
Remove-PastItem -Path "C:\Work\test-1", "C:\Work\test-2" -Day 90 -Property CreationTime

.EXAMPLE
"C:\Work\test-1", "C:\Work\test-2" | Remove-PastItem -Day 90 -Property CreationTime

.EXAMPLE
Remove-PastItem -Path "C:\Work\test-1", "C:\Work\test-2" -Day 90 -Property LastWriteTime

.EXAMPLE
"C:\Work\test-1", "C:\Work\test-2" | Remove-PastItem -Day 90 -Property LastWriteTime

.NOTES
Author: nekrassov01
#>

Function Remove-PastItem
{
    [OutputType([System.Object])]
    [CmdletBinding()]
    Param
    (
        [Parameter(
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Mandatory = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string[]]$Path,

        [Parameter(
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Mandatory = $true
        )]
        [ValidateNotNullOrEmpty()]
        [int]$Day,

        [Parameter(
            Mandatory = $false
        )]
        [ValidateSet(
            "CreationTime",
            "LastWriteTime"
        )]
        [string]$Property = "CreationTime"
    )

    Begin
    {
        # 結果を格納する配列を作る
        $Result = New-Object -TypeName System.Collections.ArrayList
    }

    Process
    {
        # 配列で渡されたフォルダを1つずつ処理する
        $Path | ForEach-Object -Process {

            $Target = Get-ChildItem -Path $_ -Recurse            

            # 基準にするプロパティを決める
            If($Property -eq "LastWriteTime")
            {
                # 「最終更新日時」を基準にする
                $Target = $Target | Where-Object -FilterScript { $_.LastWriteTime -le (Get-Date).AddDays(-$Day) }
            }
            Else
            {
                # 「作成日時」を基準にする
                $Target = $Target | Where-Object -FilterScript { $_.CreationTime -le (Get-Date).AddDays(-$Day) }
            }

            $Target | ForEach-Object -Process {

                $Obj = New-Object -TypeName PSCustomObject | Select-Object -Property "FullName", "Property", "Result"
                $Obj."FullName" = $_.FullName
                $Obj."Property" = $Property

                # 条件に該当するファイル・フォルダを削除する
                $_ | Remove-Item -Force -Recurse #-WhatIf

                # 直前のコマンド結果に応じて結果を格納する
                If($?)
                {
                    $Obj."Result" = "Success"
                }
                Else
                {
                    $Obj."Result" = "Error"
                }

                # 結果格納用の配列にオブジェクトを加算する
                $Result += $Obj
            }         
        }
    }

    End
    {
        # オブジェクト配列を返す
        return $Result
    }
}

経過日数を計算する際、CreationTimeLastWriteTimeのうちどちらのプロパティを基準にするかを指定できます。デフォルト値はCreationTimeです。


Remove-PastItem -Path $DataDir, $LogDir -Day 365 -Property CreationTime

ログ出力

ログに書き出す際は出力行ごとにプレフィックスとして現在時刻が挿入されるようにしたいので、そのための関数を用意しています。

スクリプト

### Requires -Version 5.1 ###

<#
.Synopsis
Display DateTime on the Console Output

.DESCRIPTION
Display DateTime on the Console Output

.EXAMPLE
Out-Log -String "test"

.EXAMPLE
Out-Log "test"

.NOTES
Author: nekrassov01
#>

Function Out-Log
{
    [OutputType([System.String])]
    [CmdletBinding()]
    Param
    (
        [Parameter(
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Mandatory = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]$String
    )

    Process
    {
        $LogTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
        Write-Host "[${LogTime}] ${String}"
    }

    End
    {
        # 変数はクリアしておく
        Clear-Item -Path Variable:LogTime
        Clear-Item -Path Variable:String
    }
}

これは地味に便利です。個人的にお気に入りで多用しています。主としてログファイルへの書き出しで利用することが多いので、色付け等の機能は実装していません。


PS C:\WINDOWS\system32> Out-Log "処理を実行しました"
[2020-11-15 21:53:59] 処理を実行しました

全体の組み立て

使っている自作関数を紹介し終わったので、メインスクリプトを見ていきます。3パターン用意しました。

パターン 1: 出力が1ファイル+パラメータを連想配列で直書き

基本となるパターンです。外部ファイル等もなくスクリプト単独で完結させます。出力は1ファイルでまとめて出します。

スクリプト

Push-Location -Path $PSScriptRoot

$FunctionsDir = "..\..\powershell-functions\scripts"
Get-ChildItem -Path $FunctionsDir | ForEach-Object -Process { .$_.FullName }

$Directories = @(
    "data"
    "data\$((Get-Date).ToString("yyyy-MM-dd"))"
    "log"
)

$Directories = New-FolderConstruction -Path $Directories -Root $PSScriptRoot

$DataDir  = $Directories[0].FullName
$MonthDir = $Directories[1].FullName
$LogDir   = $Directories[2].FullName

Start-Transcript -Path "${LogDir}\$((Get-Date).ToString("yyyyMMddHHmmss")).log" -Force | Out-Null

Remove-PastItem -Path $DataDir, $LogDir -Day 365 -Property CreationTime

$Params = @(
    @{
        ComputerName = "remotehost1"
        LogName      = "System", "Application"
        Level        = 1,2,3
        Recently     = 24
    }
    @{
        ComputerName = "remotehost1"
        LogName      = "Security"
        Level        = 0
        Recently     = 24
        EventId      = 4625
    }
    @{
        ComputerName = "remotehost2"
        LogName      = "System", "Application"
        Level        = 1,2,3
        Recently     = 24
    }
    @{
        ComputerName = "remotehost2"
        LogName      = "Security"
        Level        = 0
        Recently     = 24
        EventId      = 4625
    }
)

$Result = @()

$Params | ForEach-Object -Process {

    $Ping = Test-Connection -ComputerName $_.ComputerName -Quiet

    If($Ping)
    {
        $Output = Backup-Eventlog @_ -ErrorAction SilentlyContinue
        $Output = $Output | Sort-Object -Property LogName, LevelId, Date, Time
        $Result += $Output
        Out-Log "Done: $($_.ComputerName) - $($_.LogName -join ", ")"
    }
    Else
    {
        Out-Log "No Reachable: $($_.ComputerName) - $($_.LogName -join ", ")"
    }
}

$Result | Export-Csv -Path "${MonthDir}\eventlog.csv" -Encoding Default -Force -NoTypeInformation

Pop-Location
Stop-Transcript | Out-Null
Get-Variable | Remove-Variable -ErrorAction SilentlyContinue

コメントがない代わりにポイントを説明します。まず、このパターンは必要なパラメータを連想配列(HashTable)で定義しています。


$Params = @(
    @{
        ComputerName = "remotehost1"
        LogName      = "System", "Application"
        Level        = 1,2,3
        Recently     = 24
    }
    @{
        ComputerName = "remotehost1"
        LogName      = "Security"
        Level        = 0
        Recently     = 24
        EventId      = 4625
    }
    @{
        ComputerName = "remotehost2"
        LogName      = "System", "Application"
        Level        = 1,2,3
        Recently     = 24
    }
    @{
        ComputerName = "remotehost2"
        LogName      = "Security"
        Level        = 0
        Recently     = 24
        EventId      = 4625
    }
)

定義した連想配列は、Get-WinEvent の持つ -FilterHashTable パラメータにそのまま指定できます。したがって、ループで回すときはこのように記述できます。


$Params | ForEach-Object -Process { Backup-Eventlog @_ }

めちゃくちゃすっきり書けますね。ループで回すときですが、対象ホストに Ping を飛ばして、疎通が取れる場合にだけメイン処理を実行するようにしました。こうすることで、一部のサーバーを撤去したけどパラメータをメンテナンスするのを忘れていた、といったケースでも安全に読み飛ばすことができます。


$Params | ForEach-Object -Process {

    # -Quiet スイッチをつけることで戻り値が Boolean 型になる
    $Ping = Test-Connection -ComputerName $_.ComputerName -Quiet

    If($Ping)
    {
        Backup-Eventlog @_
        Out-Log "Done: $($_.ComputerName) - $($_.LogName -join ", ")"
    }
    Else
    {
        Out-Log "No Reachable: $($_.ComputerName) - $($_.LogName -join ", ")"
    }
}

あとは、コンソール出力をログとしてキャプチャしたいので、Transcript しておきましょう。「トランスクリプトが開始されました / 停止されました」の文言がログに記録されないように、Out-Nullコマンドレットにパイプして出力を破棄しています。


Start-Transcript -Path "${LogDir}\$((Get-Date).ToString("yyyyMMddHHmmss")).log" -Force | Out-Null
・・・
Stop-Transcript | Out-Null

全体を見るに、自作関数のおかげでだいぶ記述を簡潔にすることができています。

パターン 2: 出力が1ファイル+パラメータを外部に保持

続いて、パラメータを外部ファイルにストアしておくパターンです。パラメータをスクリプトに直書きするのは、ある程度の規模になってくると管理が厳しいと思います。今回は JSON 形式でテキストとして保持しておき、スクリプトで使うときに変換して利用する方式を紹介します。まずは全体像です。

スクリプト

Push-Location -Path $PSScriptRoot

$FunctionsDir = "..\..\powershell-functions\scripts"
Get-ChildItem -Path $FunctionsDir | ForEach-Object -Process { .$_.FullName }

$Directories = @(
    "data"
    "data\$((Get-Date).ToString("yyyy-MM-dd"))"
    "log"
)

$Directories = New-FolderConstruction -Path $Directories -Root $PSScriptRoot

$DataDir  = $Directories[0].FullName
$MonthDir = $Directories[1].FullName
$LogDir   = $Directories[2].FullName

Start-Transcript -Path "${LogDir}\$((Get-Date).ToString("yyyyMMddHHmmss")).log" -Force | Out-Null

Remove-PastItem -Path $DataDir, $LogDir -Day 365 -Property CreationTime

$Json = Get-Content -Path ".\params.json"
$Params = @()
($Json | ConvertFrom-Json).Params | ForEach-Object -Process {
    $Serializer = New-Object -TypeName System.Web.Script.Serialization.JavaScriptSerializer
    $Hashtable = $Serializer.Deserialize(($_ | ConvertTo-Json), [System.Collections.Hashtable])
    $Params += $Hashtable
}

$Result = @()

$Params | ForEach-Object -Process {

    $Ping = Test-Connection -ComputerName $_.ComputerName -Quiet

    If($Ping)
    {
        $Output = Backup-Eventlog @_ -ErrorAction SilentlyContinue
        $Output = $Output | Sort-Object -Property LogName, LevelId, Date, Time
        $Result += $Output
        Out-Log "Done: $($_.ComputerName) - $($_.LogName -join ", ")"
    }
    Else
    {
        Out-Log "No Reachable: $($_.ComputerName) - $($_.LogName -join ", ")"
    }
}

$Result | Export-Csv -Path "${MonthDir}\eventlog.csv" -Encoding Default -Force -NoTypeInformation

Pop-Location
Stop-Transcript | Out-Null
Get-Variable | Remove-Variable -ErrorAction SilentlyContinue

連想配列の記述が、JSON を変換する記述に差し替わっているだけです。この変換は、いちばんややこしい部分かもしれません。まず JSON の中身です。


# params.json
{
    "Params": [
        {
            "ComputerName": "remotehost1",
            "LogName": ["System", "Application"],
            "Level": ["1", "2", "3"],
            "Recently": "24"
        },
        {
            "ComputerName": "remotehost1",
            "LogName": ["Security"],
            "Level": ["0"],
            "Recently": "24",
            "EventId": ["4625"]
        },
        {
            "ComputerName": "remotehost2",
            "LogName": ["System", "Application"],
            "Level": ["1", "2", "3"],
            "Recently": "24"
        },
        {
            "ComputerName": "remotehost2",
            "LogName": ["Security"],
            "Level": ["0"],
            "Recently": "24",
            "EventId": ["4625"]
        }
    ]
}

なんの変哲もない JSON です。これを連想配列にするコードは以下です。


# JSON の中身を読み込む
$Json = Get-Content -Path ".\params.json"

# 出力用の配列を初期化する
$Params = @()

# JSON を PSCustomObject に変換して Params の中身をひとつずつ処理する
($Json | ConvertFrom-Json).Params | ForEach-Object -Process {

    # JSON をシリアライズ / デシリアライズできる .NET クラスを初期化
    $Serializer = New-Object -TypeName System.Web.Script.Serialization.JavaScriptSerializer

    # PSCustomObject のひとつを JSON に戻し、型に連想配列を指定してデシリアライズする
    $Hashtable = $Serializer.Deserialize(($_ | ConvertTo-Json), [System.Collections.Hashtable])

    # 連想配列を出力用の配列に加算する
    $Params += $Hashtable
}

コメントを見ていただければわかりますが、面倒です。なぜ面倒なのかというと、以下が成立しないためです。これをやると間違ったオブジェクトができあがってしまいます。


$Json = Get-Content -Path ".\params.json"
$Serializer = New-Object -TypeName System.Web.Script.Serialization.JavaScriptSerializer
$Params = $Serializer.Deserialize($Json, [System.Collections.Hashtable])

JSON が以下のような単純な構造なら問題なく変換できるのですが、今回のような JSON 配列の場合はその要素ごとに変換をかける必要があるようです。


{
    "ComputerName": "remotehost-1",
    "LogName": ["System", "Application"],
    "Level": ["1", "2", "3"],
    "Recently": "24"
}

PowerShell は単なるテキストである JSON を直接解釈することができないので、JSON 配列を要素ごとに処理するためには PowerShell が解釈できるなんらかのデータ構造に変換してあげる必要があります。JSON => HashTable が機能しない状況下でいちばん簡単なのは、PSCustomObject を経由して JSON <=> HashTable を変換する方法です。流れを整理すると以下の感じです。

  1. HashTable を入れるための配列を用意する
  2. JSON 配列を PSCustomObject 配列に変換する(要素ごとに処理できるようになる)
  3. 要素をひとつ取り出して JSON に戻す
  4. JSON を HashTable に変換する
  5. HashTable を配列に格納する(以下繰り返し)

ここまで説明してアレですが、こんなやり方をしなければならないのは、PowerShell のバージョンが 5.1 だからです。6以上の PowerShell では、ConvertFrom-Json-AsHashtableという超絶便利なパラメータが追加されているようです。いつまでも 5.1 使っているとこういうことになるわけですね・・


# PowerShell 6 以上だとこれですむらしい
($Json | ConvertFrom-Json -AsHashtable).Params

パターン 3: 出力が LogName ごと+パラメータを外部に保持

最後の事例ですが、これはログの種類ごとに CSV を出し分けしたいといった場合にどうやるかです。ForEach のネストをひとつ深くすることになります。

スクリプト

Push-Location -Path $PSScriptRoot

$FunctionsDir = "..\..\powershell-functions\scripts"
Get-ChildItem -Path $FunctionsDir | ForEach-Object -Process { .$_.FullName }

$Directories = @(
    "data"
    "data\$((Get-Date).ToString("yyyy-MM-dd"))"
    "log"
)

$Directories = New-FolderConstruction -Path $Directories -Root $PSScriptRoot

$DataDir  = $Directories[0].FullName
$MonthDir = $Directories[1].FullName
$LogDir   = $Directories[2].FullName

Start-Transcript -Path "${LogDir}\$((Get-Date).ToString("yyyyMMddHHmmss")).log" -Force | Out-Null

Remove-PastItem -Path $DataDir, $LogDir -Day 365 -Property CreationTime

$Json = Get-Content -Path ".\params.json"
$Params = @()
($Json | ConvertFrom-Json).Params | ForEach-Object -Process {
    $Serializer = New-Object -TypeName System.Web.Script.Serialization.JavaScriptSerializer
    $Hashtable = $Serializer.Deserialize(($_ | ConvertTo-Json), [System.Collections.Hashtable])
    $Params += $Hashtable
}

$Result = @()

$Params | ForEach-Object -Process {

    $ComputerName = $_.ComputerName
    $LogName      = $_.LogName
    $Level        = $_.Level
    $Recently     = $_.Recently
    $EventId      = $_.EventId

    $LogName | ForEach-Object -Process {
        
        $Params = @{
            ComputerName = $ComputerName
            LogName      = $_
            Level        = $Level
            Recently     = $Recently
            EventId      = $EventId
        }

        $Ping = Test-Connection -ComputerName $ComputerName -Quiet

        If($Ping)
        {
            $Output = Backup-Eventlog @Params -ErrorAction SilentlyContinue
            $Output | Export-Csv -Path "${MonthDir}\${ComputerName}.${_}.csv" -Encoding Default -Force -NoTypeInformation
            $Result += $Output
            Out-Log "Done: ${ComputerName} - ${_}"
        }
        Else
        {
            Out-Log "No Reachable: ${ComputerName} - ${_}"
        }
    }
}

$Result | Export-Csv -Path "${MonthDir}\eventlog.csv" -Encoding Default -Force -NoTypeInformation

Pop-Location
Stop-Transcript | Out-Null
Get-Variable | Remove-Variable -ErrorAction SilentlyContinue

もはや特筆すべきポイントもさほどありませんが、強いて言えば、ネストしたForEach-Objectで親ループの$_.[Property]を子ループ内で使いたい場合は、親ループの$_.[Property]を変数に入れておきましょう、ということくらいでしょうか。親ループの予約変数$_が子ループ内で上書きされてしまうことの対策です。ちなみに、パイプするオブジェクトの親玉がコマンドレットである場合は-PipelineVariableパラメータが使えるのですが、今回は配列からパイプするパターンなのでそれができず、以下のようになっています。


$Params | ForEach-Object -Process {

    $ComputerName = $_.ComputerName
    $LogName      = $_.LogName
    $Level        = $_.Level
    $Recently     = $_.Recently
    $EventId      = $_.EventId

    $LogName | ForEach-Object -Process {
        
        $_Params = @{
            ComputerName = $ComputerName
            LogName      = $_
            Level        = $Level
            Recently     = $Recently
            EventId      = $EventId
        }

        Backup-Eventlog @_Params
    }
}

実際のユースケースでは LogName ごとのファイルも全体のファイルも両方出しておくのが無難な気もします(上記スクリプトはそうなっています)

おわりに

最後になりますが、注意点です。リモートコンピューターに対してGet-WinEventしたときに「RPC サーバを使用できません」というエラーが出た場合は、Windows ファイアウォールで通信がブロックされている可能性が高いです。その場合は一度ファイアウォールを無効にしてみて、エラーが発生するかどうかを確認してみましょう。成功する場合は、こちらこちらを参照して、差分をチェックしながらファイアウォールに穴開けしてみてください。ちなみに私は RPC の動的ポートを開ける必要がありました。