cve-check 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. #!/usr/bin/env python3
  2. # SPDX-License-Identifier: GPL-2.0-or-later
  3. #
  4. # Enriches the input CycloneDX SBOM with vulnerability information from the NVD
  5. # database.
  6. #
  7. # The NVD database is cloned using a mirror of it and the content is compared
  8. # locally.
  9. #
  10. # Example usage:
  11. # $ make show-info | utils/generate-cyclonedx | support/script/cve-check --nvd-path dl/buildroot-nvd/
  12. from collections import defaultdict
  13. from pathlib import Path
  14. from typing import TypedDict
  15. import argparse
  16. import sys
  17. import json
  18. import cve as cvecheck
  19. class Options(TypedDict, total=True):
  20. include_resolved: bool
  21. DESCRIPTION = """
  22. Enriches the input CycloneDX SBOM with vulnerability information from the NVD
  23. database.
  24. The NVD database is cloned using a mirror of it and the content is compared
  25. locally.
  26. """
  27. brpath = Path(__file__).parent.parent.parent
  28. def cve_api_get_lang_from_list(values, lang="en") -> (str | None):
  29. for x in values:
  30. if x.get("lang") == lang:
  31. return x.get("value")
  32. return None
  33. def nvd_cve_weaknesses_to_cdx(weaknesses) -> list[int]:
  34. """
  35. See the CycloneDX specification for 'cwes' [1]
  36. [1] https://cyclonedx.org/docs/1.6/json/#vulnerabilities_items_cwes
  37. """
  38. res = []
  39. for node in weaknesses:
  40. value = cve_api_get_lang_from_list(node.get("description", []))
  41. if value is None:
  42. continue
  43. cwe = value.replace("CWE-", "")
  44. if not cwe.isnumeric():
  45. continue
  46. res.append(int(cwe))
  47. return res
  48. def nvd_cve_cvss_to_cdx(metrics):
  49. """
  50. See the CycloneDX specification for 'ratings' [1]
  51. [1] https://cyclonedx.org/docs/1.6/json/#vulnerabilities_items_ratings
  52. """
  53. KEY_METHOD_DICT = {
  54. "cvssMetricV40": "CVSSv4",
  55. "cvssMetricV31": "CVSSv31",
  56. "cvssMetricV3": "CVSSv3",
  57. "cvssMetricV2": "CVSSv2"
  58. }
  59. res = []
  60. for key, values in metrics.items():
  61. for value in values:
  62. data = value.get("cvssData", {})
  63. res.append({
  64. "method": KEY_METHOD_DICT.get(key, "other"),
  65. **({
  66. "score": data["baseScore"],
  67. } if "baseScore" in data else {}),
  68. **({
  69. "severity": data["baseSeverity"].lower(),
  70. } if "baseSeverity" in data else {}),
  71. **({
  72. "vector": data["vectorString"],
  73. } if "vectorString" in data else {}),
  74. })
  75. return res
  76. def nvd_cve_references_to_cdx(references):
  77. advisories = []
  78. for ref in references:
  79. if not {"url", "tags"}.issubset(ref):
  80. continue
  81. tags = ref["tags"]
  82. if not isinstance(tags, list) or len(tags) == 0:
  83. continue
  84. advisories.append({
  85. "title": next((t for t in tags if "Advisory" not in t), tags[0]),
  86. "url": ref["url"]
  87. })
  88. return advisories
  89. def nvd_cve_to_cdx_vulnerability(nvd_cve):
  90. """
  91. Turns the CVE object fetched from the NVD API into a CycloneDX
  92. vulnerability that fits the spec (see [1]).
  93. [1] https://cyclonedx.org/docs/1.6/json/#vulnerabilities
  94. """
  95. vulnerability = {
  96. "bom-ref": nvd_cve["id"],
  97. "id": nvd_cve["id"],
  98. "description": cve_api_get_lang_from_list(nvd_cve.get("descriptions", [])) or "",
  99. "source": {
  100. "name": "NVD",
  101. "url": "https://nvd.nist.gov/"
  102. },
  103. **({
  104. "published": nvd_cve["published"],
  105. } if "published" in nvd_cve else {}),
  106. **({
  107. "updated": nvd_cve["lastModified"],
  108. } if "lastModified" in nvd_cve else {}),
  109. **({
  110. "cwes": nvd_cve_weaknesses_to_cdx(nvd_cve["weaknesses"]),
  111. } if "weaknesses" in nvd_cve else {}),
  112. **({
  113. "ratings": nvd_cve_cvss_to_cdx(nvd_cve["metrics"]),
  114. } if "metrics" in nvd_cve else {}),
  115. **({
  116. "advisories": nvd_cve_references_to_cdx(nvd_cve["references"]),
  117. } if "references" in nvd_cve else {}),
  118. }
  119. return vulnerability
  120. def vuln_append_or_update_affects_if_exists(vulnerabilities, vulnerability):
  121. """
  122. Append 'vulnerability' passed as argument to the 'vulnerabilities' argument
  123. if an entry with the same 'id' doesn't exist yet.
  124. If the vulnerability already exists, the input reference is added to the
  125. 'affects' list of the existing entry.
  126. Args:
  127. vulnerabilities (list): The vulnerabilities array reference retrieved
  128. from the input CycloneDX SBOM
  129. vulnerability (dict): Vulnerability to add to the 'vulnerabilities' list.
  130. """
  131. # Search if a vulnerability with the same identifier already exists in the
  132. # SBOM vulnerability list.
  133. matching_vuln = next(
  134. (vuln for vuln in vulnerabilities if vuln.get("id") == vulnerability["id"]),
  135. None
  136. )
  137. # bom-ref to the component is passed to the affects of the vulnerability
  138. # passed as argument
  139. bom_ref = next((a["ref"] for a in vulnerability.get("affects", [])), None)
  140. if matching_vuln is not None:
  141. # Remove the affect to not use it while updating matching vuln.
  142. if "affects" in vulnerability:
  143. del vulnerability["affects"]
  144. if matching_vuln.get("analysis") is not None and "analysis" in vulnerability:
  145. # We don't update vulnerabilities that already have an
  146. # 'analysis'.
  147. # Buildroot ignored vulnerabilities will already have
  148. # an analysis and need to remain as such.
  149. del vulnerability["analysis"]
  150. affects = matching_vuln.setdefault("affects", [])
  151. if bom_ref is not None:
  152. ref = next((a["ref"] for a in affects if a["ref"] == bom_ref), None)
  153. if ref is None:
  154. # Add a 'ref' (bom reference) to the component if not
  155. # already present in the 'affects' list.
  156. affects.append({
  157. "ref": bom_ref
  158. })
  159. # Update the metadata of the vulnerability with the one
  160. # downloaded from the database.
  161. matching_vuln.update(vulnerability)
  162. else:
  163. vulnerabilities.append(vulnerability)
  164. def check_package_cve_affects(cve: cvecheck.CVE, cpe_product_pkgs, sbom, opt: Options):
  165. vulnerabilities = sbom.setdefault("vulnerabilities", [])
  166. for product in cve.affected_products:
  167. for comp in cpe_product_pkgs.get(product, []):
  168. cve_status = cve.affects(comp["name"], comp["version"], comp["cpe"])
  169. if cve_status == cve.CVE_UNKNOWN:
  170. continue
  171. if cve_status == cve.CVE_DOESNT_AFFECT and not opt["include_resolved"]:
  172. continue
  173. vulnerability = nvd_cve_to_cdx_vulnerability(cve.nvd_cve)
  174. vulnerability["analysis"] = {
  175. "state": "exploitable" if cve_status == cve.CVE_AFFECTS else "resolved"
  176. }
  177. vulnerability["affects"] = [{
  178. "ref": comp["bom-ref"]
  179. }]
  180. vuln_append_or_update_affects_if_exists(vulnerabilities, vulnerability)
  181. def check_package_cves(nvd_path: Path, sbom, opt: Options):
  182. """
  183. Iterate over every entry of the NVD API mirror. Each vulnerability is
  184. compared to the set of components passed as argument in the 'sbom'.
  185. The vulnerabilities set of that 'sbom' argument is enriched with analysis
  186. of vulnerabilities that match that set of components.
  187. Args:
  188. nvd_path (Path): Path of the mirror of the NVD API.
  189. sbom (dict): Input SBOM containing a set of vulnerabilities that will be enriched.
  190. opt (Options): Options for the analysis.
  191. """
  192. cpe_product_pkgs = defaultdict(list)
  193. for comp in sbom.get("components", []):
  194. if comp.get("cpe") and comp.get("version"):
  195. cpe_product = cvecheck.CPE(comp["cpe"]).product
  196. cpe_product_pkgs[cpe_product].append(comp)
  197. for cve in cvecheck.CVE.read_nvd_dir(nvd_path):
  198. check_package_cve_affects(cve, cpe_product_pkgs, sbom, opt)
  199. def enrich_vulnerabilities(nvd_path: Path, sbom):
  200. """
  201. Iterate over the vulnerabilities present in the 'sbom' passed as arguments
  202. and enrich the vulnerability with content from the NVD API mirror.
  203. Args:
  204. nvd_path (Path): Path of the mirror of the NVD API.
  205. sbom (dict): Input SBOM containing a set of vulnerabilities that will be enriched.
  206. """
  207. vulnerabilities = sbom.setdefault("vulnerabilities", [])
  208. for vuln in vulnerabilities:
  209. vuln_id = vuln.get("id")
  210. if vuln_id is None or not vuln_id.upper().startswith("CVE-"):
  211. continue
  212. cve = cvecheck.CVE.read_nvd_entry(nvd_path, vuln_id)
  213. if cve is None:
  214. print(f"Warning: '{vuln_id}' doesn't exist in NVD database.", file=sys.stderr)
  215. continue
  216. vulnerability = nvd_cve_to_cdx_vulnerability(cve.nvd_cve)
  217. vuln_append_or_update_affects_if_exists(vulnerabilities, vulnerability)
  218. def main():
  219. parser = argparse.ArgumentParser(description=DESCRIPTION)
  220. parser.add_argument("-i", "--in-file", nargs="?", type=argparse.FileType("r"),
  221. default=(None if sys.stdin.isatty() else sys.stdin))
  222. parser.add_argument("-o", "--out-file", nargs="?", type=argparse.FileType("w"),
  223. default=sys.stdout)
  224. parser.add_argument('--nvd-path', dest='nvd_path',
  225. default=brpath / 'dl' / 'buildroot-nvd',
  226. help='Path to the local NVD database',
  227. type=lambda p: Path(p).expanduser().resolve())
  228. parser.add_argument("--enrich-only", default=False, action='store_true',
  229. help="Only update metadata for the vulnerabilities currently present " +
  230. "in the input CycloneDX SBOM. Don't do an analysis.")
  231. parser.add_argument("--include-resolved", default=False, action='store_true',
  232. help="Add vulnerabilities already 'resolved' that don't affect a " +
  233. "component to the output CycloneDX vulnerabilities analysis.")
  234. parser.add_argument("--no-nvd-update", default=False, action='store_true',
  235. help="Doesn't update the NVD database.")
  236. args = parser.parse_args()
  237. if args.in_file is None or args.nvd_path is None:
  238. parser.print_help()
  239. sys.exit(1)
  240. sbom = json.load(args.in_file)
  241. opt = Options(
  242. include_resolved=args.include_resolved,
  243. )
  244. args.nvd_path.mkdir(parents=True, exist_ok=True)
  245. if not args.no_nvd_update:
  246. cvecheck.CVE.download_nvd(args.nvd_path)
  247. if args.enrich_only:
  248. enrich_vulnerabilities(args.nvd_path, sbom)
  249. else:
  250. check_package_cves(args.nvd_path, sbom, opt)
  251. args.out_file.write(json.dumps(sbom, indent=2))
  252. args.out_file.write('\n')
  253. if __name__ == "__main__":
  254. main()