
脆弱性を管理しようとしたら、脆弱性を管理する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...
github.com
crates.io
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 を配置して(上のサンプルなどをコピーしていただいても大丈夫です)、お試しください。このようなグラフィカルな表示も可能です。
もちろん、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...
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
}