Microsoft365の監査ログを取得するLambda

# はじめに
Microsoft365の監査ログの保存期間がデフォルト90日なので、それを長期間保存するためS3に出力するLambdaを作成しました。

# 参考

https://github.com/nickadam/exo-lambda

# 構成図

構成図は以下の様になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/553680/d856cd57-3909-6efd-7a94-67f958ac718a.png)

- 任意のサービスが、対象のLambdaをInvokeします。
    - Step Functionsを想定
    - 与える引数は以下
        - 監査ログ取得対象開始日時
        - 監査ログ取得対象終了日時
        - 保存先バケット
        - オブジェクトキー名
- Microsoft365への接続に使う情報はSystemsManagerのParameter Storeから取得します。
    - 格納先のParameter Storeの名前は環境変数で指定
- 取得した内容は一度ローカルにJSONファイルで保存し、最後にS3へPUTします。

# このLambda関数の仕組み

- Microsoft365の監査ログを取得するためにPowerShellを使います。
- 普通のLambdaのOS(Amazon Linux2)では取得に使うモジュールが入らないので、OSがUbuntuのコンテナイメージから作成します。
    - Microsoft公式にあるPowerShellインストール済みのイメージを使用
- PowerShellとLambdaとやり取りするRICは無いので、Pythonに取り持ってもらいます。
    - Pythonから、PowerShellの処理を実行する形

# 環境
いつものCloud9になります。詳細は以下と同じです。

https://qiita.com/a_b_/items/d9398fd6efe0f66b6c6f#%E7%92%B0%E5%A2%83

# やったこと

## 準備
ディレクトリ作成したり、よく使う文字列を環境変数に入れておきます。
```bash
# プロジェクトディレクトリ作成
mkdir sample-ecr-lambda
cd sample-ecr-lambda

# リージョンとアカウントIDを環境変数にセット
REGION="ap-northeast-1"
ACCOUNTID=$(aws sts get-caller-identity --output text --query Account)

# 保存先のバケット名。何度も使うのでセット
BUCKETNAME="<<BUCKET_NAME>>"
```

基本のフォルダ構成は以下のようになります。その他CFnファイルなどは都度作成していきます。
```
sample-ecr-lambda
├── script
│   ├── mycert.pfx
│   └── GetMs365AuditLog.ps1
├── function
│   └── app.py
└── Dockerfile
```
`script/mycert.pfx`は、以下のページの認証方法を用いる際の認証ファイルになります。

https://docs.microsoft.com/ja-jp/powershell/exchange/app-only-auth-powershell-v2?view=exchange-ps

他の方法もありますので、そちらを使う際は適宜`script/GetMs365AuditLog.ps1`も書き換えてください。

https://docs.microsoft.com/ja-jp/powershell/exchange/connect-to-exchange-online-powershell?view=exchange-ps

https://blog.rykoma.net/2020/09/27/1551/

## 接続情報をパラメータストアに登録

MS365との接続情報は、パラメータストアで管理します。Secrets Managerも検討しましたが、そこまでの機能はいらないと考えこちらにしました。

https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/systems-manager-parameter-store.html

Connect-ExchangeOnlineで与える引数をまとめて、JSON文字列にしてファイルに保存します。
```json:env.json
{
 "MS365ORG":"<<.onmicrosoft.comでおわる組織>>",
 "APPID":"<<アプリケーション ID>>",
 "CERTPASSWORD":"<<認証ファイルのパスワード>>"
}
```
パラメータストアは、CloudFormationではSecureStringが使えないので、AWS CLIを使って登録します。
名前にスラッシュが入っていると、IAMポリシー設定時に指定できなくなるので、スラッシュがない名前にしています。
```bash
# パラメータストアの名前を格納
SSMPARAMETERNAME="GetMs365AuditLog-ExchangeOnlineParameters"
# 作成
aws ssm put-parameter --region ${REGION} --name ${SSMPARAMETERNAME} --type SecureString --value file://env.json
```
## スクリプト・Dockerfile

MS365監査ログを取得するPoweshellファイルは以下になります。以下のページを参考にしました。

https://docs.microsoft.com/ja-jp/microsoft-365/compliance/audit-log-search-script?view=o365-worldwide#step-2-modify-and-run-the-script-to-retrieve-audit-records

- `Connect-ExchangeOnline`で`CommandName`を使って、用いるコマンドを制限しておくと早くなるらしいので指定しています。
- ファイルに出力する際は、Athenaで扱えるよう1行ずつ、改行区切りのJSON文字列にしています。
```powershell:script/GetMs365AuditLog.ps1
$AWS = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($args)) | ConvertFrom-Json


# Get from ParameterStore
# Json文字列で入っているため、ConvertFrom-Jsonでそのまま扱う
$ExchangeOnlineParameters = (Get-SSMParameter -Name $Env:SSMPARAMETERNAME -WithDecryption $True).Value | ConvertFrom-Json

$resultSize = 5000
# 繰り返し取得する際に使う、一意のセッションID
$sessionID = [Guid]::NewGuid().ToString() + "_" +  "ExtractLogs" + (Get-Date).ToString("yyyyMMddHHmmssfff")
$OutputFile = "/tmp/" + $sessionID


# 空のファイル作成
New-Item $OutputFile -type file -Force 

try
{
  # Connect
  # CommandNameオプションで使用するコマンドを制限しておくとよいらしい
  Connect-ExchangeOnline `
   -CommandName Search-UnifiedAuditLog `
   -CertificateFilePath "/script/mycert.pfx" `
   -CertificatePassword `
    (ConvertTo-SecureString -String $ExchangeOnlineParameters.CERTPASSWORD -AsPlainText -Force) `
   -AppID $ExchangeOnlineParameters.APPID `
   -Organization $ExchangeOnlineParameters.MS365ORG `
   -ErrorAction Stop
  
  do
  {
  
    $each_result = Search-UnifiedAuditLog `
     -StartDate $AWS.event.StartDate -EndDate $AWS.event.EndDate `
     -SessionId $sessionID `
     -SessionCommand ReturnLargeSet `
     -ResultSize $resultSize `
     -ErrorAction Stop
    
    if (($each_result | Measure-Object).Count -ne 0)
    {
      $results = $results + $each_result
    }
  
  }
  while (($each_result | Measure-Object).Count -ne 0)
  
  # ファイルに出力
  ## Athenaで扱えるよう、各行1データ
  foreach($eachLog in $results)
  {
    ConvertTo-Json -Compress $eachLog | Out-File $OutputFile -Encoding utf8 -Append
  }
  
  
  # Copy to S3
  Write-S3Object -BucketName $AWS.event.BucketName -Key "$($AWS.event.Key)" -File $OutputFile
}
finally
{
  Disconnect-ExchangeOnline -Confirm:$false
}
```

Powershellを起動するだけのPythonは以下になります。
CloudWatch Logsに出力される際、ひとかたまりにするためloggingを使っています。（これでもあまり見栄えよくないです、どなたかわかる方教えてください）
```python:function/app.py
import subprocess
import json
import base64
from logging import getLogger, INFO

logger = getLogger(__name__)
logger.setLevel(INFO)

def convertto_base64(input_object):
    return str(base64.b64encode(json.dumps(input_object).encode('utf-8')), 'utf-8')

def handler(event, context):
  
  encoded_event_and_context = convertto_base64({
    'event': event
  })
  
  cmd = ['pwsh', '-Command', f'/script/GetMs365AuditLog.ps1 {encoded_event_and_context}']
  o = subprocess.run(cmd, encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  stdout = o.stdout.strip()
  stderr = o.stderr.strip()
  logger.info( json.dumps(stdout, ensure_ascii=False, indent=2) )
  logger.info( json.dumps(stderr, ensure_ascii=False, indent=2) )


  return {
      "status": len(stderr)==0 ,
      "body": json.dumps(
          {
              "target": event,
              "stdout": stdout,
              "stderr": stderr
          }
      )
  }
```

Dockerfileは以下になります。
```Dockerfile:Dockerfile
FROM mcr.microsoft.com/powershell:lts-7.2-ubuntu-20.04 AS pip

RUN apt-get update && apt-get install -y python3-pip

RUN mkdir /function && \
  pip install --target /function awslambdaric


############################################################
FROM mcr.microsoft.com/powershell:lts-7.2-ubuntu-20.04

RUN apt-get update && apt-get install -y python3

# Install powershell modules
RUN pwsh -command 'Install-Module -Name ExchangeOnlineManagement -Scope AllUsers -Force \
  && Install-Module -Name PSWSMan -Scope AllUsers -Force \
  && Install-Module -Name AWS.Tools.Installer -Scope AllUsers -Force \
  && Install-AWSToolsModule AWS.Tools.SimpleSystemsManagement,AWS.Tools.S3 -Scope AllUsers -Force \
  && Install-WSMan'

COPY --from=pip /function /function

COPY function/app.py /function


RUN mkdir /script
COPY script/mycert.pfx script/GetMs365AuditLog.ps1 /script/


WORKDIR /function

ENTRYPOINT [ "/usr/bin/python3", "-m", "awslambdaric" ]

CMD [ "app.handler" ]
```

できたらBuildします。

```
docker build -t func1 .
``` 

## ローカルでテスト

作成したイメージをローカルで動かす際は、以前書いた記事のようにします。

https://qiita.com/a_b_/items/8d3dc340b5109c0c0d56


まずは動かします。エントリポイントは無効化して、使う環境変数を持ってきます。
```bash
docker run --rm -it \
  --name=testGetAuditLog \
  --entrypoint= \
  -e SSMPARAMETERNAME=$SSMPARAMETERNAME \
  -e BUCKETNAME=$BUCKETNAME \
  func1 bash
```

### PowerShellのファイルを実行
コンテナ内で先のPS1ファイルを動かす際には以下のように、渡すeventを作成して、それを引数として実行しました。
```powershell
$ pwsh
PS > $event = ConvertTo-Json @{ 'event' = @{
 'StartDate'='2022-07-25 09:00:00'
 'EndDate'='2022-07-25 09:59:59'
 'BucketName'=$Env:BUCKETNAME
 'Key'='testwrite/20220725090000.csv'
}}

PS > /script/GetMs365AuditLog.ps1 $([System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($event)))
```

### Pythonのファイルを実行
コンテナ内でPythonファイルで動かす際は以下のようにします。
```python
$ python3
>>> import os
>>> import app
>>> event = {
 'StartDate':'2022-07-26 09:00:00',
 'EndDate':'2022-07-26 09:59:59',
 'BucketName':os.environ['BUCKETNAME'],
 'Key':'testwrite/20220726090000.csv'
}
>>> app.handler(event,None)
```
修正したい場合はホストのファイルを修正して、`docker cp`でコンテナ内へコピー
```bash
# 別のターミナルから
CURID=$(docker ps -q --filter "name=testGetAuditLog")
docker cp function/app.py $CURID:/function/app.py & docker cp script/GetMs365AuditLog.ps1 $CURID:/script/GetMs365AuditLog.ps1
```
修正が終わったらもう一度`docker build`を忘れずに。

## ローカルでLambdaをエミュレート
前回書いた記事と同様、RIEをホストにダウンロードしコンテナと共有して、エミュレートします。

https://qiita.com/a_b_/items/954c682f38735e3ec427

```bash
# RIEダウンロード
mkdir aws-lambda-rie

curl -Lo aws-lambda-rie/aws-lambda-rie \
 https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie

chmod +x aws-lambda-rie/aws-lambda-rie

# コンテナ起動
docker run --rm -p 9000:8080 \
 --name=testGetAuditLog \
 -v `pwd`/aws-lambda-rie:/aws-lambda \
 -e SSMPARAMETERNAME=$SSMPARAMETERNAME \
 --entrypoint /aws-lambda/aws-lambda-rie \
 func1:latest \
 /usr/bin/python3 -m awslambdaric app.handler

# テスト
## 別ターミナルから
### ターミナルが別なので、そちらでも環境変数をセット
BUCKETNAME="<<BUCKET_NAME>>"
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d "{
 \"StartDate\":\"2022-07-27 09:00:00\",
 \"EndDate\":\"2022-07-27 09:59:59\",
 \"BucketName\":\"${BUCKETNAME}\",
 \"Key\":\"testwrite/20220727090000.csv\"
}"
```

## レジストリに登録

以前と同様にECRをCFnで作成します。

https://qiita.com/a_b_/items/d9398fd6efe0f66b6c6f#ecr%E3%83%AA%E3%83%9D%E3%82%B8%E3%83%88%E3%83%AA%E4%BD%9C%E6%88%90%E3%82%A4%E3%83%A1%E3%83%BC%E3%82%B8push

```bash
# ECRリポジトリ作成CFn
touch createECRRepository.yaml
```
```yaml:createECRRepository.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Create ECR Stack Test

Resources:
  ########################################################
  ### ECR Repository
  ########################################################
  TestEcrPoc:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: func1
```
```bash
# ECR作成
aws cloudformation create-stack --stack-name tempecrrepo --template-body file://createECRRepository.yaml --region ${REGION}

# タグ付与
docker tag func1:latest \
  ${ACCOUNTID}.dkr.ecr.${REGION}.amazonaws.com/func1:latest

# 認証
aws ecr get-login-password --region ${REGION} | docker login --username AWS --password-stdin ${ACCOUNTID}.dkr.ecr.${REGION}.amazonaws.com

# PUSH サイズは250MB程度
docker push ${ACCOUNTID}.dkr.ecr.${REGION}.amazonaws.com/func1:latest
```

## Lambada作成・実行
Lambadaを作成するCFnファイルを記述します。
```bash
# Lambda作るCFn
touch createLambda.yaml
```
たくさん書いてありますが、以下を作っています。
- Lambda
    - イメージのサイズが大きいので、メモリサイズも大きいものにしています。
    - 接続に時間がかかるので、タイムアウトは最大値で設定。
- Lambda用ロググループ
- Lambda用IAMロール
    - ロググループ書き込み
    - S3書き込み
    - パラメータストア取得
 
CFnのパラメータは以下になります。
- Lambdaの名前
- ECRのイメージURI
- ファイルを置くバケット名
- パラメータストアの名前

<details><summary>クリックで表示</summary><div>

```yaml:createLambda.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Get MS365 Audit Log Function

Parameters:
  LambdaFunctionName:
    Type: String
  ImageUri:
    Type: String
  TargetBucket:
    Type: String
  TargetParameterStore:
    Type: String
    
Resources:
  ########################################################
  ### Log Group
  ########################################################
  FunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${LambdaFunctionName}"
      RetentionInDays: 3653

  ########################################################
  ### IAM Role
  ########################################################
  FunctionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "for-lambdafunction-${LambdaFunctionName}"
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      Path: '/service-role/'
      Policies:
        # CloudWatch
        - PolicyName: write-cloudwatchlogs
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action: 
                  - 'logs:CreateLogStream'
                  - 'logs:PutLogEvents'
                Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${LambdaFunctionName}:*"    
        # S3 
        - PolicyName: write-s3object
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action: 
                  - 's3:PutObject'
                Resource: !Sub "arn:aws:s3:::${TargetBucket}/*"    
        # SSM Parameter Store
        - PolicyName: get-ssmparameter
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action: 
                  - 'ssm:GetParameter'
                Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${TargetParameterStore}"  


  ########################################################
  ### Lambda Function
  ########################################################
  TargetFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref LambdaFunctionName
      Role: !GetAtt FunctionRole.Arn
      MemorySize: 384
      Timeout: 900
      PackageType: Image
      Code:
        ImageUri: !Ref ImageUri
      Environment:
        Variables:
          SSMPARAMETERNAME: !Ref TargetParameterStore
```
</div></details>

作成したCFnのファイルから、Lambdaをデプロイし実行します。

```bash
# CFnのパラメータに使う文字列を生成
DIGEST=$(aws ecr list-images --repository-name func1 --region ${REGION} --out text --query 'imageIds[?imageTag==`latest`].imageDigest')
IMAGEURI=${ACCOUNTID}.dkr.ecr.${REGION}.amazonaws.com/func1@${DIGEST}

# LambdaデプロイCFn実行。IAM作るのでcapabilities指定
aws cloudformation create-stack --stack-name tempecrlambda \
  --template-body file://createLambda.yaml \
  --region ${REGION}  \
  --parameters \
    ParameterKey=LambdaFunctionName,ParameterValue=get-ms365auditlog \
    ParameterKey=ImageUri,ParameterValue=${IMAGEURI} \
    ParameterKey=TargetBucket,ParameterValue=${BUCKETNAME} \
    ParameterKey=TargetParameterStore,ParameterValue=${SSMPARAMETERNAME} \
  --capabilities CAPABILITY_NAMED_IAM

# 実行
aws lambda invoke \
  --function-name get-ms365auditlog \
   --payload "{
    \"StartDate\":\"2022-07-28 09:00:00\",
    \"EndDate\":\"2022-07-28 09:59:59\",
    \"BucketName\":\"${BUCKETNAME}\",
    \"Key\":\"testwrite/20220728090000.csv\"
    }"\
   --region ${REGION} output ; cat output
```




## 片づけ


```bash
# Lambda削除
aws cloudformation delete-stack --stack-name tempecrlambda --region ${REGION}

# ECR上イメージ削除
aws ecr batch-delete-image --repository-name func1 --region ${REGION} --image-ids imageDigest=${DIGEST}

# ECRリポジトリ削除
aws cloudformation delete-stack --stack-name tempecrrepo --region ${REGION}

# dockerイメージ削除
docker rmi <<対象イメージ>>
# その他作られたイメージを削除
docker image prune
```

# おわりに
今回はLambdaでMicrosoft365の監査ログを取得する関数を作ってみました。
Azure Functions使った方が楽かも…とも思いましたが、AWSのほうが使い勝手がいいので、何とか実装しました。
同様の課題をお持ちの方の参考になれば幸いです。