あなたの組織は大丈夫?Google Workspaceで外部公開ファイルを炙り出す

Googleフォームの情報漏洩や、Googleグループの情報漏洩といった事件がたびたび報道されています。Google Workspaceでは基本現場任せで運用するとこのような事故が発生します。

そういった内の1つに、ドライブのファイルの外部公開。ここは割と制御できる対象なのですが、それでもマイドライブからの外部共有を許可していたり、共有ドライブの外部共有権限をユーザーに与えていたりするケースが散見されます。

そんな外部共有されているファイルの探索用のツールを作って、外部公開ファイルをあぶり出してみたいと思います。

今回利用する素材

そもそもの話として、導入時の時点でマイドライブの外部共有禁止 & 共有ドライブも外部公開は一部のドライブにのみ限定といった運用にしていないこと自体が、自分からしたら「セキュリティ軽視で運用優先」にしか見えません。そういった企業は遅かれ早かれ、漏洩事故を起こすでしょう。

実際に、この厳し目の運用をしていても何ら障害にもならなかったので、言い訳をする前にまずは、以下のエントリーにあるように共有ドライブ管理を徹底することから始めましょう。外部共有状況の確認はその次のお話です。

Googleフォームの件もGoogleグループの件も殆どがそういった、現場の要望優先で起きているバカバカしい事故です。

Google Workspaceの共有ドライブ管理を極める

管理コンソールからの確認

さて外部公開されている状況を確認するといっても、完璧な手法は用意されていません。マイドライブのファイルについては管理者もタッチする事が出来ない領域であるので、そもそもマイドライブは外部共有は封じておくべき最優先次項です。

そんな中で標準で用意されている機能をもってして、まずは調べられる手段をまとめました。

セキュリティダッシュボードを使う

この機能はEnterprise Standard以上でなければ利用することが出来ません。以下の手順で開くことが可能です。

  1. 管理コンソールにログインする
  2. 左サイドバーから、セキュリティ→セキュリティセンター→ダッシュボードの順番に開きます。
  3. ドメインの外部とのファイル共有の状況というパネルが出てくるので、レポートを表示をクリック
  4. ファイルの公開レポートが開かれる。4種類の区分があり、外部が注目ポイントです。
  5. 下のほうにある表示されているファイルは、ファイルへのリンクもありますが、全量を表示してるわけではないので注意。
  6. シートをエクスポートすると、スプレッドシート形式でデータが出力されます(但し統計情報なのでファイル追跡には使えません)。
  7. よってこのダッシュボードで得られる情報は公開されてると思われるファイルの個数と一部のファイルの状況がわかる程度の参考情報。

しかし、この機能のログデータは、レポートの種類にもよりますが、30日〜180日までしか保持されません。またデータの反映にタイムラグがあるためリアルタイムデータではありません

図:セキュリティダッシュボード

ドライブのログイベントから追跡する

同じく、セキュリティの中にある調査ツールで調べられないか?ということなのですが、こちらは以下の手順で利用することが可能です。こちらもEnterprise Standard以上でなければ利用することが出来ません。

  1. 管理コンソールにログインする
  2. 左サイドバーから、セキュリティ→セキュリティセンター→調査ツールを開く
  3. データソースにて、ドライブのログイベントを選択
  4. 条件を追加をクリックして、公開設定を選択、
  5. 公開設定では、リンクを知っているユーザー、限定公開、外部と共有中、ウェブ上で一般公開など必要なものをAND条件で追加が可能
  6. 結果をグループ化にてドキュメントIDを選ぶ
  7. 検索を実行してみる。

但しこちらもログイベントを調査する為のものなので、最大180日間のログデータの中から調査するものになるため、リアルタイムでもなければ、過去共有状態で動きのなかったものはリストに出てきません。よって、セキュリティダッシュボードの外部共有数とは数字が一致しません

あくまでもイベントを追跡する為のツールです。

図:ログイベントから探索する為のツール

Google Driveからの確認

この方法はあくまでも個人単位レベルでの調査方法であり、組織全体での情報漏洩情報の包括的な探索手段ではありません。どちらかというと個人のGoogleアカウント向けですが、もちろんGoogle Workspaceでも使える手法です。その為、マイドライブの調査を目的としています。

以下はGoogle Workspaceの場合の検索方法です。

  1. Google Driveを開く
  2. マイドライブを選択する
  3. プルダウンのユーザーでは、「外部ユーザー」を選択
  4. 出てきたファイルは何らかの公開設定で外部共有されてるマイドライブ配下のファイルです。

ファイルタイプを絞って検索することも出来ます。基本リアルタイムなので、マイドライブだけに限れば割と正確な情報を取得することが可能です。検索結果が出るまで、相当時間が掛かるので要注意(故に先に、3.の手順の前に種類でファイルの種類を絞っておいたほうが楽)。

図:マイドライブで使えるあぶり出し法

ツールを使ってあぶり出す

概要

組織のファイル総数にもよりますが、Drive APIやAdmin SDKのReports APIを使うことで調査することが可能です。自分のテナントで探索した際には3000個ほどのファイルが検出されましたが、Google Apps Scriptでの6分の壁ギリギリでした。ので、これらAPIを使って調査をするという場合には、Node.jsElectronといった別の開発環境を使って、REST APIとして上記のAPIを叩くツールを自作する必要があります。

事前準備

今回のAPIを使うためには、Google Apps Scriptのサービスに於いて2つのサービスを有効化する必要があります。

  • Drive API v3
  • Reports API v1

Drive APIは+アイコンからDrive APIを見つけて追加をクリックするだけなのですが、Reports APIの追加はちょっと癖があります。以下の手順で追加します。

  1. サービス下の+アイコンをクリックする
  2. Admin SDKがいるので選択して追加をクリックする
  3. このままではAdmin SDKのままなので、表示されてるAdminDirectoryをクリックする
  4. バージョンのプルダウンをクリックするとdirectory_v1の下にreports_v1が出てくるので選択する
  5. 今回ID欄の値は、「AdminReports」に変更します
  6. 保存をクリックする

これでAdmin Reports APIとして呼び出すことが可能になりました。

図:Reports API追加はちょっと手間が掛かる

Drive APIを使って調査する

スクリプトの内容

こちらのスクリプトはドライブに対してファイル全部を調査して現時点での外部公開状況を、一個ずつDrive APIで調査する仕様であるため、組織の所有するファイルが膨大な場合には、GASではタイムアウトする可能性があります。また、個人のマイドライブは権限がある場合はともかく、管理者に対して共有を掛けていないようなものは権限上調査は出来ません

今回のツールでは、共有ドライブを含めて調査し、リンクを知っている者や外部のメアドで共有されてるファイルをリストアップしてスプシに書き出します。ファイルID, ファイル名, 共有理由, ファイルのURLの4列のデータを取得します。こちらは自身のファイルを調査してると、リアルタイムであるためアクセス不能や削除済みといったことがありません。

重複してるファイルのIDはまとめられてユニークなファイルだけをピックアップするように作られています。Drive APIのリストアップは500個単位で持ってこれますが1個ずつの調査を要するので時間が掛かります

図:小さな組織なら調査しきれると思う

ソースコード

リストアップしたものから一個ずつDrive APIで情報を取り出しては、次の500個の取得をページネーション処理をして取得していきます。

外部共有情報を取得して最終的にスプシに一括で書き出しを行います。タイムアウトする場合にはNode.jsなどローカルで動くプログラムとして開発を行いましょう。6分の壁を突破する不死鳥関数的処理も実装は出来ますが、あまり現実的な手法とは言えません。3500個までは自テナントでは調査出来ました。

Google Apps Scriptで6分の壁(タイムアウト)を突破する【GAS】

Google Apps Scriptで6分の壁(タイムアウト)を突破する - 番外編【GAS】

//Drive上のファイルの外部共有情報を全量探索する
function exportAllCurrentSharesToSheet() {
  //シート名を指定する
  const sheetname = 'driveapi';

  //重複を避けるためMapを使用
  const externallySharedFiles = new Map(); 
  let pageToken = null;

  //スプレッドシートを取得
  const ui = SpreadsheetApp.getUi();
  const ss = SpreadsheetApp.getActiveSpreadsheet()

  //実行者のメアドから組織のドメインを取得する
  const domain = Session.getEffectiveUser().getEmail().split('@')[1];
  if (!domain) {
    SpreadsheetApp.getUi().alert('このスクリプトはGoogle Workspaceアカウントで実行する必要があります。');
    return;
  }

  try {
    //Drive APIを使い、全ファイルの現在の共有設定をチェック(1回に500個まで調べられる)
    do {
      //リクエストオプション設定
      const options = {
        q: "trashed = false",
        // ファイル名、共有設定、URLを一度に取得
        fields: "nextPageToken, files(id, name, permissions, webViewLink)",
        pageSize: 500,
        pageToken: pageToken,
        supportsAllDrives: true,
        includeItemsFromAllDrives: true,
        corpora: 'allDrives'
      };

      //探索結果を取得する
      const response = Drive.Files.list(options);

      //探索結果情報から外部公開状況を調査
      if (response.files && response.files.length > 0) {
        response.files.forEach(file => {
          if (file.permissions) {
            for (const p of file.permissions) {
              let isExternal = false;
              let reason = '';

              //リンクを知っている者なのか?外部のメアドに対して共有なのか?
              if (p.type === 'anyone') {
                isExternal = true;
                reason = `一般公開 (${p.role})`;
              } else if (p.emailAddress && p.emailAddress.split('@')[1] !== domain) {
                isExternal = true;
                reason = `直接共有 (${p.emailAddress})`;
              }

              if (isExternal) {
                // 外部共有が見つかったらMapに追加してこのファイルのチェックは終了
                if (!externallySharedFiles.has(file.id)) {
                  externallySharedFiles.set(file.id, {
                    name: file.name,
                    link: file.webViewLink,
                    reason: reason
                  });
                }
                break;
              }
            }
          }
        });
      }
      //ページネーション処理
      pageToken = response.nextPageToken;
    } while (pageToken);

    //出力結果から
    const resultsArray = Array.from(externallySharedFiles.entries());
    if (resultsArray.length === 0) {
      console.log('外部共有されているファイルは見つかりませんでした。');
      ui.alert('外部共有されているファイルは見つかりませんでした。');
      return;
    }

    //シートを取得して内容をクリアする
    let sheet = ss.getSheetByName(sheetname);
    sheet.getRange("A2:D").clearContent();

    //シート書き出し用のデータを整備する
    const outputData = resultsArray.map(([id, fileInfo]) => [
      id,
      fileInfo.name,
      fileInfo.reason,
      fileInfo.link
    ]);

    //スプレッドシートに出力を実行
    sheet.getRange(2, 1, outputData.length, outputData[0].length).setValues(outputData);
    sheet.autoResizeColumns(1, 5);

    //終了処理
    ui.alert(`処理が完了しました。\n${outputData.length}件のデータをスプレッドシートに出力しました。`);

  } catch (e) {
    //エラー発生時
    console.error(`エラーが発生しました: ${e.toString()}`);
    ui.alert(`エラーが発生しました: ${e.message}`);
  }
}

Reports APIを使って調査する

スクリプトの内容

こちらのスクリプトは基本、ドライブの監査ログのレポートに対してリクエストをして調査するAPIであるので、ログの最大保持期限180日の制約が付きます。よって、180日間動きのない外部共有されてるファイルについては、ログに出てこないので要注意です。

今回のツールでは公開設定が「一般公開」「組織外に共有」のファイルを監査ログから引っ張ってスプシに書き出します。ファイルID,ファイル名, 共有先メアド, ファイルへのURLの4のデータを取得します。

重複してるファイルのIDはまとめられてユニークなファイルだけをピックアップするように作られています。外部からの共有等もヒットする為、相手側で削除済みやアクセス不能になってるものはファイル名が「(アクセス不可または削除済み)」という表記になります。

絞り込んだファイル情報から取得してるので比較的動作は速いですが、前述にもあるようにこれはリアルタイム情報ではないので、参考情報となります。

図:比較的早く情報を取得することが可能

ソースコード

Reports APIは1度のリクエストで500件までしか取得できないので、ページネーション処理が必要です。最終ページに到達するまで繰り返しAPIを実行して全情報を引き抜きます。

監査ログ情報からユニークなファイルIDのものだけを取り出して整形し、外部に公開されていたと思われるファイル情報のみをスプレッドシートに書き出します。

//Reports APIを使ってログから外部共有情報をあぶり出す
function exportReportExternalFileEvent() {
  //出力先シート名を指定する
  const sheetname = 'reportapi';

  //実行者のメアドからドメインを取得
  const domain = Session.getEffectiveUser().getEmail().split('@')[1];

  //結果格納用の配列
  const results = [];

  //スプシのUIを取得
  let ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();

  try {
    //一般公開ファイルイベントを探索
    fetchAndCollectActivities('change_document_visibility', results, domain);
    
    //直接外部共有ファイルのイベントを探索
    fetchAndCollectActivities('change_user_access', results, domain);
    
    //結果が無い場合
    if (results.length === 0) {
      ui.alert('外部共有されているファイルは見つかりませんでした。');
      return;
    }

    //結果情報からファイル情報を取り出し
    const uniqueFileIds = [...new Set(results.map(r => r.fileId))];
    const fileInfoMap = new Map(); // ファイル名とURLを格納

    //ユニークなファイルIDのみでフィルタリング
    uniqueFileIds.forEach(fileId => {
      try {
        //個別ファイルの情報を取得
        const file = Drive.Files.get(fileId, { fields: 'id, name, webViewLink' });
        fileInfoMap.set(fileId, { name: file.name, link: file.webViewLink });
      } catch (e) {
        //エラー発生時にはアクセス不可のファイルとして処理
        fileInfoMap.set(fileId, { name: '(アクセス不可または削除済み)', link: '' });
      }
    });

    //スプシ書き出し用にデータを整備
    const outputData = results.map(result => {
      const fileInfo = fileInfoMap.get(result.fileId) || { name: '', link: '' };
      return [
        result.fileId,
        fileInfo.name,
        result.targetUser,
        fileInfo.link
      ];
    });

    //シート情報をクリアする
    let sheet = ss.getSheetByName(sheetname);
    sheet.getRange("A2:D").clearContent();

    //書き出し用データを出力する
    sheet.getRange(2, 1, outputData.length, outputData[0].length).setValues(outputData);
    sheet.autoResizeColumns(1, 4);

    //終了処理
    const sheetUrl = ss.getUrl();
    ui.alert(`処理が完了しました。\n${outputData.length}件のデータをスプレッドシートに出力しました。`);
  } catch (e) {
    //エラー発生時
    console.error(`エラーが発生しました: ${e.toString()}`);
    ui.alert(`エラーが発生しました: ${e.message}`);
  }
}

//監査ログからの探索を担当する補助関数
function fetchAndCollectActivities(eventName, results, localDomain) {
  let pageToken = null;
  do {

    //リクエストオプションの指定
    const options = {
      eventName: eventName,
      maxResults: 500,
      pageToken: pageToken
    };

    //Report APIのリクエストを実行
    const report = AdminReports.Activities.list('all', 'drive', options);
    
    //取得ログの処理
    if (report.items && report.items.length > 0) {
      report.items.forEach(activity => {
        activity.events.forEach(event => {
          let isExternal = false;
          let targetUser = '公開設定の変更';

          //ドキュメントの公開設定チェンジを検出
          if (event.name === 'change_document_visibility') {
            const visibilityParam = event.parameters.find(p => p.name === 'new_visibility');
            if (visibilityParam) {
              const v = visibilityParam.value.toLowerCase();
              if (v === 'public' || v === 'shared_externally') {
                isExternal = true;
                targetUser = `一般公開 (${v})`;
              }
            }
          } else if (event.name === 'change_user_access') {
            const targetUserParam = event.parameters.find(p => p.name === 'target_user');
            if (targetUserParam) {
              const targetDomain = targetUserParam.value.split('@')[1];
              if (targetDomain && targetDomain !== localDomain) {
                isExternal = true;
                targetUser = targetUserParam.value;
              }
            }
          }

          if (isExternal) {
            const docIdParam = event.parameters.find(p => p.name === 'doc_id');
            if (docIdParam) {
              results.push({
                eventName: event.name,
                fileId: docIdParam.value,
                targetUser: targetUser
              });
            }
          }
        });
      });
    }

    //ページネーションの処理
    pageToken = report.nextPageToken;
  } while (pageToken);
}

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)