Skip to content

Commit f335d41

Browse files
committed
feat: add vulnupdatelist hack script
Signed-off-by: bwplotka <bwplotka@google.com>
1 parent 10c5314 commit f335d41

File tree

8 files changed

+499
-340
lines changed

8 files changed

+499
-340
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,8 @@ require (
337337
)
338338

339339
replace (
340+
// Bump unitl we upgrade Prometheus deps.
341+
github.com/prometheus/common => github.com/prometheus/common v0.61.0
340342
// Go modules keeps resetting the version to a random unversioned commit.
341343
// So this is required for unknown reasons.
342344
github.com/prometheus/prometheus => github.com/prometheus/prometheus v0.45.2

go.sum

Lines changed: 48 additions & 340 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

hack/go.mod

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module github.com/GoogleCloudPlatform/promethue-engine/hack
2+
3+
go 1.24.0
4+
5+
require (
6+
github.com/Masterminds/semver/v3 v3.3.1
7+
github.com/stretchr/testify v1.10.0
8+
)
9+
10+
require (
11+
github.com/davecgh/go-spew v1.1.1 // indirect
12+
github.com/pmezard/go-difflib v1.0.0 // indirect
13+
gopkg.in/yaml.v3 v3.0.1 // indirect
14+
)

hack/go.sum

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

hack/vulnupdatelist/main.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// package main implements vulnupdatelist script.
2+
//
3+
// Run this script to list all the vulnerable Go modules to upgrade.
4+
// Compared to govulncheck binary, it also checks severity and groups the results
5+
// into clear table per module to upgrade.
6+
//
7+
// Example use:
8+
//
9+
// go run ./... \
10+
// -go-version=1.23.0 \
11+
// -only-fixed \
12+
// -dir=../../../prometheus \
13+
// -nvd-api-key="$(cat ./api.text)" | tee vuln.txt
14+
package main
15+
16+
import (
17+
"bytes"
18+
"flag"
19+
"fmt"
20+
"log"
21+
"log/slog"
22+
"os"
23+
"os/exec"
24+
"path/filepath"
25+
26+
"github.com/Masterminds/semver/v3"
27+
)
28+
29+
var (
30+
goVersion = flag.String("go-version", "", "Go version to test vulnerabilities in (stdlib). Otherwise the `go env GOVERSION` is used")
31+
dir = flag.String("dir", ".", "Where to run the script from")
32+
nvdAPIKey = flag.String("nvd-api-key", "", "API Key for avoiding rate-limiting on severity checks; see https://nvd.nist.gov/developers/request-an-api-key")
33+
onlyFixed = flag.Bool("only-fixed", false, "Don't print vulnerable modules without fixed version; note: fixed version often means sometimes that a new major version contains a fix.")
34+
)
35+
36+
// UpdateList presents the minimum version to upgrade to solve all CVEs with
37+
// a fixed version. The CVE refers to the important CVE.
38+
// For example critical CVE 1 is fixed in v0.5.1 and low is fixed in v0.10.1.
39+
// TODO(bwplotka): Logically, there might be cases where low contains heavy breaking changes that we can't fix easily; add option to print those too.
40+
type UpdateList struct {
41+
CVE CVE // If CVE has +<number> suffix, it means the top CVE.
42+
AdditionalCVEs int // Lower priority CVEs included in the "fixed" version.
43+
Module string
44+
FixedVersion *semver.Version
45+
Version string
46+
}
47+
48+
func (u UpdateList) String() string {
49+
fixedVersion := "???"
50+
if u.FixedVersion != nil {
51+
fixedVersion = u.FixedVersion.String()
52+
}
53+
if u.AdditionalCVEs > 0 {
54+
return fmt.Sprintf("%s %s@%s %s(+%d more) now@%s", u.CVE.Severity, u.Module, fixedVersion, u.CVE.ID, u.AdditionalCVEs, u.Version)
55+
}
56+
return fmt.Sprintf("%s %s@%s %s now@%s", u.CVE.Severity, u.Module, fixedVersion, u.CVE.ID, u.Version)
57+
}
58+
59+
func main() {
60+
flag.Parse()
61+
62+
workDir, err := filepath.Abs(*dir)
63+
if err != nil {
64+
log.Fatalf("Failed to resolve work dir: %v", err)
65+
}
66+
slog.Info("Running vulnupdatelist", "dir", workDir)
67+
68+
if err := ensureGovulncheck(workDir); err != nil {
69+
log.Fatalf("Failed to ensure govulncheck is installed: %v", err)
70+
}
71+
72+
slog.Info("Running govulncheck... ")
73+
vulnJSON, err := runGovulncheck(workDir, *goVersion)
74+
if err != nil {
75+
log.Fatalf("Error running govulncheck: %v", err)
76+
}
77+
78+
if len(vulnJSON) == 0 {
79+
slog.Info("govulncheck produced no output; no vulnerabilities found.")
80+
os.Exit(0)
81+
}
82+
83+
slog.Info("Parsing vulnerabilities and finding updates...")
84+
updates, err := compileUpdateList(bytes.NewReader(vulnJSON), *onlyFixed)
85+
if err != nil {
86+
log.Fatalf("Error parsing govulncheck output: %v", err)
87+
}
88+
if len(updates) == 0 {
89+
slog.Info("No actionable vulnerabilities with fixed versions found.")
90+
os.Exit(0)
91+
}
92+
for _, up := range updates {
93+
fmt.Println(up.String())
94+
}
95+
}
96+
97+
// ensureGovulncheck checks if govulncheck is in the PATH, and installs it if not.
98+
func ensureGovulncheck(dir string) error {
99+
_, err := exec.LookPath("govulncheck")
100+
if err == nil {
101+
slog.Info("govulncheck is already installed")
102+
return nil
103+
}
104+
105+
slog.Info("govulncheck not found. Installing...")
106+
cmd := exec.Command("go", "install", "golang.org/x/vuln/cmd/govulncheck@latest")
107+
cmd.Dir = dir
108+
cmd.Stdout = os.Stdout
109+
cmd.Stderr = os.Stderr
110+
if err := cmd.Run(); err != nil {
111+
return fmt.Errorf("failed to run 'go install': %w", err)
112+
}
113+
slog.Info("govulncheck installed successfully.")
114+
return nil
115+
}
116+
117+
// runGovulncheck executes `govulncheck -json ./...` and returns the output.
118+
func runGovulncheck(dir string, goVersion string) ([]byte, error) {
119+
cmd := exec.Command("govulncheck", "--format=json", "./...")
120+
if goVersion != "" {
121+
cmd.Env = append(os.Environ(), "GOVERSION="+goVersion)
122+
}
123+
124+
cmd.Dir = dir
125+
var out bytes.Buffer
126+
var stderr bytes.Buffer
127+
cmd.Stdout = &out
128+
cmd.Stderr = &stderr
129+
130+
// govulncheck exits with a non-zero status code if vulns are found.
131+
// We ignore the exit code and check stderr instead. If stderr is empty,
132+
// it's a successful run (even with vulnerabilities).
133+
_ = cmd.Run()
134+
135+
if stderr.Len() > 0 {
136+
// Only return an error if stderr is not empty, as this indicates a real execution problem.
137+
return nil, fmt.Errorf("govulncheck execution error: %s", stderr.String())
138+
}
139+
return out.Bytes(), nil
140+
}

hack/vulnupdatelist/nvdapi.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"log/slog"
8+
"net/http"
9+
"strings"
10+
"time"
11+
)
12+
13+
// NVDResponse is the top-level object for the NVD CVE API.
14+
type NVDResponse struct {
15+
Vulnerabilities []struct {
16+
CVE struct {
17+
ID string `json:"id"`
18+
Metrics struct {
19+
CVSSMetricV31 []struct {
20+
CVSSData struct {
21+
BaseSeverity string `json:"baseSeverity"`
22+
} `json:"cvssData"`
23+
} `json:"cvssMetricV31"`
24+
} `json:"metrics"`
25+
} `json:"cve"`
26+
} `json:"vulnerabilities"`
27+
}
28+
29+
// getCVSSSeverity fetches vulnerability details from the NVD API and returns the CVSS V3 severity.
30+
func getCVSSSeverity(apiKey string, cveID string) (string, error) {
31+
// https://nvd.nist.gov/developers/vulnerabilities
32+
apiURL := fmt.Sprintf("https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=%s", cveID)
33+
34+
client := &http.Client{Timeout: 15 * time.Second}
35+
req, err := http.NewRequest("GET", apiURL, nil)
36+
if err != nil {
37+
return "", fmt.Errorf("failed to create request: %w", err)
38+
}
39+
if apiKey != "" {
40+
req.Header.Set("apiKey", apiKey)
41+
}
42+
resp, err := client.Do(req)
43+
if err != nil {
44+
return "", fmt.Errorf("failed to make request to NVD API: %w", err)
45+
}
46+
defer resp.Body.Close()
47+
48+
if resp.StatusCode != http.StatusOK {
49+
return "", fmt.Errorf("NVD API returned non-200 status: %s", resp.Status)
50+
}
51+
52+
body, err := io.ReadAll(resp.Body)
53+
if err != nil {
54+
return "", fmt.Errorf("failed to read response body: %w", err)
55+
}
56+
57+
var nvdResponse NVDResponse
58+
if err := json.Unmarshal(body, &nvdResponse); err != nil {
59+
return "", fmt.Errorf("failed to parse JSON from NVD API: %w", err)
60+
}
61+
62+
if len(nvdResponse.Vulnerabilities) > 0 {
63+
metrics := nvdResponse.Vulnerabilities[0].CVE.Metrics
64+
if len(metrics.CVSSMetricV31) > 0 {
65+
return metrics.CVSSMetricV31[0].CVSSData.BaseSeverity, nil
66+
}
67+
}
68+
69+
return "UNKNOWN", nil
70+
}
71+
72+
type CVE struct {
73+
ID string
74+
Severity string
75+
}
76+
77+
func (a CVE) LessThan(b CVE) bool {
78+
order := map[string]int{
79+
"CRITICAL": 0,
80+
"HIGH": 1,
81+
"MEDIUM": 2,
82+
"UNKNOWN": 3,
83+
"": 3,
84+
}
85+
return order[a.Severity] < order[b.Severity]
86+
}
87+
88+
func getCVEDetails(apiKey string, osv OSV) CVE {
89+
cveID := CVE{Severity: "UNKNOWN"}
90+
// Assume ID is GO-... ID and use it as a fallback.
91+
for _, a := range osv.Aliases {
92+
if strings.HasPrefix(a, "CVE") {
93+
cveID.ID = a
94+
break
95+
}
96+
}
97+
if cveID.ID == "" {
98+
return CVE{ID: osv.ID, Severity: "UNKNOWN"} // Fallback to GO ID.
99+
}
100+
sev, err := getCVSSSeverity(apiKey, cveID.ID)
101+
if err != nil {
102+
slog.Error("failed to find severity", "cve", cveID, "err", err)
103+
} else {
104+
cveID.Severity = sev
105+
}
106+
return cveID
107+
}

hack/vulnupdatelist/nvdapi_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestGetCVEDetails(t *testing.T) {
10+
t.Skip("depends on NVDE API")
11+
12+
c := getCVEDetails("", OSV{
13+
ID: "GO-2021-0065",
14+
Aliases: []string{"GHSA-jmrx-5g74-6v2f", "CVE-2019-11250"},
15+
})
16+
require.Equal(t, "CVE-2019-11250", c.ID)
17+
require.Equal(t, "MEDIUM", c.Severity)
18+
}

0 commit comments

Comments
 (0)