CloudWatch Logs のデータ保護ポリシーでメールアドレスをマスクして、ログの読み方・出力先ごとの見え方を確認してみた

CloudWatch Logs のデータ保護ポリシーでメールアドレスをマスクして、ログの読み方・出力先ごとの見え方を確認してみた

CloudWatch Logs のデータ保護ポリシーでメールアドレスをマスクしたとき、ログの読み方や出力先によって見え方がどう変わるのかを検証してみました。コンソール・Logs Insights・メトリクスフィルター・S3 エクスポートなど、6 つの出力ポイントでの動作をまとめています。
2026.10.24

はじめに

テクニカルサポートの 片方 です。
利用者の登録がある Web アプリでは、アプリのログに利用者のメールアドレスが混ざることがあります。ログを出さないように修正するのが先ですが、漏れた 1 行を後から消すのは手間がかかります。Amazon CloudWatch Logs には、ログの中の個人情報を見つけてマスクする(伏せる)「データ保護ポリシー」 があります。

ここで気になったのは、マスクした後のログがどこでどう見えるかです。ログはコンソールで読むほか、CloudWatch Logs Insights で調べ、メトリクスフィルターで数え、サブスクリプションフィルターで送り、Amazon S3 にも書き出します。AWS のドキュメントでは、こうしたログの読み方や出力先を「出力ポイント」と呼んでいます。

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/mask-sensitive-log-data.html

データ保護ポリシーを作成すると、デフォルトでは、ユーザーの選択したデータ識別子に一致する機密データがすべての出力ポイント (CloudWatch Logs Insights、メトリクスフィルター、サブスクリプションフィルターなど) でマスクされます。

そこで、架空のメールアドレスを入れたログで、読み方・出力先ごとの見え方を検証用の AWS アカウントの東京リージョンで確認しました。

構成

構成の図。AWS CLI からロググループ by-y13-app にログを入れ、6 つの読み方・出力先で見え方を比べる。監査の結果は by-y13-audit に送る

ロググループ by-y13-app に、マネージドデータ識別子 EmailAddress を監査してマスクするデータ保護ポリシーを付けました。マネージドデータ識別子は、AWS が用意した機密データの種類の定義です。ログは AWS CLI の PutLogEvents で入れ、ポリシーを付ける前に 1 件、付けた後に 4 件を入れました。
読み方・出力先は、図の 6 つです。監査の結果は、別のロググループ by-y13-audit に送りました。

読む側は 2 つのロールで比べました。作業のロールはマスクする前の値を見る権限 logs:Unmask を持ち、読み取り専用ロールは ReadOnlyAccess だけで logs:Unmask を持ちません。

先に結論

  • ポリシーの後の 4 件は、見えた所のすべてで * に置き換わりました。* の数は元の文字数と同じで、長さは分かります。
  • ポリシーより前の行はマスクされません。GetLogEvents・FilterLogEvents・S3 へのエクスポートでは元のままで、Logs Insights では返りませんでした。
  • メトリクスフィルターはマスク後の文で評価され、ドメインで数えるフィルターのデータ点は作られませんでした。FilterLogEvents の検索も、--unmask を付けないとマスクした行に当たりませんでした。
  • logs:Unmask を持たないロールで --unmask を付けると、AccessDeniedException になりました。

やってみた

記事の CLI のコマンドを実行し直すと、create-log-group などは「既にある」エラーで止まり、put-log-events は同じイベントがもう一度入ります。実行し直すときは、先に削除するか、あるかを確認してから作ります。

1. ロググループを作り、ポリシーを付ける前のログを入れる

ロググループの保持は 1 日にしました。

aws logs create-log-group --log-group-name by-y13-app --tags blog-yattemita=Y13
aws logs put-retention-policy --log-group-name by-y13-app --retention-in-days 1
aws logs create-log-group --log-group-name by-y13-audit --tags blog-yattemita=Y13
aws logs put-retention-policy --log-group-name by-y13-audit --retention-in-days 1
aws logs create-log-stream --log-group-name by-y13-app --log-stream-name app-1

最初のログは、--log-events の短縮構文(shorthand syntax)で JSON を渡そうとして、次のエラーになりました。

aws: [ERROR]: An error occurred (ParamValidation): Error parsing parameter '--log-events': Expected: '=', received: '"' for input:

短縮構文の message= の値に JSON の " をそのまま書いたため、短縮構文の記号として読まれて失敗しました。シェルのエスケープを避けるため、イベントを JSON のファイルにして file:// で渡す形に直しました。

ファイルは [{"timestamp": <ミリ秒の現在時刻>, "message": "<1 行の文>"}] の形です。message には JSON を文字列にして入れます。入れたメッセージは次の 1 件です(アドレスは架空のものです)。

{"stage": "before-policy", "event": "signup", "email": "member-0001@example.com"}
aws logs put-log-events --log-group-name by-y13-app --log-stream-name app-1 --log-events file://ev-before.json

2. データ保護ポリシーを付ける

ポリシーは、監査(Audit)とマスク(Deidentify)の 2 つのステートメントを、同じデータ識別子で並べる形です。監査の結果の送り先は、先に作ったロググループにしました。

{
  "Name": "by-y13-mask-email",
  "Description": "mask email addresses in application logs",
  "Version": "2021-06-01",
  "Statement": [
    {
      "Sid": "audit",
      "DataIdentifier": ["arn:aws:dataprotection::aws:data-identifier/EmailAddress"],
      "Operation": {
        "Audit": {
          "FindingsDestination": {
            "CloudWatchLogs": { "LogGroup": "by-y13-audit" }
          }
        }
      }
    },
    {
      "Sid": "mask",
      "DataIdentifier": ["arn:aws:dataprotection::aws:data-identifier/EmailAddress"],
      "Operation": {
        "Deidentify": { "MaskConfig": {} }
      }
    }
  ]
}
aws logs put-data-protection-policy --log-group-identifier by-y13-app --policy-document file://data-protection-policy.json
aws logs describe-log-groups --log-group-name-prefix by-y13-app --query "logGroups[].[logGroupName,dataProtectionStatus]" --output text
by-y13-app	ACTIVATED

コンソールでは、ロググループの画面の「ロググループポリシー」の欄に、マネージドデータ識別子が出ます。下の「新しい機密データが検出されました」の欄には、監査ログの送り先が出ます。

ロググループ by-y13-app の画面の「ロググループポリシー」の欄。マネージドデータ識別子 EmailAddress と、監査ログ by-y13-audit に赤い枠

3. 出力先を用意する

サブスクリプションフィルターの先には、受け取った文をそのままログに出す Lambda 関数(Python 3.13、名前は by-y13-sub。作成の手順は省きます)を置きました。

import base64
import gzip
import json

def handler(event, context):
    # サブスクリプションフィルターから届くデータは、gzip で圧縮して Base64 にした JSON
    payload = json.loads(gzip.decompress(base64.b64decode(event["awslogs"]["data"])))
    for e in payload["logEvents"]:
        print(json.dumps({"received_from": payload["logGroup"], "message": e["message"]}))
    return {"count": len(payload["logEvents"])}

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/SubscriptionFilters.html

データメッセージは、"DATA_MESSAGE" 型を使用します。CloudWatch Logs は、主に送信先が到達可能であるかどうかをチェックするために、"CONTROL_MESSAGE" 型の Lambda レコードを発行することがあります。

aws lambda add-permission --function-name by-y13-sub --statement-id by-y13-logs \
  --principal logs.amazonaws.com --action lambda:InvokeFunction \
  --source-arn "arn:aws:logs:ap-northeast-1:<account>:log-group:by-y13-app:*" --source-account "<account>"
aws logs put-subscription-filter --log-group-name by-y13-app --filter-name by-y13-all \
  --filter-pattern "" --destination-arn "arn:aws:lambda:ap-northeast-1:<account>:function:by-y13-sub"

メトリクスフィルターは 2 つ付けました。1 つはメールアドレスのドメインで数え、もう 1 つはイベントの名前で数えます。

aws logs put-metric-filter --log-group-name by-y13-app --filter-name by-y13-by-domain \
  --filter-pattern '{ $.email = "*@example.com" }' \
  --metric-transformations metricName=SignupByDomain,metricNamespace=by-y13,metricValue=1
aws logs put-metric-filter --log-group-name by-y13-app --filter-name by-y13-by-event \
  --filter-pattern '{ $.event = "signup" }' \
  --metric-transformations metricName=SignupByEvent,metricNamespace=by-y13,metricValue=1

4. ポリシーを付けた後のログを入れる

まず、アドレスを JSON の項目に入れた行、ふつうの文の中に入れた行、1 行に 2 つ入れた行の 3 件を入れました。「確認してみた」の GetLogEvents で読んだ後に、短いアドレスの 1 件を足しました。

{"stage": "after-policy", "event": "signup", "email": "member-0002@example.com"}
after-policy plain text: password reset mail sent to member-0003@example.com
{"stage": "after-policy", "event": "invite", "from": "member-0004@example.com", "to": "member-0005@example.org"}
{"stage": "after-policy", "event": "signup", "email": "a@example.jp"}

確認してみた

ここからの出力は、載せる前に元のアドレスを私が <email> に置き換えています。* は CloudWatch Logs がマスクした所です。

GetLogEvents とコンソール

--unmask を付けずに読むと、ポリシーを付けた後の 3 件はマスクされ、付ける前の 1 件は元のままでした。

aws logs get-log-events --log-group-name by-y13-app --log-stream-name app-1 --start-from-head --query "events[].message" --output text

--output text の出力(タブ区切りの 1 行)を、1 件ずつ改行しています。

{"stage": "before-policy", "event": "signup", "email": "<email>"}
{"stage": "after-policy", "event": "signup", "email": "***********************"}
after-policy plain text: password reset mail sent to ***********************
{"stage": "after-policy", "event": "invite", "from": "***********************", "to": "***********************"}

* の数は、元のアドレスの文字数と同じでした(例:23 文字のアドレスは 23 個)。文の中のアドレスも、1 行に 2 つあるアドレスもマスクされ、--unmask を付けると元のアドレスに戻りました。

短いアドレスの 1 件を入れた後のコンソールでも、ポリシーの後の 4 件がマスクされています。
※ 画像の 1 行目は、私の方で黒く塗っています。

by-y13-app > app-1 のログイベント。ポリシーの後の行のアドレスが * になっている

ポリシーを付ける前の行はマスクされないので、注意してください。

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/mask-sensitive-log-data.html

データ保護ポリシーを設定しても、それ以前にロググループに取り込まれたログイベントはマスクされません。

FilterLogEvents の検索

ドメインで検索すると、--unmask を付けない場合は、ポリシーを付ける前の 1 件にしか当たりませんでした。

aws logs filter-log-events --log-group-name by-y13-app --filter-pattern '"example.com"' --query 'events[].message' --output text
{"stage": "before-policy", "event": "signup", "email": "<email>"}

同じ検索に --unmask を付けると、example.com を含む 4 件に当たりました。

Logs Insights

fields @message の結果は、GetLogEvents と同じマスクのされ方でした。ただし、ポリシーを付ける前に入れた 1 件は、GetLogEvents では返るのに、Logs Insights ではどの問い合わせにも返りませんでした。10 月 23 日の再現で、時間範囲を直近 24 時間にしても同じでした。

マスクする前の値は unmask(@message) で取れます。ただ、私が最初に書いた次の問い合わせは 0 件でした。

fields unmask(@message) as raw | filter raw like /example.com/ | sort @timestamp asc

filter を外すと、ポリシーの後の 4 件が元のアドレスで返りました。関数を filter の中に直接書く形にすると、期待どおりに当たりました。

filter unmask(@message) like /example.com/ | fields unmask(@message) as raw
raw	{"stage": "after-policy", "event": "invite", "from": "<email>", "to": "<email>"}
raw	after-policy plain text: password reset mail sent to <email>
raw	{"stage": "after-policy", "event": "signup", "email": "<email>"}

メトリクスフィルター

1 時間の合計を取ると、イベントの名前で数えた方は 2 で、ドメインで数えた方はデータが 1 つもありませんでした。名前空間 by-y13 のメトリクスの一覧にも、SignupByEvent しかありませんでした。

aws cloudwatch get-metric-statistics --namespace by-y13 --metric-name SignupByEvent \
  --start-time $(date -u -d '-60 min' +%FT%TZ) --end-time $(date -u +%FT%TZ) \
  --period 3600 --statistics Sum SampleCount --query 'Datapoints[].[Sum,SampleCount]' --output text
2.0	2.0

フィルターの書き方を確かめるため、TestMetricFilter に、マスクする前の文とマスクした後の文を 1 つずつ渡しました。合ったのは 1 番目のマスクする前の文だけでした(eventMessage で確認)。eventNumber は、TestMetricFilter の API リファレンスの例では 0 から数えていますが、この出力では 1 番目の文が 1 でした。出力は、2026 年 10 月 23 日に同じ入力で実行し直したものです。

aws logs test-metric-filter --filter-pattern '{ $.email = "*@example.com" }' \
  --log-event-messages '{"event":"signup","email":"<email>"}' '{"event":"signup","email":"***********************"}' \
  --output json
{
    "matches": [
        {
            "eventNumber": 1,
            "eventMessage": "{\"event\":\"signup\",\"email\":\"<email>\"}",
            "extractedValues": {}
        }
    ]
}

パターンは正しく、実際のログではマスクした後の文に当てられているので、データ点が 1 つも作られません。

サブスクリプションフィルターの先と S3 へのエクスポート

Lambda 関数が受け取った文も、マスクした後のものでした。

{"received_from": "by-y13-app", "message": "{\"stage\": \"after-policy\", \"event\": \"signup\", \"email\": \"***********************\"}"}
{"received_from": "by-y13-app", "message": "after-policy plain text: password reset mail sent to ***********************"}
{"received_from": "by-y13-app", "message": "{\"stage\": \"after-policy\", \"event\": \"invite\", \"from\": \"***********************\", \"to\": \"***********************\"}"}
{"received_from": "by-y13-app", "message": "{\"stage\": \"after-policy\", \"event\": \"signup\", \"email\": \"************\"}"}

S3 へのエクスポートは、CloudWatch Logs からの書き込みを許すバケットポリシーを付けた非公開のバケットに出しました。なお、ログデータがエクスポートできるようになるまで、最大 12 時間かかる場合があります。書き出したファイルも、ポリシーを付けた後の行はマスクされ、付ける前の行は元のままでした。

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/S3Export.html

ログデータは、エクスポートできるようになるまで最大 12時間かかる場合があります。

aws logs create-export-task --task-name by-y13-export --log-group-name by-y13-app \
  --from 1792660787134 --to 1792664387134 --destination <bucket> --destination-prefix y13
aws s3 cp s3://<bucket>/y13/<task-id>/app-1/000000.gz - | gunzip
2026-10-22T10:13:09.888Z {"stage": "before-policy", "event": "signup", "email": "<email>"}
2026-10-22T10:15:05.748Z {"stage": "after-policy", "event": "signup", "email": "***********************"}
2026-10-22T10:15:05.749Z after-policy plain text: password reset mail sent to ***********************
2026-10-22T10:15:05.750Z {"stage": "after-policy", "event": "invite", "from": "***********************", "to": "***********************"}
2026-10-22T10:15:30.630Z {"stage": "after-policy", "event": "signup", "email": "************"}

logs:Unmask を持たないロール

読み取り専用ロールで --unmask を付けると、拒否されました。--unmask を外すと、5 件(マスクした 4 件と、ポリシーを付ける前の 1 件)を読めました。

読み取り専用ロールで --unmask を付けた結果。AccessDeniedException のエラーの文に赤い枠

マスク前の値を見る人は、logs:Unmask の 1 つの権限で分けられます。ただし、AWS 管理ポリシーの CloudWatchLogsFullAccess にはこの権限が含まれるので、管理者や運用のロールが持っていないかを確認しておきます。

監査の結果

監査の送り先のロググループには、マスクした 4 件の分の結果が 4 件届きました。結果に入るのは、見つかった種類・数・文字の位置で、アドレスそのものは入りません。

{"auditTimestamp":"2026-10-22T10:15:09Z","resourceArn":"arn:aws:logs:ap-northeast-1:<account>:log-group:by-y13-app:*","dataIdentifiers":[{"name":"EmailAddress","count":1,"detections":[{"start":55,"end":77}]}],"policyName":"by-y13-mask-email","cloudWatchLogs.EventTimestamp":1792664105748,"cloudWatchLogs.LogStream":"app-1"}

AWS/Logs 名前空間の LogEventsWithFindings も 4 でした。このメトリクスは、ディメンションに LogGroupName だけを指定すると何も返りませんでした。DataProtectionOperation=Audit と LogGroupName の 2 つを指定して、値が取れました。下記ドキュメントのメトリクスの表では、LogEventsWithFindings のディメンションは 「None」 で、この 2 つは載っていません。

https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CloudWatch-Logs-Monitoring-CloudWatch-Metrics.html

まとめ

アドレスの値で数えたり探したりする仕組みは、エラーを出さずに当たらなくなるので、入れる前に洗い出しておきます。対象になるのは、メトリクスフィルター、サブスクリプションフィルターの先の処理、FilterLogEvents や Logs Insights の保存した問い合わせです。Logs Insights は、表示がマスクされることまでを確認しました。次は、アカウント全体のポリシーとロググループのポリシーを重ねたときの見え方を試したいと思います。
本ブログが誰かの参考となれば幸いです。

参考資料

クラスメソッドオペレーションズ株式会社について

クラスメソッドグループのオペレーション企業です。
運用・保守開発・サポート・情シス・バックオフィスの専門チームが、IT・AIをフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 コーポレートサイト をぜひご覧ください。※2026年1月 アノテーション㈱から社名変更しました

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事