NVD APIが2002年のCVEを返してくる — 日付フィルタ無しの検索は「登録順の先頭」を返す

脆弱性情報サイト cve-watch は、CISA KEV と NVD から High/Critical の CVE を毎日取ってきて、AWS/GCP/Azure/Linux それぞれの対応状況を整理している。

そのデータ取得スクリプトが、ある日から2002年の CVE を「新規候補」として返すようになった。

1
2
3
CVE-2002-0083 | OpenSSH 2.0 through 3.0.2 off-by-one ... | published: 2002-03-15
CVE-2002-0639 | Integer overflow in sshd in OpenSSH ... | published: 2002-07-03
CVE-2002-0671 | Pingtel xpressa SIP-based VoIP phone ... | published: 2002-07-23

24年前の OpenSSH の脆弱性である。今日の新着ではない。

原因はクエリに日付が無かったこと

問題のURLはこうなっていた。

1
https://services.nvd.nist.gov/rest/json/cves/2.0?cvssV3Severity=CRITICAL&resultsPerPage=40

一見すると「CRITICAL な CVE を40件」で正しそうに見える。実際これは正しく動いている。「CRITICAL な CVE の、登録順で先頭40件」を返しているのだ。

NVD のデータベースには CVE-1999 系から現在までが全部入っている。日付で絞らなければ、当然いちばん古いところから返ってくる。「最近のものが返るはずだ」というのは、こちらが勝手に置いた前提でしかなかった。

新規性の判定は、取得後に「既に掲載済みのCVE番号を除く」処理でやっていた。だから一度も掲載していない2002年のCVEは、永遠に「新規」判定を通り続ける。除外リストが増えるほど、さらに古いものが繰り上がってくるという、じわじわ悪化する種類のバグだった。

pubStartDate と pubEndDate はセットで渡す

NVD API 2.0 には公開日で絞るパラメータがある。ここに2つ制約がある。

  1. pubStartDatepubEndDate両方指定しなければならない。片方だけだとエラーになる
  2. 指定できる範囲は最大120日

そして日付は ISO 8601 形式で、ミリ秒を含まない形にする必要がある。Date#toISOString() はミリ秒付きの 2026-07-23T01:30:00.000Z を返すので、そのままでは通らないことがある。

実際に書いた関数がこれだ。

1
2
3
4
5
6
7
8
9
10
11
12
13
function nvdUrl(days) {
const span = Math.min(Math.max(Number(days) || 30, 1), 120);
const end = new Date();
const start = new Date(end.getTime() - span * 24 * 60 * 60 * 1000);
const iso = (d) => d.toISOString().replace(/\.\d{3}Z$/, ".000");
const q = new URLSearchParams({
cvssV3Severity: "CRITICAL",
resultsPerPage: "40",
pubStartDate: iso(start),
pubEndDate: iso(end),
});
return `https://services.nvd.nist.gov/rest/json/cves/2.0?${q}`;
}

Math.min(Math.max(days, 1), 120) で 120日制限に自分で丸めている。API 側のエラーで気づくより、こちらで先に潰しておくほうが運用が静かになる。

.replace(/\.\d{3}Z$/, ".000") の部分は、末尾のミリ秒とタイムゾーン指示子 Z.000 に置き換えている。NVD が受け付ける形式に合わせるための処理だ。

URLSearchParams を使うとエンコードを手で書かずに済む。日付にはコロンが含まれるので、素朴な文字列連結より安全だ。

もうひとつの罠:返ってくる順序は保証されない

日付で絞っても、まだ足りなかった。NVD の既定の並び順は公開日の降順ではない。120日ぶんの CRITICAL を40件取ると、その120日の中での「登録順の先頭」が返る。つまり120日前あたりのものが先に来る。

なので取得後に自分で並べ替える。

1
2
3
4
const items = ((data && data.vulnerabilities) || []).slice();
items.sort((a, b) =>
String((b.cve || {}).published || "").localeCompare(String((a.cve || {}).published || "")),
);

published は ISO 8601 の文字列なので、localeCompare による辞書順比較がそのまま時系列の比較になる。new Date() に変換してから引き算する必要はない。ISO 8601 が辞書順と時系列順を一致させる設計になっているのは、こういうときに効く。

.slice() を挟んでいるのは、元の配列を破壊しないためだ。この関数の外で data を再利用する可能性がある以上、sort() の破壊的な性質は封じておく。

直った結果

修正後、同じコマンドの出力はこうなった。

1
2
3
4
CVE-2026-50137 | NVD | 2026-06-26T21:16:34 | cvss=9.4
CVE-2026-54636 | NVD | 2026-06-26T17:16:34 | cvss=9.0
CVE-2026-48930 | NVD | 2026-06-26T02:16:52 | cvss=9.8
CVE-2025-71338 | NVD | 2026-06-25T22:16:59 | cvss=10.0

2026年のものが、公開日の新しい順に並ぶようになった。

手元で試す

APIキー無しでも叩ける(レート制限は厳しくなる)。直近7日の CRITICAL を見るだけなら、こうなる。

1
2
3
4
5
6
END=$(date -u +%Y-%m-%dT%H:%M:%S.000)
START=$(date -u -v-7d +%Y-%m-%dT%H:%M:%S.000) # GNU date なら: date -u -d '7 days ago' +...

curl -s "https://services.nvd.nist.gov/rest/json/cves/2.0?cvssV3Severity=CRITICAL&resultsPerPage=20&pubStartDate=${START}&pubEndDate=${END}" \
| jq -r '.vulnerabilities[] | "\(.cve.published) \(.cve.id)"' \
| sort -r

sort -r を付けているのは、上に書いたとおり API の返す順序が新しい順ではないからだ。付けずに実行すると、期間内で古いものが先に来るのが確認できる。

教訓

「フィルタを指定しなかったとき、APIは何を返すのか」を確かめないまま使うと、こういうことになる。エラーは出ない。レスポンスは 200 で、形式も正しい。中身だけが期待と違うという、いちばん気づきにくい壊れ方をする。

今回それに気づけたのは、出力に published を含めていたからだ。CVE番号だけを表示していたら、CVE-2002-0083 を見ても「見慣れない番号だな」で流していた可能性が高い。日付を持つデータは、日付を表示する。 デバッグ出力の設計としてはそれだけの話だが、効き目は大きかった。

この修正を入れた cve-watch の実際のデータは cve.autoarticles.net で見られる。KEV 登録済みの脆弱性と、クラウド各社での対応状況を並べている。


NVD APIが2002年のCVEを返してくる — 日付フィルタ無しの検索は「登録順の先頭」を返す
https://blog.hashito.biz/2026/07/23/nvd-api-pubstartdate-filter-old-cve-bug/
著者
hashito
作成日
2026年7月23日
著作権