NTTドコモR&Dの技術ブログです。

GAS × Sysdig Secure API × Backlog APIでCSPMを楽にしたい

はじめに

この記事はドコモアドベントカレンダー23日目の記事になります

明日はクリスマスイブですね〜

皆さんはもうサンタさんにお願いするプレゼントは決まりましたか?

自分はASUS ROG Strix GeForce RTX™ 4090の白いやつです

プレゼントにしては比較的コンパクトだしサンタさんも届けやすいんじゃないでしょうか(白目)

 

雑談はさておき、今回はGAS(Google Apps Script)からSysdig Secure APIとBacklog APIを使ってCSPMの改善に挑戦してみた様子をお届けしたいと思います!


自己紹介

初めましてNTTドコモ サービスデザイン部の浴林(さこばやし)です

普段は主にRAFTEL*1という共通API基盤の開発運用を行っています

NTTドコモではd払いやLeminoなどのスマートライフ系アプリを提供していますが、RAFTELはこのようなアプリを高速に低コストでリリースするための基盤です

SOAP APIをREST APIへ変換するなど、古くからあるオンプレミスのシステムへのリクエストをRAFTELが代替することで、アプリ開発者は難しいAPIリファレンスを読み解いたり複雑なリクエストパラメータを用意したりしなくて済むので、本来のアプリ開発に注力でき迅速なリリースが可能になる という仕組みです

 

私自身はチームに参画したばかりでまだまだ勉強中ではありますが、手を動かすのは好きなのでよくコードを書いたり簡単なアプリを作ったりして知見を深めています


CSPMって?

いきなりですがCSPMってなんでしょう? GPTに聞いてみました

CSPMってなに?

CSPM(Cloud Security Posture Management)は、クラウド環境におけるセキュリティの設定や状態を管理し、改善するためのツールやプロセスを指します。クラウドサービスを利用する際には、多くの設定が必要ですが、誤った設定やセキュリティ上のミスが原因で脆弱性が生まれることがあります。CSPMは、これらの問題を自動的に検出し、修正の提案を行うことで、セキュリティリスクを軽減します。

とのことです

セキュリティリスクを軽減するために、Security Posture(セキュリティに対する姿勢)を正すことや、正し方を提案してくれるツール という感じでしょうか?

 

世の中にはCSPMを提供する色々な製品がありますが、RAFTELではSysdig Secureというツールを導入し、クラウド環境のセキュリティを日々管理しています

具体的には、CSPM定例と呼んでいる週1回の定例ミーティングで主に以下2つのポイントを確認しています

1. セキュリティポリシーの違反数の増減

  Sysdig Secureが定義するセキュリティポリシーの違反の数が先週と比べて増えていないかを確認します

2. 新たな違反への対応

  違反が増加している場合、その原因を特定しどのように対処するかを検討します

  例えば、特定のリソースで設定ミスが見つかったり、設定の変更が必要と分かった場合はそのリソースの担当者に修正を依頼をします

こうした取り組みを通じて、RAFTELチームはクラウド環境のセキュリティレベルを維持・向上させています

 

ちなみにSysdig Secureは正確にはCNAPPなので様々な機能が含まれますが、今回はクラウド環境全体をカバーするCSPMの話をします

RAFTELでのSysdig Secure活用方法

 

少し余談ですが、クラウドセキュリティについてこれから学びたいと思っている方はSysdig社の「クラウドネイティブを学ぼう」というページが分かりやすくまとめられていますので、よければ活用してみてください sysdig.jp

Sysdigの中の人も「綺麗にまとまっていて結構有益なページだと思うんですよね〜」と自負していました(笑


困りごと

前置きが長くなりましたが本題に入ります

Sysdig Secureを使っている中で困っていることが2つあります

1つ目は、Sysdig SecureのUIでポリシーごとに検知した数(違反を確認したリソースの数)の変化は分かるのですが、具体的に"どの項目で検知数が変化したか分からない"こと です

そのため、先週と比べて検知数が増加している時にどれが新たに検知した項目か把握するためには、全ての項目をしらみつぶしに見ていく必要があります、、

 

2つ目は、対応履歴をSysdig Secure上に残せないこと です


やりたいこと

1つ目の困りごとに関しては、全てのポリシーにおいて各項目ごとの検知数を取得して、過去の数と比較して差分を出力する、

2つ目の困りごとに関しては、対応履歴を残せるようにチケット管理できるようにすることで改善したいと考えました

今回はこれをSysdig Secure APIとBacklog APIを活用して実装してみようと思います

実装環境については、手軽に扱えて定期的な実行も楽かなということで、GoogleのスプレッドシートとGoogle Apps Scriptを選びました

改めてイメージはざっくりこんな感じ↓


実装してみた

まずは、Sysdig Secure APIで ① 情報を取得 を行います

① 情報を取得 のコード

function fetchAndWriteComplianceResults() {
  // HTTP GETリクエストの設定
  const apiUrl = 'https://app.us4.sysdig.com/api/cspm/v1/compliance/requirements';
  const options = {
    method: 'get',
    headers: {
      Authorization: 'Bearer ' + Sysdig_API_TOKEN // トークンの取得方法はhttps://docs.sysdig.com/en/retrieve-the-sysdig-api-tokenを参照
    }
  };
  // APIリクエストを送信
  const response = UrlFetchApp.fetch(apiUrl, options);
  const jsonResponse = JSON.parse(response.getContentText());

// スプレッドシートを取得(新しいシートを作成するか、既存のシートを指定)
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const currentSheet = spreadsheet.getSheetByName('CurrentRequirements') || spreadsheet.insertSheet('CurrentRequirements');
  const previousSheet = spreadsheet.getSheetByName('PreviousRequirements') || spreadsheet.insertSheet('PreviousRequirements');

  // ヘッダー行を設定
  const headers = [
    'PolicyName / Name / Control Name',
    'Severity',
    'Objects Count',
    'description',
    'Resource API Endpoint'
  ];
  
  // 過去データをクリア
  previousSheet.clear();
  
  const sourceRange = currentSheet.getDataRange();
  const currentData = sourceRange.getValues();  
  previousSheet.getRange(1, 1, currentData.length, currentData[0].length).setValues(currentData); // 前回実行時のデータを移動
  currentSheet.clear(); // 前回実行時のデータをクリア
  
  const now = new Date();
  currentSheet.appendRow([`Execution Time: ${now.toLocaleString()}`]); // 実行時間を出力
  currentSheet.appendRow(headers);

  // レスポンスデータを処理
  const data = jsonResponse.data;
  data.forEach(item => {
    const policyName = item.policyName;
    const controls = item.controls;

    controls.forEach(control => {
      // objectsCountが1以上のものだけ追加
      if (control.objectsCount > 0) {
        const combinedName = `${policyName} / ${item.name} / ${control.name}`; // 結合された名前
        const description = control.description || ''; // descriptionフィールドを取得(存在しない場合は空文字列)
        const row = [
          combinedName,
          control.severity,
          control.objectsCount,
          description,
          control.resourceApiEndpoint
        ];
        currentSheet.appendRow(row);
      }
    });
  });
}

ポリシーの各項目ごとに検知数を含む情報が出力されました

 

次に ② 前回結果と比較 を行います

② 前回結果と比較 のコード

function compareResultsWithObjectsCount() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const currentSheet = spreadsheet.getSheetByName('CurrentRequirements');
  const previousSheet = spreadsheet.getSheetByName('PreviousRequirements');
  const diffSheet = spreadsheet.getSheetByName('RequirementsDifferences') || spreadsheet.insertSheet('RequirementsDifferences');
  
  // シートをクリア
  diffSheet.clear();
  
  // ヘッダー設定
  const headers = [
    'Change Type', // 変更の種類: Newly detected, Counts has been changed, Has been passed
    'PolicyName / Name / Control Name',
    'Severity',
    'Objects Count',
    'description',
    'Resource API Endpoint',
    'Processing Result'
  ];
  diffSheet.appendRow(headers);
  
  // データ取得
  const currentData = currentSheet.getDataRange().getValues();
  const previousData = previousSheet.getDataRange().getValues();

  // 実行時間の行をスキップしてデータ部分だけを取得
  const currentResults = currentData.slice(2);
  const previousResults = previousData.slice(2);
  
  // 前回と現在の結果を比較するために、Mapを作成
  const previousMap = new Map();
  previousResults.forEach(row => {
    const key = row[0]; // "PolicyName / Name / Control Name"をキーに
    previousMap.set(key, row);
  });

  const currentMap = new Map();
  currentResults.forEach(row => {
    const key = row[0];
    currentMap.set(key, row);
  });

  // 比較ロジック
  const added = [];
  const removed = [];
  const modified = [];

  // 現在のデータと比較
  currentMap.forEach((currentRow, key) => {
    if (!previousMap.has(key)) {
      const processingResult = processResourceApiEndpoint(currentRow[2], currentRow[4]); // Resource API Endpointを処理
      added.push(['Newly detected.', ...currentRow, processingResult]);
    } else {
      const previousValue = previousMap.get(key);
      if (!arraysEqual(currentRow, previousValue)) {
        const processingResult = processResourceApiEndpoint(currentRow[2], currentRow[4]); // Resource API Endpointを処理
        modified.push(['Counts has been changed.', ...currentRow, processingResult]);
      }
      previousMap.delete(key); // 処理済みのキーを削除
    }
  });

  // 削除されたデータを検出
  previousMap.forEach((value, key) => {
    removed.push(['Has been passed.', value[0], value[1], '-', '-', '-', '-']);
  });


  // 差分データを差分シートに書き込み
  const allChanges = [...added, ...removed, ...modified];
  if (allChanges.length > 0) {
    diffSheet.getRange(2, 1, allChanges.length, allChanges[0].length).setValues(allChanges);
  } else {
    diffSheet.appendRow(['No changes detected.']);
  }
}

// 配列を比較するヘルパー関数
function arraysEqual(arr1, arr2) {
  if (arr1.length !== arr2.length) return false;
  for (let i = 0; i < arr1.length; i++) {
    if (arr1[i] !== arr2[i]) return false;
  }
  return true;
}

// Resource API Endpointを処理する関数
function processResourceApiEndpoint(resourceObjectsCount, resourceApiEndpoint) {
  if (resourceApiEndpoint == ''){
    return 'Error: Could not catch API Endpoint.'
  } 
  
  // Resource API Endpointを処理し、結果を返す
  try {
    // HTTP GETリクエストの設定
    const apiUrl = 'https://app.us4.sysdig.com' + resourceApiEndpoint + ' and pass=false&pageSize=' + resourceObjectsCount;

    const options = {
      method: 'get',
      headers: {
        Authorization: 'Bearer ' + Sysdig_API_TOKEN
      }
    };
    // APIリクエストを送信
    const response = UrlFetchApp.fetch(apiUrl, options);
    const jsonResponse = JSON.parse(response.getContentText());
    
    var names = '';
    names = jsonResponse.data.map(item => item.name);

    return names.join(', ');

  } catch (error) {
    return `Error: ${error.message}`;
  }
}

"Has been passed.(検知されなくなった)/Newly detected.(新たに検知した)/Counts has been changed.(検知数が変わった)"といった差分の種類が出力されました

 

次にBacklog APIを使って ③ 課題の一覧を取得 をします

③ 課題の一覧を取得 のコード

function getBacklogIssues() {
  // Backlog API の情報
  const statusId = '1'; // ステータスID 1:未対応 2:処理中 3:処理済み 4:完了
  var offset = 0;
  const MAX_COUNT = 100;
  
  try {
    // スプレッドシートに出力
    const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = spreadsheet.getSheetByName('Backlog_Issues') || spreadsheet.insertSheet('Backlog_Issues');
    sheet.clear(); // シートをクリア

    // ヘッダー行を設定
    const headers = ['課題キー', '件名', 'ステータス', '担当者', '期限日'];
    sheet.appendRow(headers);
    
    var bln_loop = true;
    while(bln_loop){
      // Backlog API エンドポイント
      const BACKLOG_API_URL = `https://${BACKLOG_SPACE}.backlog.jp/api/v2/issues?apiKey=${BACKLOG_API_TOKEN}&projectId[]=${projectId}&statusId[]=${statusId}&offset=${offset.toString()}&count=${MAX_COUNT}&issueTypeId[]=${issueTypeId}`; // スペースIDやプロジェクトIDはURLから取得可能 参考:[https://support-ja.backlog.com/hc/ja/articles/360036151593-スペースIDとは], [https://yatta47.hateblo.jp/entry/2017/07/08/130000]

      // API リクエストを送信
      const response = UrlFetchApp.fetch(BACKLOG_API_URL);
      const issues = JSON.parse(response.getContentText());
      Utilities.sleep(1000);

      // 課題データを追加
      issues.forEach(issue => {
        const row = [
          issue.issueKey, // 課題キー
          issue.summary,  // 件名
          issue.status.name, // ステータス
          issue.assignee ? issue.assignee.name : '未設定', // 担当者
          issue.dueDate || 'なし', // 期限日
        ];
        sheet.appendRow(row);
      });
      
      if(Object.keys(issues).length == MAX_COUNT){
        Number(offset);
        offset += MAX_COUNT;
      } else{
        bln_loop = false;
      }
    }

    Logger.log('課題一覧を取得しました!');
  } catch (error) {
    Logger.log('エラーが発生しました: ' + error.message);
  }
}

Backlog課題一覧が出力されました

 

最後は、これまでの処理で取得した情報をもとに ④ 既に課題があるか判定→無ければ作成あれば更新 を行います

④ 既に課題があるか判定→無ければ作成あれば更新 のコード

function checkExistence() {
  // スプレッドシートの取得
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  
  // シートの取得
  const sheet1 = spreadsheet.getSheetByName("RequirementsDifferences");
  const sheet2 = spreadsheet.getSheetByName("Backlog_Issues");
  
  // シート1とシート2のデータを取得
  const data1 = sheet1.getRange(2, 1, sheet1.getLastRow()-1, 7).getValues();
  const data2 = sheet2.getRange(2, 2, sheet2.getLastRow()-1, 1).getValues();
  
  const task_keys = sheet2.getRange(2, 1, sheet2.getLastRow()-1, 1).getValues();
  
  // === 判定結果を格納する配列 ===
  const firstCheckResults = []; // 判定1: ○/×
  const secondCheckResults = []; // 判定2: 課題の削除/更新/作成
  const foundRowResults = []; // 見つかった行番号

  // === 判定ロジック ===
  data1.forEach(([changetype, title, severity, objectscount, description, resouceapiendpoint, prosessingresult]) => {
    
    const searchKey = '【CSPM】' + title;
    const foundIndex = data2.findIndex(row => row[0] === searchKey);
    // 判定1: シート1のB列がシート2のB列に存在するか
    const firstCheck = foundIndex !== -1 ? "○" : "×";
    
    // 見つかった行番号 (存在しない場合は空白)
    const foundRow = foundIndex !== -1 ? foundIndex: "";
    
    // 判定2: A列と判定1の結果に基づく判定
    let secondCheck = "";
    if (changetype === "Has been passed." && firstCheck === "○") {
      secondCheck = "課題の削除";
      deleteBacklogIssue(task_keys[foundRow]);
    } else if (changetype === "Counts has been changed.") {
      secondCheck = firstCheck === "○" ? "課題の更新" : "課題の作成";
      secondCheck = firstCheck === "○" ? updateBacklogIssue(prosessingresult, task_keys[foundRow]) : createBacklogIssue(title, prosessingresult);
    } else if (changetype === "Newly detected.") {
      secondCheck = firstCheck === "○" ? "課題の更新" : "課題の作成";
      secondCheck = firstCheck === "○" ? updateBacklogIssue(prosessingresult, task_keys[foundRow]) : createBacklogIssue(title, prosessingresult);
    }

    // 判定結果を配列に格納
    firstCheckResults.push([firstCheck]);
    secondCheckResults.push([secondCheck]);
    foundRowResults.push([task_keys[foundRow]]);
  });
  
  // === 判定結果をシートに書き込み ===
  sheet1.getRange(2, 8, firstCheckResults.length, 1).setValues(firstCheckResults); // H列に判定1を書き込み
  sheet1.getRange(2, 9, secondCheckResults.length, 1).setValues(secondCheckResults); // I列に判定2を書き込み
  sheet1.getRange(2, 10, foundRowResults.length, 1).setValues(foundRowResults); // J列に見つかった行番号を書き込み
}

// 課題の作成
function createBacklogIssue(title, prosessingresult) {
  //descriptionの作成
  var description_text = '## 期限付きアクセプト理由・時期\n-\n## 対象(大量にある場合はCSVファイル添付)\n';
  prosessingresult = prosessingresult.split(', ');
  
  //対象が9を超せばcsvファイル作成へ遷移
  if (prosessingresult.length > 9) {
    var attachmentId = createCsvAndUploadToBacklog(prosessingresult);
  }else if(prosessingresult.length < 10){
    //対象が10未満であれば文字列としてdescriptionへ追加
    for (let i = 0; i < prosessingresult.length; i++) {
      description_text = description_text + '- ' + prosessingresult[i] + '\n';
    }
  }
  description_text = description_text + '## 備考';

  // 課題の詳細情報
  const payload = {
    projectId: 'xxxxxxx', // プロジェクトID (Backlog管理画面で確認可能)
    summary: '【CSPM】' + title,
    issueTypeId: 'xxxxxxx', // 課題タイプID
    'categoryId[]': 'xxxxxxx', // 課題カテゴリーID
    priorityId: '3', // 優先度ID (2:高, 3:中, 4:低) 
    //description: '## 期限付きアクセプト理由・時期\n-\n## 対象(大量にある場合はCSVファイル添付)\n- 1\n- 2\n- 3\n## 備考', // 課題の詳細
    description: description_text,
    assigneeId: 'xxxxxxx', // 担当者ID
  };

  try {
    // APIリクエストを送信
    const options = {
      method: 'post',
      payload: payload,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      }
    };

    if (attachmentId) {
      var BACKLOG_API_URL = `https://${BACKLOG_SPACE}.backlog.jp/api/v2/issues?apiKey=${BACKLOG_API_TOKEN}&attachmentId[]=${parseInt(attachmentId,10)}`;
    }else{
      var BACKLOG_API_URL = `https://${BACKLOG_SPACE}.backlog.jp/api/v2/issues?apiKey=${BACKLOG_API_TOKEN}`;
    }

    const response = UrlFetchApp.fetch(BACKLOG_API_URL, options);
    const result = JSON.parse(response.getContentText());

    Logger.log('課題を作成しました: ' + result.issueKey);
  } catch (error) {
    Logger.log('課題作成中にエラーが発生しました: ' + error.message);
  }
}


//課題の更新
function updateBacklogIssue(prosessingresult, taskid) {
  //descriptionの作成
  var description_text = '## 期限付きアクセプト理由・時期\n-\n## 対象(大量にある場合はCSVファイル添付)\n';
  prosessingresult = prosessingresult.split(', ');

  //対象が9を超せばcsvファイル作成へ遷移
  if (prosessingresult.length > 9) {
    var attachmentId = createCsvAndUploadToBacklog(prosessingresult);
  }else if(prosessingresult.length < 10){
    //対象が10未満であれば文字列としてdescriptionへ追加
    for (let i = 0; i < prosessingresult.length; i++) {
      description_text = description_text + '- ' + prosessingresult[i] + '\n';
    }
  }
  description_text = description_text + '\n## 備考';
  
  // 更新する内容
  const payload = {    
    description: description_text // 更新された詳細
  };

  try {
    // APIリクエストを送信
    const options = {
      method: 'patch',
      payload: payload,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      }
    };

    const ISSUE_KEY = taskid; // 更新対象の課題キー
    
    if (attachmentId != undefined) {
      var BACKLOG_API_URL = `https://${BACKLOG_SPACE}.backlog.jp/api/v2/issues/${ISSUE_KEY}?apiKey=${BACKLOG_API_TOKEN}&attachmentId[]=${parseInt(attachmentId,10)}`;
    }else{
      var BACKLOG_API_URL = `https://${BACKLOG_SPACE}.backlog.jp/api/v2/issues/${ISSUE_KEY}?apiKey=${BACKLOG_API_TOKEN}`;
    }

    const response = UrlFetchApp.fetch(BACKLOG_API_URL, options);
    const result = JSON.parse(response.getContentText());

    Logger.log('課題を更新しました: ' + result.issueKey);
  } catch (error) {
    Logger.log('課題更新中にエラーが発生しました: ' + error.message);
  }
}

//課題の削除
function deleteBacklogIssue(deleteBacklogIssue) {
  const BACKLOG_API_URL = `https://${BACKLOG_SPACE}.backlog.jp/api/v2/issues/${deleteBacklogIssue}?apiKey=${BACKLOG_API_TOKEN}&projectId[]=${projectId}`;

  try {
    // APIリクエストを送信
    const options = {
      method: 'delete'
    };
    const response = UrlFetchApp.fetch(BACKLOG_API_URL, options);
    const result = JSON.parse(response.getContentText());

    Logger.log('課題を削除しました: ' + result.issueKey);
  } catch (error) {
    Logger.log('課題削除中にエラーが発生しました: ' + error.message);
  }
}

//csvファイル作成
function createCsvAndUploadToBacklog(processingresult) {
  const filename = 'data.csv';
  const csvContent = processingresult.map(item => [item]).join('\n'); // 1列のCSVに変換
  const csvBlob = Utilities.newBlob(csvContent, 'text/csv', filename); // CSV Blobを作成

  // CSVファイルをBacklogにアップロード
  const uploadOptions = {
    method: 'post',
    headers: {
      'Authorization': 'Bearer ' + BACKLOG_API_TOKEN
    },
    payload: {
      file: csvBlob
    },
    muteHttpExceptions: true
  };

  const BACKLOG_UPLOAD_URL = `https://${BACKLOG_SPACE}.backlog.jp/api/v2/space/attachment`;
  try {
    const uploadResponse = UrlFetchApp.fetch(BACKLOG_UPLOAD_URL + '?apiKey=' + BACKLOG_API_TOKEN, uploadOptions);
    const uploadResponseCode = uploadResponse.getResponseCode(); // レスポンスコードを取得
    const uploadResponseText = uploadResponse.getContentText(); // レスポンステキストを取得

    Logger.log('アップロードレスポンスコード: ' + uploadResponseCode);
    Logger.log('アップロードレスポンス内容: ' + uploadResponseText);
    if (uploadResponseCode === 200) {
      const uploadResult = JSON.parse(uploadResponse.getContentText());
      const attachmentId = uploadResult.id; // アップロード成功後の添付ファイルID
      const attachmentName = uploadResult.name; // アップロードされたファイル名

      Logger.log('添付ファイルをアップロードしました。ID: ' + attachmentId + ', 名前: ' + attachmentName);
      return attachmentId;
      
    } else {
      Logger.log('ファイルアップロード時にエラーが発生しました: ' + uploadResponseText);
    }
    
  } catch (error) {
    Logger.log('エラーが発生しました: ' + error.message);
  }
}

H列には現在のBacklog課題の有無、I列には差分の種類に応じたBacklogへのアクション、J列にはBacklog課題のIDが出力されました

同時にI列の結果に応じたBacklog APIを実行します


結果

↓のようなSysdig Secureの更新を

↓Google Apps Scriptでキャッチし

↓Backlog課題に反映することができました 違反を検知したリソースは添付しているcsvに記載

ということで「どの項目の検知数が変化したか分からない」と「CSPMの対応履歴を残せない」を解消することができました

チームではこのスクリプトを毎日実行させて、今対応すべきものの明確化や対応履歴を参考にした早期のアクション決定を行っています


終わりに

今回はCSPMに関する困りごとの解消に向けて、Google Apps ScriptとSysdig Secure APIとBacklog APIを活用してみました

今回新たに実装した新しい違反が検出された際に自動でBacklog課題を作成できる仕組みは、本来あった困りごとの解消だけでなく見落としを減らしたりセキュリティへの意識を自然に向かせる効果も期待できるかなと感じています

次はAIを使って違反に対する対応を自動化させようかな〜とかのんびり考えたりしていますが、触ったことのないものを使って新しい知見を得るのはやっぱり面白いですね!

これからも、日々の業務を少しでも便利にする方法や面白そうな技術を探して、いろいろと試していきたいと思います!