Sentry導入にあたりデータスクラビングの信頼性を確認する

Sentryは、アプリケーションで発生したエラーやパフォーマンスの低下をリアルタイムに検知し、迅速な原因究明を可能にする非常に強力なモニタリングプラットフォームです。その導入は、サービスの品質と開発者体験を大きく向上させます。

しかし、その強力さゆえに、導入時には慎重な検討が求められます。特に、パスワードや個人情報、APIキーといった機密データが、意図せずSentryに送信されてしまうリスクをいかにして防ぐか、という点は、全ての開発者が向き合うべきセキュリティ上の重要な課題です。

この記事では、そんな課題を解決するためにSentryが提供するデータスクラビングの仕組みを解説し、さらにLaravel・React両方で応用可能な実装の一例を紹介します。

## Sentryが提供する2段階のデータスクラビング機能

Sentryは、機密データを保護するために、性質の異なる2つの強力なスクラビング機能を提供しています。これらを組み合わせることで、より安全なデータ管理を実現できます。

### 1. アプリケーション側で実行するSDKスクラビング

これは、エラーイベントがアプリケーション（サーバーやブラウザ）からSentryのサーバーへ **送信される前** に、SDK側でデータを加工・除去する仕組みです。

#### 仕組み：

beforeSendというコールバックフックを利用し、送信直前のイベントデータにアクセスして内容を自由に変更できます。

#### 利点：

機密データが自社の管理環境の外部（ネットワーク上）に出ること自体を完全に防ぐことができます。
セキュリティの観点から最も堅牢なアプローチであり、対策の主軸となります。

### 2. Sentryサーバー側で実行されるフィルタリング

これは、イベントデータがSentryのサーバーに到着した後、Sentryが自身のデータベースに **データを保存する直前** に、特定のルールに基づいて値をマスクする仕組みです。

#### 仕組み：

Sentryの管理画面 [Project Settings] > [Security & Privacy] > [DATA SCRUBBING] で設定します。
passwordやsecretなど、一般的な機密キー名がデフォルトで登録されているほか、独自のキー名も追加できます。

![スクリーンショット 2025-06-09 9.43.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3953105/65e8811d-292c-490f-8c61-f75585950607.png)

#### 利点：

SDK側の設定漏れや、予期せぬデータが送られてしまった場合でも、最終防衛ラインとして機能します。コードの変更なしで設定できるため、強力なセーフティネットとして有効です。

## 実装例：メンテナンス性の高いスクラビング処理

ここでは、メンテナンス性と拡張性を考慮した具体的な実装例を紹介します。重要な設計方針としては、スクラビング対象のキーワードリスト（設定）と、実際の処理ロジック（コード）を明確に分離することです。これにより、将来的なルールの追加・変更が容易になります。

なお、SentryのSDKのインストールはすでに済んでいる前提として進めます。（公式手順が分かりやすいため迷わないはずです）

### 1. Laravel（バックエンド）での実装

#### 設定ファイル（config/scrubbing.php）:

```php
<?php

declare(strict_types=1);

return [
    /*
    |--------------------------------------------------------------------------
    | Sentry Data Scrubbing Keywords
    |--------------------------------------------------------------------------
    |
    | ここで定義されたキーワードがキー名に含まれている場合、
    | Sentryに送信されるデータの値はマスクされます。
    */
    'keywords' => [
        'password',
        'token',
        'email',
        'phone',
        'first_name',
        'last_name',
    ],
];
```

#### サービスクラス（app/Services/Infrastructures/Sentry/ScrubberService.php）:

```php
<?php

declare(strict_types=1);

namespace App\Services\Infrastructures\Sentry;

use Sentry\Event;

class ScrubberService
{
    private const string MASKING_WORD = '[Filtered]'; // Sentry側のデフォルトのマスキングワードに揃える

    /**
     * Sentryイベントをスクラビングするためのメインの実行メソッド。
     * このクラスがコールバックとして呼ばれた際に実行される。
     *
     * @param  Event  $event
     * @return Event
     */
    public function __invoke(Event $event): Event
    {
        // リクエスト情報を取得
        $requestPayload = $event->getRequest();

        if (empty($requestPayload) || !is_array($requestPayload) || !isset($requestPayload['data'])) {
            // リクエストボディがない場合はそのままイベントを返す
            return $event;
        }

        // 配列を再帰的にたどり、特定のキーワードを含むキーの値をマスクする
        $this->recursiveScrubber($requestPayload['data']);

        // 変更をイベントに反映
        $event->setRequest($requestPayload);

        return $event;
    }

    /**
     * 配列を再帰的にたどり、特定のキーワードを含むキーの値をマスクする。
     *
     * @param array $array
     */
    private function recursiveScrubber(array &$array): void
    {
        // configファイルからキーワードリストを取得
        $keywordsToScrub = config('scrubbing.keywords', []);

        foreach ($array as $key => &$value) {
            $shouldScrub = false;

            if (is_string($key)) {
                foreach ($keywordsToScrub as $keyword) {
                    if (str_contains(strtolower($key), $keyword)) {
                        $shouldScrub = true;
                        break;
                    }
                }
            }

            if ($shouldScrub) {
                $value = self::MASKING_WORD;
            } elseif (is_array($value)) {
                // 自分自身を再帰的に呼び出す
                $this->recursiveScrubber($value);
            }
        }
    }
}
```

#### Sentry設定の接続（config/sentry.php）:

```php
<?php

declare(strict_types=1);

/**
 * Sentry Laravel SDK configuration file.
 *
 * @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
 */

use App\Services\Infrastructures\Sentry\ScrubberService;

return [
    ~~ 省略 ~~
    
    'before_send' => function (\Sentry\Event $event) {
        return app(ScrubberService::class)($event);
    },
]
```

### 2. React（フロントエンド）での実装

#### 設定ファイル（resources/js/sentry.config.ts）:

```ts
/**
 * Sentryに関する設定を管理するオブジェクト
 */
export const sentryConfig = {
  /**
   * データスクラビングに関する設定
   */
  scrubbing: {
    /**
     * ここで定義されたキーワードがキー名に含まれている場合、
     * Sentryに送信されるデータの値はマスクされます。
     */
    keywords: [
      'password',
      'token',
      'email',
      'phone',
      'first_name',
      'last_name',
    ],
  },
};
```

#### サービスクラス（resources/script/Services/Infrastructures/Sentry/ScrubberService.ts）:

```ts
import type { Event } from '@sentry/react';
import { sentryConfig } from '../../../sentry.config';

/**
 * Sentryイベントのデータスクラビングを行うサービスクラス。
 * バックエンドの App\Services\Infrastructures\Sentry\ScrubberService と役割を合わせる。
 */
export class ScrubberService {
  // PHPの private const MASKING_WORD に相当
  private static readonly MASKING_WORD = '[Filtered]';

  /**
   * Sentryイベントを処理するメインの実行メソッド。
   * @param event - Sentryイベントオブジェクト
   * @returns 加工後のイベント、またはnull
   */
  public processEvent(event: Event): Event | null {
    // スクラビング対象の主要なデータをここで処理します。

    // ユーザーが手動で追加した情報
    if (event.extra) {
      this.recursiveScrubber(event.extra);
    }

    // ユーザー操作の履歴
    if (event.breadcrumbs) {
      this.recursiveScrubber(event.breadcrumbs);
    }

    // ユーザー情報
    if (event.user) {
      this.recursiveScrubber(event.user);
    }

    // リクエストデータ
    if (event.request?.data) {
      this.recursiveScrubber(event.request.data);
    }

    return event;
  }

  /**
   * 配列やオブジェクトを再帰的にたどり、特定のキーワードを含むキーの値をマスクする。
   * @param data - 処理対象のオブジェクトまたは配列
   */
  private recursiveScrubber(data: unknown): void {
    if (typeof data !== 'object' || data === null) {
      return;
    }

    const keywordsToScrub = sentryConfig.scrubbing.keywords;

    for (const key in data) {
      if (Object.prototype.hasOwnProperty.call(data, key)) {
        let shouldScrub = false;

        for (const keyword of keywordsToScrub) {
          if (key.toLowerCase().includes(keyword)) {
            shouldScrub = true;
            break;
          }
        }

        const record = data as Record<string, unknown>;
        const value = record[key];

        if (shouldScrub) {
          record[key] = ScrubberService.MASKING_WORD;
        } else if (typeof value === 'object' && value !== null) {
          // 自分自身を再帰的に呼び出す
          this.recursiveScrubber(value);
        }
      }
    }
  }
}
```

#### Sentry設定の接続（resources/script/app.tsx）：

```ts
Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_REACT_DSN_PUBLIC,
  ~~ 省略 ~~
  
  beforeSend: (event) => scrubber.processEvent(event),
});
```

## 検証結果の確認

#### Laravel（バックエンド）：

- `email`, `first_name`, `last_name` はアプリケーション側でSDKによってスクラビングされている
- `_token` はSentryサーバ側でフィルタリングされている

![スクリーンショット 2025-06-09 10.03.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3953105/427568e0-cd87-46fb-a581-59218fee0ce4.png)

#### React（フロントエンド）：

- `Email` はアプリケーション側でSDKによってスクラビングされている
- `authToken` はSentryサーバ側でフィルタリングされている

![スクリーンショット 2025-06-09 10.08.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3953105/d7463b0d-02e6-4dc2-bc36-a99381b1f173.png)

## まとめ：2つの機能を組み合わせた堅牢なデータ保護戦略

検証の結果、Sentryが提供する2つのスクラビング機能は、それぞれ異なるタイミングで役割を果たす、効果的な仕組みであることが分かりました。

| | **SDKによるスクラビング** | **Sentryサーバーでのフィルタリング** |
| :--- | :--- | :--- |
| **実行タイミング** | アプリケーションからSentryへ**送信前** | Sentryサーバー到着後、**DB保存前** |
| **主な役割** | **アプリケーション側での事前対策** | **サーバー側での最終チェック** |
| **設定方法** | アプリケーションコード (`beforeSend`) | Sentry管理画面 (DATA SCRUBBING) |
| **特徴** | 柔軟なカスタムロジックを実装可能。<br>機密情報が外部に出ない。 | 設定が容易。<br>SDK側の設定漏れをカバーできる。 |

このように、2つの機能はどちらか一方を選ぶものではなく、それぞれの役割を理解した上で **両方を組み合わせる** ことが、最も堅牢なデータ保護戦略となります。

推奨されるアプローチは、SDKスクラビングを主要な対策として、アプリケーション固有の機密データを確実に送信前に除去しつつ、Sentryサーバー側のフィルタリングも有効にして万が一の漏れを防ぐ、というのが良いかと思います。

本記事が、Sentryの導入を検討している方々の参考になれば幸いです。

## 参考

- [Sentry - Data Scrubbing](https://docs.sentry.io/security-legal-pii/scrubbing/)