package vulnscan import ( "bytes" "encoding/json" "fmt" "io" "net/http" "time" ) const defaultOSVAPI = "https://api.osv.dev/v1" // Client queries the OSV (Open Source Vulnerabilities) API. // https://osv.dev/docs/ type Client struct { baseURL string http *http.Client } // NewClient creates a client that queries the public OSV API. func NewClient() *Client { return &Client{ baseURL: defaultOSVAPI, http: &http.Client{ Timeout: 30 * time.Second, }, } } // QueryRequest is sent to POST /v1/query. type QueryRequest struct { Package PackageID `json:"package"` Version string `json:"version"` } // PackageID identifies a package in a specific ecosystem. type PackageID struct { PURL string `json:"purl,omitempty"` Name string `json:"name,omitempty"` Ecosystem string `json:"ecosystem,omitempty"` } // QueryResponse is the response from POST /v1/query. type QueryResponse struct { Vulns []OSVVuln `json:"vulns"` } // OSVVuln is a vulnerability returned by the OSV API. type OSVVuln struct { ID string `json:"id"` Summary string `json:"summary"` Details string `json:"details"` Aliases []string `json:"aliases"` Fixed string `json:"fixed,omitempty"` Severity []Severity `json:"severity,omitempty"` Affected []Affected `json:"affected,omitempty"` Published string `json:"published,omitempty"` Modified string `json:"modified,omitempty"` } // Severity holds a CVSS score from the OSV response. type Severity struct { Type string `json:"type"` Score string `json:"score"` } // Affected describes a package version range. type Affected struct { Package PackageID `json:"package"` Ranges []AffectedRange `json:"ranges"` Versions []string `json:"versions"` } type AffectedRange struct { Type string `json:"type"` Events []RangeEvent `json:"events"` } type RangeEvent struct { Introduced string `json:"introduced"` Fixed string `json:"fixed"` Limit string `json:"limit"` } // QueryByPURL queries OSV for vulnerabilities affecting a given PURL + version. func (c *Client) QueryByPURL(purl, version string) ([]OSVVuln, error) { body := QueryRequest{ Package: PackageID{PURL: purl}, Version: version, } return c.doQuery(body) } // QueryByEcosystem queries OSV for vulnerabilities affecting a package in a // specific ecosystem (e.g. "npm", "Go", "PyPI", "cargo", "Maven", "RubyGems"). func (c *Client) QueryByEcosystem(ecosystem, name, version string) ([]OSVVuln, error) { body := QueryRequest{ Package: PackageID{ Name: name, Ecosystem: ecosystem, }, Version: version, } return c.doQuery(body) } func (c *Client) doQuery(body interface{}) ([]OSVVuln, error) { payload, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("vulnscan: marshal body: %w", err) } req, err := http.NewRequest(http.MethodPost, c.baseURL+"/query", bytes.NewReader(payload)) if err != nil { return nil, fmt.Errorf("vulnscan: create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("vulnscan: query: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("vulnscan: read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("vulnscan: OSV returned %d: %s", resp.StatusCode, string(respBody)) } var qr QueryResponse if err := json.Unmarshal(respBody, &qr); err != nil { return nil, fmt.Errorf("vulnscan: parse response: %w", err) } return qr.Vulns, nil }