
CloudWatch Logs のデータ保護ポリシーでメールアドレスをマスクして、ログの読み方・出力先ごとの見え方を確認してみた
はじめに
テクニカルサポートの 片方 です。
利用者の登録がある Web アプリでは、アプリのログに利用者のメールアドレスが混ざることがあります。ログを出さないように修正するのが先ですが、漏れた 1 行を後から消すのは手間がかかります。Amazon CloudWatch Logs には、ログの中の個人情報を見つけてマスクする(伏せる)「データ保護ポリシー」 があります。
ここで気になったのは、マスクした後のログがどこでどう見えるかです。ログはコンソールで読むほか、CloudWatch Logs Insights で調べ、メトリクスフィルターで数え、サブスクリプションフィルターで送り、Amazon S3 にも書き出します。AWS のドキュメントでは、こうしたログの読み方や出力先を「出力ポイント」と呼んでいます。
データ保護ポリシーを作成すると、デフォルトでは、ユーザーの選択したデータ識別子に一致する機密データがすべての出力ポイント (CloudWatch Logs Insights、メトリクスフィルター、サブスクリプションフィルターなど) でマスクされます。
そこで、架空のメールアドレスを入れたログで、読み方・出力先ごとの見え方を検証用の AWS アカウントの東京リージョンで確認しました。
構成

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

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"])}
データメッセージは、"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 行目は、私の方で黒く塗っています。

ポリシーを付ける前の行はマスクされないので、注意してください。
データ保護ポリシーを設定しても、それ以前にロググループに取り込まれたログイベントはマスクされません。
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 時間かかる場合があります。書き出したファイルも、ポリシーを付けた後の行はマスクされ、付ける前の行は元のままでした。
ログデータは、エクスポートできるようになるまで最大 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 件)を読めました。

マスク前の値を見る人は、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 つは載っていません。
まとめ
アドレスの値で数えたり探したりする仕組みは、エラーを出さずに当たらなくなるので、入れる前に洗い出しておきます。対象になるのは、メトリクスフィルター、サブスクリプションフィルターの先の処理、FilterLogEvents や Logs Insights の保存した問い合わせです。Logs Insights は、表示がマスクされることまでを確認しました。次は、アカウント全体のポリシーとロググループのポリシーを重ねたときの見え方を試したいと思います。
本ブログが誰かの参考となれば幸いです。
参考資料
- 機密性の高いログデータをマスキングで保護する - Amazon CloudWatch Logs
- データをマスクせずに表示する - Amazon CloudWatch Logs
- Amazon S3 へのログデータのエクスポート - Amazon CloudWatch Logs
- ロググループレベルのサブスクリプションフィルター - Amazon CloudWatch Logs
- Monitoring with CloudWatch metrics - Amazon CloudWatch Logs
- TestMetricFilter - Amazon CloudWatch Logs
クラスメソッドオペレーションズ株式会社について
クラスメソッドグループのオペレーション企業です。
運用・保守開発・サポート・情シス・バックオフィスの専門チームが、IT・AIをフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 コーポレートサイト をぜひご覧ください。※2026年1月 アノテーション㈱から社名変更しました








