CUEBiC TEC BLOG

キュービックTECチームの技術ネタを投稿しております。

WORDPRESS 脆弱性検知の仕組みを構築してみた!

こんにちは、キュービックで SRE をやっております、hskn-cuebic と申します。

今回は、WORDPRESS脆弱性に対応するための検知の仕組みを構築したので、そのときのお話をしたいと思います。

はじめに

キュービックでは多くの WORDPRESS メディアを管理しています。 WORDPRESS に限った話ではありませんが、日々発生する脆弱性を個別に確認してアップデートを促していくというのはなかなか大変な作業になります。

キュービックでの脆弱性検知から WORDPRESS アップデートまでは、おおよそ以下のプロセスになっています。

  1. WORDPRESS で検知した脆弱性(コアとプラグイン)をリスト化
  2. WORDPRESS で作られたメディアごとに、メディア管理者への通知を行う
  3. メディア管理者が関係者と連携して アップデート計画(非互換検証など)を立てる
  4. アップデートの実施

脆弱性については、WPSCAN という WORDPRESS脆弱性データベースを提供してくれるサービスのプロフェッショナルプラン (300API コール/日) の契約 (25€/月の年間契約) を行い、脆弱性情報を取得しています。 他にもエンタープライズプランもありますが、お値段は要相談という感じです。

なぜ作ろうと思ったのか

はじめのうちは MainWP というプラグインと WPSCAN を連携して、各 WORDPRESS からコアやプラグイン脆弱性情報を取得し、その後 CVE データベースから CVSS スコアを取得する流れで対応していました。

MainWP

しかし、WPSCAN の API コールが 1 日 300 回までという制限がある中、MainWP と連携させた WPSCAN では、各 WORDPRESS 単位で個別にコアとプラグインの情報を取得するため、API がすぐに消費されてしまい、すべての WORDPRESS に対して処理が完了するまでに 約 1 ヶ月かかってしまっていました。

対応方法

MainWP は、各 WORDPRESS の情報を取得するに留め、各 WORDPRESS で重複するコアやプラグインのバージョンをマージしてから WPSCAN の API コールを行うことで処理の短縮を図りました。

簡単な仕様

結果

1 ヶ月かかっていた処理が 1 日で完了するようになりました。

現在では毎日処理を実行し、脆弱性を確認しています。

今回構築した仕組みの構成図

architecture

処理の流れ

  1. RDS の mainwp データベースの情報を取得 (CRON により処理開始)
  2. データベースから取得したデータを元に、コアとプラグインのユニークなバージョン情報を作成
  3. バージョン情報を元に WPSCAN API より、脆弱性情報を取得
  4. 脆弱性情報の CVE ID を元に CVE API より、CVSS スコアを取得
  5. 生成したメディアの脆弱性一覧を S3 バケットに転送
  6. S3 に保存されたメディアの脆弱性一覧を Google Drive にダウンロード (GAS トリガーにより処理開始)
  7. Google Drive に保存されたメディアの脆弱性一覧を読み込み
  8. Google Spreadsheet にデータを書き込み
  9. Slack に処理完了を通知

2 での加工処理は Lambda でやるなど自由度がありますが、今回は EC2 の Bash で直接データベースをクエリする形で実装しています。

Bash のコード

MainWP プラグインWORDPRESS が管理されていることを前提に、MainWP がインストールされている WORDPRESS サーバ上で実行するイメージになります。

https://github.com/cuebic/wpscan

GAS のコード

S3 と Slack を使用しているので、GAS のメニューでそれぞれのライブラリをインポートする必要があります。

function getWpscanResult() {
  const AWS_S3_KEY = PropertiesService.getScriptProperties().getProperty("AWS_S3_KEY");
  const AWS_S3_SECRET_KEY = PropertiesService.getScriptProperties().getProperty("AWS_S3_SECRET_KEY");
  const AWS_S3_BUCKET_NAME = PropertiesService.getScriptProperties().getProperty("AWS_S3_BUCKET_NAME");
  const DRIVE_FOLDER_ID = PropertiesService.getScriptProperties().getProperty("DRIVE_FOLDER_ID");
  const VUL_FILE_NAME = PropertiesService.getScriptProperties().getProperty("VUL_FILE_NAME");
  const SLACK_WEBHOOK_URL = PropertiesService.getScriptProperties().getProperty("SLACK_WEBHOOK_URL");
  const SLACK_POST_CHANNEL = PropertiesService.getScriptProperties().getProperty("SLACK_POST_CHANNEL");
  const TARGET_DATE = Utilities.formatDate(new Date(), "JST", "YYYYMMdd");

  const s3 = S3.getInstance(AWS_S3_KEY, AWS_S3_SECRET_KEY);
  const res = s3.getObject(AWS_S3_BUCKET_NAME, TARGET_DATE + "/" + VUL_FILE_NAME);
  const destfolder = DriveApp.getFolderById(DRIVE_FOLDER_ID);
  const fileList = DriveApp.getFolderById(DRIVE_FOLDER_ID).getFiles();
  const book = SpreadsheetApp.getActiveSpreadsheet();
  const outputSheet = book.getSheetByName("vulnerabilities");

  // 前回の vulnerabilities.tsv ファイルを削除
  var targetId = "";
  while (fileList.hasNext()) {
    var f = fileList.next();
    var fileName = f.getName();
    var fileId = f.getId();
    if (fileName == VUL_FILE_NAME) {
      targetId = fileId;
    }
  }
  if (targetId != "") {
    const rmFile = DriveApp.getFileById(targetId);
    rmFile.setTrashed(true);
  }

  // 全データと書式のクリア
  outputSheet.clear();

  // 取得データの書き出し
  const file = destfolder.createFile(res);
  const data = file.getBlob().getDataAsString("utf8");
  const newDataList = Utilities.parseCsv(data, "\t");
  const labels = ["wpid", "name", "url", "type", "slug", "ver.", "fixed", "title", "cve_id", "score"];
  var i = 1;
  for (var key in labels) {
    outputSheet.getRange(2, i).setValue(labels[key]);
    i++;
  }
  outputSheet.getRange(3, 1, newDataList.length, newDataList[0].length).setValues(newDataList);

  // 基本書式設定
  var dataRowEndNum = outputSheet.getDataRange().getLastRow();
  var dataColEndNum = outputSheet.getDataRange().getLastColumn();
  outputSheet.clearFormats();
  outputSheet.setColumnWidth(1, 50); // wpid
  outputSheet.setColumnWidth(2, 250); // name
  outputSheet.setColumnWidth(3, 300); // url
  outputSheet.setColumnWidth(4, 60); // type
  outputSheet.setColumnWidth(5, 200); // slug
  outputSheet.setColumnWidth(6, 60); // ver.
  outputSheet.setColumnWidth(7, 60); // fixed
  outputSheet.setColumnWidth(8, 300); // title
  outputSheet.setColumnWidth(9, 80); // cve_id
  outputSheet.setColumnWidth(10, 60); // score
  outputSheet.setFrozenRows(2);

  var range = outputSheet.getRange(1, 1);
  range.setValue("このシートは定期的にデータと書式が更新されるため編集を行わないでください。");
  range.setFontSize(8);
  range.setFontColor("red");

  range = outputSheet.getRange(1, 1, dataRowEndNum, dataColEndNum);
  range.setFontFamily("Ubuntu");
  range.setHorizontalAlignment("left");

  range = outputSheet.getRange(2, 1, 1, dataColEndNum);
  range.setFontWeight("bold");
  range.setHorizontalAlignment("center");
  range.setBackground("#c6f7d1");

  for (i = 0; i < dataRowEndNum - 2; i++) {
    range = outputSheet.getRange(3 + i, 1, 1, dataColEndNum);
    if (i % 2 == 0) {
      range.setBackground("#e8faec");
    }
  }

  const headers = { "Content-type": "application/json" };
  const message = { text: "WORDPRESS 脆弱性検知プログラムの実行が完了しました!\n<スプレッドシートのURL>" };
  const options = {
    method: "post",
    headers: headers,
    payload: JSON.stringify(message),
    muteHttpExceptions: true,
  };
  UrlFetchApp.fetch(SLACK_WEBHOOK_URL, options);
}

出力される脆弱性一覧

vulnerabilities_list

脆弱性対応の優先度

取得した脆弱性情報から CVE データベースに問い合わせを行い、CVSS スコアを抽出しています。(スコアが取得できないものもあります)

CVSS スコアは、脆弱性の緊急度を表す指標で次のように定義されています。

検知された脆弱性については、いずれもアップデートなどの対応を行いますが、タスクの優先度を考慮する場合の指標としています。

深刻度 スコア
緊急(Critical) 9.0 〜 10.0
重要(Important) 7.0 〜 8.9
警告(Warning) 4.0 〜 6.9
注意(Attention) 0.1 〜 3.9

共通脆弱性評価システム CVSS 概説

補足

2022 年 5 月に WPSCAN エンタープライズ以外のプランは終了がアナウンスされ、以後は Jetpack というスキャンサービスを使用するよう案内されました。(現在の契約が終了するまで API は利用できます)

Jetpack脆弱性を含む統合的な WORDPRESS スキャナの機能を提供(MainWP と同じようなものかなと思います)してくれるようですが、WPSCAN のように 脆弱性データベースの API は現時点では提供されていないようです。

現在の契約が終了するまでに、新しい API に切り換えるか、プランを変更するかなど検討中です。