Pythonで始めるテストツール製作(5)NVDからCVSSメトリクスを取得しTextileの表形式に整形する

## 1. はじめに
[第一世代のQAエンジニア(7)JIS Q 9005で読み解くQAの役割](https://qiita.com/pbjpkas/items/f97c2c2e9be434ad6d58)[^1]で述べたようにQAエンジニアの社会的価値の取り組みの一つにセキュリティ対応があります。

ハードウェアやソフトウェアが脆弱性を解消したときに複数のCVE IDを開示することがあります。そこで脆弱性情報をWikiなどへ転記する際、CVSSメトリクスを俯瞰しやすいよう、CVSSメトリクスをTextileの表形式に整形して転記できるようにします。

:::note
Textileの代わりにMarkdownに対応する方法は3.4節をご覧ください。
:::

## 2. CVSSメトリクスの取得
NIST（アメリカ国立標準技術研究所）が提供している脆弱性情報のREST API[^2]を利用させていただきます。注意点として「It is also recommended that users "sleep" their scripts for six seconds between requests.[^3]」とあり連続してアクセスする場合は6秒おきにします。

CVSSメトリクスはCVSS v2とCVSS v3の両方が提供されますが、CVEによってどちらもなかったり、どちらか一方だけ、両方ともあるなどまちまちです。

|CVE ID|V2(Primary)|V2(Secondary)|V3(Primary)|V3(Secondary)|
|----|----|----|----|----|
|[CVE-2024-36387](https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2024-36387)|-|-|-|-|
|[CVE-2019-1010218](https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2019-1010218)|✓|-|✓|-|
|[CVE-2024-38475](https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2024-38475)|-|-|-|✓|
|[CVE-2024-38476](https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2024-38476)|-|-|✓|✓|

## 3. テストツール
次のようなテストツールをPythonで作りました。

- CVE IDの指定方法は1)対話形式で手入力、2)ファイルに記述、の両方に対応
- ファイルでCVE IDを指定する場合は次の3つのファイルを使用する
  - cvelist.txt（CVE IDを1行1IDで記述する）
  - cvssMetricV20.txt（CVE IDとcvssMetricV2情報を1行1IDで保存する）
  - cvssMetricV31.txt（CVE IDとcvssMetricV31情報を1行1IDで保存する）
- CVSSメトリクスをTextileの表形式に整形する。
  - CVE IDはNISTのCVE Detailへリンクする
  - metrics情報がない場合：No metricsを記述する
  - cvssMetricV2情報がない場合：No cvssMetricV2を記述する
  - cvssMetricV31情報がない場合：No cvssMetricV31を記述する

### 3.1 ソースコード
動作確認：Python 3.13.0 / Microsoft Windows 11 Pro 23H2
実行方法：py mytools_cvss_metrics.py
追加ライブラリ：[requests](https://pypi.org/project/requests/) Ver. 2.32.3

```python:mytools_cvss_metrics.py
# mytools_cvss_metrics.py by ka's@pbjpkas 2024
# MIT License
#
# references
#   National Vulnerability Database
#     https://nvd.nist.gov/developers/vulnerabilities
#   NVD API Key
#     https://nvd.nist.gov/general/news/API-Key-Announcement
#     **** It is also recommended that users "sleep" their scripts for six seconds between requests. ****
#   NVD公開のREST APIを用いて脆弱性情報を取得する
#     https://qiita.com/riikunn_ryo/items/97e385ed0a78dc28534f
#   PythonでWeb APIを叩いてJSONをパースする
#     https://qiita.com/bow_arrow/items/4dcab3389c892baba1a5
#   Pythonで辞書のキー・値の存在を確認、取得（検索）
#     https://note.nkmk.me/python-dict-in-values-items/
#   Textile Markup Language Documentation
#     https://textile-lang.com/
#
# 動作確認
#   Python 3.13.0 / Microsoft Windows 11 Pro 23H2
#

# py -m pip install requests
import requests
import json
import time

# Textile Table Header
# bS : baseScore
# eS : exploitabilityScore
# iS : impactScore
header_v20 = '|_. CVE ID |_. type |_. vectorString |_. bS |_. baseSeverity |_. eS |_. iS |'
header_v31 = '|_. CVE ID |_. type |_. vectorString |_. bS |_. baseSeverity |_. eS |_. iS |'


def get_cve_detail(cveId):
    url = 'https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=' + cveId
    response = requests.get(url)
    return response


def get_cve_detail_dbg(cveId):
    print(cveId)
    
    response = get_cve_detail(cveId)
    if response.status_code != requests.codes.ok:
        print('RESPONSE is NOT OK', response.status_code)
        return False
    
    jsonData = response.json()
    print(jsonData)
    print('----------')

    if len(jsonData['vulnerabilities'][0]['cve']['metrics']) == 0:
        print('No metrics')
        return False
    else:
        print(jsonData['vulnerabilities'][0]['cve']['descriptions'][0]['value'])
        return True


def notate_cvssMetric_in_Textile_table(cveId):
    cveIdLink = '"' + cveId + '":https://nvd.nist.gov/vuln/detail/' + cveId
    
    response = get_cve_detail(cveId)
    if response.status_code != requests.codes.ok:
        #print('RESPONSE is NOT OK', response.status_code)
        data_v20 = '|' + cveIdLink + '|RESPONSE is NOT OK, ' + str(response.status_code) + '| | | | |'
        data_v31 = '|' + cveIdLink + '|RESPONSE is NOT OK, ' + str(response.status_code) + '| | | | |'
        return data_v20, data_v31
    
    jsonData = response.json()
    if jsonData['vulnerabilities'][0]['cve'].get('metrics') == None:
        #print('No metrics')
        data_v20 = '|' + cveIdLink + '|No metrics| | | | | |'
        data_v31 = '|' + cveIdLink + '|No metrics| | | | | |'
        return data_v20, data_v31
    
    if jsonData['vulnerabilities'][0]['cve']['metrics'].get('cvssMetricV2') == None:
        #print('No cvssMetricV2')
        data_v20 = '|' + cveIdLink + '|No cvssMetricV2| | | | | |'
    else:
        type                    = jsonData['vulnerabilities'][0]['cve']['metrics']['cvssMetricV2'][0]['type']
        vectorString            = jsonData['vulnerabilities'][0]['cve']['metrics']['cvssMetricV2'][0]['cvssData']['vectorString']
        baseScore               = jsonData['vulnerabilities'][0]['cve']['metrics']['cvssMetricV2'][0]['cvssData']['baseScore']
        baseSeverity            = jsonData['vulnerabilities'][0]['cve']['metrics']['cvssMetricV2'][0]['baseSeverity']
        exploitabilityScore     = jsonData['vulnerabilities'][0]['cve']['metrics']['cvssMetricV2'][0]['exploitabilityScore']
        impactScore             = jsonData['vulnerabilities'][0]['cve']['metrics']['cvssMetricV2'][0]['impactScore']
        data_v20 = '|' + cveIdLink +  '|' + type + '|' + vectorString + '|' + str(baseScore) + '|' + baseSeverity + '|' + str(exploitabilityScore) + '|' + str(impactScore) + '|'
    
    if jsonData['vulnerabilities'][0]['cve']['metrics'].get('cvssMetricV31') == None:
        #print('No cvssMetricV31')
        data_v31 = '|' + cveIdLink + '|No cvssMetricV31| | | | | |'
    else:
        type                = jsonData['vulnerabilities'][0]['cve']['metrics']['cvssMetricV31'][0]['type']
        vectorString        = jsonData['vulnerabilities'][0]['cve']['metrics']['cvssMetricV31'][0]['cvssData']['vectorString']
        baseScore           = jsonData['vulnerabilities'][0]['cve']['metrics']['cvssMetricV31'][0]['cvssData']['baseScore']
        baseSeverity        = jsonData['vulnerabilities'][0]['cve']['metrics']['cvssMetricV31'][0]['cvssData']['baseSeverity']
        exploitabilityScore = jsonData['vulnerabilities'][0]['cve']['metrics']['cvssMetricV31'][0]['exploitabilityScore']
        impactScore         = jsonData['vulnerabilities'][0]['cve']['metrics']['cvssMetricV31'][0]['impactScore']
        data_v31 = '|' + cveIdLink +  '|' + type + '|' + vectorString + '|' + str(baseScore) + '|' + baseSeverity + '|' + str(exploitabilityScore) + '|' + str(impactScore) + '|'
    
    return data_v20, data_v31


def notate_cvssMetric_in_Textile_table_from_cvelist_file():
    i_cvelist       = 'cvelist.txt'
    o_cvssMetricV20 = 'cvssMetricV20.txt'
    o_cvssMetricV31 = 'cvssMetricV31.txt'
    
    with open(i_cvelist) as fi:
        line_strip = [line.rstrip() for line in fi.readlines()]
        if len(line_strip) == 0:
            print('NO DATA FOUND')
            return
    
    with open(o_cvssMetricV20, mode='w') as fo_v20:
        with open(o_cvssMetricV31, mode='w') as fo_v31:
            fo_v20.write(header_v20+'\n')
            fo_v31.write(header_v31+'\n')
            for cveid in line_strip:
                data_v20, data_v31 = notate_cvssMetric_in_Textile_table(cveid)
                print(data_v20)
                fo_v20.write(data_v20+'\n')
                print(data_v31)
                fo_v31.write(data_v31+'\n')
                time.sleep(6)


def main():
    while True:
        print('= myTools cvssMetric =')
        print('a: get CVE detail(debug)')
        print('b: notate cvssMetric in Textile Table')
        print('c: notate cvssMetric in Textile Table(from cvelist file)')
        print('x: exit')
        
        s = input('>')
        
        if s == 'a':
            cveId = input('Enter CVE ID(ex:CVE-2019-1010218)>')
            get_cve_detail_dbg(cveId)
            
        if s == 'b':
            cveId = input('Enter CVE ID(ex:CVE-2019-1010218)>')
            data_v20, data_v31 = notate_cvssMetric_in_Textile_table(cveId)
            print(header_v20)
            print(data_v20)
            print(header_v31)
            print(data_v31)
        
        if s == 'c':
            notate_cvssMetric_in_Textile_table_from_cvelist_file()
            
        if s == 'x':
            if __name__ == '__main__':
                print('Bye.')
            return


if __name__ == '__main__':
    main()
```

### 3.2 実行例
あらかじめ次のcvelist.txtを作成します。

```text:cvelist.txt
CVE-2024-36387
CVE-2019-1010218
CVE-2024-38475
CVE-2024-38476
```

Python3で実行し、メニューのcを選びます。

```cmd:実行例
>py mytools_cvss_metrics.py
= myTools cvssMetric =
a: get CVE detail(debug)
b: notate cvssMetric in Textile Table
c: notate cvssMetric in Textile Table(from cvelist file)
x: exit
>c
|"CVE-2024-36387":https://nvd.nist.gov/vuln/detail/CVE-2024-36387|No cvssMetricV2| | | | | |
|"CVE-2024-36387":https://nvd.nist.gov/vuln/detail/CVE-2024-36387|No cvssMetricV31| | | | | |
|"CVE-2019-1010218":https://nvd.nist.gov/vuln/detail/CVE-2019-1010218|Primary|AV:N/AC:L/Au:N/C:N/I:N/A:P|5.0|MEDIUM|10.0|2.9|
|"CVE-2019-1010218":https://nvd.nist.gov/vuln/detail/CVE-2019-1010218|Primary|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H|7.5|HIGH|3.9|3.6|
|"CVE-2024-38475":https://nvd.nist.gov/vuln/detail/CVE-2024-38475|No cvssMetricV2| | | | | |
|"CVE-2024-38475":https://nvd.nist.gov/vuln/detail/CVE-2024-38475|Secondary|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N|9.1|CRITICAL|3.9|5.2|
|"CVE-2024-38476":https://nvd.nist.gov/vuln/detail/CVE-2024-38476|No cvssMetricV2| | | | | |
|"CVE-2024-38476":https://nvd.nist.gov/vuln/detail/CVE-2024-38476|Primary|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H|9.8|CRITICAL|3.9|5.9|
= myTools cvssMetric =
a: get CVE detail(debug)
b: notate cvssMetric in Textile Table
c: notate cvssMetric in Textile Table(from cvelist file)
x: exit
>
```

次のような出力ファイルを得ました。

```text:cvssMetricV20.txtの例
|_. CVE ID |_. type |_. vectorString |_. bS |_. baseSeverity |_. eS |_. iS |
|"CVE-2024-36387":https://nvd.nist.gov/vuln/detail/CVE-2024-36387|No cvssMetricV2| | | | | |
|"CVE-2019-1010218":https://nvd.nist.gov/vuln/detail/CVE-2019-1010218|Primary|AV:N/AC:L/Au:N/C:N/I:N/A:P|5.0|MEDIUM|10.0|2.9|
|"CVE-2024-38475":https://nvd.nist.gov/vuln/detail/CVE-2024-38475|No cvssMetricV2| | | | | |
|"CVE-2024-38476":https://nvd.nist.gov/vuln/detail/CVE-2024-38476|No cvssMetricV2| | | | | |
```

```text:cvssMetricV31.txtの例
|_. CVE ID |_. type |_. vectorString |_. bS |_. baseSeverity |_. eS |_. iS |
|"CVE-2024-36387":https://nvd.nist.gov/vuln/detail/CVE-2024-36387|No cvssMetricV31| | | | | |
|"CVE-2019-1010218":https://nvd.nist.gov/vuln/detail/CVE-2019-1010218|Primary|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H|7.5|HIGH|3.9|3.6|
|"CVE-2024-38475":https://nvd.nist.gov/vuln/detail/CVE-2024-38475|Secondary|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N|9.1|CRITICAL|3.9|5.2|
|"CVE-2024-38476":https://nvd.nist.gov/vuln/detail/CVE-2024-38476|Primary|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H|9.8|CRITICAL|3.9|5.9|
```

### 3.3 Textile表形式の例
cvssMetricV20.txtおよびcvssMetricV31.txtを[Tables / Textile Markup Language Documentation](https://textile-lang.com/doc/tables)へ入力して表示された表を以下に示します。テーブルヘッダのbS、eS、iSはそれぞれ以下の略です。
- bS : baseScore
- eS : exploitabilityScore
- iS : impactScore

![cvssMetricV2-TextileTable-sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/130d26bd-d53e-0316-4d56-47c54a98f80b.png)
![cvssMetricV31-TextileTable-sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221758/bdced0f1-b7ec-988d-c3d1-924126eda11a.png)

### 3.4 Markdown対応
Markdownなどテーブルのセルの区切り文字が "|" のマークアップ言語であれば次の3行を改修して対応できます。
- テーブルのヘッダーの書式：header_v20、header_v31
- CVE Detailのリンクの書式：cveIdLink

Markdown対応の例を以下に示します。

```python
#header_v20 = '|_. CVE ID |_. type |_. vectorString |_. bS |_. baseSeverity |_. eS |_. iS |'
header_v20 = '| CVE ID | type | vectorString | bS | baseSeverity | eS | iS |\n|----|----|----|----|----|----|----|'

#header_v31 = '|_. CVE ID |_. type |_. vectorString |_. bS |_. baseSeverity |_. eS |_. iS |'
header_v31 = '| CVE ID | type | vectorString | bS | baseSeverity | eS | iS |\n|----|----|----|----|----|----|----|'

#    cveIdLink = '"' + cveId + '":https://nvd.nist.gov/vuln/detail/' + cveId
    cveIdLink = '[' + cveId + '](https://nvd.nist.gov/vuln/detail/' + cveId + ')'
```

```text:cvssMetricV31.txtの例
| CVE ID | type | vectorString | bS | baseSeverity | eS | iS |
|----|----|----|----|----|----|----|
|[CVE-2024-36387](https://nvd.nist.gov/vuln/detail/CVE-2024-36387)|No cvssMetricV31| | | | | |
|[CVE-2019-1010218](https://nvd.nist.gov/vuln/detail/CVE-2019-1010218)|Primary|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H|7.5|HIGH|3.9|3.6|
|[CVE-2024-38475](https://nvd.nist.gov/vuln/detail/CVE-2024-38475)|Secondary|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N|9.1|CRITICAL|3.9|5.2|
|[CVE-2024-38476](https://nvd.nist.gov/vuln/detail/CVE-2024-38476)|Primary|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H|9.8|CRITICAL|3.9|5.9|
```
Markdown表示例

| CVE ID | type | vectorString | bS | baseSeverity | eS | iS |
|----|----|----|----|----|----|----|
|[CVE-2024-36387](https://nvd.nist.gov/vuln/detail/CVE-2024-36387)|No cvssMetricV31| | | | | |
|[CVE-2019-1010218](https://nvd.nist.gov/vuln/detail/CVE-2019-1010218)|Primary|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H|7.5|HIGH|3.9|3.6|
|[CVE-2024-38475](https://nvd.nist.gov/vuln/detail/CVE-2024-38475)|Secondary|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N|9.1|CRITICAL|3.9|5.2|
|[CVE-2024-38476](https://nvd.nist.gov/vuln/detail/CVE-2024-38476)|Primary|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H|9.8|CRITICAL|3.9|5.9|

## 4. おわりに
1. 複数のCVEのCVSSメトリクスを表形式にしたことで俯瞰しやすくなったと思います。
2. セキュリティ対応はExcel VBAでJVNのREST APIを叩いて返ってきたXMLを加工する記事（[ルータの脆弱性情報を備品管理のExcelファイルで自動取得する](https://qiita.com/pbjpkas/items/327af9a1afbe703922f2)）を以前書きましたが、今回PythonでNVDのREST APIを叩いて返ってきたJSONを加工してみて、次のような理由で脆弱性情報の収集や加工、分析は思っていたよりもハードルが高くないように思いました[^4]。
    - 脆弱性の情報源がすでに用意されている
    - Excel VBAにしてもPythonにしてもググるとREST APIの叩き方やXML、JSONの加工方法を解説している記事が見つかる
    - 今回作成したテストツールはコメントを除くと140行程度とコンパクト
3. 次のような機能追加は追々試してみたいです。
    - 入力データ（CVE ID）のチェック
      - 今はcvelist.txtが空かどうかのチェックのみ
    - CVSSにPrimaryとSecondaryの両方ある場合にどちらも転記するようにする
      - 今は0番目を決め打ちで転記している

## A. 参考資料
- [Vulnerability APIs](https://nvd.nist.gov/developers/vulnerabilities)
- [NVD API Key](https://nvd.nist.gov/general/news/API-Key-Announcement)
- [NVD公開のREST APIを用いて脆弱性情報を取得する](https://qiita.com/riikunn_ryo/items/97e385ed0a78dc28534f)
- [PythonでWeb APIを叩いてJSONをパースする](https://qiita.com/bow_arrow/items/4dcab3389c892baba1a5)
- [Pythonで辞書のキー・値の存在を確認、取得（検索）](https://note.nkmk.me/python-dict-in-values-items/)
- [Textile Markup Language Documentation](https://textile-lang.com/)

## B. Pythonで始めるテストツール製作
- [Pythonで始めるテストツール製作(1)](https://qiita.com/pbjpkas/items/0b8a32e2b0e126fab8bc)
- [Pythonで始めるテストツール製作(2)オシロスコープの操作](https://qiita.com/pbjpkas/items/be69d2cb34eb7715170a)
- [Pythonで始めるテストツール製作(3)シリアルポートのデータ送受信](https://qiita.com/pbjpkas/items/6c64c199ee3fd2584ae8)
- [Pythonで始めるテストツール製作(4)電源（一次側）のオンオフ（Windows編）](https://qiita.com/pbjpkas/items/8811e64301b057434b28)
- Pythonで始めるテストツール製作(5)NVDからCVSSメトリクスを取得しTextileの表形式に整形する

(1)～(3)をPDF化した「[Pythonで始めるテストツール製作 Menu Based CLI編](https://techbookfest.org/product/5348598382329856)」を技術書典で頒布しています。

[^1]: [第一世代のQAエンジニア](https://techbookfest.org/product/9rxL2BNsWTXAFYBdK18m0G)も併せてご覧ください。
[^2]: [Vulnerability APIs](https://nvd.nist.gov/developers/vulnerabilities)
[^3]: [NVD API Key](https://nvd.nist.gov/general/news/API-Key-Announcement)
[^4]: ダニングクルーガー効果という気がしなくもないですが…