mirror of
https://github.com/fleetdm/fleet
synced 2026-05-20 07:29:08 +00:00
* Bump go to 1.19.1 * Bump remaining go-version to the 1.19.1 * Add extra paths for test-go * Oops, putting the right path in the right place * gofmt file * gofmt ALL THE THINGS * Moar changes * Actually, go.mod doesn't like minor versions
565 lines
14 KiB
Go
565 lines
14 KiB
Go
package vulnerabilities
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/doug-martin/goqu/v9"
|
|
"github.com/fleetdm/fleet/v4/pkg/download"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/vulnerabilities/oval"
|
|
kitlog "github.com/go-kit/kit/log"
|
|
"github.com/go-kit/kit/log/level"
|
|
"github.com/google/go-github/v37/github"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
const (
|
|
owner = "fleetdm"
|
|
repo = "nvd"
|
|
)
|
|
|
|
func GetLatestNVDRelease(client *http.Client) (*github.RepositoryRelease, error) {
|
|
ghclient := github.NewClient(client)
|
|
ctx := context.Background()
|
|
releases, _, err := ghclient.Repositories.ListReleases(ctx, owner, repo, &github.ListOptions{Page: 0, PerPage: 10})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, release := range releases {
|
|
// skip draft releases
|
|
if !release.GetDraft() {
|
|
return release, nil
|
|
}
|
|
}
|
|
|
|
return nil, errors.New("no nvd release found")
|
|
}
|
|
|
|
const cpeDBFilename = "cpe.sqlite"
|
|
|
|
var cpeDBRegex = regexp.MustCompile(`^cpe-.*\.sqlite\.gz$`)
|
|
|
|
// DownloadCPEDB downloads the CPE database to the given vulnPath. If cpeDBURL is empty, attempts to download it
|
|
// from the latest release of github.com/fleetdm/nvd. Skips downloading if CPE database is newer than the release.
|
|
func DownloadCPEDB(
|
|
vulnPath string,
|
|
client *http.Client,
|
|
cpeDBURL string,
|
|
) error {
|
|
path := filepath.Join(vulnPath, cpeDBFilename)
|
|
|
|
if cpeDBURL == "" {
|
|
release, err := GetLatestNVDRelease(client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stat, err := os.Stat(path)
|
|
switch {
|
|
case errors.Is(err, os.ErrNotExist):
|
|
// okay
|
|
case err != nil:
|
|
return err
|
|
default:
|
|
if stat.ModTime().After(release.CreatedAt.Time) {
|
|
// file is newer than release, do nothing
|
|
return nil
|
|
}
|
|
}
|
|
|
|
for _, asset := range release.Assets {
|
|
if cpeDBRegex.MatchString(asset.GetName()) {
|
|
cpeDBURL = asset.GetBrowserDownloadURL()
|
|
break
|
|
}
|
|
}
|
|
if cpeDBURL == "" {
|
|
return errors.New("failed to find cpe database in nvd release")
|
|
|
|
}
|
|
}
|
|
|
|
u, err := url.Parse(cpeDBURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := download.DownloadAndExtract(client, u, path); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type IndexedCPEItem struct {
|
|
ID int `json:"id" db:"rowid"`
|
|
Title string `json:"title" db:"title"`
|
|
Product string `json:"product" db:"product"`
|
|
Vendor string `json:"vendor" db:"vendor"`
|
|
Version *string `json:"version" db:"version"`
|
|
TargetSW *string `json:"target_sw" db:"target_sw"`
|
|
CPE23 string `json:"cpe23" db:"cpe23"`
|
|
Deprecated bool `json:"deprecated" db:"deprecated"`
|
|
}
|
|
|
|
func cleanAppName(appName string) string {
|
|
return strings.TrimSuffix(appName, ".app")
|
|
}
|
|
|
|
var nonAlphaNumeric = regexp.MustCompile(`[^a-zA-Z0-9]+`)
|
|
|
|
// sanitizeMatch sanitizes the search string for sqlite fts queries. Replaces all non alpha numeric characters with spaces.
|
|
func sanitizeMatch(s string) string {
|
|
return nonAlphaNumeric.ReplaceAllString(s, " ")
|
|
}
|
|
|
|
var sanitizeVersionRe = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
|
|
|
|
// sanitizeVersion attempts to sanitize versions and attempt to make it dot separated.
|
|
// Eg Zoom reports version as "5.11.1 (8356)". In the NVD CPE dictionary it should be 5.11.1.8356.
|
|
func sanitizeVersion(version string) string {
|
|
parts := sanitizeVersionRe.Split(version, -1)
|
|
return strings.Trim(strings.Join(parts, "."), ".")
|
|
}
|
|
|
|
const cpeTranslationsFilename = "cpe_translations.json"
|
|
|
|
func loadCPETranslations(path string) (CPETranslations, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
var translations CPETranslations
|
|
if err := json.NewDecoder(f).Decode(&translations); err != nil {
|
|
return nil, fmt.Errorf("decode json: %w", err)
|
|
}
|
|
|
|
return translations, nil
|
|
}
|
|
|
|
// DownloadCPETranslations downloads the CPE translations to the given vulnPath. If cpeTranslationsURL is empty, attempts to download it
|
|
// from the latest release of github.com/fleetdm/nvd. Skips downloading if CPE translations is newer than the release.
|
|
func DownloadCPETranslations(vulnPath string, client *http.Client, cpeTranslationsURL string) error {
|
|
path := filepath.Join(vulnPath, cpeTranslationsFilename)
|
|
|
|
if cpeTranslationsURL == "" {
|
|
release, err := GetLatestNVDRelease(client)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stat, err := os.Stat(path)
|
|
switch {
|
|
case errors.Is(err, os.ErrNotExist):
|
|
// okay
|
|
case err != nil:
|
|
return err
|
|
default:
|
|
if stat.ModTime().After(release.CreatedAt.Time) {
|
|
// file is newer than release, do nothing
|
|
return nil
|
|
}
|
|
}
|
|
|
|
for _, asset := range release.Assets {
|
|
if cpeTranslationsFilename == asset.GetName() {
|
|
cpeTranslationsURL = asset.GetBrowserDownloadURL()
|
|
break
|
|
}
|
|
}
|
|
if cpeTranslationsURL == "" {
|
|
return errors.New("failed to find cpe translations in nvd release")
|
|
|
|
}
|
|
}
|
|
|
|
u, err := url.Parse(cpeTranslationsURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := download.Download(client, u, path); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// regexpCache caches compiled regular expressions. Not safe for concurrent use.
|
|
type regexpCache struct {
|
|
re map[string]*regexp.Regexp
|
|
}
|
|
|
|
func newRegexpCache() *regexpCache {
|
|
return ®expCache{re: make(map[string]*regexp.Regexp)}
|
|
}
|
|
|
|
func (r *regexpCache) Get(pattern string) (*regexp.Regexp, error) {
|
|
if re, ok := r.re[pattern]; ok {
|
|
return re, nil
|
|
}
|
|
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.re[pattern] = re
|
|
return re, nil
|
|
}
|
|
|
|
// CPETranslations include special case translations for software that fail to match entries in the NVD CPE Dictionary
|
|
// using the standard logic. This may be due to unexpected vendor or product names.
|
|
//
|
|
// Example:
|
|
//
|
|
// [
|
|
// {
|
|
// "match": {
|
|
// "bundle_identifier": ["com.1password.1password"]
|
|
// },
|
|
// "translation": {
|
|
// "product": ["1password"],
|
|
// "vendor": ["agilebits"]
|
|
// }
|
|
// }
|
|
// ]
|
|
type CPETranslations []CPETranslationItem
|
|
|
|
func (c CPETranslations) Translate(reCache *regexpCache, s *fleet.Software) (CPETranslation, bool, error) {
|
|
for _, item := range c {
|
|
match, err := item.Software.Matches(reCache, s)
|
|
if err != nil {
|
|
return CPETranslation{}, false, err
|
|
}
|
|
if match {
|
|
return item.Filter, true, nil
|
|
}
|
|
}
|
|
|
|
return CPETranslation{}, false, nil
|
|
}
|
|
|
|
type CPETranslationItem struct {
|
|
Software CPETranslationSoftware `json:"software"`
|
|
Filter CPETranslation `json:"filter"`
|
|
}
|
|
|
|
// CPETranslationSoftware represents software match criteria for cpe translations.
|
|
type CPETranslationSoftware struct {
|
|
Name []string `json:"name"`
|
|
BundleIdentifier []string `json:"bundle_identifier"`
|
|
Source []string `json:"source"`
|
|
}
|
|
|
|
// Matches returns true if the software satifies all the match criteria.
|
|
func (c CPETranslationSoftware) Matches(reCache *regexpCache, s *fleet.Software) (bool, error) {
|
|
matches := func(a, b string) (bool, error) {
|
|
// check if its a regular expression enclosed in '/'
|
|
if len(a) > 2 && a[0] == '/' && a[len(a)-1] == '/' {
|
|
pattern := a[1 : len(a)-1]
|
|
re, err := reCache.Get(pattern)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return re.MatchString(b), nil
|
|
}
|
|
return a == b, nil
|
|
}
|
|
|
|
if len(c.Name) > 0 {
|
|
found := false
|
|
for _, name := range c.Name {
|
|
match, err := matches(name, s.Name)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if match {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false, nil
|
|
}
|
|
}
|
|
if len(c.BundleIdentifier) > 0 {
|
|
found := false
|
|
for _, bundleID := range c.BundleIdentifier {
|
|
match, err := matches(bundleID, s.BundleIdentifier)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if match {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false, nil
|
|
}
|
|
}
|
|
if len(c.Source) > 0 {
|
|
found := false
|
|
for _, source := range c.Source {
|
|
match, err := matches(source, s.Source)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if match {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false, nil
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
type CPETranslation struct {
|
|
Product []string `json:"product"`
|
|
Vendor []string `json:"vendor"`
|
|
TargetSW []string `json:"target_sw"`
|
|
}
|
|
|
|
// CPEFromSoftware attempts to find a matching cpe entry for the given software in the NVD CPE dictionary. `db` contains data from the NVD CPE dictionary
|
|
// and is optimized for lookups, see `GenerateCPEDB`. `translations` are used to aid in cpe matching. When searching for cpes, we first check if it matches
|
|
// any translations, and then lookup in the cpe database based on the title, product, vendor, target_sw, and version.
|
|
func CPEFromSoftware(db *sqlx.DB, software *fleet.Software, translations CPETranslations, reCache *regexpCache) (string, error) {
|
|
version := sanitizeVersion(software.Version)
|
|
|
|
ds := goqu.Dialect("sqlite").From(goqu.I("cpe_2").As("c")).
|
|
Select(
|
|
"c.rowid",
|
|
"c.title",
|
|
"c.product",
|
|
"c.vendor",
|
|
"c.version",
|
|
"c.target_sw",
|
|
"c.cpe23",
|
|
"c.deprecated",
|
|
).
|
|
Join(
|
|
goqu.I("cpe_search").As("cs"),
|
|
goqu.On(goqu.I("cs.rowid").Eq(goqu.I("c.rowid"))),
|
|
).
|
|
Where(
|
|
goqu.I("c.version").Eq(version),
|
|
)
|
|
|
|
translation, match, err := translations.Translate(reCache, software)
|
|
if err != nil {
|
|
return "", fmt.Errorf("translate software: %w", err)
|
|
}
|
|
if match {
|
|
if len(translation.Product) > 0 {
|
|
var exps []goqu.Expression
|
|
for _, product := range translation.Product {
|
|
exps = append(exps, goqu.I("c.product").Eq(product))
|
|
}
|
|
ds = ds.Where(goqu.Or(exps...))
|
|
}
|
|
if len(translation.Vendor) > 0 {
|
|
var exps []goqu.Expression
|
|
for _, vendor := range translation.Vendor {
|
|
exps = append(exps, goqu.I("c.vendor").Eq(vendor))
|
|
}
|
|
ds = ds.Where(goqu.Or(exps...))
|
|
}
|
|
if len(translation.TargetSW) > 0 {
|
|
var exps []goqu.Expression
|
|
for _, targetSW := range translation.TargetSW {
|
|
exps = append(exps, goqu.I("c.target_sw").Eq(targetSW))
|
|
}
|
|
ds = ds.Where(goqu.Or(exps...))
|
|
}
|
|
} else {
|
|
|
|
name := software.Name
|
|
var targetSW string
|
|
|
|
switch software.Source {
|
|
case "apps":
|
|
name = cleanAppName(software.Name)
|
|
|
|
// match on bundle identifier to reduce false positives for software with short names eg notes,
|
|
// printer, calculator.
|
|
// match the following target_sw
|
|
// - mac
|
|
// - mac_os
|
|
// - mac_os_x
|
|
// - macos
|
|
ds = ds.Where(
|
|
goqu.L("? LIKE '%' || c.vendor || '%'", software.BundleIdentifier),
|
|
goqu.Or(
|
|
goqu.I("c.target_sw").Eq(""),
|
|
goqu.I("c.target_sw").Like("mac%"),
|
|
),
|
|
)
|
|
case "python_packages":
|
|
targetSW = "python"
|
|
case "chrome_extensions":
|
|
targetSW = "chrome"
|
|
case "firefox_addons":
|
|
targetSW = "firefox"
|
|
case "safari_extensions":
|
|
targetSW = "safari"
|
|
case "npm_packages":
|
|
targetSW = `node.js`
|
|
case "programs":
|
|
|
|
// match the following target_sw
|
|
// - windows
|
|
// - windows_10
|
|
// - windows_7
|
|
// - windows_8
|
|
// - windows_8.1
|
|
// - windows_ce
|
|
// - windows_communication_foundation
|
|
// - windows_integrated_security
|
|
// - windows_mobile
|
|
// - windows_phone
|
|
// - windows_server
|
|
// - windows_server_2003
|
|
// - windows_server_2008
|
|
// - windows_vista
|
|
// - windows_xp
|
|
ds = ds.Where(
|
|
goqu.Or(
|
|
goqu.I("c.target_sw").Like("windows%"),
|
|
),
|
|
)
|
|
}
|
|
if targetSW != "" {
|
|
ds = ds.Where(
|
|
goqu.L("c.target_sw").Eq(targetSW),
|
|
)
|
|
}
|
|
|
|
// sanitize name for full text search on title
|
|
nameTerms := sanitizeMatch(name)
|
|
ds = ds.Where(
|
|
goqu.L("cs.title MATCH ?", nameTerms),
|
|
)
|
|
}
|
|
|
|
sql, args, err := ds.ToSQL()
|
|
if err != nil {
|
|
return "", fmt.Errorf("sql: %w", err)
|
|
}
|
|
|
|
var indexedCPEs []IndexedCPEItem
|
|
err = db.Select(&indexedCPEs, sql, args...)
|
|
if err != nil {
|
|
return "", fmt.Errorf("getting cpes for: %s: %w", software.Name, err)
|
|
}
|
|
|
|
// if there are any non-deprecated cpes, return the first one
|
|
for _, item := range indexedCPEs {
|
|
if !item.Deprecated {
|
|
return item.CPE23, nil
|
|
}
|
|
}
|
|
|
|
// try to find a non-deprecated cpe by looking up deprecated_by
|
|
for _, item := range indexedCPEs {
|
|
deprecatedItem := item
|
|
for {
|
|
var deprecation IndexedCPEItem
|
|
|
|
err = db.Get(
|
|
&deprecation,
|
|
`
|
|
SELECT
|
|
rowid,
|
|
title,
|
|
product,
|
|
vendor,
|
|
version,
|
|
target_sw,
|
|
cpe23,
|
|
deprecated
|
|
FROM
|
|
cpe_2
|
|
WHERE
|
|
cpe23 IN (
|
|
SELECT cpe23 FROM deprecated_by d WHERE d.cpe_id = ?
|
|
)
|
|
`,
|
|
deprecatedItem.ID,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("getting deprecation: %w", err)
|
|
}
|
|
if deprecation.Deprecated {
|
|
deprecatedItem = deprecation
|
|
continue
|
|
}
|
|
|
|
return deprecation.CPE23, nil
|
|
}
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
func TranslateSoftwareToCPE(
|
|
ctx context.Context,
|
|
ds fleet.Datastore,
|
|
vulnPath string,
|
|
logger kitlog.Logger,
|
|
) error {
|
|
dbPath := filepath.Join(vulnPath, cpeDBFilename)
|
|
|
|
// Skip software from platforms for which we will be using OVAL for vulnerability detection.
|
|
iterator, err := ds.AllSoftwareWithoutCPEIterator(ctx, oval.SupportedHostPlatforms)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "all software iterator")
|
|
}
|
|
defer iterator.Close()
|
|
|
|
db, err := sqliteDB(dbPath)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "opening the cpe db")
|
|
}
|
|
defer db.Close()
|
|
|
|
cpeTranslationsPath := filepath.Join(vulnPath, cpeTranslationsFilename)
|
|
cpeTranslations, err := loadCPETranslations(cpeTranslationsPath)
|
|
if err != nil {
|
|
level.Error(logger).Log("msg", "failed to load cpe translations", "err", err)
|
|
}
|
|
|
|
reCache := newRegexpCache()
|
|
|
|
for iterator.Next() {
|
|
software, err := iterator.Value()
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting value from iterator")
|
|
}
|
|
cpe, err := CPEFromSoftware(db, software, cpeTranslations, reCache)
|
|
if err != nil {
|
|
level.Error(logger).Log("software->cpe", "error translating to CPE, skipping...", "err", err)
|
|
continue
|
|
}
|
|
if cpe == "" {
|
|
continue
|
|
}
|
|
err = ds.AddCPEForSoftware(ctx, *software, cpe)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "inserting cpe")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|