providers/nvd/cve.go (510 lines of code) (raw):

// Copyright (c) Facebook, Inc. and its affiliates. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package nvd import ( "archive/zip" "bytes" "compress/gzip" "context" "crypto/sha256" "encoding/hex" "fmt" "io" "io/ioutil" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "text/template" "time" "github.com/facebookincubator/flog" "github.com/facebookincubator/nvdtools/providers/lib/client" ) // CVE defines the CVE data feed for synchronization. type CVE int // Supported CVE feeds. const ( cve20xmlGz CVE = iota // CVE database in XML 2.0 format, gzip compressed. cve20xmlZip // CVE database in XML 2.0 format, zip compressed. cve12xmlGz // CVE database in XML 1.2 format, gzip compressed. cve12xmlZip // CVE database in XML 1.2 format, zip compressed. cve10jsonGz // CVE database in JSON 1.0 format, gzip compressed. cve10jsonZip // CVE database in JSON 1.0 format, zip compressed. cve11jsonGz // CVE database in JSON 1.1 format, gzip compressed. cve11jsonZip // CVE database in JSON 1.1 format, zip compressed. ) // SupportedCVE contains all supported CVE feeds indexed by name. var SupportedCVE = map[string]CVE{ "cve-1.2.xml.gz": cve12xmlGz, "cve-1.2.xml.zip": cve12xmlZip, "cve-2.0.xml.gz": cve20xmlGz, "cve-2.0.xml.zip": cve20xmlZip, "cve-1.0.json.gz": cve10jsonGz, "cve-1.0.json.zip": cve10jsonZip, "cve-1.1.json.gz": cve11jsonGz, "cve-1.1.json.zip": cve11jsonZip, } // Set implements the flag.Value interface. func (c *CVE) Set(v string) error { feed, exists := SupportedCVE[v] if !exists { return fmt.Errorf("unsupported CVE feed: %q", v) } *c = feed return nil } // String implements the fmt.Stringer interface. func (c CVE) String() string { return "cve-" + c.version() + "." + c.encoding() + "." + c.compression() } // Help returns the CVE flag help. func (c CVE) Help() string { opts := make([]string, 0, len(SupportedCVE)) for k := range SupportedCVE { opts = append(opts, k) } sort.Strings(opts) return fmt.Sprintf( "CVE feed to sync (default: %s)\navailable:\n%s", c, strings.Join(opts, "\n"), ) } // encoding returns the data feed encoding: xml or json. func (c CVE) encoding() string { switch c { case cve12xmlGz, cve12xmlZip, cve20xmlGz, cve20xmlZip: return "xml" case cve10jsonGz, cve10jsonZip, cve11jsonGz, cve11jsonZip: return "json" default: panic("unsupported CVE encoding") } } // compression returns the data feed compression: gz or zip. func (c CVE) compression() string { switch c { case cve10jsonGz, cve11jsonGz, cve12xmlGz, cve20xmlGz: return "gz" case cve10jsonZip, cve11jsonZip, cve12xmlZip, cve20xmlZip: return "zip" default: panic("unsupported CVE compression") } } // version returns the data feed version. func (c CVE) version() string { switch c { case cve12xmlGz, cve12xmlZip: return "1.2" case cve20xmlGz, cve20xmlZip: return "2.0" case cve10jsonGz, cve10jsonZip: return "1.0" case cve11jsonGz, cve11jsonZip: return "1.1" default: panic("unsupported CVE version") } } // Sync synchronizes the CVE feed to a local directory. func (c CVE) Sync(ctx context.Context, src SourceConfig, localdir string) error { var err error files := cveFileList(c) for _, f := range files { if err = f.Sync(ctx, src, localdir); err != nil { return err } } return nil } func cveFileList(c CVE) []cveFile { filefmt := func(version, suffix, encoding, compression string) string { s := fmt.Sprintf("nvdcve-%s-%s.%s", version, suffix, encoding) if compression != "" { s += "." + compression } return s } // nvd data feeds start in 2002 const startingYear = 2002 currentYear := time.Now().Year() if currentYear < startingYear { panic("system date is in the past, cannot continue") } entries := (currentYear - startingYear) + 1 f := make([]cveFile, entries+2) // +recent +modified version := c.version() encoding := c.encoding() compression := c.compression() for i := 0; i < entries; i++ { year := startingYear + i suffix := strconv.Itoa(year) f[i] = cveFile{ CVE: c, MetaFile: filefmt(version, suffix, "meta", ""), DataFile: filefmt(version, suffix, encoding, compression), } } // recent f[entries] = cveFile{ CVE: c, MetaFile: filefmt(version, "recent", "meta", ""), DataFile: filefmt(version, "recent", encoding, compression), } // modified f[entries+1] = cveFile{ CVE: c, MetaFile: filefmt(version, "modified", "meta", ""), DataFile: filefmt(version, "modified", encoding, compression), } return f } type cveFile struct { CVE MetaFile string DataFile string } func (cf cveFile) baseURL(src SourceConfig) (string, error) { tmpl, err := template.New("path").Parse(src.CVEFeedPath) if err != nil { return "", err } b := bytes.Buffer{} err = tmpl.Execute(&b, struct { Encoding string Version string }{ Encoding: cf.encoding(), Version: cf.version(), }) if err != nil { return "", err } u := url.URL{ Scheme: src.Scheme, Host: src.Host, Path: b.String(), } baseURL := u.String() if !strings.HasSuffix(baseURL, "/") { baseURL += "/" } return baseURL, nil } func (cf cveFile) Sync(ctx context.Context, src SourceConfig, localdir string) error { baseURL, err := cf.baseURL(src) if err != nil { return err } remoteMetaURL := baseURL + cf.MetaFile flog.V(1).Infof("checking meta file %q for updates to %q", cf.MetaFile, cf.DataFile) remoteMeta, needsUpdate, err := cf.needsUpdate(ctx, remoteMetaURL, localdir) if err != nil { return err } if !needsUpdate { return nil } remoteFileURL := baseURL + cf.DataFile tempDataFilename, err := cf.downloadAndVerify(ctx, remoteMeta, remoteFileURL) if err != nil { return err } defer os.Remove(tempDataFilename) // write metadata file metaFilename := filepath.Join(localdir, cf.MetaFile) err = remoteMeta.WriteFile(metaFilename) if err != nil { return err } // write data file dataFilename := filepath.Join(localdir, cf.DataFile) bakDataFilename := dataFilename + ".bak" xRename(dataFilename, bakDataFilename) if err = xRename(tempDataFilename, dataFilename); err != nil { xRename(bakDataFilename, dataFilename) return err } os.Remove(bakDataFilename) return nil } func (cf cveFile) needsUpdate(ctx context.Context, remoteMetaURL, localdir string) (*metaFile, bool, error) { flog.V(1).Infof("downloading meta file %q", remoteMetaURL) remoteMeta, err := newMetaFromURL(ctx, remoteMetaURL) if err != nil { return nil, false, err } metaFilename := filepath.Join(localdir, cf.MetaFile) if _, err := os.Stat(metaFilename); os.IsNotExist(err) { flog.V(1).Infof("meta file %q does not exist in %q, needs sync", cf.MetaFile, localdir) return &remoteMeta, true, nil } localMeta, err := newMetaFromFile(metaFilename) if err != nil { return nil, false, err } if !localMeta.Equal(remoteMeta) { flog.V(1).Infof("data file %q needs update in %q: local%+v != remote%+v", cf.DataFile, localdir, localMeta, remoteMeta) return &remoteMeta, true, nil } dataFilename := filepath.Join(localdir, cf.DataFile) fi, err := os.Stat(dataFilename) if err != nil { if os.IsNotExist(err) { flog.V(1).Infof("data file %q does not exist in %q, needs sync", cf.DataFile, localdir) return &remoteMeta, true, nil } return nil, false, err } var sizeOK bool var hashFunc func(filename string) (string, error) switch cf.compression() { case "gz": sizeOK = fi.Size() == int64(localMeta.GzSize) hashFunc = gunzipFileAndComputeSHA256 case "zip": sizeOK = fi.Size() == int64(localMeta.ZipSize) hashFunc = unzipFileAndComputeSHA256 } if !sizeOK { flog.V(1).Infof("data file %q needs update in %q: size mismatch", cf.DataFile, localdir) return &remoteMeta, true, nil } hash, err := hashFunc(dataFilename) if err != nil { return nil, false, err } if hash != localMeta.SHA256 { flog.V(1).Infof("data file %q needs update in %q: hash mismatch %q != %q", cf.DataFile, localdir, hash, localMeta.SHA256) return &remoteMeta, true, nil } return &remoteMeta, false, nil } // downloadAndVerify downloads a remote file into a temporary local file, and performs checksum using size and hash from m. // Returns the path to the local file. func (cf cveFile) downloadAndVerify(ctx context.Context, m *metaFile, remoteFileURL string) (string, error) { req, err := httpNewRequestContext(ctx, "GET", remoteFileURL) if err != nil { return "", err } flog.V(1).Infof("downloading data file %q", remoteFileURL) resp, err := client.Default().Do(req) if err != nil { return "", err } defer resp.Body.Close() if err = httpResponseNotOK(resp); err != nil { return "", err } var wantSize int64 var hashFunc func(filename string) (string, error) switch cf.compression() { case "gz": wantSize = int64(m.GzSize) hashFunc = gunzipFileAndComputeSHA256 case "zip": wantSize = int64(m.ZipSize) hashFunc = unzipFileAndComputeSHA256 } if resp.ContentLength != wantSize { return "", fmt.Errorf( "unexpected size for %q (%s): want %d, have %d", remoteFileURL, resp.Status, wantSize, resp.ContentLength, ) } dataFile, err := ioutil.TempFile("", "nvdsync-data-") if err != nil { return "", err } _, err = io.Copy(dataFile, resp.Body) dataFile.Close() if err != nil { return "", err } hash, err := hashFunc(dataFile.Name()) if err != nil { defer os.Remove(dataFile.Name()) // TODO: delet? return "", err } if hash != m.SHA256 { defer os.Remove(dataFile.Name()) // TODO: delet? return "", fmt.Errorf( "unexpected hash for %q (%s): want %q, have %q", remoteFileURL, resp.Status, m.SHA256, hash, ) } return dataFile.Name(), nil } // metaFile represents a .meta file from CVE data feeds. type metaFile struct { LastModifiedDate time.Time Size int ZipSize int GzSize int SHA256 string } // Equal compares two meta files. func (m metaFile) Equal(other metaFile) bool { switch { case !m.LastModifiedDate.Equal(other.LastModifiedDate), m.Size != other.Size, m.ZipSize != other.ZipSize, m.GzSize != other.GzSize, m.SHA256 != other.SHA256: return false } return true } // WriteTo writes the contents of m to w. func (m metaFile) WriteTo(w io.Writer) (int64, error) { lines := []string{ "lastModifiedDate:%s\r\n", "size:%d\r\n", "zipSize:%d\r\n", "gzSize:%d\r\n", "sha256:%s\r\n", } params := []interface{}{ m.LastModifiedDate.Format(time.RFC3339), m.Size, m.ZipSize, m.GzSize, strings.ToUpper(m.SHA256), } var total int64 for i := 0; i < len(lines); i++ { n, err := fmt.Fprintf(w, lines[i], params[i]) if err != nil { return 0, err } total += int64(n) } return total, nil } // WriteFile writes m to a file. func (m metaFile) WriteFile(name string) error { f, err := ioutil.TempFile("", "nvdsync-meta-") if err != nil { return err } _, err = m.WriteTo(f) f.Close() if err != nil { return err } bak := name + ".bak" xRename(name, bak) if err = xRename(f.Name(), name); err != nil { xRename(bak, name) return err } os.Remove(bak) return err } // newMetaFile loads metadata from r. func newMetaFile(r io.Reader) (metaFile, error) { m := metaFile{} r = io.LimitReader(r, 16*1024) b, err := ioutil.ReadAll(r) if err != nil { return m, err } lines := bytes.Split(b, []byte("\r\n")) for i, line := range lines { if len(line) == 0 { break } lineno := i + 1 parts := bytes.SplitN(line, []byte(":"), 2) if len(parts) != 2 { return m, fmt.Errorf("line %d: expecting key:value not %q", lineno, string(line)) } key := string(parts[0]) val := string(parts[1]) switch key { case "lastModifiedDate": t, err := time.Parse(time.RFC3339, val) if err != nil { return m, fmt.Errorf("line %d: expecting lastModifiedDate={RFC3339} not %q", lineno, string(line)) } m.LastModifiedDate = t case "size": v, err := strconv.Atoi(val) if err != nil { return m, fmt.Errorf("line %d: expecting size={int} not %q", lineno, string(line)) } m.Size = v case "zipSize": v, err := strconv.Atoi(val) if err != nil { return m, fmt.Errorf("line %d: expecting zipSize={int} not %q", lineno, string(line)) } m.ZipSize = v case "gzSize": v, err := strconv.Atoi(val) if err != nil { return m, fmt.Errorf("line %d: expecting gzSize={int} not %q", lineno, string(line)) } m.GzSize = v case "sha256": m.SHA256 = strings.ToUpper(val) } } return m, nil } // newMetaFromURL loads metadata from a URL pointing to a .meta file. func newMetaFromURL(ctx context.Context, url string) (metaFile, error) { m := metaFile{} req, err := httpNewRequestContext(ctx, "GET", url) if err != nil { return m, err } resp, err := client.Default().Do(req) if err != nil { return m, err } defer resp.Body.Close() if err = httpResponseNotOK(resp); err != nil { return m, err } m, err = newMetaFile(resp.Body) if err != nil { return m, fmt.Errorf("malformed data in remote metadata %q: %v", url, err) } return m, nil } // newMetaFromFile loads metadata from a local .meta file. func newMetaFromFile(filename string) (metaFile, error) { m := metaFile{} f, err := os.Open(filename) if err != nil { return m, err } defer f.Close() m, err = newMetaFile(f) if err != nil { return m, fmt.Errorf("malformed data in local metadata %q: %v", filename, err) } return m, nil } func computeSHA256(r io.Reader) (string, error) { hasher := sha256.New() _, err := io.Copy(hasher, r) if err != nil { return "", err } hash := hasher.Sum(nil) return strings.ToUpper(hex.EncodeToString(hash)), nil } func gunzipAndComputeSHA256(r io.Reader) (string, error) { f, err := gzip.NewReader(r) if err != nil { return "", err } defer f.Close() return computeSHA256(f) } func gunzipFileAndComputeSHA256(filename string) (string, error) { f, err := os.Open(filename) if err != nil { return "", err } defer f.Close() return gunzipAndComputeSHA256(f) } func unzipFileAndComputeSHA256(filename string) (string, error) { f, err := zip.OpenReader(filename) if err != nil { return "", err } defer f.Close() if len(f.File) != 1 { return "", fmt.Errorf( "unexpected number of files in zip %q: want 1, have %d", filename, len(f.File), ) } ff, err := f.File[0].Open() if err != nil { return "", err } defer ff.Close() return computeSHA256(ff) }