こんにちは、キュービックで SRE をやっております、hskn-cuebic と申します。
今回は、WORDPRESS の脆弱性に対応するための検知の仕組みを構築したので、そのときのお話をしたいと思います。
はじめに
キュービックでは多くの WORDPRESS メディアを管理しています。 WORDPRESS に限った話ではありませんが、日々発生する脆弱性を個別に確認してアップデートを促していくというのはなかなか大変な作業になります。
キュービックでの脆弱性検知から WORDPRESS アップデートまでは、おおよそ以下のプロセスになっています。
- WORDPRESS で検知した脆弱性(コアとプラグイン)をリスト化
- WORDPRESS で作られたメディアごとに、メディア管理者への通知を行う
- メディア管理者が関係者と連携して アップデート計画(非互換検証など)を立てる
- アップデートの実施
脆弱性については、WPSCAN という WORDPRESS の脆弱性データベースを提供してくれるサービスのプロフェッショナルプラン (300API コール/日) の契約 (25€/月の年間契約) を行い、脆弱性情報を取得しています。 他にもエンタープライズプランもありますが、お値段は要相談という感じです。
なぜ作ろうと思ったのか
はじめのうちは MainWP というプラグインと WPSCAN を連携して、各 WORDPRESS からコアやプラグインの脆弱性情報を取得し、その後 CVE データベースから CVSS スコアを取得する流れで対応していました。
しかし、WPSCAN の API コールが 1 日 300 回までという制限がある中、MainWP と連携させた WPSCAN では、各 WORDPRESS 単位で個別にコアとプラグインの情報を取得するため、API がすぐに消費されてしまい、すべての WORDPRESS に対して処理が完了するまでに 約 1 ヶ月かかってしまっていました。
対応方法
MainWP は、各 WORDPRESS の情報を取得するに留め、各 WORDPRESS で重複するコアやプラグインのバージョンをマージしてから WPSCAN の API コールを行うことで処理の短縮を図りました。
簡単な仕様
- mainwp データベースを元に各メディアの WORDPRESS コア、プラグインのバージョン情報をバージョン重複を除外して取得
- コア、プラグインのバージョンが、脆弱性が修正されたバージョンよりも低い場合にスプレッドシートに情報を出力
- 細かい脆弱性情報へのリンク URL は取得しない
結果
1 ヶ月かかっていた処理が 1 日で完了するようになりました。
現在では毎日処理を実行し、脆弱性を確認しています。
今回構築した仕組みの構成図
処理の流れ
- RDS の mainwp データベースの情報を取得 (CRON により処理開始)
- データベースから取得したデータを元に、コアとプラグインのユニークなバージョン情報を作成
- バージョン情報を元に WPSCAN API より、脆弱性情報を取得
- 脆弱性情報の CVE ID を元に CVE API より、CVSS スコアを取得
- 生成したメディアの脆弱性一覧を S3 バケットに転送
- S3 に保存されたメディアの脆弱性一覧を Google Drive にダウンロード (GAS トリガーにより処理開始)
- Google Drive に保存されたメディアの脆弱性一覧を読み込み
- Google Spreadsheet にデータを書き込み
- 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); }
出力される脆弱性一覧
脆弱性対応の優先度
取得した脆弱性情報から CVE データベースに問い合わせを行い、CVSS スコアを抽出しています。(スコアが取得できないものもあります)
CVSS スコアは、脆弱性の緊急度を表す指標で次のように定義されています。
検知された脆弱性については、いずれもアップデートなどの対応を行いますが、タスクの優先度を考慮する場合の指標としています。
深刻度 | スコア |
---|---|
緊急(Critical) | 9.0 〜 10.0 |
重要(Important) | 7.0 〜 8.9 |
警告(Warning) | 4.0 〜 6.9 |
注意(Attention) | 0.1 〜 3.9 |
補足
2022 年 5 月に WPSCAN エンタープライズ以外のプランは終了がアナウンスされ、以後は Jetpack というスキャンサービスを使用するよう案内されました。(現在の契約が終了するまで API は利用できます)
Jetpack は脆弱性を含む統合的な WORDPRESS スキャナの機能を提供(MainWP と同じようなものかなと思います)してくれるようですが、WPSCAN のように 脆弱性データベースの API は現時点では提供されていないようです。
現在の契約が終了するまでに、新しい API に切り換えるか、プランを変更するかなど検討中です。