背景
エンジニアとして活動していると、ブログの記事や手順書等のドキュメントに、設定画面等のスクリーンショットを添付する機会が発生します。
しかし、スクリーンショットの中にはアカウントIDやパブリックIPアドレスといった、外部に公開すべきではない個人識別用情報(以下、PII)が含まれている場合もあります。
今までは手動でマスキングを行っていましたが、記事の分量が増えると作業量も増すことに加え、マスキング漏れが生じるリスクも発生します。 そこで、画像内の文字抽出を行うことができるAmazon Textractと、実際のマスキング処理を行うLambdaを組み合わせ、PIIを自動的にマスキングする機能を作成しようと考えました。
概要
Amazon Textractとは
Amazon Textractとは、画像やPDF、表などのドキュメントからテキストを抽出する、機械学習を活用したOCRサービスです。 フルマネージドサービスであり、ユーザ側でモデルのトレーニングや実装をする必要がなく、すぐに高精度のテキスト抽出を行うことができます。
Textractはテキストの分析に特化しており、抽出できる単語数に上限が無い、APIによってキーバリューペア・表形式など構造化データの抽出にも対応可能、といった特徴があります。
そのため、単語数の多いドキュメントや、関連性を抽出したい場合(例えば、名簿を電子化する際にName : John Doeというペアで抽出)等に適しています。
今回の目的は画像からのテキスト抽出であるため、この用途に適したTextractを採用しました。
利用するAWSサービス
Amazon Textract
前項の通り、PII検出にあたり、スクリーンショット内からテキストを抽出するために用いるサービスです。
Amazon S3
AWSが提供するシンプルなオブジェクトストレージです。マスキング対象の画像のアップロードと、マスキング後の画像の保存に使用します。
AWS Lambda
サーバレスでプログラムを実行できるサービスです。Amazon Textractと連携し、実際にマスキング処理を行う箇所となります。
AWS Systems Manager Parameter Store
AWS上に設定データを保管し、管理するサービスです。今回はAWS Lambdaで使用するパラメータを保管します。
手順
実装の手順は以下の通りです。
- スクリーンショット保存用のS3バケットを作成する。
- AWS Systems Manager Parameter Storeに、Lambdaで使用するパラメータを保管する。
- Lambdaをプログラムし、AWSにデプロイする。
- S3のイベントトリガーを用いて、ObjectCreated時にLambdaを起動する設定を行う。
- S3にスクリーンショットをアップロードし、動作を確認する。
Textractはフルマネージドかつサーバレスのサービスであるため、ユーザ側で設定や環境構築は不要です。

実装
Textractは現在日本国内のリージョン(東京・大阪)で提供されていないため、以下の手順は外部リージョンで実施する必要があります。
今回は日本から地理的に近いソウルリージョン(ap-northeast-2)を使用します。
スクリーンショット保存用のS3バケットを作成する
まず、ファイルのアップロード先および、マスキング処理後の出力先となるS3バケットを作成し、それぞれプレフィックスを分割します。
今回はアップロード先をraw/、出力先をedited/としました。

プレフィックス分割しないと、出力されたオブジェクトでS3のイベントが発生し再度Lambdaが呼ばれてオブジェクトが出力され、S3のイベントが発生する無限ループが起こります。
プレフィックスやバケット自体を分ける等して、ループが起きないようにする必要があります。
ライフサイクルポリシーの設定
次に、raw/プレフィックスに、ライフサイクルポリシーの設定を行います。
今回S3にアップロードされる画像はPIIを含む可能性のある画像であるため、不必要にクラウド上に残り続けるのは避けるべきです。

この設定は必須ではありませんが、PIIを含むデータの消し忘れに対するガードレールとすることができます。
なお、ライフサイクルポリシーでなくLambdaの実行時に削除することも考えられますが、 マスキング処理が失敗する等で再利用したい場合に元画像が必要となるため、ライフサイクルポリシーによる削除を使用しました。
AWS Systems Manager Parameter Storeに、Lambdaで使用するパラメータを保管する
次に、AWS Systems Manager Parameter Store(以下Parameter Store)でパラメータの設定を行います。
Lambdaは一度デプロイすると、内容を変更するためには再デプロイの必要があります。
そこで、Parameter Storeにパラメータを保存し、Lambdaから参照する設定にすることで、検出対象が増えた場合等に対応しやすくなります。
AWSリソースID
AWSのリソースIDは、{固有プレフィックス}-{17桁の16進数}というフォーマットで表されるサービスが複数存在するため、今回はこれらをマスキング対象とします。
現在、このフォーマットで表されるサービスは以下の通りです。(参考:AWS公式ドキュメント)
これらを検出対象とするため、Parameter StoreのString Listに以下のパラメータを保存します。
- 固有プレフィックス部分
vpc-,subnet-,i-,ami-,snap-,vol-,sg-,eni-,igw-,rtb-,acl-,dopt-,cgw-,vgw-,vpn-,pcx-,vpce-,pl-,eipalloc-,eipassoc-,eni-attach-,rtbassoc-,subnet-cidr-assoc-,vpc-cidr-assoc-,fl-,bun-,conv-,export-,import-,r-
- サフィックス部分
[0-9a-zA-Z]{17}
上記の正規表現は17桁の英数字を表しており、本来の17桁の16進数よりも範囲が広くなっています。この理由については後述します。
その他のPII
次に、上記の形式に属さず、フォーマットが固定されているPIIとして、IPv4アドレス、AWSアカウントID、ARN等はコンソール画面やパラメータに出現しやすく、かつ重要度も高い要素です。
これらは統一された書式が存在しないため、個別に正規表現を作成し、Stringに保存します。
[
"\\barn:(aws|aws-cn|aws-us-gov):[a-zA-Z0-9-]+:[a-z0-9-]*:\\d{0,12}:[\\w+=,.@/:-]+\\b",
"\\b\\d{4}-\\d{4}-\\d{4}\\b|\\b\\d{12}\\b",
"\\b(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\b"
]
Lambdaをプログラムし、AWSにデプロイする
今回のLambdaのコードは以下の通りです。
1つのLambdaで以下の処理を行います。
- S3からの画像参照
- TextractのAPIを呼び出し
- マスキング処理
- マスキングした画像をS3に格納
Lambdaのコード
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using Amazon.Lambda.Core;
using Amazon.Lambda.S3Events;
using Amazon.S3;
using Amazon.S3.Model;
using Amazon.SimpleSystemsManagement;
using Amazon.SimpleSystemsManagement.Model;
using Amazon.Textract;
using Amazon.Textract.Model;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
namespace maskingFunction;
public class Function
{
private static readonly IAmazonS3 S3 = new AmazonS3Client();
private static readonly IAmazonTextract Textract = new AmazonTextractClient();
private static readonly IAmazonSimpleSystemsManagement Ssm = new AmazonSimpleSystemsManagementClient();
// 以下の項目は、S3バケットのプレフィックス設定および、ParameterStoreの設定項目に合わせてください。
// Parameter Storeで、このLambdaから呼び出すパラメータのパス
const string ParamPath = "/masking/";
// 接頭辞一覧を保存するパラメータ名
const string PrefixParamName = "prefix";
// 共通の接尾辞を保存するパラメータ名
const string SuffixParamName = "suffix";
// 固有の正規表現一覧を保存するパラメータ名
const string UniqueParamName = "unique";
// 出力先プレフィックス
const string DestinationPrefix = "edited/";
//設定項目に合わせる値はここまでです。
// パディング幅
const int Padding = 4;
// Parameter Storeから取得した設定を保持
private static MaskingSettings? _settings;
/// <summary>
/// Lambdaのエントリーポイント
/// S3のイベントを受け取り、画像内のPIIをマスキングしてS3に保存する。
/// </summary>
/// <param name="evnt">S3から送られるイベント</param>
/// <param name="context">Lambdaの実行コンテキスト</param>
public async Task<string> FunctionHandler(S3Event evnt, ILambdaContext context)
{
// ParameterStoreから設定値を読み取る、既に読み取り済なら何もしない
var settings = _settings ??= await LoadSettingsAsync(context);
// 処理済みのオブジェクトカウント
var masked = 0;
foreach (var record in evnt.Records)
{
var bucket = record.S3.Bucket.Name;
// S3イベントからオブジェクトのキー情報を取得
var key = WebUtility.UrlDecode(record.S3.Object.Key.Replace("+", "%20"));
// 出力先を設定
var fileName = key[(key.LastIndexOf('/') + 1)..];
var outputKey = DestinationPrefix + fileName;
// マスキング処理
await MaskObject(bucket, key, bucket, outputKey, settings, context);
masked++;
}
// 処理済みのオブジェクト数を返す
return $"masked {masked} object(s)";
}
/// <summary>
/// マスク処理を行う関数
/// </summary>
/// <param name="bucket"> 元データバケット</param>
/// <param name="key"> 加工を行う画像データのキー </param>
/// <param name="destinationBucket"> 加工後画像の保存先バケット </param>
/// <param name="outputKey"> 保存先のキー </param>
/// <param name="settings"> </param>
/// <param name="context"> Lambdaの実行コンテキスト </param>
private static async Task MaskObject(
string bucket, string key, string destinationBucket, string outputKey, MaskingSettings settings, ILambdaContext context)
{
// TextractにS3オブジェクトを通知し、読み込ませる
var detected = await Textract.DetectDocumentTextAsync(new DetectDocumentTextRequest
{
Document = new Document
{
// S3のバケット名とオブジェクトのキーを通知
S3Object = new Amazon.Textract.Model.S3Object { Bucket = bucket, Name = key }
}
});
var targets = detected.Blocks
// BoundingBox が無いブロックは除外する。
.Where(b => b.BlockType == BlockType.WORD && b.Geometry.BoundingBox != null)
// 登録した正規表現のいずれかにマッチしたらマスキング対象とみなす。
.Where(b => settings.Patterns.Any(p => p.IsMatch(b.Text)))
.Select(b => b.Geometry.BoundingBox)
.ToList();
// CloudWatch Logsに処理したログを出力する
context.Logger.LogInformation(
$"{key}: words={detected.Blocks.Count(b => b.BlockType == BlockType.WORD)}, pii={targets.Count}");
// 検出が無い場合は処理を終える
if (targets.Count == 0)
{
return;
}
// S3から元画像を取得する
using var response = await S3.GetObjectAsync(bucket, key);
using var source = new MemoryStream();
await response.ResponseStream.CopyToAsync(source);
source.Position = 0; // コピー直後は末尾にいるので先頭に戻す
// 元画像のフォーマット(PNG / JPEGなど)を判定しておく。
// 保存時に同じフォーマットで書き戻すために使う。
var format = await Image.DetectFormatAsync(source);
source.Position = 0; // 判定でストリームを読み進めたので再度先頭に戻す
using var image = await Image.LoadAsync<Rgba32>(source);
// 検出した領域を黒く塗り潰す
foreach (var box in targets)
{
FillBlack(image, box, Padding);
}
// マスキング済み画像をS3に保存する。
var encoder = Configuration.Default.ImageFormatsManager.FindEncoder(format);
using var output = new MemoryStream();
await image.SaveAsync(output, encoder);
output.Position = 0;
await S3.PutObjectAsync(new PutObjectRequest
{
BucketName = destinationBucket,
Key = outputKey,
InputStream = output,
ContentType = format.DefaultMimeType
});
context.Logger.LogInformation($"saved: s3://{destinationBucket}/{outputKey}");
}
/// <summary>
/// BoundingBoxが指定する座標を塗りつぶす処理を行う
/// </summary>
/// <param name="image">加工対象の画像</param>
/// <param name="box">Textractが返した矩形。値は0.0〜1.0の相対座標</param>
/// <param name="padding">文字の端が残らないように矩形を上下左右に広げるピクセル数</param>
private static void FillBlack(Image<Rgba32> image, BoundingBox box, int padding)
{
double boxLeft = box.Left.GetValueOrDefault();
double boxTop = box.Top.GetValueOrDefault();
double boxWidth = box.Width.GetValueOrDefault();
double boxHeight = box.Height.GetValueOrDefault();
// TextractのBoundingBoxは画像サイズに依存しない割合で返ってくるため、画像の幅・高さを掛けて実ピクセル座標に変換する
// paddingで矩形を少し広げ、文字の縁が黒塗りの外に残るのを防ぐ
var left = Math.Clamp((int)(boxLeft * image.Width) - padding, 0, image.Width - 1);
var top = Math.Clamp((int)(boxTop * image.Height) - padding, 0, image.Height - 1);
var right = Math.Clamp((int)((boxLeft + boxWidth) * image.Width) + padding, left + 1, image.Width);
var bottom = Math.Clamp((int)((boxTop + boxHeight) * image.Height) + padding, top + 1, image.Height);
image.ProcessPixelRows(accessor =>
{
for (var y = top; y < bottom; y++)
{
accessor.GetRowSpan(y)[left..right].Fill(Color.Black.ToPixel<Rgba32>());
}
});
}
/// <summary>
/// ParameterStoreから設定を取得する
/// </summary>
private static async Task<MaskingSettings> LoadSettingsAsync(ILambdaContext context)
{
var values = new Dictionary<string, string>();
string? nextToken = null;
// GetParametersByPathメソッドで、パス配下のパラメータを全て取得する
do {
var response = await Ssm.GetParametersByPathAsync(new GetParametersByPathRequest
{
Path = ParamPath,
Recursive = true,
NextToken = nextToken
});
foreach (var parameter in response.Parameters)
{
values[parameter.Name[(parameter.Name.LastIndexOf('/') + 1)..]] = parameter.Value;
}
nextToken = response.NextToken;
} while(!string.IsNullOrEmpty(nextToken));
// PIIの判定用パラメータを変数に代入
var resourceIdPrefixes = values[PrefixParamName].Split(',');
// AWSリソースの共通接尾辞を取得
var resourceIdSuffix = values[SuffixParamName];
// 上記2つを結合する
var resourceIdPattern = "(?:" + string.Join("|", resourceIdPrefixes.Select(Regex.Escape)) + ")" + resourceIdSuffix;
// 固有の正規表現を取得
var patterns = JsonSerializer.Deserialize<string[]>(values[UniqueParamName])!
.Append(resourceIdPattern)
.Select(p => new Regex(p, RegexOptions.Compiled | RegexOptions.IgnoreCase))
.ToArray();
// 取得した設定値を返す
return new MaskingSettings(Patterns: patterns);
}
/// <summary>
/// Parameter Storeから取得した値を保持する構造体
/// </summary>
private sealed record MaskingSettings
(
IReadOnlyCollection<Regex> Patterns // PIIを判定する正規表現のCollection
);
}Lambdaに付与するIAMロール
本Lambdaを動作させるためには、以下のようにIAMロールを設定し、アタッチする必要があります。
- AWSマネージドポリシー
AWSLambdaBasicExecutionRoleをアタッチします。
CloudWatchへのログ出力に関する権限が付与されます。
- カスタマーインラインポリシー
以下のポリシーを設定します。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ParameterStore",
"Effect": "Allow",
"Action": "ssm:GetParametersByPath",
"Resource": "{YOUR-SSM-ARN}/*"
},
{
"Sid": "S3ReadRaw",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "{YOUR-S3BUCKET-ARN}/raw/*"
},
{
"Sid": "S3WriteEdited",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "{YOUR-S3BUCKET-ARN}/edited/*"
},
{
"Sid": "Textract",
"Effect": "Allow",
"Action": "textract:DetectDocumentText",
"Resource": "*"
}
]
}Parameter Storeの読み取り権限、S3バケットの読み書き権限、TextractのAPIを呼び出しテキスト情報を取得する権限が付与されます。
{YOUR-SSM-ARN}および、{YOUR-S3BUCKET-ARN}の箇所は、それぞれParameter Storeおよび、S3バケットのARNを設定してください。
S3のイベントトリガーを用いて、ObjectCreated時にLambdaを起動する設定を行う
最後に、S3のイベントを設定し、先ほどデプロイしたLambdaを呼び出すようにします。

プレフィックスにraw/を設定し、すべてのオブジェクト作成イベントにチェックします。

送信先にLambda関数を設定することで、S3へのアップロードをトリガーとして、自動的にLambdaを呼び出す設定ができます。
S3にスクリーンショットをアップロードし、動作を確認する

今回はVPCダッシュボード画面のスクリーンショットをサンプルとして使用します。
サンプルのためにすぐに削除するVPCを使用しておりますが、実際はこのような固有のIDが表示されているスクリーンショットは外部に公開しないよう注意してください。
コンソールからS3バケットを開き、raw/プレフィックスへアップロードします。

edited/プレフィックスに格納された画像を確認します。
VPC IDやネットワークACL ID等、PIIがマスキングされていることが確認できました。
考察
マスキングの基準について
今回、AWSリソースIDのサフィックス部分を検出する正規表現として、半角英数字を表す[0-9a-zA-Z]{17}を用いました。
これは、本来の16進数17桁を表す[0-9a-f]{17}よりも範囲が広いです。
この理由としては、今回のアーキテクチャのテスト中に数字の0とアルファベットのO(オー)をTextractが誤検出し、マスキングから漏れるという現象が起きたためです。
また、これ以外にも、数字の5とアルファベットのS(エス)、数字の1とアルファベットのI(アイ)やl(エル)等、誤検出の可能性がある文字が存在します。
対象のPIIは、過剰にマスキングしてしまうことより、マスキング漏れによる情報漏洩のリスクを最優先で防ぐべき情報です。
更に、{プレフィックス}-{半角英数字17桁}という書式でもかなり範囲は限定されるため、誤ってマスキングする可能性は低いです。
これらの理由から、範囲を広げることによる誤検出は許容し、マスキング漏れの可能性を低減する方針としました。
利用するサービスについて
AWSには、フルマネージドでPII検出機能を提供しているAmazon Comprehendというサービスも存在します。
ただしComprehendはAWSリソースIDの検出には対応していないため、今回はユーザが自由に処理を記述できるLambdaを用いることとなりました。
一方で、電話番号やクレジットカード番号のようなPIIを検出対象とする場合、Comprehendのネイティブ機能で完結することができます。
もし多種のPIIが存在することが想定されるドキュメントであれば、LambdaとComprehendを組み合わせるアーキテクチャも考えられます。

このように、AWSマネージドサービスで完結できる処理はAWSに任せる・対応しきれていない処理のみLambda等に切り分けてユーザ側で実装する、といった区別が柔軟に行えるのもAWSの魅力だと感じました。
We Are Hiring!
「勉強をしていたら、良いアイデアが浮かんだ!けど実際にはどうやって作ればいいんだろう…?」と、手が止まってしまった経験はありませんか?
FindConsultingでは、経験豊富なエンジニアがメンターとしてつく制度を設けており、日々の業務での疑問はもちろん、日報へのフィードバックや週次の面談を通じてAWS資格取得の学習進捗までサポートしています。「わからないことをすぐに相談できる」「一人で抱え込まなくていい」環境で、着実にスキルを積み上げることができます。
現在、FindConsultingでは一緒に成長してくれる仲間を募集しています。
「話を聞いてみたい」くらいの気持ちで、ぜひ一度カジュアル面談でお話しましょう。


