PowerShellからLog Analyticsワークスペースに接続する際の「ゴミ」問題

# 背景

PowerShellからSentinelのLog Analytics ワークスペースに証明書認証で接続してKQLを実行し、その結果を得たい場合、PowerShellのAzモジュールで認証と接続を確立部分を関数にすると、不要な「ゴミ」データが付いてきます。それをどうやって除去するかについての記事です。

何のことかさっぱり分からないと思いますので、以下に例を挙げてご説明します。

# PowerShellからのKQL実行

PowerShellで証明書認証を使ってKQLを実行するスクリプトを順を追ってご説明します。

## 証明書による認証部分

PowerShellからSentinelのLog Analyticsワークスペース内のログデータに対してKQLを実行するには、まずEntra ID(Azure AD)側でアプリケーションの登録が必要ですが、一般的な手順ですのでこの部分は省略します。

KQLによるクエリーを受け入れるアプリケーションはEntra ID上に登録済みという前提です。

今回はアプリケーションIDとクライアントシークレットによる認証ではなく、証明書認証を使っています。

アプリケーションIDとクライアントシークレットによる認証でも同じ結果になると思いますが、勝手ながら手元のサンプルどおり証明書認証にさせて頂きます。また、認証にはPowerShellのAzモジュールを利用します。

以上の前提で、Log Analyticsのワークスペースに接続するスクリプトは以下の通りになります。

```PowerShell
# 証明書の拇印
$thumbprint = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
# テナントID
$tenantId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
# Entra ID側に登録したアプリケーションのID
$applicationId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
# アプリケーションへ接続
Connect-AzAccount -CertificateThumbprint $thumbprint -Tenant $tenantId `
  -ApplicationId $applicationId -ServicePrincipal

# Log Analyticsワークスペースへの接続
# ワークスペース名
$workspaceName = "sample_ws"
# そのワークスペースが存在するリソースグループ名
$workspaceResourceGroup = "sample_rg"
# コンテクストを上記のテナントに設定して・・・
Set-AzContext -Tenant $tenantId
# ワークスペースに接続します
$workspace = Get-AzOperationalInsightsWorkspace -Name $workspaceName `
  -ResourceGroupName $workspaceResourceGroup 
# これでワークスペースIDを得ることができます
$workspaceId = $workspace.CustomerID
```

このあたりのコードについては各コマンドレットのドキュメントに従った記述ですので特に違和感はないかと思います。

## KQLを実行する部分

ここまででLog Analyticsワークスペースへの接続ができましたので、続けてKQLを実行するスクリプトを記述します。細かい部分はいろいろな書き方があるの思いますので、以下はあくまで一例とお考え下さい。

```PowerShell
$query = "EmailEvents | where TimeGenerated >= ago(15m)"
$ret = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $query
```

KQLのクエリー本文を変数に格納し`Invoke-AzOperationalInsightsQuery`を呼び出すだけのシンプルな内容です。

余談ですが、PowerShellの`ImportExcel`モジュールを利用すると、クエリー結果をそのままExcelに書き出すこともできます。

```PowerShell
$ret.Results | Export-Excel -Path ".\sample.xlsx" -AutoSize -AutoFilter -TableStyle Medium4
```

`ImportExcel`モジュールについてはこちらをご参照ください。

https://www.powershellgallery.com/packages/ImportExcel/

## 「ゴミ」問題の発生

ここまでで何の問題もなくPowerShellからKQLを実行できているからいいじゃないか、となりますが、上記の証明書による認証部分を関数に変えた途端、問題が起こります。

具体的には以下のように書き換えた場合です。

```PowerShell
# 証明書による認証部分を関数に書き換える
function getWorkspaceId () {
    # 証明書の拇印
    $thumbprint = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
    # テナントID
    $tenantId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
    # Entra ID側に登録したアプリケーションのID
    $applicationId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
    # アプリケーションへ接続
    Connect-AzAccount -CertificateThumbprint $thumbprint -Tenant $tenantId `
      -ApplicationId $applicationId -ServicePrincipal

    # Log Analyticsワークスペースへの接続
    # ワークスペース名
    $workspaceName = "sample_ws"
    # そのワークスペースが存在するリソースグループ名
    $workspaceResourceGroup = "sample_rg"
    # コンテクストを上記のテナントに設定して・・・
    Set-AzContext -Tenant $tenantId
    # ワークスペースに接続します
    $workspace = Get-AzOperationalInsightsWorkspace -Name $workspaceName `
      -ResourceGroupName $workspaceResourceGroup 
    # これでワークスペースIDを得ることができます
    $workspaceId = $workspace.CustomerID

    return $workspaceId
}

# 実際のクエリ
$query = "EmailEvents | where TimeGenerated >= ago(15m)"

# 関数に書き換えた認証部分からワークスペースIDを得る
$workspaceId = getWorkspaceId

# エラーになる
$ret = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $query
```

最後の`Invoke-AzOperationalInsightsQuery`にわたすワークスペースIDを、関数からの戻り値に変更しただけなのにエラーになってしまいます。

エラーメッセージは以下のような内容になるはずです。

`
Cannot validate argument on parameter 'WorkspaceId'. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
`

したがってエラーを解消するには最後の行を以下のように書き換える必要があります。

```PowerShell
$ret = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId[3].Guid -Query $query
```

書き換えたのは`$workspaceId[3].Guid`の部分です。

認証部分を関数化すると余計な「ゴミ」が付いてくるため、その「ゴミ」を除去するために、関数の戻り値を配列とみなし、わざわざその要素の4つめ (添え字でいうと"3") の`Guid`プロパティを使うことで初めてエラーが解消されます。

つまり、配列の最初の3つの要素 (添え字でいうと"0"～"2") は「ゴミ」なわけです。認証部分を関数にした途端「ゴミ」が付いてくるということです。

## 「ゴミ」の中身

「ゴミ」の中身は、関数化した認証部分にある`Connect-AzAccount`や`Set-AzContext`といったコマンドレットの出力です。認証部分を関数化してその戻り値を取るように書き換えると、これら余計な出力まで一緒についてきてしまいます。

これはPowerShellで関数を書いた場合の仕様のようです。下記リンク先の公式ドキュメントにこのように書かれています。

>PowerShell では、Return キーワードを含むステートメントがなくても、各ステートメントの結果が出力として返されます。 C や C# などの言語では、 return キーワードで指定された値のみが返されます。

https://learn.microsoft.com/ja-jp/powershell/module/microsoft.powershell.core/about/about_return

ではこれらのコマンドレットの出力をどこかに逃がしてやればいいということで、例えば`Out-Null`に逃がしてみることにします。

```PowerShell
# 証明書による認証部分を関数に書き換える
function getWorkspaceId () {
    # 証明書の拇印
    $thumbprint = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
    # テナントID
    $tenantId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
    # Entra ID側に登録したアプリケーションのID
    $applicationId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
    # アプリケーションへ接続
    Connect-AzAccount -CertificateThumbprint $thumbprint -Tenant $tenantId `
      -ApplicationId $applicationId -ServicePrincipal | Out-Null

    # Log Analyticsワークスペースへの接続
    # ワークスペース名
    $workspaceName = "sample_ws"
    # そのワークスペースが存在するリソースグループ名
    $workspaceResourceGroup = "sample_rg"
    # コンテクストを上記のテナントに設定して・・・
    Set-AzContext -Tenant $tenantId | Out-Null
    # ワークスペースに接続します
    $workspace = Get-AzOperationalInsightsWorkspace -Name $workspaceName `
      -ResourceGroupName $workspaceResourceGroup 
    # これでワークスペースIDを得ることができます
    $workspaceId = $workspace.CustomerID

    return $workspaceId
}
```

`Connect-AzAccount`と`Set-AzContext`のそれぞれの行の末尾に`| Out-Null`が追加されているのがお分かりかと思います。

これで余分な「ゴミ」が取れてめでたしめでたし、とは残念ながらなりません。

この結果得られる戻り値は依然として2つの要素を持つ「ゴミ」の配列になっており、1つ目の要素は以下のような内容です。

```
Id                    : xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Type                  : ServicePrincipal
Tenants               : {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}
Credential            : 
TenantMap             : {}
CertificateThumbprint : 
ExtendedProperties    : {[CertificateThumbprint, XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX], [Tenants, xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx], [Subscriptions, xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx]}
```

2つ目の要素は以下のような内容で、こちらが本当に欲しいワークスペースIDになっています。

```
Guid
----
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
```

したがってエラーを解消するには以下のように書き換える必要があります。

```PowerShell
$ret = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId[1].Guid -Query $query
```

添え字が`$workspaceId[3]`から`$workspaceId[1]`に変わっただけで何の解決にもなっていません。２か所「Out-Null」を追加したにもかかわらず、まだどこかで「ゴミ」が出力されているわけです。

それがどこなのか私にはついに分からなかったため、PowerShellの仕様に沿った暫定策としてクラスに書き換えることとしました。

## 暫定策 : クラスに書き換える

どうせ暫定策をとるので、先ほど関数内に追加した`| Out-Null`は削除してしまいます。その上で以下のように関数をクラス定義に変更します。

```PowerShell:ワークスペースへの認証・接続をクラス化
class Workspace {

    # クラスのメンバー変数: ワークスペースオブジェクト自体を格納する
    [psobject] $ws

    # クラスの初期化
    Workspace() {

        # 以下、もとの関数と同じ内容

        Disconnect-AzAccount

        # Azureへのサインイン
        $thumbprint = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
        $tenantId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
        $applicationId = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
        Connect-AzAccount -CertificateThumbprint $thumbprint -Tenant $tenantId `
          -ApplicationId $applicationId -ServicePrincipal
    
        # Log Analyticsワークスペースへの接続
        $workspaceName = "sample_ws"
        $workspaceResourceGroup = "sample_rg"
        Set-AzContext -Tenant $tenantId
        # 取得したワークスペースオブジェクトは、このクラス自体のメンバー変数に代入する
        $this.ws = Get-AzOperationalInsightsWorkspace -Name $workspaceName `
          -ResourceGroupName $workspaceResourceGroup
    }

    # クラスメンバーにあるワークスペースオブジェクトから、ワークスペースIDの文字列を返すメソッド
    [string] GetId() {
        return $this.ws.CustomerID.Guid
    }
}
```

これでLog Analyticsのワークスペース自体を表現するクラスが出来上がり、そのワークスペースIDを返してくれるメソッドも実装できました。

ちなみに`Get-AzOperationalInsightsWorkspace`というコマンドレットが返すワークスペースオブジェクトの型名が分からなかったのですが汎用的な`[PSObject]`型で良いようです。

あとはこのクラスをインスタンス化してその`GetId`メソッドを呼び出すと、無事に「ゴミ」なしのクリーンな状態でワークスペースIDを取得できます。

```PowerShell
$ws = [Workspace]::new()
$workspaceId = $ws.GetId()  # この$workspaceIdにゴミは付いてこない
$ret = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $query
```

これで暫定策としてのクラス化ができたわけで、なぜPowerShellの関数が各ステートメントの結果を出力する仕様にしたのかは不明ですね。公式ドキュメントから再度引用してみます。

>PowerShell では、Return キーワードを含むステートメントがなくても、各ステートメントの結果が出力として返されます。 C や C# などの言語では、 return キーワードで指定された値のみが返されます。

以上、最後までお読みいただきありがとうございました。