;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2015-2021 Ludovic Courtès <ludo@gnu.org>
;;; Copyright © 2025 Nicolas Graves <ngraves@ngraves.fr>
;;;
;;; This file is part of GNU Guix.
;;;
;;; GNU Guix is free software; you can redistribute it and/or modify it
;;; under the terms of the GNU General Public License as published by
;;; the Free Software Foundation; either version 3 of the License, or (at
;;; your option) any later version.
;;;
;;; GNU Guix is distributed in the hope that it will be useful, but
;;; WITHOUT ANY WARRANTY; without even the implied warranty of
;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;;; GNU General Public License for more details.
;;;
;;; You should have received a copy of the GNU General Public License
;;; along with GNU Guix. If not, see <http://www.gnu.org/licenses/>.
(define-module (guix cve)
#:use-module (guix utils)
#:use-module (guix http-client)
#:use-module (guix i18n)
#:use-module ((guix diagnostics) #:select (formatted-message))
#:use-module (json)
#:use-module (web uri)
#:use-module (srfi srfi-1)
#:use-module (srfi srfi-9)
#:use-module (srfi srfi-19)
#:use-module (srfi srfi-26)
#:use-module (srfi srfi-34)
#:use-module (srfi srfi-35)
#:use-module (srfi srfi-71)
#:use-module (ice-9 match)
#:use-module (ice-9 regex)
#:use-module (ice-9 vlist)
#:export (json->cve-items
cve-item?
cve-item-id
cve-item-configurations
cve-item-published-date
cve-item-last-modified-date
cve-reference?
cve-reference-url
cve-reference-tags
vulnerability?
vulnerability-id
vulnerability-packages
json->vulnerabilities
current-vulnerabilities
vulnerabilities->lookup-proc))
;;; Commentary:
;;;
;;; This module provides the tools to fetch, parse, and digest part of the
;;; Common Vulnerabilities and Exposures (CVE) feeds provided by the US NIST
;;; at <https://nvd.nist.gov/vuln/data-feeds>.
;;;
;;; Code:
(define (string->date* str)
(string->date str "~Y-~m-~dT~H:~M:~S"))
(define-json-mapping <cve-item> cve-item cve-item?
json->cve-item
(id cve-item-id "id") ;string
(configurations cve-item-configurations ;list of sexps
"configurations" configuration-data->cve-configurations)
(published-date cve-item-published-date
"published" string->date*)
(last-modified-date cve-item-last-modified-date
"lastModified" string->date*))
(define-json-mapping <cve-reference> cve-reference cve-reference?
json->cve-reference
(url cve-reference-url) ;string
(tags cve-reference-tags ;list of strings
"tags" vector->list))
(define %cpe-package-rx
;; For applications: "cpe:2.3:a:VENDOR:PACKAGE:VERSION", or sometimes
;; "cpe:2.3:a:VENDOR:PACKAGE:VERSION:PATCH-LEVEL".
(make-regexp "^cpe:2\\.3:a:([^:]+):([^:]+):([^:]+):([^:]+):"))
(define (cpe->package-identifier cpe)
"Converts the Common Platform Enumeration (CPE) string CPE to a package
identifier, in a very naive way. Return three values: the CPE vendor, the
package name, and its version string.
Return three #f values if CPE does not look like an application CPE string."
(cond ((regexp-exec %cpe-package-rx cpe)
=>
(lambda (matches)
(values (match:substring matches 1)
(match:substring matches 2)
(match (match:substring matches 3)
("*" '_)
(version
(string-append version
(match (match:substring matches 4)
("" "")
(patch-level
;; Drop the colon from things like
;; "cpe:2.3:a:openbsd:openssh:6.8:p1".
(string-drop patch-level 1)))))))))
(else
(values #f #f #f))))
(define (cpe-match->cve-configuration alist)
"Convert ALIST, a \"cpeMatch\" alist, into an sexp representing the package
and versions matched. Return #f if ALIST doesn't correspond to an application
package."
(let* ((cpe (assoc-ref alist "criteria"))
(vendor package version (cpe->package-identifier cpe))
(starti (assoc-ref alist "versionStartIncluding"))
(starte (assoc-ref alist "versionStartExcluding"))
(endi (assoc-ref alist "versionEndIncluding"))
(ende (assoc-ref alist "versionEndExcluding")))
(and package
`(,vendor
,package
,(cond ((and (or starti starte) (or endi ende))
`(and ,(if starti `(>= ,starti) `(> ,starte))
,(if endi `(<= ,endi) `(< ,ende))))
(starti `(>= ,starti))
(starte `(> ,starte))
(endi `(<= ,endi))
(ende `(< ,ende))
(else version))))))
(define (configuration-data->cve-configurations vector)
"Given ALIST, a JSON dictionary for the baroque \"configurations\"
element found in CVEs, return an sexp such as (\"binutils\" (<
\"2.31\")) that represents matching configurations."
(define string->operator
(match-lambda
("OR" 'or)
("AND" 'and)))
(define (maybe-vector->alist vector)
(vector->list (or (and (unspecified? vector) #()) vector #())))
(define (node->configuration node)
(let ((operator (string->operator (assoc-ref node "operator"))))
(cond
((assoc-ref node "cpeMatch")
=>
(lambda (matches)
(let ((matches (vector->list matches)))
(match (filter-map cpe-match->cve-configuration
matches)
(() #f)
((one) one)
(lst (cons operator lst))))))
((assoc-ref node "children") ;typically for 'and'
=>
(lambda (children)
(match (filter-map node->configuration (vector->list children))
(() #f)
((one) one)
(lst (cons operator lst)))))
(else
#f))))
(let* ((alist (maybe-vector->alist vector))
(nodes (if (null? alist)
'()
(maybe-vector->alist (assoc-ref (car alist) "nodes")))))
(filter-map node->configuration nodes)))
(define (json->cve-items json)
"Parse JSON, an input port or a string, and return a list of <cve-item>
records."
(let ((alist (json->scm json)))
(match (assoc-ref alist "format")
("NVD_CVE"
#t)
(format
(raise (formatted-message (G_ "unsupported CVE format: '~a'")
format))))
(match (assoc-ref alist "version")
("2.0"
#t)
(version
(raise (formatted-message (G_ "unsupported CVE data version: '~a'")
version))))
(map (compose json->cve-item (cut assoc-ref <> "cve"))
(vector->list (assoc-ref alist "vulnerabilities")))))
(define (version-matches? version sexp)
"Return true if VERSION, a string, matches SEXP."
(match sexp
('_
#t)
((? string? expected)
(version-prefix? expected version))
(('or sexps ...)
(any (cut version-matches? version <>) sexps))
(('and sexps ...)
(every (cut version-matches? version <>) sexps))
(('< max)
(version>? max version))
(('<= max)
(version>=? max version))
(('> min)
(version>? version min))
(('>= min)
(version>=? version min))))
(define (vulnerability-matches? vuln vendor hidden-vendors)
"Checks if a VENDOR matches at least one of <vulnerability> VULN
packages. When VENDOR is #f, ignore packages that have a vendor among
HIDDEN-VENDORS."
(define hidden-vendor?
(if (list? hidden-vendors)
(cut member <> hidden-vendors)
(const #f)))
(define vendor=?
(if vendor
(cut string=? <> vendor)
(const #f)))
(match vuln
(($ <vulnerability> id packages)
(any (match-lambda
((? vendor=?)
#t)
((? hidden-vendor?)
#f)
(otherwise
(not vendor)))
(map car packages))))) ;candidate vendors
;;;
;;; High-level interface.
;;;
(define %now
(current-date))
(define %current-year
(date-year %now))
(define %past-year
(- %current-year 1))
(define (yearly-feed-uri year)
"Return the URI for the CVE feed for YEAR."
(string->uri
(string-append "https://nvd.nist.gov/feeds/json/cve/2.0/nvdcve-2.0-"
(number->string year) ".json.gz")))
(define %current-year-ttl
;; According to <https://nvd.nist.gov/download.cfm#CVE_FEED>, feeds are
;; updated "approximately every two hours."
(* 60 30))
(define %past-year-ttl
;; Update the previous year's database more and more infrequently.
(* 3600 24 (date-month %now)))
(define-record-type <vulnerability>
(vulnerability id packages)
vulnerability?
(id vulnerability-id) ;string
(packages vulnerability-packages)) ;((v1 p1 sexp1) (v2 p2 sexp2) ...)
(define vulnerability->sexp
(match-lambda
(($ <vulnerability> id packages)
`(v ,id ,packages))))
(define sexp->vulnerability
(match-lambda
(('v id (packages ...))
(vulnerability id packages))))
(define sexp-v1->vulnerability
(match-lambda
(('v id (packages ...))
(vulnerability id (map (cut cons #f <>) packages)))))
(define vendor-tuple?
(match-lambda
((vendor package _)
#t)
(otherwise
#f)))
(define (cve-configuration->package-list config)
"Parse CONFIG, a config sexp, and return a list of the form (V P SEXP)
where V is a CPE vendor, P is a package name and SEXP expresses constraints on
the matching versions."
(let loop ((config config)
(results '()))
(match config
(('or configs ...)
(fold loop results configs))
(('and config _ ...) ;XXX
(loop config results))
(((? string? vendor) (? string? package) '_) ;any version
(cons `(,vendor ,package _) (remove vendor-tuple? results)))
(((? string? vendor) (? string? package) sexp)
(match (assoc-ref (assoc-ref results vendor) package)
((previous)
(cons `(,vendor ,package (or ,sexp ,previous))
(remove vendor-tuple? results)))
(_
(cons `(,vendor ,package ,sexp) results)))))))
(define (merge-package-lists lst)
"Merge the list in LST, each of which has the form (V P SEXP), where V is a
CPE vendor, P is the name of a package and SEXP is an sexp that constrains
matching versions."
(fold (lambda (plist result) ;XXX: quadratic
(fold (match-lambda*
(((vendor package version) result)
(match (assoc-ref result vendor)
(((? (cut string=? package <>)) previous)
(cons `(,vendor ,package (or ,version ,previous))
(remove vendor-tuple? result)))
(_
(cons `(,vendor ,package ,version) result)))))
result
plist))
'()
lst))
(define (cve-item->vulnerability item)
"Return a <vulnerability> corresponding to ITEM, a <cve-item> record;
return #f if ITEM does not list any configuration or if it does not list
any \"a\" (application) configuration."
(match (cve-item-configurations item)
(() ;no configurations
#f)
((configs ...)
(vulnerability (cve-item-id item)
(merge-package-lists
(map cve-configuration->package-list configs))))))
(define (json->vulnerabilities json)
"Parse JSON, an input port or a string, and return the list of
vulnerabilities found therein."
(filter-map cve-item->vulnerability (json->cve-items json)))
(define (write-cache input cache)
"Read vulnerabilities as gzipped JSON from INPUT, and write it as a compact
sexp to CACHE."
(call-with-decompressed-port 'gzip input
(lambda (input)
(define vulns
(json->vulnerabilities input))
(write `(vulnerabilities
2 ;format version
,(map vulnerability->sexp vulns))
cache))))
(define* (fetch-vulnerabilities year ttl #:key (timeout 10))
"Return the list of <vulnerability> for YEAR, assuming the on-disk cache has
the given TTL (fetch from the NIST web site when TTL has expired)."
(define (cache-miss uri)
(format (current-error-port) "fetching CVE database for ~a...~%" year))
(define (read* port)
;; Disable read options to avoid populating the source property weak
;; table, which speeds things up, saves memory, and works around
;; <https://lists.gnu.org/archive/html/guile-devel/2017-09/msg00031.html>.
(let ((options (read-options)))
(dynamic-wind
(lambda ()
(read-disable 'positions))
(lambda ()
(read port))
(lambda ()
(read-options options)))))
;; Note: We used to keep the original JSON files in cache but parsing it
;; would take typically ~15s for a year of data. Thus, we instead store a
;; summarized version thereof as an sexp, which can be parsed in 1s or so.
(let* ((port (http-fetch/cached (yearly-feed-uri year)
#:ttl ttl
#:write-cache write-cache
#:cache-miss cache-miss
#:timeout timeout))
(sexp (read* port)))
(close-port port)
(match sexp
(('vulnerabilities 2 vulns)
(map sexp->vulnerability vulns))
(('vulnerabilities 1 vulns) ;old format, lacks vendor info
(map sexp-v1->vulnerability vulns)))))
(define* (current-vulnerabilities #:key (timeout 10))
"Return the current list of Common Vulnerabilities and Exposures (CVE) as
published by the US NIST. TIMEOUT specifies the timeout in seconds for
connection establishment."
(let ((past-years (unfold (cut > <> 3)
(lambda (n)
(- %current-year n))
1+
1))
(past-ttls (unfold (cut > <> 3)
(lambda (n)
(* n %past-year-ttl))
1+
1)))
(append-map (cut fetch-vulnerabilities <> <> #:timeout timeout)
(cons %current-year past-years)
(cons %current-year-ttl past-ttls))))
(define (vulnerabilities->lookup-proc vulnerabilities)
"Return a lookup procedure built from VULNERABILITIES that takes a package
name and optionally a version number. When the version is omitted, the lookup
procedure returns a list of vulnerabilities; otherwise, it returns a list of
vulnerabilities affecting the given package version."
(define table
;; Map package names to lists of version/vulnerability pairs.
(fold (lambda (vuln table)
(match vuln
(($ <vulnerability> id packages)
(fold (lambda (package table)
(match package
((vendor name . versions)
(vhash-cons name (cons vuln versions)
table))))
table
packages))))
vlist-null
vulnerabilities))
(lambda* (package #:optional version #:key (vendor #f) (hidden-vendors '()))
(vhash-fold*
(lambda (pair result)
(match pair
((vuln sexp)
(if (and (or (and (not vendor) (null? hidden-vendors))
(vulnerability-matches? vuln vendor hidden-vendors))
(or (not version) (version-matches? version sexp)))
(cons vuln result)
result))))
'()
package table)))
;;; cve.scm ends here