From 8e74fe65e9756ca9d21a93c8a6472418171b3ea4 Mon Sep 17 00:00:00 2001 From: zadjadr Date: Fri, 19 Jul 2024 11:22:08 +0200 Subject: [PATCH] Allow adding an external package file --- Readme.md | 75 +++++++++++- cmd/prometheus-cve-exporter/main.go | 4 +- config.json | 7 +- config/config.go | 104 ++++++++++------- config/config_test.go | 159 +++++++++++++++++++++----- go.mod | 2 +- internal/exporter/exporter.go | 81 ++++++------- internal/exporter/exporter_test.go | 169 ++++++++++++++++++++-------- 8 files changed, 438 insertions(+), 163 deletions(-) diff --git a/Readme.md b/Readme.md index 794ffa7..b07e814 100644 --- a/Readme.md +++ b/Readme.md @@ -4,6 +4,20 @@ Prometheus CVE Exporter is a Golang application that scans your system for all installed packages and compares them with the recent [NVD JSON feed](https://nvd.nist.gov/vuln/data-feeds#JSON_FEED). It exports metrics that provide insights into the security status of your packages. +## TOC + +- [Prometheus CVE Exporter](#prometheus-cve-exporter) + * [Features](#features) + * [Exported Metrics](#exported-metrics) + + [Example output](#example-output) + * [Building](#building) + + [Prerequisites](#prerequisites) + + [Steps](#steps) + * [Usage](#usage) + + [Binary without config](#binary-without-config) + + [Binary with config](#binary-with-config) + + [Docker](#docker) + ## Features - **Vulnerability Detection**: Identifies installed packages that have known vulnerabilities. @@ -18,6 +32,25 @@ Prometheus CVE Exporter is a Golang application that scans your system for all i | `nvd_total_vulnerabilities` | Gauge | Total number of vulnerabilities detected | None | | `nvd_last_update_time` | Gauge | Timestamp of the last successful update | None | +### Example output + +``` +# HELP nvd_last_update_time Timestamp of the last successful update +# TYPE nvd_last_update_time gauge +nvd_last_update_time 1.7213802588068807e+09 +# HELP nvd_total_vulnerabilities Total number of vulnerabilities detected +# TYPE nvd_total_vulnerabilities gauge +nvd_total_vulnerabilities 6 +# HELP nvd_vulnerable_packages Indicates if a package is vulnerable (1) or not (metric not present) +# TYPE nvd_vulnerable_packages gauge +nvd_vulnerable_packages{cve="CVE-2024-21513",impact="HIGH",package="langchain-experimental",version="0.0.17"} 1 +nvd_vulnerable_packages{cve="CVE-2024-6072",impact="MEDIUM",package="wp_estore",version="8.5.3"} 1 +nvd_vulnerable_packages{cve="CVE-2024-6073",impact="MEDIUM",package="wp_estore",version="8.5.3"} 1 +nvd_vulnerable_packages{cve="CVE-2024-6074",impact="MEDIUM",package="wp_estore",version="8.5.3"} 1 +nvd_vulnerable_packages{cve="CVE-2024-6075",impact="HIGH",package="wp_estore",version="8.5.3"} 1 +nvd_vulnerable_packages{cve="CVE-2024-6076",impact="MEDIUM",package="wp_estore",version="8.5.3"} 1 +``` + ## Building ### Prerequisites @@ -58,6 +91,8 @@ To customize the settings, use the following flags: path to config file -nvd-feed-url string URL for the NVD feed (default "https://nvd.nist.gov/feeds/json/cve/1.1/nvdcve-1.1-recent.json.gz") + -package-file string + Path to file containing packages and versions -port int Port to run the server on (default 10250) -severity string @@ -66,26 +101,56 @@ To customize the settings, use the following flags: Update interval duration (default 24h0m0s) ``` -Example: +### Binary without config ```sh -./bin/prometheus-cve-exporter -port 9090 -severity "HIGH,CRITICAL" -update-interval 12h +./bin/prometheus-cve-exporter -port 9090 -severity "HIGH,CRITICAL" -update-interval 12h -package-file /tmp/packages.txt ``` -Example with config file: +### Binary with config ```json { + "package_file": "/tmp/packages.txt", "nvd_feed_url": "https://nvd.nist.gov/feeds/json/cve/1.1/nvdcve-1.1-recent.json.gz", - "update_interval": "12h", - "port": 9090, + "update_interval": "5m", + "port": 8080, "severity": [ + "LOW", + "MEDIUM", "HIGH", "CRITICAL" ] } + ``` ```sh ./bin/prometheus-cve-exporter -config config.json ``` + +### Docker + +Released version tags are: + +- latest +- major version (e.g. `v1`) +- `major.minor` version (e.g. `v1.1`) +- tag name (e.g. `v1.0.0`) + +For the docker version, you will need to provide a `package-file`, otherwise the scanner will only +scan the container. + +```shell +docker run -it -v $(pwd):/app -p 10250:10250 --rm ghcr.io/zadjadr/prometheus-cve-exporter:latest -- -package-file /app/packages.txt +``` + +``` +Current configuration: + NVD Feed URL: https://nvd.nist.gov/feeds/json/cve/1.1/nvdcve-1.1-recent.json.gz + Update Interval: 24h0m0s + Severity Levels: [CRITICAL] + Port: 10250 + Package file: packages.txt +Starting server on :10250 +``` diff --git a/cmd/prometheus-cve-exporter/main.go b/cmd/prometheus-cve-exporter/main.go index b6cc7d6..2100bc8 100644 --- a/cmd/prometheus-cve-exporter/main.go +++ b/cmd/prometheus-cve-exporter/main.go @@ -5,8 +5,8 @@ import ( "log" "net/http" - "io.ki/prometheus-cve-exporter/config" - "io.ki/prometheus-cve-exporter/internal/exporter" + "zops.top/prometheus-cve-exporter/config" + "zops.top/prometheus-cve-exporter/internal/exporter" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" diff --git a/config.json b/config.json index e5bed67..f7218b9 100644 --- a/config.json +++ b/config.json @@ -1,8 +1,11 @@ { + "package_file": "", "nvd_feed_url": "https://nvd.nist.gov/feeds/json/cve/1.1/nvdcve-1.1-recent.json.gz", - "update_interval": "24h", - "port": 10250, + "update_interval": "5m", + "port": 8080, "severity": [ + "LOW", + "MEDIUM", "HIGH", "CRITICAL" ] diff --git a/config/config.go b/config/config.go index 9711f94..e05081d 100644 --- a/config/config.go +++ b/config/config.go @@ -21,6 +21,7 @@ type Config struct { UpdateInterval time.Duration `json:"update_interval"` Port int `json:"port"` Severity []string `json:"severity"` + PackageFile string `json:"package_file,omitempty"` } type configHelper struct { @@ -28,6 +29,7 @@ type configHelper struct { UpdateInterval string `json:"update_interval"` Port int `json:"port"` Severity []string `json:"severity"` + PackageFile string `json:"package_file,omitempty"` } func NewConfig() *Config { @@ -40,31 +42,40 @@ func NewConfig() *Config { } func Load() (*Config, error) { + cfg := NewConfig() + configFile := parseFlags(cfg) + + if configFile != "" { + if err := loadConfigFile(cfg, configFile); err != nil { + return nil, fmt.Errorf("error loading config file: %w", err) + } + } + + overrideWithEnv(cfg) + + if err := validateConfig(cfg); err != nil { + return nil, err + } + + fmt.Print(prettyfyCfg(cfg)) + return cfg, nil +} + +func parseFlags(cfg *Config) string { var configFile string flag.StringVar(&configFile, "config", "", "path to config file") - cfg := NewConfig() flag.StringVar(&cfg.NVDFeedURL, "nvd-feed-url", defaultNVDFeedURL, "URL for the NVD feed") flag.DurationVar(&cfg.UpdateInterval, "update-interval", defaultUpdateInterval, "Update interval duration") flag.IntVar(&cfg.Port, "port", defaultPort, "Port to run the server on") + flag.StringVar(&cfg.PackageFile, "package-file", "", "Path to file containing packages and versions") var severity string flag.StringVar(&severity, "severity", defaultSeverity, "Comma separated list of severity levels for vulnerabilities") - flag.Parse() cfg.Severity = parseSeverity(severity) - - if configFile != "" { - if err := loadConfigFile(cfg, configFile); err != nil { - return nil, fmt.Errorf("error loading config file: %w", err) - } - } - - cfg = overrideWithEnv(cfg) - - prettyPrintCfg(cfg) - return cfg, nil + return configFile } func loadConfigFile(cfg *Config, filename string) error { @@ -81,6 +92,7 @@ func loadConfigFile(cfg *Config, filename string) error { cfg.NVDFeedURL = helper.NVDFeedURL cfg.Port = helper.Port cfg.Severity = helper.Severity + cfg.PackageFile = helper.PackageFile duration, err := time.ParseDuration(helper.UpdateInterval) if err != nil { @@ -91,32 +103,41 @@ func loadConfigFile(cfg *Config, filename string) error { return nil } -func overrideWithEnv(cfg *Config) *Config { - if value := os.Getenv("PCE_NVD_JSON_GZ_FEED_URL"); value != "" { - cfg.NVDFeedURL = value +func overrideWithEnv(cfg *Config) { + envVars := map[string]func(string){ + "PCE_NVD_JSON_GZ_FEED_URL": func(value string) { cfg.NVDFeedURL = value }, + "PCE_UPDATE_INTERVAL": func(value string) { + if duration, err := time.ParseDuration(value); err == nil { + cfg.UpdateInterval = duration + } else { + fmt.Printf("Warning: invalid PCE_UPDATE_INTERVAL, using current value: %v\n", cfg.UpdateInterval) + } + }, + "PCE_PORT": func(value string) { + if port, err := parseIntEnv(value); err == nil { + cfg.Port = port + } else { + fmt.Printf("Warning: invalid PCE_PORT, using current value: %d\n", cfg.Port) + } + }, + "PCE_SEVERITY": func(value string) { cfg.Severity = parseSeverity(value) }, + "PCE_PACKAGE_FILE": func(value string) { cfg.PackageFile = value }, } - if value := os.Getenv("PCE_UPDATE_INTERVAL"); value != "" { - if duration, err := time.ParseDuration(value); err == nil { - cfg.UpdateInterval = duration - } else { - fmt.Printf("Warning: invalid PCE_UPDATE_INTERVAL, using current value: %v\n", cfg.UpdateInterval) + for envVar, action := range envVars { + if value := os.Getenv(envVar); value != "" { + action(value) } } +} - if value := os.Getenv("PCE_PORT"); value != "" { - if port, err := parseIntEnv(value); err == nil { - cfg.Port = port - } else { - fmt.Printf("Warning: invalid PCE_PORT, using current value: %d\n", cfg.Port) +func validateConfig(cfg *Config) error { + if cfg.PackageFile != "" { + if _, err := os.Stat(cfg.PackageFile); os.IsNotExist(err) { + return fmt.Errorf("the file %s does not exist", cfg.PackageFile) } } - - if value := os.Getenv("PCE_SEVERITY"); value != "" { - cfg.Severity = parseSeverity(value) - } - - return cfg + return nil } func parseSeverity(severity string) []string { @@ -129,10 +150,17 @@ func parseIntEnv(value string) (int, error) { return result, err } -func prettyPrintCfg(cfg *Config) { - fmt.Println("Current configuration:") - fmt.Printf(" NVD Feed URL: %s\n", cfg.NVDFeedURL) - fmt.Printf(" Update Interval: %s\n", cfg.UpdateInterval.String()) - fmt.Printf(" Severity Levels: %v\n", cfg.Severity) - fmt.Printf(" Port: %d\n", cfg.Port) +func prettyfyCfg(cfg *Config) string { + var output strings.Builder + + output.WriteString("Current configuration:\n") + output.WriteString(fmt.Sprintf(" NVD Feed URL: %s\n", cfg.NVDFeedURL)) + output.WriteString(fmt.Sprintf(" Update Interval: %s\n", cfg.UpdateInterval.String())) + output.WriteString(fmt.Sprintf(" Severity Levels: %v\n", cfg.Severity)) + output.WriteString(fmt.Sprintf(" Port: %d\n", cfg.Port)) + if cfg.PackageFile != "" { + output.WriteString(fmt.Sprintf(" Package file: %s\n", cfg.PackageFile)) + } + + return output.String() } diff --git a/config/config_test.go b/config/config_test.go index 65c8b7e..1ae9124 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -2,62 +2,161 @@ package config import ( "os" + "strings" "testing" "time" ) -func TestLoad(t *testing.T) { - // Test default values +func TestNewConfig(t *testing.T) { cfg := NewConfig() if cfg.NVDFeedURL != defaultNVDFeedURL { - t.Errorf("Expected NVDFeedURL to be %s, got %s", defaultNVDFeedURL, cfg.NVDFeedURL) + t.Errorf("Expected NVDFeedURL %s, got %s", defaultNVDFeedURL, cfg.NVDFeedURL) } - if cfg.UpdateInterval != defaultUpdateInterval { - t.Errorf("Expected UpdateInterval to be %v, got %v", defaultUpdateInterval, cfg.UpdateInterval) + t.Errorf("Expected UpdateInterval %v, got %v", defaultUpdateInterval, cfg.UpdateInterval) } - if cfg.Port != defaultPort { - t.Errorf("Expected Port to be %d, got %d", defaultPort, cfg.Port) + t.Errorf("Expected Port %d, got %d", defaultPort, cfg.Port) } - if len(cfg.Severity) != 1 || cfg.Severity[0] != defaultSeverity { - t.Errorf("Expected Severity to be [%s], got %v", defaultSeverity, cfg.Severity) + t.Errorf("Expected Severity %v, got %v", []string{defaultSeverity}, cfg.Severity) + } + if cfg.PackageFile != "" { + t.Errorf("Expected PackageFile to be empty, got %s", cfg.PackageFile) + } +} + +func TestLoadConfigFile(t *testing.T) { + cfg := NewConfig() + + configContent := `{ + "nvd_feed_url": "https://example.com/feed.json", + "update_interval": "48h", + "port": 9090, + "severity": ["HIGH", "MEDIUM"], + "package_file": "/tmp/package.txt" + }` + + configFile := "/tmp/config.json" + if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { + t.Fatalf("Error writing config file: %v", err) + } + defer os.Remove(configFile) + + if err := loadConfigFile(cfg, configFile); err != nil { + t.Fatalf("Error loading config file: %v", err) + } + + if cfg.NVDFeedURL != "https://example.com/feed.json" { + t.Errorf("Expected NVDFeedURL https://example.com/feed.json, got %s", cfg.NVDFeedURL) + } + if cfg.UpdateInterval != 48*time.Hour { + t.Errorf("Expected UpdateInterval 48h, got %v", cfg.UpdateInterval) + } + if cfg.Port != 9090 { + t.Errorf("Expected Port 9090, got %d", cfg.Port) + } + expectedSeverity := []string{"HIGH", "MEDIUM"} + for i, s := range expectedSeverity { + if cfg.Severity[i] != s { + t.Errorf("Expected Severity %v, got %v", expectedSeverity, cfg.Severity) + } + } + if cfg.PackageFile != "/tmp/package.txt" { + t.Errorf("Expected PackageFile /tmp/package.txt, got %s", cfg.PackageFile) } } func TestOverrideWithEnv(t *testing.T) { - // Set environment variables - os.Setenv("PCE_NVD_JSON_GZ_FEED_URL", "https://example.com/feed.json.gz") - os.Setenv("PCE_UPDATE_INTERVAL", "12h") + cfg := NewConfig() + + os.Setenv("PCE_NVD_JSON_GZ_FEED_URL", "https://env.com/feed.json") + os.Setenv("PCE_UPDATE_INTERVAL", "72h") os.Setenv("PCE_PORT", "8080") - os.Setenv("PCE_SEVERITY", "HIGH,MEDIUM") + os.Setenv("PCE_SEVERITY", "LOW,INFO") + os.Setenv("PCE_PACKAGE_FILE", "/env/package.txt") + defer func() { + os.Unsetenv("PCE_NVD_JSON_GZ_FEED_URL") + os.Unsetenv("PCE_UPDATE_INTERVAL") + os.Unsetenv("PCE_PORT") + os.Unsetenv("PCE_SEVERITY") + os.Unsetenv("PCE_PACKAGE_FILE") + }() - // Load configuration - cfg := overrideWithEnv(NewConfig()) + overrideWithEnv(cfg) - // Check if environment variables override default values - if cfg.NVDFeedURL != "https://example.com/feed.json.gz" { - t.Errorf("Expected NVDFeedURL to be https://example.com/feed.json.gz, got %s", cfg.NVDFeedURL) + if cfg.NVDFeedURL != "https://env.com/feed.json" { + t.Errorf("Expected NVDFeedURL https://env.com/feed.json, got %s", cfg.NVDFeedURL) + } + if cfg.UpdateInterval != 72*time.Hour { + t.Errorf("Expected UpdateInterval 72h, got %v", cfg.UpdateInterval) + } + if cfg.Port != 8080 { + t.Errorf("Expected Port 8080, got %d", cfg.Port) } + expectedSeverity := []string{"LOW", "INFO"} + for i, s := range expectedSeverity { + if cfg.Severity[i] != s { + t.Errorf("Expected Severity %v, got %v", expectedSeverity, cfg.Severity) + } + } + if cfg.PackageFile != "/env/package.txt" { + t.Errorf("Expected PackageFile /env/package.txt, got %s", cfg.PackageFile) + } +} - if cfg.UpdateInterval != 12*time.Hour { - t.Errorf("Expected UpdateInterval to be 12h, got %v", cfg.UpdateInterval) +func TestParseSeverity(t *testing.T) { + severity := "CRITICAL,HIGH,MEDIUM,LOW" + expected := []string{"CRITICAL", "HIGH", "MEDIUM", "LOW"} + result := parseSeverity(severity) + + for i, s := range expected { + if result[i] != s { + t.Errorf("Expected Severity %v, got %v", expected, result) + } } +} - if cfg.Port != 8080 { - t.Errorf("Expected Port to be 8080, got %d", cfg.Port) +func TestParseIntEnv(t *testing.T) { + value := "1024" + expected := 1024 + result, err := parseIntEnv(value) + if err != nil { + t.Fatalf("Error parsing int env: %v", err) + } + if result != expected { + t.Errorf("Expected %d, got %d", expected, result) } - expectedSeverity := []string{"HIGH", "MEDIUM"} - if len(cfg.Severity) != len(expectedSeverity) || cfg.Severity[0] != expectedSeverity[0] || cfg.Severity[1] != expectedSeverity[1] { - t.Errorf("Expected Severity to be %v, got %v", expectedSeverity, cfg.Severity) + invalidValue := "invalid" + _, err = parseIntEnv(invalidValue) + if err == nil { + t.Fatalf("Expected error for invalid int, got none") } +} - // Clean up environment variables - os.Unsetenv("PCE_NVD_JSON_GZ_FEED_URL") - os.Unsetenv("PCE_UPDATE_INTERVAL") - os.Unsetenv("PCE_PORT") - os.Unsetenv("PCE_SEVERITY") +func TestPrettyfyCfg(t *testing.T) { + cfg := NewConfig() + cfg.NVDFeedURL = "https://example.com/feed.json" + cfg.UpdateInterval = 48 * time.Hour + cfg.Port = 9090 + cfg.Severity = []string{"HIGH", "MEDIUM"} + cfg.PackageFile = "/tmp/package.txt" + + output := prettyfyCfg(cfg) + expectedStrings := []string{ + "Current configuration:", + " NVD Feed URL: https://example.com/feed.json", + " Update Interval: 48h0m0s", + " Severity Levels: [HIGH MEDIUM]", + " Port: 9090", + " Package file: /tmp/package.txt", + } + + for _, expected := range expectedStrings { + if !strings.Contains(output, expected) { + t.Errorf("Expected output to contain %s", expected) + } + } } diff --git a/go.mod b/go.mod index fe99c9d..93cbaab 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module io.ki/prometheus-cve-exporter +module zops.top/prometheus-cve-exporter go 1.22.5 diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go index 8793608..d0e2499 100644 --- a/internal/exporter/exporter.go +++ b/internal/exporter/exporter.go @@ -1,6 +1,7 @@ package exporter import ( + "bufio" "compress/gzip" "encoding/json" "fmt" @@ -11,9 +12,9 @@ import ( "strings" "time" - "io.ki/prometheus-cve-exporter/config" - "io.ki/prometheus-cve-exporter/internal/metrics" - "io.ki/prometheus-cve-exporter/internal/models" + "zops.top/prometheus-cve-exporter/config" + "zops.top/prometheus-cve-exporter/internal/metrics" + "zops.top/prometheus-cve-exporter/internal/models" ) type PackageManager struct { @@ -28,35 +29,49 @@ var packageManagers = []PackageManager{ {"apk", []string{"info", "-v"}}, } -func GetInstalledPackages() (map[string]string, error) { - var cmd *exec.Cmd +func GetInstalledPackages(packageFile string) (map[string]string, error) { + var output []byte + var err error - for _, pm := range packageManagers { - if _, err := exec.LookPath(pm.Command); err == nil { - cmd = exec.Command(pm.Command, pm.Args...) - break - } + if packageFile == "" { + output, err = parseInstalledPackagesFromPackageManager() + } else { + output, err = os.ReadFile(packageFile) } - if cmd == nil { - return nil, fmt.Errorf("no suitable package manager found") + if err != nil { + return nil, fmt.Errorf("error getting installed packages: %w", err) } - output, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("error executing package manager command: %w", err) + return parsePackagesOutput(output), nil +} + +func parseInstalledPackagesFromPackageManager() ([]byte, error) { + for _, pm := range packageManagers { + if _, err := exec.LookPath(pm.Command); err == nil { + output, err := exec.Command(pm.Command, pm.Args...).Output() + if err != nil { + return nil, fmt.Errorf("error executing package manager command: %w", err) + } + return output, nil + } } + return nil, fmt.Errorf("no suitable package manager found") +} +func parsePackagesOutput(output []byte) map[string]string { packages := make(map[string]string) - lines := strings.Split(string(output), "\n") - for _, line := range lines { + scanner := bufio.NewScanner(strings.NewReader(string(output))) + + for scanner.Scan() { + line := scanner.Text() parts := strings.Fields(line) if len(parts) >= 2 { packages[parts[0]] = parts[1] } } - return packages, nil + return packages } func fetchNVDFeed() (*models.NVDFeed, error) { @@ -81,8 +96,7 @@ func fetchNVDFeed() (*models.NVDFeed, error) { defer gzReader.Close() var feed models.NVDFeed - decoder := json.NewDecoder(gzReader) - if err := decoder.Decode(&feed); err != nil { + if err := json.NewDecoder(gzReader).Decode(&feed); err != nil { return nil, fmt.Errorf("error decoding JSON: %v", err) } @@ -94,10 +108,8 @@ func checkVulnerabilities(packages map[string]string, feed *models.NVDFeed, seve totalVulnerabilities := 0 for _, item := range feed.CVEItems { - if item.Impact.BaseMetricV3 != nil { - if contains(severity, item.Impact.BaseMetricV3.CVSSV3.BaseSeverity) { - totalVulnerabilities += checkConfigurationNode(packages, item.CVE.CVEDataMeta.ID, item.Impact.BaseMetricV3.CVSSV3.BaseSeverity, item.Configurations.Nodes) - } + if item.Impact.BaseMetricV3 != nil && contains(severity, item.Impact.BaseMetricV3.CVSSV3.BaseSeverity) { + totalVulnerabilities += checkConfigurationNode(packages, item.CVE.CVEDataMeta.ID, item.Impact.BaseMetricV3.CVSSV3.BaseSeverity, item.Configurations.Nodes) } } @@ -128,28 +140,21 @@ func checkConfigurationNode(packages map[string]string, cveID string, impact str } func isVersionVulnerable(installedVersion string, cpeMatch models.CPEMatch) bool { - // This is a simplistic version check and should be improved - if cpeMatch.VersionStartExcluding != "" && installedVersion <= cpeMatch.VersionStartExcluding { - return false - } - if cpeMatch.VersionStartIncluding != "" && installedVersion < cpeMatch.VersionStartIncluding { - return false - } - if cpeMatch.VersionEndExcluding != "" && installedVersion >= cpeMatch.VersionEndExcluding { - return false - } - if cpeMatch.VersionEndIncluding != "" && installedVersion > cpeMatch.VersionEndIncluding { + if (cpeMatch.VersionStartExcluding != "" && installedVersion <= cpeMatch.VersionStartExcluding) || + (cpeMatch.VersionStartIncluding != "" && installedVersion < cpeMatch.VersionStartIncluding) || + (cpeMatch.VersionEndExcluding != "" && installedVersion >= cpeMatch.VersionEndExcluding) || + (cpeMatch.VersionEndIncluding != "" && installedVersion > cpeMatch.VersionEndIncluding) { return false } + return true } func UpdateMetrics(cfg *config.Config) { for { - packages, err := GetInstalledPackages() + packages, err := GetInstalledPackages(cfg.PackageFile) if err != nil { log.Printf("Error getting installed packages: %v", err) - log.Println("Waiting for 5m before next try.") time.Sleep(5 * time.Minute) continue } @@ -157,7 +162,6 @@ func UpdateMetrics(cfg *config.Config) { feed, err := fetchNVDFeed() if err != nil { log.Printf("Error fetching NVD feed: %v", err) - log.Println("Waiting for 5m before next try.") time.Sleep(5 * time.Minute) continue } @@ -165,7 +169,6 @@ func UpdateMetrics(cfg *config.Config) { checkVulnerabilities(packages, feed, cfg.Severity) log.Println("Metrics updated successfully") - log.Printf("Waiting for %v until next check.", cfg.UpdateInterval) time.Sleep(cfg.UpdateInterval) } } diff --git a/internal/exporter/exporter_test.go b/internal/exporter/exporter_test.go index 88ff661..1adc04a 100644 --- a/internal/exporter/exporter_test.go +++ b/internal/exporter/exporter_test.go @@ -1,85 +1,162 @@ package exporter import ( + "os" + "reflect" "testing" - "io.ki/prometheus-cve-exporter/internal/models" + "zops.top/prometheus-cve-exporter/internal/metrics" + "zops.top/prometheus-cve-exporter/internal/models" ) func TestGetInstalledPackages(t *testing.T) { - // This test might be challenging to implement without mocking the package manager commands - // For now, we'll just check if the function runs without errors - _, err := GetInstalledPackages() + // Create a temporary file with mock package data + tmpfile, err := os.CreateTemp("", "packages") if err != nil { - t.Fatalf("GetInstalledPackages() failed: %v", err) + t.Fatal(err) } + defer os.Remove(tmpfile.Name()) + + mockData := "package1 1.0.0\npackage2 2.0.0\n" + if _, err := tmpfile.Write([]byte(mockData)); err != nil { + t.Fatal(err) + } + if err := tmpfile.Close(); err != nil { + t.Fatal(err) + } + + packages, err := GetInstalledPackages(tmpfile.Name()) + if err != nil { + t.Fatalf("GetInstalledPackages failed: %v", err) + } + + expected := map[string]string{ + "package1": "1.0.0", + "package2": "2.0.0", + } + + if !reflect.DeepEqual(packages, expected) { + t.Errorf("GetInstalledPackages() = %v, want %v", packages, expected) + } +} + +func TestParsePackagesOutput(t *testing.T) { + input := []byte("package1 1.0.0\npackage2 2.0.0\npackage3 3.0.0") + expected := map[string]string{ + "package1": "1.0.0", + "package2": "2.0.0", + "package3": "3.0.0", + } + + result := parsePackagesOutput(input) + + if !reflect.DeepEqual(result, expected) { + t.Errorf("parsePackagesOutput() = %v, want %v", result, expected) + } +} + +func TestCheckVulnerabilities(t *testing.T) { + packages := map[string]string{ + "vulnerable_package": "1.0.0", + "safe_package": "2.0.0", + } + + feed := &models.NVDFeed{ + CVEItems: []models.CVEItem{ + { + CVE: models.CVE{ + CVEDataMeta: models.CVEDataMeta{ + ID: "CVE-2023-12345", + }, + }, + Impact: models.Impact{ + BaseMetricV3: &models.BaseMetricV3{ + CVSSV3: models.CVSSV3{ + BaseSeverity: "HIGH", + }, + }, + }, + Configurations: models.Configurations{ + Nodes: []models.Node{ + { + CPEMatch: []models.CPEMatch{ + { + Vulnerable: true, + CPE23Uri: "cpe:2.3:a:vendor:vulnerable_package:1.0.0:*:*:*:*:*:*:*", + }, + }, + }, + }, + }, + }, + }, + } + + severity := []string{"HIGH"} + + // Reset metrics before test + metrics.ResetVulnerablePackagesGauge() + + checkVulnerabilities(packages, feed, severity) + + // Here you would typically check the metrics. + // For this example, we'll just verify that the function runs without panicking. + // In a real test, you'd use a mocked metrics package to verify the correct metrics were set. } func TestIsVersionVulnerable(t *testing.T) { + packages := map[string]string{ + "package1": "1.5.0", + } + tests := []struct { - name string - installedVersion string - cpeMatch models.CPEMatch - expected bool + name string + cpeMatch models.CPEMatch + want bool }{ { - name: "Version within range", - installedVersion: "1.2.3", - cpeMatch: models.CPEMatch{ - VersionStartIncluding: "1.0.0", - VersionEndExcluding: "2.0.0", - }, - expected: true, - }, - { - name: "Version before range", - installedVersion: "0.9.0", + name: "Version in range", cpeMatch: models.CPEMatch{ + Vulnerable: true, + CPE23Uri: "cpe:2.3:a:vendor:package1:*:*:*:*:*:*:*:*", VersionStartIncluding: "1.0.0", VersionEndExcluding: "2.0.0", }, - expected: false, + want: true, }, { - name: "Version after range", - installedVersion: "2.1.0", + name: "Version out of range", cpeMatch: models.CPEMatch{ - VersionStartIncluding: "1.0.0", - VersionEndExcluding: "2.0.0", + Vulnerable: false, + CPE23Uri: "cpe:2.3:a:vendor:package1:*:*:*:*:*:*:*:*", + VersionStartIncluding: "2.0.0", + VersionEndExcluding: "3.0.0", }, - expected: false, + want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := isVersionVulnerable(tt.installedVersion, tt.cpeMatch) - if result != tt.expected { - t.Errorf("isVersionVulnerable() = %v, want %v", result, tt.expected) + if got := isVersionVulnerable(packages["package1"], tt.cpeMatch); got != tt.want { + t.Errorf("isVersionVulnerable() = %v, want %v", got, tt.want) } }) } } -func TestCheckConfigurationNode(t *testing.T) { - packages := map[string]string{ - "openssl": "1.1.1", - } +func TestFetchNVDFeed(t *testing.T) { + // This is a mock test. In a real scenario, you'd use a mock HTTP server. + t.Skip("Skipping TestFetchNVDFeed as it requires network access") - node := models.Node{ - CPEMatch: []models.CPEMatch{ - { - Vulnerable: true, - CPE23Uri: "cpe:2.3:a:openssl:openssl:1.1.1:*:*:*:*:*:*:*", - VersionStartIncluding: "1.1.0", - VersionEndExcluding: "1.1.2", - }, - }, + feed, err := fetchNVDFeed() + if err != nil { + t.Fatalf("fetchNVDFeed() error = %v", err) } - count := checkConfigurationNode(packages, "CVE-2023-1234", "HIGH", []models.Node{node}) - - if count != 1 { - t.Errorf("Expected 1 vulnerable package, got %d", count) + if feed == nil { + t.Error("fetchNVDFeed() returned nil feed") } + + // Add more specific checks on the feed content if needed }