improve_kernel_cve_report.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. #! /usr/bin/env python3
  2. #
  3. # Copyright OpenEmbedded Contributors
  4. #
  5. # The script uses another source of CVE information from linux-vulns
  6. # to enrich the cve-summary from cve-check or vex.
  7. # It can also use the list of compiled files from the kernel spdx to ignore CVEs
  8. # that are not affected since the files are not compiled.
  9. #
  10. # It creates a new json file with updated CVE information
  11. #
  12. # Compiled files can be extracted adding the following in local.conf
  13. # SPDX_INCLUDE_COMPILED_SOURCES:pn-linux-yocto = "1"
  14. #
  15. # Tested with the following CVE sources:
  16. # - https://git.kernel.org/pub/scm/linux/security/vulns.git
  17. # - https://github.com/CVEProject/cvelistV5
  18. #
  19. # Example:
  20. # python3 ./openembedded-core/scripts/contrib/improve_kernel_cve_report.py --spdx tmp/deploy/spdx/3.0.1/qemux86_64/recipes/recipe-linux-yocto.spdx.json --kernel-version 6.12.27 --datadir ./vulns
  21. # python3 ./openembedded-core/scripts/contrib/improve_kernel_cve_report.py --spdx tmp/deploy/spdx/3.0.1/qemux86_64/recipes/recipe-linux-yocto.spdx.json --datadir ./vulns --old-cve-report build/tmp/log/cve/cve-summary.json
  22. #
  23. # SPDX-License-Identifier: GPLv2
  24. import argparse
  25. import json
  26. import sys
  27. import logging
  28. import glob
  29. import os
  30. import pathlib
  31. from packaging.version import Version
  32. def is_linux_cve(cve_info):
  33. '''Return true is the CVE belongs to Linux'''
  34. if not "affected" in cve_info["containers"]["cna"]:
  35. return False
  36. for affected in cve_info["containers"]["cna"]["affected"]:
  37. if not "product" in affected:
  38. return False
  39. if affected["product"] == "Linux" and affected["vendor"] == "Linux":
  40. return True
  41. return False
  42. def get_kernel_cves(datadir, compiled_files, version):
  43. """
  44. Get CVEs for the kernel
  45. """
  46. cves = {}
  47. check_config = len(compiled_files) > 0
  48. base_version = Version(f"{version.major}.{version.minor}")
  49. # Check all CVES from kernel vulns
  50. pattern = os.path.join(datadir, '**', "CVE-*.json")
  51. cve_files = glob.glob(pattern, recursive=True)
  52. not_applicable_config = 0
  53. fixed_as_later_backport = 0
  54. vulnerable = 0
  55. not_vulnerable = 0
  56. for cve_file in sorted(cve_files):
  57. cve_info = {}
  58. with open(cve_file, "r", encoding='ISO-8859-1') as f:
  59. cve_info = json.load(f)
  60. if len(cve_info) == 0:
  61. logging.error("Not valid data in %s. Aborting", cve_file)
  62. break
  63. if not is_linux_cve(cve_info):
  64. continue
  65. cve_id = os.path.basename(cve_file)[:-5]
  66. description = cve_info["containers"]["cna"]["descriptions"][0]["value"]
  67. if cve_file.find("rejected") >= 0:
  68. logging.debug("%s is rejected by the CNA", cve_id)
  69. cves[cve_id] = {
  70. "id": cve_id,
  71. "status": "Ignored",
  72. "detail": "rejected",
  73. "summary": description,
  74. "description": f"Rejected by CNA"
  75. }
  76. continue
  77. if any(elem in cve_file for elem in ["review", "reverved", "testing"]):
  78. continue
  79. is_vulnerable, first_affected, last_affected, better_match_first, better_match_last, affected_versions = get_cpe_applicability(cve_info, version)
  80. logging.debug("%s: %s (%s - %s) (%s - %s)", cve_id, is_vulnerable, better_match_first, better_match_last, first_affected, last_affected)
  81. if is_vulnerable is None:
  82. logging.warning("%s doesn't have good metadata", cve_id)
  83. if is_vulnerable:
  84. is_affected = True
  85. affected_files = []
  86. if check_config:
  87. is_affected, affected_files = check_kernel_compiled_files(compiled_files, cve_info)
  88. if not is_affected and len(affected_files) > 0:
  89. logging.debug(
  90. "%s - not applicable configuration since affected files not compiled: %s",
  91. cve_id, affected_files)
  92. cves[cve_id] = {
  93. "id": cve_id,
  94. "status": "Ignored",
  95. "detail": "not-applicable-config",
  96. "summary": description,
  97. "description": f"Source code not compiled by config. {affected_files}"
  98. }
  99. not_applicable_config +=1
  100. # Check if we have backport
  101. else:
  102. if not better_match_last:
  103. fixed_in = last_affected
  104. else:
  105. fixed_in = better_match_last
  106. logging.debug("%s needs backporting (fixed from %s)", cve_id, fixed_in)
  107. cves[cve_id] = {
  108. "id": cve_id,
  109. "status": "Unpatched",
  110. "detail": "version-in-range",
  111. "summary": description,
  112. "description": f"Needs backporting (fixed from {fixed_in})"
  113. }
  114. vulnerable += 1
  115. if (better_match_last and
  116. Version(f"{better_match_last.major}.{better_match_last.minor}") == base_version):
  117. fixed_as_later_backport += 1
  118. # Not vulnerable
  119. else:
  120. if not first_affected:
  121. logging.debug("%s - not known affected %s",
  122. cve_id,
  123. better_match_last)
  124. cves[cve_id] = {
  125. "id": cve_id,
  126. "status": "Patched",
  127. "detail": "version-not-in-range",
  128. "summary": description,
  129. "description": "No CPE match"
  130. }
  131. not_vulnerable += 1
  132. continue
  133. backport_base = Version(f"{better_match_last.major}.{better_match_last.minor}")
  134. if version < first_affected:
  135. logging.debug('%s - fixed-version: only affects %s onwards',
  136. cve_id,
  137. first_affected)
  138. cves[cve_id] = {
  139. "id": cve_id,
  140. "status": "Patched",
  141. "detail": "fixed-version",
  142. "summary": description,
  143. "description": f"only affects {first_affected} onwards"
  144. }
  145. not_vulnerable += 1
  146. elif last_affected <= version:
  147. logging.debug("%s - fixed-version: Fixed from version %s",
  148. cve_id,
  149. last_affected)
  150. cves[cve_id] = {
  151. "id": cve_id,
  152. "status": "Patched",
  153. "detail": "fixed-version",
  154. "summary": description,
  155. "description": f"fixed-version: Fixed from version {last_affected}"
  156. }
  157. not_vulnerable += 1
  158. elif backport_base == base_version:
  159. logging.debug("%s - cpe-stable-backport: Backported in %s",
  160. cve_id,
  161. better_match_last)
  162. cves[cve_id] = {
  163. "id": cve_id,
  164. "status": "Patched",
  165. "detail": "cpe-stable-backport",
  166. "summary": description,
  167. "description": f"Backported in {better_match_last}"
  168. }
  169. not_vulnerable += 1
  170. else:
  171. logging.debug("%s - version not affected %s", cve_id, str(affected_versions))
  172. cves[cve_id] = {
  173. "id": cve_id,
  174. "status": "Patched",
  175. "detail": "version-not-in-range",
  176. "summary": description,
  177. "description": f"Range {affected_versions}"
  178. }
  179. not_vulnerable += 1
  180. logging.info("Total CVEs ignored due to not applicable config: %d", not_applicable_config)
  181. logging.info("Total CVEs not vulnerable due version-not-in-range: %d", not_vulnerable)
  182. logging.info("Total vulnerable CVEs: %d", vulnerable)
  183. logging.info("Total CVEs already backported in %s: %s", base_version,
  184. fixed_as_later_backport)
  185. return cves
  186. def read_spdx(spdx_file):
  187. '''Open SPDX file and extract compiled files'''
  188. with open(spdx_file, 'r', encoding='ISO-8859-1') as f:
  189. spdx = json.load(f)
  190. if "spdxVersion" in spdx:
  191. if spdx["spdxVersion"] == "SPDX-2.2":
  192. return read_spdx2(spdx)
  193. if "@graph" in spdx:
  194. return read_spdx3(spdx)
  195. return []
  196. def read_spdx2(spdx):
  197. '''
  198. Read spdx2 compiled files from spdx
  199. '''
  200. cfiles = []
  201. if 'files' not in spdx:
  202. return cfiles
  203. for item in spdx['files']:
  204. for ftype in item['fileTypes']:
  205. if ftype == "SOURCE":
  206. filename = item["fileName"][item["fileName"].find("/")+1:]
  207. cfiles.append(filename)
  208. return cfiles
  209. def read_spdx3(spdx):
  210. '''
  211. Read spdx3 compiled files from spdx
  212. '''
  213. cfiles = []
  214. for item in spdx["@graph"]:
  215. if "software_primaryPurpose" not in item:
  216. continue
  217. if item["software_primaryPurpose"] == "source":
  218. filename = item['name'][item['name'].find("/")+1:]
  219. cfiles.append(filename)
  220. return cfiles
  221. def check_kernel_compiled_files(compiled_files, cve_info):
  222. """
  223. Return if a CVE affected us depending on compiled files
  224. """
  225. files_affected = []
  226. is_affected = False
  227. for item in cve_info['containers']['cna']['affected']:
  228. if "programFiles" in item:
  229. for f in item['programFiles']:
  230. if f not in files_affected:
  231. files_affected.append(f)
  232. if len(files_affected) > 0:
  233. for f in files_affected:
  234. if f in compiled_files:
  235. logging.debug("File match: %s", f)
  236. is_affected = True
  237. return is_affected, files_affected
  238. def get_cpe_applicability(cve_info, v):
  239. '''
  240. Check if version is affected and return affected versions
  241. '''
  242. base_branch = Version(f"{v.major}.{v.minor}")
  243. affected = []
  244. if not 'cpeApplicability' in cve_info["containers"]["cna"]:
  245. return None, None, None, None, None, None
  246. for nodes in cve_info["containers"]["cna"]["cpeApplicability"]:
  247. for node in nodes.values():
  248. vulnerable = False
  249. matched_branch = False
  250. first_affected = Version("5000")
  251. last_affected = Version("0")
  252. better_match_first = Version("0")
  253. better_match_last = Version("5000")
  254. if len(node[0]['cpeMatch']) == 0:
  255. first_affected = None
  256. last_affected = None
  257. better_match_first = None
  258. better_match_last = None
  259. for cpe_match in node[0]['cpeMatch']:
  260. version_start_including = Version("0")
  261. version_end_excluding = Version("0")
  262. if 'versionStartIncluding' in cpe_match:
  263. version_start_including = Version(cpe_match['versionStartIncluding'])
  264. else:
  265. version_start_including = Version("0")
  266. # if versionEndExcluding is missing we are in a branch, which is not fixed.
  267. if "versionEndExcluding" in cpe_match:
  268. version_end_excluding = Version(cpe_match["versionEndExcluding"])
  269. else:
  270. # if versionEndExcluding is missing we are in a branch, which is not fixed.
  271. version_end_excluding = Version(
  272. f"{version_start_including.major}.{version_start_including.minor}.5000"
  273. )
  274. affected.append(f" {version_start_including}-{version_end_excluding}")
  275. # Detect if versionEnd is in fixed in base branch. It has precedence over the rest
  276. branch_end = Version(f"{version_end_excluding.major}.{version_end_excluding.minor}")
  277. if branch_end == base_branch:
  278. if version_start_including <= v < version_end_excluding:
  279. vulnerable = cpe_match['vulnerable']
  280. # If we don't match in our branch, we are not vulnerable,
  281. # since we have a backport
  282. matched_branch = True
  283. better_match_first = version_start_including
  284. better_match_last = version_end_excluding
  285. if version_start_including <= v < version_end_excluding and not matched_branch:
  286. if version_end_excluding < better_match_last:
  287. better_match_first = max(version_start_including, better_match_first)
  288. better_match_last = min(better_match_last, version_end_excluding)
  289. vulnerable = cpe_match['vulnerable']
  290. matched_branch = True
  291. first_affected = min(version_start_including, first_affected)
  292. last_affected = max(version_end_excluding, last_affected)
  293. # Not a better match, we use the first and last affected instead of the fake .5000
  294. if vulnerable and better_match_last == Version(f"{base_branch}.5000"):
  295. better_match_last = last_affected
  296. better_match_first = first_affected
  297. return vulnerable, first_affected, last_affected, better_match_first, better_match_last, affected
  298. def copy_data(old, new):
  299. '''Update dictionary with new entries, while keeping the old ones'''
  300. for k in new.keys():
  301. old[k] = new[k]
  302. return old
  303. # Function taken from cve_check.bbclass. Adapted to cve fields
  304. def cve_update(cve_data, cve, entry):
  305. # If no entry, just add it
  306. if cve not in cve_data:
  307. cve_data[cve] = entry
  308. return
  309. # If we are updating, there might be change in the status
  310. if cve_data[cve]['status'] == "Unknown":
  311. cve_data[cve] = copy_data(cve_data[cve], entry)
  312. return
  313. if cve_data[cve]['status'] == entry['status']:
  314. return
  315. if entry['status'] == "Unpatched" and cve_data[cve]['status'] == "Patched":
  316. logging.warning("CVE entry %s update from Patched to Unpatched from the scan result", cve)
  317. cve_data[cve] = copy_data(cve_data[cve], entry)
  318. return
  319. if entry['status'] == "Patched" and cve_data[cve]['status'] == "Unpatched":
  320. logging.warning("CVE entry %s update from Unpatched to Patched from the scan result", cve)
  321. cve_data[cve] = copy_data(cve_data[cve], entry)
  322. return
  323. # If we have an "Ignored", it has a priority
  324. if cve_data[cve]['status'] == "Ignored":
  325. logging.debug("CVE %s not updating because Ignored", cve)
  326. return
  327. # If we have an "Ignored", it has a priority
  328. if entry['status'] == "Ignored":
  329. cve_data[cve] = copy_data(cve_data[cve], entry)
  330. logging.debug("CVE entry %s updated from Unpatched to Ignored", cve)
  331. return
  332. logging.warning("Unhandled CVE entry update for %s %s from %s %s to %s",
  333. cve, cve_data[cve]['status'], cve_data[cve]['detail'], entry['status'], entry['detail'])
  334. def main():
  335. parser = argparse.ArgumentParser(
  336. description="Update cve-summary with kernel compiled files and kernel CVE information"
  337. )
  338. parser.add_argument(
  339. "-s",
  340. "--spdx",
  341. help="SPDX2/3 for the kernel. Needs to include compiled sources",
  342. )
  343. parser.add_argument(
  344. "--datadir",
  345. type=pathlib.Path,
  346. help="Directory where CVE data is",
  347. required=True
  348. )
  349. parser.add_argument(
  350. "--old-cve-report",
  351. help="CVE report to update. (Optional)",
  352. )
  353. parser.add_argument(
  354. "--kernel-version",
  355. help="Kernel version. Needed if old cve_report is not provided (Optional)",
  356. type=Version
  357. )
  358. parser.add_argument(
  359. "--new-cve-report",
  360. help="Output file",
  361. default="cve-summary-enhance.json"
  362. )
  363. parser.add_argument(
  364. "-D",
  365. "--debug",
  366. help='Enable debug ',
  367. action="store_true")
  368. args = parser.parse_args()
  369. if args.debug:
  370. log_level=logging.DEBUG
  371. else:
  372. log_level=logging.INFO
  373. logging.basicConfig(format='[%(filename)s:%(lineno)d] %(message)s', level=log_level)
  374. if not args.kernel_version and not args.old_cve_report:
  375. parser.error("either --kernel-version or --old-cve-report are needed")
  376. return -1
  377. # by default we don't check the compiled files, unless provided
  378. compiled_files = []
  379. if args.spdx:
  380. compiled_files = read_spdx(args.spdx)
  381. logging.info("Total compiled files %d", len(compiled_files))
  382. if args.old_cve_report:
  383. with open(args.old_cve_report, encoding='ISO-8859-1') as f:
  384. cve_report = json.load(f)
  385. else:
  386. #If summary not provided, we create one
  387. cve_report = {
  388. "version": "1",
  389. "package": [
  390. {
  391. "name": "linux-yocto",
  392. "version": str(args.kernel_version),
  393. "products": [
  394. {
  395. "product": "linux_kernel",
  396. "cvesInRecord": "Yes"
  397. }
  398. ],
  399. "issue": []
  400. }
  401. ]
  402. }
  403. for pkg in cve_report['package']:
  404. is_kernel = False
  405. for product in pkg['products']:
  406. if product['product'] == "linux_kernel":
  407. is_kernel=True
  408. if not is_kernel:
  409. continue
  410. kernel_cves = get_kernel_cves(args.datadir,
  411. compiled_files,
  412. Version(pkg["version"]))
  413. logging.info("Total kernel cves from kernel CNA: %s", len(kernel_cves))
  414. cves = {issue["id"]: issue for issue in pkg["issue"]}
  415. logging.info("Total kernel before processing cves: %s", len(cves))
  416. for cve in kernel_cves:
  417. cve_update(cves, cve, kernel_cves[cve])
  418. pkg["issue"] = []
  419. for cve in sorted(cves):
  420. pkg["issue"].extend([cves[cve]])
  421. logging.info("Total kernel cves after processing: %s", len(pkg['issue']))
  422. with open(args.new_cve_report, "w", encoding='ISO-8859-1') as f:
  423. json.dump(cve_report, f, indent=2)
  424. return 0
  425. if __name__ == "__main__":
  426. sys.exit(main())