メインコンテンツへスキップ
ZUNDA株式会社

脆弱性を管理しようとしたら、脆弱性を管理するOSSを作っていた件

皆様、こんにちは。いかがお過ごしでしょうか?ZUNDA ID 開発チームの大石です。
私は毎日脆弱性対応をしたり、セキュリティ関係の対応をしたりするのに大変疲れております。
昨今、悪用性の高い脆弱性が様々な著名なソフトウェアで見つかっており、それらを使わずに開発したり、仕事をしたり、生活したりしている人は少ないのではないでしょうか?もちろん、私も弊社も例外ではありません。ZUNDA ID ももちろんそうです。
元々、弊社では GitHub Enterprise を利用しており、Dependabot alerts などを活用してライブラリの脆弱性を管理していましたが、昨今は脆弱性が発見されるペースが大幅に早まり、悪用までの時間も短くなり、危険度の高い脆弱性も増えてきました。
つまり、週に1回ぐらい Alerts の画面を見に行けばええやん、という温度感ではなくなってきたわけですね。
そしてもちろん、自分たちで書いたコードに脆弱なところがないか、くまなくチェックするのもとても大事ですが、使っているライブラリ、フレームワーク、ミドルウェア、OS…といった、サービスを提供するのに利用しているものすべての脆弱性をしっかり管理することも大事です。

そこで、使っているライブラリなどの脆弱性を毎日 Slack に通知する仕組みを導入した

弊チームでは自作の BOT で、利用しているライブラリなどに脆弱性がないかどうかを毎日チェックしてチャンネルに投稿する仕組みを導入しております。
この仕組みでは、 pnpm audit や dotnet list package --vulnerable --include-transitive などの普段利用しているパッケージマネージャーの標準機能から出力されるパッケージの脆弱性情報に加えて、私が開発した「kagami」というOSSを用いて、Kubernetes の中で利用しているコンポーネント、パッケージマネージャーの管理対象外になっている様々な SDK やランタイム、そしてパッケージマネージャーそのものなどの脆弱性も管理しています。

パッケージマネージャーの管理外にあるものの脆弱性管理が大変だった

前述した通り、パッケージマネージャーで管理されていないものの脆弱性をパッケージマネージャーの機能を用いて取得することはできません。
そこで、各種セキュリティ系の SaaS やイメージスキャニングツールなど様々なものを試しましたが、自分たちでは修正できないベースイメージの脆弱性ばかりがひたすら報告され、肝心の使っているミドルウェアそのものの脆弱性は報告されないといったことも多くありました。ノイズが多く、脆弱性を管理するのにはあまり適していませんでした。
もちろん、それらの脆弱性を管理することも大事ですが、コンテナの中で最も表に出てくるアプリケーションの脆弱性が検知できなければ、重大かつ悪用性の高い脆弱性を見逃す可能性が高くなってしまいます。
私たちがやりたかったのは、使っているものはすべて把握しているのだから、その一覧をYAMLに記述しておき、指定した頻度で脆弱性が公表されていないかをひたすら調べて、機械的に処理しやすい形式で出力してくれるプログラムを BOT と組み合わせて、全自動で脆弱性を調べることでした。
つまり、脆弱性について、管理対象と参照先を定義するだけで継続的に調査できる仕組みです。まさに、このような形で定義して、調べられればと思っていました。
targets:
  - name: k8s.io/ingress-nginx
    version: 1.11.4
    ecosystem: go
    advisory_type: github

  - version: 8.5.9
    advisory_type: github_repository
    repository_path: php/php-src
※ 上記はあくまで例であり、いずれのソフトウェアも弊社では利用しておりません

そんな都合の良いものはなかった

探せる限り探しましたが、なかなか条件に合うものが見つかりませんでした。ガッチリとしたSBOM の定義ファイル(例えば SPDX など)を書かないといけなかったり、はたまた自動でスキャニングする(見逃してしまうものもあるけれども)といったものが多かったです。
どこを見れば脆弱性の情報が載っているのかわかっているので、ただその参照先を羅列できればそれで十分なのに… というむず痒さを感じました。

なかったので作った話

GitHub - zunda-inc/kagami: This simple tool checks whether there are any publicly disclosed vulnerabilities in a list of defined software.
This simple tool checks whether there are any publicly disclosed vulnerabilities...
favicon
github.com
crates.io
favicon
crates.io
というわけで、作らせていただきました。
個人的に、Rust を今後業務で活かす機会があるかもしれないし、ちゃんと書けなきゃなぁ…と思ったりしていたので、Rust の勉強がてら、少々 Claude Code の手も借りながら作ることにしました。テストコードと一部のバージョン正規化処理、諸々のドキュメントについては Claude Code の力を借りて書きましたが、他は大部分、私自身が書いたものです。
GitHub の Advisory はバージョン範囲の書き方が曖昧で、semver 形式で書くこともそうでない形式で書くこともできるため、機械的に判定するにはそれなりに正規化処理を頑張らなければなりません。そこで、著名な OSS をすべて Claude Code に調査させて、パターンを作りました
設定ファイルはこのように書きます。
targets:
  - version: 8.5.9 # 使っているバージョンをここで指定
    advisory_type: github_repository # レポジトリごとのadvisoryを取得
    repository_path: php/php-src # 取得元レポジトリを指定

  - name: github.com/moby/moby # こちらで指定した名前がghsaの検索で使われます
    version: 26.1.4 # 使っているバージョンをここで指定
    ecosystem: go # 絞り込みのためのエコシステムを指定
    advisory_type: github # ghsaから取得する場合はこちらを指定

  - name: k8s.io/kubernetes
    version: 1.29.0
    ecosystem: go
    advisory_type: github

  - name: k8s.io/ingress-nginx
    version: 1.11.4
    ecosystem: go
    advisory_type: github

  - name: github.com/argoproj/argo-cd/v2
    version: 2.14.5
    ecosystem: go
    advisory_type: github

  - version: 1.8.3
    advisory_type: github_repository
    repository_path: envoyproxy/gateway
データソースは GHSA(GitHub Advisory Database)もしくは GitHub のレポジトリごとに存在するAdvisoryで、設定ファイルに記載されたバージョンをもとに判定を行います。
気軽に試してみたい方は podman run -it --rm rust:1.99.1 や docker run -it --rm rust:1.99.1 して cargo install kagami-cli してみてください。環境を汚さずにサクッとDockerで試すことができます。
あとは、 kagami.yml を配置して(上のサンプルなどをコピーしていただいても大丈夫です)、お試しください。
使い捨てのDocker環境で試してみたときの様子
このようなグラフィカルな表示も可能です。
もちろん、JSON や YAML で出力することも可能です。(詳しくはレポジトリの README.md をご覧ください)

Slackに投稿してみた

GitHub Actionsでkagamiを簡単に実行できるよう、専用のActionも用意しました。定期的な脆弱性チェックや、Slackへの通知などに活用できます。
kagami integration with GitHub Actions - GitHub Marketplace
Check whether there are any publicly disclosed vulnerabilities in a list of defi...
favicon
github.com
name: notify-vulnerable-components

on:
  schedule:
    - cron: "0 0 * * *"
      timezone: "Asia/Tokyo"

permissions:
  contents: read

jobs:
  notify-vulnerable-components:
    name: "notify-vulnerable-components"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          persist-credentials: false

      # 今回作成したActionsはこちらになります
      - name: check vulnerable components with kagami
        id: kagami
        uses: zunda-inc/kagami-actions@62da5e0e9ac8bab7c0b47f01fe891276450dc1e3 # v1.2.0
        with:
          output-format: simple-markdown-text

      - name: generate slack message
        id: generate-slack-message
        env:
          KAGAMI_REPORT_FILE: ${{ steps.kagami.outputs.stdout-file }}
        run: |
          {
            echo 'SLACK_MESSAGE<<EOF'
            echo "📦 リポジトリ: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}"
            echo "🪞 kagamiのチェック結果:"
            cat "$KAGAMI_REPORT_FILE"
            echo 'EOF'
          } >> "$GITHUB_OUTPUT"

      - name: notify to slack
        uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: incoming-webhook
          errors: true
          payload: |
            {
                "text": ${{ toJSON(steps.generate-slack-message.outputs.SLACK_MESSAGE) }},
                "unfurl_links": false,
                "unfurl_media": false
            }
こちらは kagami と連携した場合の最小限の例です。
zunda-inc/kagami-actions は自動的にレポジトリ直下の kagami.yml を利用するようになっているので、例示されているコンフィグを書き換えて配置してお試しください。

さいごに

↑私が好きなアイドルに会いに行った時の写真。撮影時、オールナイトのイベント開けで眠かったです。
今回、セキュリティ対応を強化するにあたり、使えるものはうまく組み合わせ、足りないものは自分たちで作ることで解決することができました。
また、すでに自分たちが使っている GitHub Actions を利用することで、新たなインフラストラクチャや管理対象を増やすことなく、コードベースの上で管理を完結させることができました。
皆様も同じような課題でお悩みでしたら、ぜひ参考にしていただければ幸いです。

付録:既存の脆弱性チェックツールと組み合わせてSlackに通知する

ここでは、kagamiと既存のパッケージマネージャーなどを組み合わせ、脆弱性のチェック結果を毎日Slackに通知する構成例を紹介します。
GitHub Actionsで各ツールを定期実行し、それぞれの検査結果をスクリプトで整形して、Slackにまとめて投稿します。
今回は、Node.js(pnpm)と.NETの2つの環境を例に紹介します。雑なスクリプトにはなってしまいますが、このようなもので解決しました。

pnpm と kagami と is-my-node-vulnerable で Slack 通知を構成する場合

.github/scripts/generate-slack-markdown.ts にこちらを配置して…
import fs from 'fs'

type Severity = 'info' | 'low' | 'moderate' | 'high' | 'critical'
type PnpmAuditJson = {
  advisories: {
    [key: string]: {
      findings: Array<{
        version: string
        paths: Array<string>
        dev: boolean
        optional: boolean
        bundled: boolean
      }>
      id: number
      title: string
      module_name: string
      vulnerable_versions: string
      patched_versions: string
      severity: Severity
      cwe: string
      github_advisory_id: string
      url: string
    }
  }
  metadata: {
    vulnerabilities: {
      info: number
      low: number
      moderate: number
      high: number
      critical: number
    }
    dependencies: number
    devDependencies: number
    optionalDependencies: number
    totalDependencies: number
  }
}

function never(t: never): never {
  throw Error(`${t} is Unexpected case.`)
}

function createEmojiSeverity(severity: Severity): string {
  if (severity === 'critical') {
    return '🚨'
  }
  if (severity === 'high') {
    return '🔴'
  }
  if (severity === 'moderate') {
    return '🟠'
  }
  if (severity === 'info' || severity === 'low') {
    return '⚪'
  }

  never(severity)
}

function formatSeverity(severity: Severity) {
  const lower = severity.toLowerCase()
  return String(lower).charAt(0).toUpperCase() + String(lower).slice(1)
}

const targetFilePath = process.argv[2]

if (!targetFilePath) {
  console.error('No target file path given.')
  process.exit(1)
}

const rawJson = await fs.promises.readFile(targetFilePath, 'utf8')
const parsedJSON: PnpmAuditJson = JSON.parse(rawJson)

const vulnerablePackageCount = Object.keys(parsedJSON.advisories).length

if (vulnerablePackageCount === 0) {
  process.stdout.write('✅ 脆弱なパッケージは発見されませんでした。\n')
  process.exit(0)
}

process.stdout.write(`⚠️ 脆弱なパッケージが *${vulnerablePackageCount}件* 発見されました ⚠️\n`)

const vulnerablePackages = Object.keys(parsedJSON.advisories).flatMap((advisoryKey) => {
  const advisory = parsedJSON.advisories[advisoryKey]!
  return advisory.findings.map((findings) => ({
    version: findings.version,
    packageName: advisory.module_name,
    severity: advisory.severity,
    advisoryUrl: advisory.url,
  }))
})

const vulnerablePackagesPacked: {
  packageName: string
  version: string
  vulnerabilities: {
    severity: Severity
    advisoryUrl: string
  }[]
}[] = []

vulnerablePackages.forEach((vPackage) => {
  const existing = vulnerablePackagesPacked.find(
    (v) => v.packageName === vPackage.packageName && v.version == vPackage.version,
  )
  if (existing) {
    existing.vulnerabilities.push({
      severity: vPackage.severity,
      advisoryUrl: vPackage.advisoryUrl,
    })
    return
  }
  vulnerablePackagesPacked.push({
    packageName: vPackage.packageName,
    version: vPackage.version,
    vulnerabilities: [
      {
        severity: vPackage.severity,
        advisoryUrl: vPackage.advisoryUrl,
      },
    ],
  })
})

vulnerablePackagesPacked.forEach((vPackage) => {
  process.stdout.write(`\`${vPackage.packageName}@${vPackage.version}\`\n`)
  vPackage.vulnerabilities.forEach((vulnerability) => {
    process.stdout.write(
      `${createEmojiSeverity(vulnerability.severity)} [${formatSeverity(vulnerability.severity)}] ${vulnerability.advisoryUrl}\n`,
    )
  })
})
name: notify-vulnerable-packages

on:
  schedule:
    - cron: "0 0 * * *"
      timezone: "Asia/Tokyo"

permissions:
  contents: read

jobs:
  notify-vulnerable-packages:
    name: "notify-vulnerable-packages"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          persist-credentials: false

      - uses: jdx/mise-action@fc42bcc7ddbf51124c30f95a877a0c5168a3bfde # v5

      - name: extract vulnerable packages
        continue-on-error: true
        run: pnpm audit --json > vulnerable_package_results.json

      - name: get node.js version
        id: nodejs-version
        run: echo "version=$(node --version)" >> "$GITHUB_OUTPUT"

      - name: check vulnerable node.js version
        id: check-nodejs
        continue-on-error: true
        uses: nodejs/is-my-node-vulnerable@c37a56bad56e34fe5223ddd3cb223cc4158136ae # v1.6.1
        with:
          node-version: ${{ steps.nodejs-version.outputs.version }}
          platform: linux

      - name: check vulnerable tools with kagami
        id: kagami
        uses: zunda-inc/kagami-actions@ab1cca0b0474ce309c46b230a15e4db18abd0814 # v1.0.0
        with:
          output-format: simple-markdown-text

      - name: generate slack message from json
        id: generate-slack-message
        env:
          NODEJS_VERSION: ${{ steps.nodejs-version.outputs.version }}
          NODEJS_CHECK_OUTCOME: ${{ steps.check-nodejs.outcome }}
          KAGAMI_REPORT_FILE: ${{ steps.kagami.outputs.stdout-file }}
        run: |
          {
            echo 'SLACK_MESSAGE<<EOF'
            echo "📦 リポジトリ: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}"
            node .github/scripts/generate-slack-markdown.ts vulnerable_package_results.json
            if [ "$NODEJS_CHECK_OUTCOME" = "success" ]; then
              echo "✅ Node.js ${NODEJS_VERSION} に既知の脆弱性は発見されませんでした。"
            else
              echo "⚠️ Node.js ${NODEJS_VERSION} は脆弱なバージョンと判定されました ⚠️"
              echo "実行ログ: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
            fi
            echo "🪞 kagamiのチェック結果:"
            cat "$KAGAMI_REPORT_FILE"
            echo 'EOF'
          } >> "$GITHUB_OUTPUT"

      - name: notify to slack
        uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: incoming-webhook
          errors: true
          payload: |
            {
                "text": ${{ toJSON(steps.generate-slack-message.outputs.SLACK_MESSAGE) }},
                "unfurl_links": false,
                "unfurl_media": false
            }

dotnet と kagami で Slack 通知を構成する場合

.github/scripts/generate-slack-markdown.cs にこちらを配置して…
using System.Text.Json;
using System.Text.Json.Serialization;

if (args.Length < 1)
{
    await Console.Error.WriteLineAsync("You must specify one file path to the json.");
    Environment.Exit(1);
}

var filePath = args[0];

if (!File.Exists(filePath))
{
    await Console.Error.WriteLineAsync("File not found: " + filePath);
    Environment.Exit(1);
}

var json = await File.ReadAllTextAsync(filePath);
var document =
    JsonSerializer.Deserialize(
        json,
        new AppJsonSerializerContext(new JsonSerializerOptions(JsonSerializerDefaults.Web)).Root
    ) ??
    throw new JsonException("Failed to parse json.");

var foundAllTopLevelVulnerablePackages = document.Projects
    .SelectMany(v => v.Frameworks?.Select(f => f.TopLevelPackages ?? []) ?? [])
    .SelectMany(v => v)
    .ToArray();
var foundAllTransitiveVulnerablePackages = document.Projects
    .SelectMany(v => v.Frameworks?.Select(f => f.TransitivePackages ?? []) ?? [])
    .SelectMany(v => v)
    .ToArray();
var foundAllVulnerablePackages = foundAllTopLevelVulnerablePackages
    .Concat(foundAllTransitiveVulnerablePackages)
    .DistinctBy(v => new { v.Id, v.ResolvedVersion })
    .ToArray();

if (foundAllVulnerablePackages.Length == 0)
{
    await Console.Out.WriteLineAsync("✅ 脆弱なパッケージは発見されませんでした。");
    return;
}

await Console.Out.WriteLineAsync($"⚠️ 脆弱なパッケージが *{foundAllVulnerablePackages.Length}件* 発見されました ⚠️");
foreach (var package in foundAllVulnerablePackages)
{
    await Console.Out.WriteLineAsync($"`{package.Id}@{package.ResolvedVersion}`");
    foreach (var vulnerability in package.Vulnerabilities)
    {
        var severityEmoji = vulnerability.Severity switch
        {
            Severity.Critical => "🚨",
            Severity.High => "🔴",
            Severity.Moderate => "🟠",
            Severity.Low => "⚪",
            _ => throw new ArgumentOutOfRangeException()
        };
        await Console.Out.WriteLineAsync($"{severityEmoji} [{vulnerability.Severity}] {vulnerability.Advisoryurl}");
    }
}

[JsonConverter(typeof(JsonStringEnumConverter<Severity>))]
public enum Severity
{
    Critical,
    High,
    Moderate,
    Low
}

public record Root(int Version, string Parameters, IReadOnlyList<string> Sources, IReadOnlyList<Projects> Projects);

public record Vulnerabilities(Severity Severity, string Advisoryurl);

public record Packages(string Id, string ResolvedVersion, IReadOnlyList<Vulnerabilities> Vulnerabilities);

public record Frameworks(
    string Framework,
    IReadOnlyList<Packages>? TransitivePackages,
    IReadOnlyList<Packages>? TopLevelPackages
);

public record Projects(string Path, IReadOnlyList<Frameworks>? Frameworks);

[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(Root))]
[JsonSerializable(typeof(Vulnerabilities))]
[JsonSerializable(typeof(Packages))]
[JsonSerializable(typeof(Frameworks))]
[JsonSerializable(typeof(Projects))]
[JsonSerializable(typeof(Severity))]
public partial class AppJsonSerializerContext : JsonSerializerContext;
name: notify-vulnerable-packages

on:
    schedule:
        -   cron: "0 0 * * *"
            timezone: "Asia/Tokyo"

permissions:
    contents: read

jobs:
    notify-vulnerable-packages:
        name: "notify-vulnerable-packages"
        runs-on: ubuntu-latest
        steps:
            -   uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
                with:
                    persist-credentials: false

            -   name: Setup .NET SDK
                uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
                with:
                    global-json-file: global.json
                    cache: true
                    cache-dependency-path: TestProject/packages.lock.json

            -   name: extract vulnerable packages
                run: dotnet list package --vulnerable --include-transitive --format JSON > vulnerable_package_results.json

            -   name: check vulnerable .NET with kagami
                id: kagami
                uses: zunda-inc/kagami-actions@9a40b0a19601c649b0b03b48ce85807d7538e278 # v1.1.0
                with:
                    output-format: simple-markdown-text

            -   name: generate slack message from json
                id: generate-slack-message
                env:
                    KAGAMI_REPORT_FILE: ${{ steps.kagami.outputs.stdout-file }}
                run: |
                    {
                        echo 'SLACK_MESSAGE<<EOF'
                        echo "📦 リポジトリ: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}"
                        dotnet .github/scripts/generate-slack-markdown.cs -- vulnerable_package_results.json
                        echo "🪞 kagamiのチェック結果:"
                        cat "$KAGAMI_REPORT_FILE"
                        echo 'EOF'
                    } >> "$GITHUB_OUTPUT"

            -   name: notify to slack
                uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
                with:
                    webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
                    webhook-type: incoming-webhook
                    errors: true
                    payload: |
                        {
                            "text": ${{ toJSON(steps.generate-slack-message.outputs.SLACK_MESSAGE) }},
                            "unfurl_links": false,
                            "unfurl_media": false
                        }