Repository: RedHatProductSecurity/cvelib

Issue:
Add the `x_generator` field.
Recently, I have been working with the CVE Automation Working Group to identify the tool utilized for filing CVEs within the program. This effort aims to ascertain which tools are  most widely used by CNAs so the program can understand if a more formalized support should be given to the projects.

Although cvelib is widely used, it does not pass the `x_generator` field, making it impossible to determine how many CVEs are filed with it using public data. Could you add `x_generator` as a default or optional field?    

The data analysis I performed on tool popularity is here: 
https://github.com/jgamblin/CVEWG/tree/main/AWG/Tools

Discussion:

Solution patch:
diff --git a/README.md b/README.md
index 708ac85..a1dd3ab 100644
--- a/README.md
+++ b/README.md
@@ -117,6 +117,11 @@ Additional options that have an accompanying environment variable include:
   before a request is sent to CVE Services. Truthy values for the environment variable are:
   `1`, `t`, `yes`.
 
+* `CVE_GENERATOR`: override the default value of `cvelib x.y.z` that is injected into the
+  `x_generator` field of every published or updated CVE record. If you'd prefer to omit setting
+  the field entirely, set the value to `-` (dash character; `export CVE_GENERATOR=-`). Existing
+  `x_generator` values are not overwritten.
+
 ### Command Autocompletion
 
 Autocompletion of subcommands is supported for the following shells:
diff --git a/cvelib/cve_api.py b/cvelib/cve_api.py
index b491bac..9ed2d69 100644
--- a/cvelib/cve_api.py
+++ b/cvelib/cve_api.py
@@ -1,4 +1,5 @@
 import json
+import os
 from datetime import datetime
 from enum import Enum
 from pathlib import Path
@@ -8,6 +9,8 @@ from urllib.parse import urljoin
 import requests
 from jsonschema import Draft7Validator
 
+from cvelib import __version__
+
 SCHEMA_DIR = Path(__file__).parent / "schemas"
 
 
@@ -178,10 +181,33 @@ class CveApi:
             cve_json["providerMetadata"] = {"orgId": org_id}
         return cve_json
 
+    @staticmethod
+    def _add_generator(cve_json: dict) -> dict:
+        """Add the x_generator field to the CVE record if defined.
+
+        Determine and inject the value of the x_generator field into all created/updated CVE
+        records to identify this library as the tool that was used to do so. Override this value
+        via the CVE_GENERATOR env var; set this env var to the value of "-" to omit adding this
+        field into the CVE record. Existing x_generator values are not overridden.
+        """
+        generator = os.getenv("CVE_GENERATOR")
+
+        # Skip adding the x_generator field if undesired or already present
+        if generator == "-" or "x_generator" in cve_json:
+            return cve_json
+
+        # If no custom value is specified, use cvelib
+        if generator is None:
+            generator = f"cvelib {__version__}"
+
+        cve_json["x_generator"] = {"engine": generator}
+        return cve_json
+
     def publish(self, cve_id: str, cve_json: dict, validate: bool = True) -> dict:
         """Publish a CVE from a JSON object representing the CNA container data."""
         cve_json = self._extract_cna_container(cve_json)
         cve_json = self._add_provider_metadata(cve_json)
+        cve_json = self._add_generator(cve_json)
         if validate:
             CveRecord.validate(cve_json, CveRecord.Schemas.CNA_PUBLISHED)
 
@@ -194,6 +220,7 @@ class CveApi:
         """Update a published CVE record from a JSON object representing the CNA container data."""
         cve_json = self._extract_cna_container(cve_json)
         cve_json = self._add_provider_metadata(cve_json)
+        cve_json = self._add_generator(cve_json)
         if validate:
             CveRecord.validate(cve_json, CveRecord.Schemas.CNA_PUBLISHED)
 
@@ -206,6 +233,7 @@ class CveApi:
         """Add or update an ADP container from a JSON object representing the ADP container data."""
         cve_json = self._extract_adp_container(cve_json)
         cve_json = self._add_provider_metadata(cve_json)
+        cve_json = self._add_generator(cve_json)
         if validate:
             CveRecord.validate(cve_json, CveRecord.Schemas.ADP)
 
@@ -218,6 +246,7 @@ class CveApi:
         """Reject a CVE from a JSON object representing the CNA container data."""
         cve_json = self._extract_cna_container(cve_json)
         cve_json = self._add_provider_metadata(cve_json)
+        cve_json = self._add_generator(cve_json)
         if validate:
             CveRecord.validate(cve_json, CveRecord.Schemas.CNA_REJECTED)
 
@@ -230,6 +259,7 @@ class CveApi:
         """Update a rejected CVE record from a JSON object representing the CNA container data."""
         cve_json = self._extract_cna_container(cve_json)
         cve_json = self._add_provider_metadata(cve_json)
+        cve_json = self._add_generator(cve_json)
         if validate:
             CveRecord.validate(cve_json, CveRecord.Schemas.CNA_REJECTED)
 


Test patch:
diff --git a/tests/test_cve_api.py b/tests/test_cve_api.py
index 9965f88..51e24eb 100644
--- a/tests/test_cve_api.py
+++ b/tests/test_cve_api.py
@@ -1,9 +1,12 @@
 import json
+import os
 import pickle
 from pathlib import Path
+from unittest import mock
 
 import pytest
 
+from cvelib import __version__
 from cvelib.cve_api import CveApi, CveRecord, CveRecordValidationError
 
 
@@ -53,3 +56,41 @@ def test_cve_record_validation_error_is_picklable():
         # Errors do not survive pickling because they include jsonschema-specific objects that
         # are not picklable.
         assert unpickled_exc.errors is None
+
+
+class TestGeneratorMetadata:
+
+    @pytest.fixture
+    def sample_cve_json(self):
+        with open(Path(__file__).parent / "data/CVEv5_basic-example.json") as record_file:
+            return json.load(record_file)
+
+    def test_add_generator_default(self, sample_cve_json):
+        cve_json = sample_cve_json.copy()
+        result = CveApi._add_generator(cve_json)
+        assert "x_generator" in result
+        assert result["x_generator"]["engine"] == f"cvelib {__version__}"
+
+    def test_add_generator_custom(self, sample_cve_json):
+        custom_generator = "awesome_cve_cli 1.2.3"
+        with mock.patch.dict(os.environ, {"CVE_GENERATOR": custom_generator}):
+            cve_json = sample_cve_json.copy()
+            result = CveApi._add_generator(cve_json)
+            assert "x_generator" in result
+            assert result["x_generator"]["engine"] == custom_generator
+
+    def test_generator_not_added(self, sample_cve_json):
+        with mock.patch.dict(os.environ, {"CVE_GENERATOR": "-"}):
+            cve_json = sample_cve_json.copy()
+            result = CveApi._add_generator(cve_json)
+            assert "x_generator" not in result
+
+    def test_generator_not_overridden(self, sample_cve_json):
+        cve_json = sample_cve_json.copy()
+        original_value = {"engine": "cve_cli 9.8.7"}
+        cve_json["x_generator"] = original_value
+
+        with mock.patch.dict(os.environ, {"CVE_GENERATOR": "should_not_be_used"}):
+            result = CveApi._add_generator(cve_json)
+            assert "x_generator" in result
+            assert result["x_generator"] == original_value