GitHub Advisory Database から脆弱性情報を Slack に通知するシステムを作ったら、チームの雰囲気が少し変わった


こんにちは。フロントエンドエンジニアの辻です。

npm パッケージの脆弱性情報、皆さんはどうやってキャッチアップされているでしょうか?
自分がアサインされているプロジェクトで利用している技術やパッケージは素早くキャッチアップできるものの、それ以外の脆弱性情報の収集はなかなかに苦労するのではないでしょうか。

持続的に開発を続けるためには、脆弱性情報のキャッチアップと素早い対応は必須ですし、Gaji-Labo としても「チームとしてちゃんと取り組んでいこう!」と機運が高まっているところです。

そこで、今回は脆弱性情報収集の一環として、GitHub Advisory Database から Critical / High の脆弱性情報を収集して Slack に通知するライトなシステムを作ってみました。

作ったもの

GitHub Actions で毎朝平日 9 時に自動実行され、GitHub Advisory Database に登録された情報のうち、High / Critical な脆弱性情報を Slack に投稿するシステムです。
とくに npm ライブラリに特化して収集したかったため、npm に絞ってフィルタリングしています。

詳細は端折りますが、全体の構成は次の通りです。

./
├── package-lock.json
├── package.json
├── src
│   ├── fetcher
│   │   └── index.ts … GitHub Advisory Database の情報を取得する処理
│   ├── slack
│   │   └── index.ts … Slack に投稿する処理
│   └── index.ts … エントリーポイント
└── tsconfig.json

実装のポイント

1. Octokit をラップした関数の作成

src/fetcher/index.ts には、GitHub Advisory Database の情報を取得するコードをまとめています。
今回は Octokit という GitHub 公式 API クライアントライブラリを利用しています。

改修をしやすくするため、関数内部で Octokit を new するのではなく、引数として受け取るようにしています。

import type { Endpoints } from '@octokit/types';
import type { Octokit } from 'octokit';

type ParamsType = Required<
  Pick<
    Endpoints['GET /advisories']['parameters'],
    'ecosystem' | 'per_page' | 'updated'
  >
>;

export const createFetcher = ({
  octokit,
  params
}: {
  octokit: Octokit;
  params: ParamsType;
}) => {
  return () => {
    return octokit.request('GET /advisories', {
      headers: {
        'X-GitHub-Api-Version': '2022-11-28'
      },
      ecosystem: params.ecosystem,
      per_page: params.per_page,
      updated: params.updated
    });
  };
};

createFetcher 関数の引数の ParamsType は、@octokit/types パッケージを使ってカスタマイズした型です。
今回のシステムでは ecosystem: 'npm' のように、特定のパラメータは必須指定にしたかったので、 Required を使っています。

type ParamsType = Required<
  Pick<
    Endpoints['GET /advisories']['parameters'],
    'ecosystem' | 'per_page' | 'updated'
  >
>;

/**
 * 実際に実行すると、こんな感じです
 */
const fetcher = createFetcher({
  octokit,
  params: {
    ecosystem: 'npm',                                    // 必須指定
    per_page: 100,                                       // 必須指定
    updated: `>=${queryDate.toISOString().slice(0, 10)}` // 必須指定
  }
});

const response = await fetcher();

2. GitHub Advisory Database から取得した情報を Slack 投稿文章へ整形する

createPost 関数では、createFetcher 関数を利用して、取得したデータを Slack へ投稿するために加工しています。

import type { components } from '@octokit/openapi-types';

const SEVERITY_EMOJI: Record<components['schemas']['global-advisory']['severity'], string> = {
  critical: '🔴',
  high: '🟠',
  medium: '🟡',
  low: '🟢',
  unknown: '⚠️'
} as const;

export const createPost = ({
  vulnerabilities,
  postDateText
}: {
  vulnerabilities: components['schemas']['global-advisory'][];
  postDateText: string;
}) => {
  const blocks: SlackBlock[] = [
    {
      type: 'header',
      text: {
        type: 'plain_text',
        text: `🚨 NPM エコシステム 脆弱性アラート (${vulnerabilities.length}件)`,
        emoji: true
      }
    },
    {
      type: 'section',
      text: {
        type: 'mrkdwn',
        text: `${postDateText}付けで新たに ${vulnerabilities.length} 件の High, Critical の脆弱性情報の更新がありました。`
      }
    },
    {
      type: 'divider'
    }
  ];

  // 脆弱性の情報をまとめる
  vulnerabilities.forEach((item, index) => {
    if (!(item.severity === 'high' || item.severity === 'critical')) return;

    const emoji = SEVERITY_EMOJI[item.severity] || '⚠️';
    const vulnerablePackagesText = (item.vulnerabilities ?? [])
      .map((pkg) => {
        const ecosystem = pkg.package?.ecosystem ?? '-';
        const name = pkg.package?.name ?? '-';

        return (
          `- エコシステム: ${ecosystem}\n` + `- package 名: ${name}\n`
        );
      })
      .join('\n');
    blocks.push({
      type: 'section',
      text: {
        type: 'mrkdwn',
        text:
          `*GHSA ID: ${item.ghsa_id} - ${emoji} ${item.severity}*\n` +
          `*Package 概要:*\n` +
          `${vulnerablePackagesText}\n` +
          `*サマリー:* ${item.summary ?? '---'}\n` +
          `<${`https://github.com/advisories/${item.ghsa_id}` || item.repository_advisory_url}|詳細を見る>`
      }
    });

    if (index < vulnerabilities.length - 1 && (index + 1) % 5 === 0) {
      blocks.push({ type: 'divider' });
    }
  });

  return blocks;
};

3. Slack に投稿する

Slack への投稿関数 postToSlack は、シンプルに node:https モジュールを使って実装しました。
引数の slackWebhookUrl には、文字通りの Web Hook URL を渡し、
引数の blocks には、先程の createPost 関数の戻り値を渡します。

import { type RequestOptions, request } from 'node:https';

interface SlackBlock {
  type: string;
  text?: {
    type: string;
    text: string;
    emoji?: boolean;
  };
}

export const postToSlack = ({
  slackWebhookUrl,
  blocks
}: {
  slackWebhookUrl: string;
  blocks: SlackBlock[];
}) => {
  return new Promise((resolve, reject) => {
    const url = new URL(slackWebhookUrl);

    const payload = {
      text: 'Critical/High の脆弱性通知',
      blocks
    };
    const data = JSON.stringify(payload);

    const options: RequestOptions = {
      protocol: url.protocol,
      hostname: url.hostname,
      port: url.port ? Number(url.port) : undefined,
      path: url.pathname + url.search,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(data)
      }
    };

    const req = request(options, (res) => {
      let body = '';
      res.setEncoding('utf8');
      res.on('data', (chunk) => {
        body += chunk;
      });
      res.on('end', () => {
        if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
          resolve(true);
          return;
        }

        reject(new Error(`Slack API error: ${res.statusCode} ${body}`));
      });
    });

    req.on('error', reject);
    req.write(data);
    req.end();
  });
};

あとはエントリーポイントとなる src/index.ts にて、createFetcher -> createPost -> postToSlack の順番で実行していき、戻り値を渡していくだけです。

実際に運用してみて

実際に今回のシステムを運用してみて、確かに脆弱性情報のキャッチアップはできるようになりました。
とくに、昨年から連続していた JavaScript に係る脆弱性情報は比較的早めに把握できました。

ただそれ以上に、チームの雰囲気の変化が一番の収穫かもしれません。
Slack に通知され、目に入るようになってから、各メンバーが率先して脆弱性情報に反応する流れが醸成されました。

「これ、うちのプロジェクトでも使ってるかパッと調査してきますね。」
「私がアサインされたプロジェクトでは脅威にならないけども、他チームにも情報共有しよう。」

…といった会話が自然に生まれ、セキュリティを「チームごと」で考える文化が少しずつ育ってきていると感じています。

「情けは人のためならず」ではありませんが、お互いに助け合うことで、よりキャッチアップが進み、セキュリティの知見も高まってきています。

今後の課題

GitHub Advisory Database の情報をライトに取得する分には、現状でも問題はないのですが、まだまだ改良の余地は残されています。


例えば、現在は直近 100 件の脆弱性情報を取得しているため、前日に公表された脆弱性が 2〜4 日程度は取得され続けてしまいます。
また、今後は AI による脆弱性情報の一次判定を実装して、対応策も同時に提案してもらうように改良したいですね。

今後の改良については、AI と壁打ちしながら、やってみたいと思います。

まとめ

GitHub Advisory Database を参照元にして、npm ライブラリの脆弱性情報をキャッチアップできるシステムを構築しました。

セキュリティ情報のキャッチアップは、地道な作業ですが非常に重要です。
こうした自動化によって、開発チーム全体のセキュリティ意識を高められたら、嬉しいですね。

Gaji-Labo は フロントエンドのAI開発の実績と知見があります

急速に進化するAI技術、進まないUIとの統合…。 ユーザー体験を損なわずにAIを導入したいと考えながら、実装や設計に悩み、開発が停滞している。 そんな課題を抱えるプロダクトや開発チームを、私たちは数多く支援してきました。

フロントエンド開発の専門企業である Gaji-Labo は、AIチャットや自然言語処理UIなどの設計・実装において、AIの特性を踏まえた体験設計・UI開発・運用まで、フェーズに応じたサポートが可能です。

フロントエンドでのAI導入を相談する!

Gaji-Labo フロントエンドエンジニア向けご案内資料

Gaji-Labo は新規事業やサービス開発に取り組む、事業会社・スタートアップへの支援を行っています。

弊社では、Next.js を用いた Web アプリケーションのフロントエンド開発をリードするフロントエンドエンジニアを募集しています!さまざまなプロダクトやチームに関わりながら、一緒に成長を体験しませんか?

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください!

求人応募してみる!


投稿者 Tsuji Atsuhiro

フロントエンドエンジニア。 DTP・Webデザイナーを経験した後、フロントエンドエンジニアに転向。HTML/CSS/JavaScriptを中心にWeb開発を担当してきました。 UI・UXに興味があり、デザイン・コーディング両面から考えられるデザインエンジニアを目指しています。 普段はマラソンやボクシングなどで体を動かしてます。