SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

Cloud DLPをGolangで叩いて見せられないデータを抽出し秘匿化してみる

f:id:tkgwy:20190606213734p:plain

今週末から北海道オフィスに出張でワクワクしている瀧川です。

私はデータ分析基盤の構築をする機会がよくあり、FluentdやEmbulk、Digdag、BigQueryを好んで使っています。

構築する際に気をつけることというと、冪等性やログ欠損(リカバリ)などいろいろあるかと思いますが、その中でも重要になるのが 個人情報などの見せられないデータ(機密情報) の扱いかな思っています。

構造化されたデータの個人情報であれば、そもそも分析基盤に転送しないことや、マスキングして送るなど対策は容易*1ですが、例えば「バグでログの可変な箇所に個人情報が入ってしまった」とか「アンケートの集計をしたいが電話番号など入ってしまっていて隠したい」などの場合、検出や秘匿化はかなり難しいと感じています。

その課題を解決してくれるのがGoogleCloud DLP(Data Loss Prevention) APIではないかなと思っています!

本記事ではGoogleCloud DLP APIをGolangのクライアントを使って、簡単なサンプルを作っていきます。

なかなか機能が多く、とっつきにくいところもあるので、その辺り参考になればと思います!

Google Cloud DLP(Data Loss Prevention) とは

機密データ保護に有用な DLP API の新機能 | Google Cloud Blog

上記の公式Blogを見ていただくと、わかりやすいアニメーションもあるのでイメージしやすいかと思います。

ざっくりと説明すると、「あらゆる場所に存在するデータの中から、90種類以上の機密情報を検出し、機密情報に応じた秘匿化を実施できる」サービスです。

ワークフローへの組み込み

対象のデータはテキストまたは画像で、Cloud Storage、BigQuery、Cloud DatastoreといったGCPのストアに配置するか、直接APIにPOSTすることで実行することができます。

また、リアルタイムでの実行や、PubSubへの通知なども対応しているようです。

検出可能な機密情報

機密情報は、「クレジット カード番号、氏名、社会保障番号、電話番号、認証情報」などがすでに機械学習によってモデル化されており、その中から必要に応じて検出項目を指定することができます。

infoType 検出器リファレンス  |  Data Loss Prevention のドキュメント  |  Google Cloud

柔軟な秘匿化方法

機密情報によっては秘匿化方法をしっかりと考える必要があるかもしれません。

例えば、「携帯電話の番号は先頭3桁だけ残してマスキングしてほしい(@080--@)」とか「Emailごと集計したいので同一Emailは同一の文字列になるようにしてほしい(仮名化、ハッシュ化)」などなど。

そういったこともCloud DLPであれば実現することができるようです。

使ってみる

早速プログラムから使ってみたいと思います!

APIに対して、Node, Golang, Java, Pythonのクライアントライブラリが提供されています。

本記事ではGolangでやっていきます。

APIドキュメントは以下なので適宜参考にしてください。

cloud - GoDoc

準備

事前に以下を準備しておいてください。

  • Golang
  • depなど依存解決ツール
  • サービスアカウントのJSONキー
    • Cloud DLPへのアクセス権を忘れずに

以下のような1ファイルにつらつら書いていきます!

main.go

package main

func main() {
}

認証

まずはサービスを利用するために認証を通す必要があります。

DLPのクライアントにJSONキーを渡しています。

この辺りはGCPのどのサービスでも共通しているお決まりな書き方ですね!

特にエラーがでなければ先に進みましょう。

エラーが出る場合、サービスアカウントの権限やJSONキーが正しいか、依存解決がうまくできているかなど確認してみてください!

(dep ensurego get などは実行してください)

package main

import (
    "context"
    "log"

    dlp "cloud.google.com/go/dlp/apiv2"
    "google.golang.org/api/option"
)

func main() {
    credJSON := "ここはJSONキーで置き換えてください"

    ctx := context.Background()
    cred := option.WithCredentialsJSON([]byte(credJSON))
    client, err := dlp.NewClient(ctx, cred)
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()
}

機密データ検出(Inspect)

それでは実際に機密データを検出してみたいと思います!

以下のように呼び出す inspectString 関数を実装していきます。

infoTypes が検出する機密情報の種類で、message が検出対象ですね。

新しくimportされている dlppb はDLP APIと通信する際のデータ構造定義になります。(Protocol Bufferで定義していますね)

こちらがdlppbのドキュメントになります。dlp - GoDoc

設定データを構築する際には参考にするといいと思います!

package main

import (
    "context"
    "fmt"
    "log"

    dlp "cloud.google.com/go/dlp/apiv2"
    "google.golang.org/api/option"
    dlppb "google.golang.org/genproto/googleapis/privacy/dlp/v2"
)

func main() {
    ...
    projectID := "サービスアカウントを発行したProject ID"
    infoTypes := []string{"PHONE_NUMBER", "EMAIL_ADDRESS"}
    message := `
TO:hoge@hoge.com
こんにちわ、瀧川です。

080-0000-0000 までご連絡ください。
`

    inspectString(projectID, client, infoTypes, message)
}

検出を実行し、結果を標準出力するコードが以下のようになります。

リクエストのデータ構造は若干難しいですね。

func inspectString(projectID string, client *dlp.Client, infoTypes []string, input string) {
    // 検出機密種別をdlppb.InfoTypeに変換
    var i []*dlppb.InfoType
    for _, it := range infoTypes {
        i = append(i, &dlppb.InfoType{Name: it})
    }

    // リクエストのデータを構築
    req := &dlppb.InspectContentRequest{
        Parent: "projects/" + projectID, // 必ずprojects/を前につける
        InspectConfig: &dlppb.InspectConfig{ // 検出条件など
            InfoTypes: i,
        },
        Item: &dlppb.ContentItem{ // 検出対象
            DataItem: &dlppb.ContentItem_Value{
                Value: input,
            },
        },
    }

    resp, err := client.InspectContent(context.Background(), req) // 検出を実行
    if err != nil {
        log.Fatal(err)
    }
 
    // 以下は結果表示のみ
    resultFormatter := func(inputStr string, result *dlppb.Finding) string {
        tmp := `
機密種別: %s
もっともらしさ(尤度): %s
範囲: %d ~ %d
検出文字列: %s
      `
        start := result.GetLocation().GetCodepointRange().GetStart()
        end := result.GetLocation().GetCodepointRange().GetEnd()
        return fmt.Sprintf(tmp,
            result.GetInfoType().GetName(),
            result.GetLikelihood(),
            start,
            end,
            string([]rune(input)[start:end]))
    }

    fmt.Printf("対象文字列: %s\n", input)
    for _, result := range resp.GetResult().GetFindings() { // 検出されたものはFinding
        fmt.Println(resultFormatter(input, result))
    }
}

実行すると以下のようになります!

検出できてそうですね!

検出結果は dlppb.Finding というデータになり、機密種別(InfoType)や精度の指標である尤度(Likelihood)、検出された箇所(Location)などが取得できるのがわかるかと思います。

$ go run main.go
対象文字列:
TO:hoge@hoge.com
こんにちわ、瀧川です。

080-0000-0000 までご連絡ください。


機密種別: EMAIL_ADDRESS
もっともらしさ(尤度): LIKELY
範囲: 4 ~ 17
検出文字列: hoge@hoge.com


機密種別: PHONE_NUMBER
もっともらしさ(尤度): POSSIBLE
範囲: 31 ~ 44
検出文字列: 080-0000-0000

秘匿化(Deidentify)

検出された機密情報を秘匿化するコードは以下のようになります!

(急にデータ構造が複雑になった気がしますね...型名が冗長だったりするだけなのでそこまで複雑ではないですよ!)

PHONE_NUMBER は「末尾から9文字をアスタリスクに変換」するようにしています。

EMAIL_ADDRESS は「abcdefghijklmnop をキーにしてハッシュ化」するようにしています。

func deidentifyString(projectID string, client *dlp.Client, infoTypes []string, input string) {
    var i []*dlppb.InfoType
    for _, it := range infoTypes {
        i = append(i, &dlppb.InfoType{Name: it})
    }

    req := &dlppb.DeidentifyContentRequest{ // Deidentifyリクエスト
        Parent: "projects/" + projectID,
        InspectConfig: &dlppb.InspectConfig{
            InfoTypes: i,
        },
        DeidentifyConfig: &dlppb.DeidentifyConfig{
            Transformation: &dlppb.DeidentifyConfig_InfoTypeTransformations{
                InfoTypeTransformations: &dlppb.InfoTypeTransformations{
                    Transformations: []*dlppb.InfoTypeTransformations_InfoTypeTransformation{ // 変換方法の配列
                        {
                            InfoTypes: []*dlppb.InfoType{&dlppb.InfoType{Name: "PHONE_NUMBER"}},
                            PrimitiveTransformation: &dlppb.PrimitiveTransformation{
                                Transformation: &dlppb.PrimitiveTransformation_CharacterMaskConfig{ // 変換方法
                                    CharacterMaskConfig: &dlppb.CharacterMaskConfig{
                                        MaskingCharacter: "*",
                                        NumberToMask:     9,
                                        ReverseOrder:     true,
                                    },
                                },
                            },
                        },
                        {
                            InfoTypes: []*dlppb.InfoType{&dlppb.InfoType{Name: "EMAIL_ADDRESS"}},
                            PrimitiveTransformation: &dlppb.PrimitiveTransformation{
                                Transformation: &dlppb.PrimitiveTransformation_CryptoHashConfig{
                                    CryptoHashConfig: &dlppb.CryptoHashConfig{
                                        CryptoKey: &dlppb.CryptoKey{ // 変換方法
                                            Source: &dlppb.CryptoKey_Unwrapped{
                                                Unwrapped: &dlppb.UnwrappedCryptoKey{
                                                    Key: []byte("abcdefghijklmnop"),
                                                },
                                            },
                                        },
                                    },
                                },
                            },
                        },
                    },
                },
            },
        },
        Item: &dlppb.ContentItem{
            DataItem: &dlppb.ContentItem_Value{
                Value: input,
            },
        },
    }
 
    r, err := client.DeidentifyContent(context.Background(), req) // Deidentify実行
    if err != nil {
        log.Fatal(err)
    }
 
    fmt.Println(r.GetItem().GetValue())
}

実行結果はこちら!

よさそうですね。

$ go run main.go
TO:9ELfD7kXS0HfGr8KjitHndGCWSO8ReSM1l8GwLKqjok=
こんにちわ、瀧川です。

080-********* までご連絡ください。

※ ドキュメントを見ると、インタフェース名_具象クラス名 のような形でポリモフィックに定義されているようです。(以下が変換方法一覧)

type PrimitiveTransformation_BucketingConfig
type PrimitiveTransformation_CharacterMaskConfig
type PrimitiveTransformation_CryptoDeterministicConfig
type PrimitiveTransformation_CryptoHashConfig
type PrimitiveTransformation_CryptoReplaceFfxFpeConfig
type PrimitiveTransformation_DateShiftConfig
type PrimitiveTransformation_FixedSizeBucketingConfig
type PrimitiveTransformation_RedactConfig
type PrimitiveTransformation_ReplaceConfig
type PrimitiveTransformation_ReplaceWithInfoTypeConfig
type PrimitiveTransformation_TimePartConfig

コード全体

package main

import (
    "context"
    "fmt"
    "log"

    dlp "cloud.google.com/go/dlp/apiv2"
    "google.golang.org/api/option"
    dlppb "google.golang.org/genproto/googleapis/privacy/dlp/v2"
)

func main() {
    credJSON := "JSONキー"

    ctx := context.Background()
    cred := option.WithCredentialsJSON([]byte(credJSON))
    client, _ := dlp.NewClient(ctx, cred)
    defer client.Close()

    infoTypes := []string{"PHONE_NUMBER", "EMAIL_ADDRESS"}
    message := `
TO:hoge@hoge.com
こんにちわ、瀧川です。

080-0000-0000 までご連絡ください。
  `
    inspectString("プロジェクトID", client, infoTypes, message)
    deidentifyString("プロジェクトID", client, infoTypes, message)
}

func inspectString(projectID string, client *dlp.Client, infoTypes []string, input string) {
    var i []*dlppb.InfoType
    for _, it := range infoTypes {
        i = append(i, &dlppb.InfoType{Name: it})
    }

    req := &dlppb.InspectContentRequest{
        Parent: "projects/" + projectID,
        InspectConfig: &dlppb.InspectConfig{
            InfoTypes: i,
        },
        Item: &dlppb.ContentItem{
            DataItem: &dlppb.ContentItem_Value{
                Value: input,
            },
        },
    }

    resp, err := client.InspectContent(context.Background(), req)
    if err != nil {
        log.Fatal(err)
    }

    resultFormatter := func(inputStr string, result *dlppb.Finding) string {
        tmp := `
機密種別: %s
もっともらしさ(尤度): %s
範囲: %d ~ %d
検出文字列: %s
      `
        start := result.GetLocation().GetCodepointRange().GetStart()
        end := result.GetLocation().GetCodepointRange().GetEnd()
        return fmt.Sprintf(tmp,
            result.GetInfoType().GetName(),
            result.GetLikelihood(),
            start,
            end,
            string([]rune(input)[start:end]))
    }

    fmt.Printf("対象文字列: %s\n", input)
    for _, result := range resp.GetResult().GetFindings() {
        fmt.Println(resultFormatter(input, result))
    }
}

func deidentifyString(projectID string, client *dlp.Client, infoTypes []string, input string) {
    var i []*dlppb.InfoType
    for _, it := range infoTypes {
        i = append(i, &dlppb.InfoType{Name: it})
    }

    req := &dlppb.DeidentifyContentRequest{
        Parent: "projects/" + projectID,
        InspectConfig: &dlppb.InspectConfig{
            InfoTypes: i,
        },
        DeidentifyConfig: &dlppb.DeidentifyConfig{
            Transformation: &dlppb.DeidentifyConfig_InfoTypeTransformations{
                InfoTypeTransformations: &dlppb.InfoTypeTransformations{
                    Transformations: []*dlppb.InfoTypeTransformations_InfoTypeTransformation{
                        {
                            InfoTypes: []*dlppb.InfoType{&dlppb.InfoType{Name: "PHONE_NUMBER"}},
                            PrimitiveTransformation: &dlppb.PrimitiveTransformation{
                                Transformation: &dlppb.PrimitiveTransformation_CharacterMaskConfig{
                                    CharacterMaskConfig: &dlppb.CharacterMaskConfig{
                                        MaskingCharacter: "*",
                                        NumberToMask:     9,
                                        ReverseOrder:     true,
                                    },
                                },
                            },
                        },
                        {
                            InfoTypes: []*dlppb.InfoType{&dlppb.InfoType{Name: "EMAIL_ADDRESS"}},
                            PrimitiveTransformation: &dlppb.PrimitiveTransformation{
                                Transformation: &dlppb.PrimitiveTransformation_CryptoHashConfig{
                                    CryptoHashConfig: &dlppb.CryptoHashConfig{
                                        CryptoKey: &dlppb.CryptoKey{
                                            Source: &dlppb.CryptoKey_Unwrapped{
                                                Unwrapped: &dlppb.UnwrappedCryptoKey{
                                                    Key: []byte("abcdefghijklmnop"),
                                                },
                                            },
                                        },
                                    },
                                },
                            },
                        },
                    },
                },
            },
        },
        Item: &dlppb.ContentItem{
            DataItem: &dlppb.ContentItem_Value{
                Value: input,
            },
        },
    }
 
    r, err := client.DeidentifyContent(context.Background(), req)
    if err != nil {
        log.Fatal(err)
    }
 
    fmt.Println(r.GetItem().GetValue())
}

最後に

Google Cloud DLPがどのようなサービスで、なにができるのか伝わりましたでしょうか。

機械学習なので検出は100%とはいきませんが、潜在的なリスクを減らしたり、重大なセキュリティインシデントにすばやく気づくなど今まで見過ごされてきたような場所で活躍すると思います。

また、先日WebConsoleもベータで発表されて今後、さらに使いやすく、信頼性も高まっていくかなとも思います。

Cloud Console での Cloud DLP  |  Data Loss Prevention のドキュメント

ぜひ利用してみてください!

近々Cloud DLPでBigQueryのテーブルを秘匿化して出力するちょっと実践に近い記事も書こうと思うのでそちらも見ていただければ嬉しいです!

*1:一例を別記事に書いてありますので参考にしてくださいtech.smartcamp.co.jp